defmodule PromptRunner.Runner do @moduledoc false alias AgentSessionManager.Rendering alias AgentSessionManager.Rendering.Renderers.{CompactRenderer, StudioRenderer, VerboseRenderer} alias AgentSessionManager.Rendering.Sinks.{CallbackSink, FileSink, JSONLSink, TTYSink} alias PromptRunner.CommitMessages alias PromptRunner.Config alias PromptRunner.Git alias PromptRunner.Progress alias PromptRunner.Prompts alias PromptRunner.RepoTargets alias PromptRunner.UI alias PromptRunner.Validator @prompt_preview_lines 10 # Maps each provider to {otp_app, primary_module, extra_modules}. # Module checks match what ASM adapters guard on at compile time. @provider_deps %{ claude: {:claude_agent_sdk, ClaudeAgentSDK, []}, codex: {:codex_sdk, Codex, [Codex.Events]}, amp: {:amp_sdk, AmpSdk, []} } @spec run(Config.t(), keyword(), list()) :: :ok | {:error, term()} def run(config, opts, remaining) do config = Config.with_overrides(config, opts) cond do opts[:validate] -> Validator.validate_all(config) opts[:list] -> list_prompts(config) opts[:dry_run] -> with {:ok, targets} <- build_targets(config, opts, remaining) do Enum.each(targets, &dry_run_prompt(config, &1, opts[:no_commit] || false)) :ok end opts[:run] -> with {:ok, targets} <- build_targets(config, opts, remaining) do run_targets(config, targets, opts[:no_commit] || false) end true -> {:error, :no_command} end end defp run_targets(config, targets, skip_commit) do Enum.reduce_while(targets, :ok, fn num, _acc -> case run_prompt(config, num, skip_commit) do :ok -> {:cont, :ok} {:error, reason} -> {:halt, {:error, reason}} end end) end defp build_targets(config, opts, remaining) do cond do remaining != [] -> {:ok, [hd(remaining)]} opts[:phase] -> {:ok, Prompts.phase_nums(config, opts[:phase])} opts[:continue] -> targets = case Progress.last_completed(config) do nil -> Prompts.nums(config) last -> next = last |> String.to_integer() |> Kernel.+(1) |> Integer.to_string() |> String.pad_leading(2, "0") Prompts.nums(config) |> Enum.filter(&(&1 >= next)) end {:ok, targets} opts[:all] -> {:ok, Prompts.nums(config)} true -> {:error, :no_target} end end defp list_prompts(config) do IO.puts("") IO.puts(UI.bold("Implementation Prompts")) IO.puts(UI.cyan("Config: #{config.config_dir}")) IO.puts(UI.cyan("Project: #{config.project_dir}")) IO.puts("") prompts = Prompts.list(config) statuses = Progress.statuses(config) {completed, total, _} = Enum.reduce(prompts, {0, 0, nil}, fn prompt, {c, t, last_phase} -> last_phase = maybe_print_phase_header(config, prompt, last_phase) {line, completed_inc} = format_prompt_line(config, statuses, prompt) IO.puts(line) {c + completed_inc, t + 1, last_phase} end) IO.puts("") IO.puts(UI.bold("Progress:")) IO.puts("#{completed}/#{total} completed") IO.puts("") end defp maybe_print_phase_header(config, prompt, last_phase) do if prompt.phase != last_phase do if last_phase != nil, do: IO.puts("") phase_name = Map.get(config.phase_names, prompt.phase, "Unknown") IO.puts(UI.green("Phase #{prompt.phase}: #{phase_name}")) end prompt.phase end defp format_prompt_line(config, statuses, prompt) do prompt_status = Progress.status(statuses, prompt.num) status_label = case prompt_status.status do "completed" -> UI.green("[x]") "failed" -> UI.red("[!]") _ -> "[ ]" end completed_inc = if Progress.completed?(statuses, prompt.num), do: 1, else: 0 prompt_path = Path.join(config.config_dir, prompt.file) missing = if File.exists?(prompt_path), do: "", else: " #{UI.red("(missing)")}" commit_suffix = format_commit_suffix(prompt_status.commit) repos_suffix = format_repos_suffix(prompt.target_repos) line = " #{status_label} #{prompt.num} - #{prompt.name} (#{prompt.sp} SP)#{repos_suffix}#{commit_suffix}#{missing}" {line, completed_inc} end defp dry_run_prompt(config, num, skip_commit) do case Prompts.get(config, num) do nil -> IO.puts(UI.red("ERROR: Prompt #{num} not found")) prompt -> prompt_path = Path.join(config.config_dir, prompt.file) llm = Config.llm_for_prompt(config, prompt) IO.puts("") IO.puts(UI.bold("[DRY RUN] Prompt #{num}: #{prompt.name}")) IO.puts("") print_prompt_file_info(prompt_path) print_execution_info(config, prompt, llm) print_git_commit_info(config, prompt, skip_commit) IO.puts("") end end defp print_prompt_file_info(prompt_path) do IO.puts(UI.yellow("1. Prompt file:")) if File.exists?(prompt_path) do stat = File.stat!(prompt_path) lines = prompt_path |> File.read!() |> String.split("\n") |> length() IO.puts(" #{prompt_path}") IO.puts(" #{lines} lines, #{stat.size} bytes") else IO.puts(" #{UI.red("NOT FOUND:")} #{prompt_path}") end IO.puts("") end defp print_execution_info(config, prompt, llm) do IO.puts(UI.yellow("2. Execution:")) IO.puts(" LLM provider: #{llm.sdk}") IO.puts(" - model: #{llm.model}") maybe_print_codex_reasoning(llm) IO.puts(" - cwd: #{llm.cwd}") maybe_print_allowed_tools(llm) maybe_print_permission_mode(llm) maybe_print_adapter_opts(llm) maybe_print_codex_thread_opts(llm) print_target_repos(config, prompt) IO.puts("") end defp maybe_print_codex_reasoning(%{sdk: :codex} = llm) do case configured_codex_reasoning(llm) do nil -> :ok reasoning -> IO.puts(" - reasoning_effort: #{reasoning}") end end defp maybe_print_codex_reasoning(_llm), do: :ok defp maybe_print_allowed_tools(llm) do if is_list(llm.allowed_tools) do IO.puts(" - allowed_tools: #{Enum.join(llm.allowed_tools, ", ")}") end end defp maybe_print_permission_mode(llm) do if llm.permission_mode != nil do IO.puts(" - permission_mode: #{llm.permission_mode}") end end defp maybe_print_adapter_opts(llm) do if is_map(llm[:adapter_opts]) and map_size(llm.adapter_opts) > 0 do IO.puts(" - adapter_opts: #{inspect(llm.adapter_opts)}") end end defp maybe_print_codex_thread_opts(%{sdk: :codex} = llm) do if is_map(llm[:codex_thread_opts]) and map_size(llm.codex_thread_opts) > 0 do IO.puts(" - codex_thread_opts: #{inspect(llm.codex_thread_opts)}") end end defp maybe_print_codex_thread_opts(_llm), do: :ok defp print_target_repos(config, prompt) do case prompt.target_repos do nil -> IO.puts(" - target_repo: #{config.project_dir}") repos when is_list(repos) -> IO.puts(" - target_repos: #{Enum.join(repos, ", ")}") {resolved_repos, errors} = RepoTargets.expand(repos, config.repo_groups) Enum.each(errors, fn error -> IO.puts(" - #{UI.red("ERR")} #{RepoTargets.format_error(error)}") end) Enum.each(resolved_repos, fn repo_name -> repo_path = get_repo_path(config, repo_name) IO.puts(" - #{repo_name}: #{repo_path || "(not configured)"}") end) end end defp print_git_commit_info(config, prompt, skip_commit) do IO.puts(UI.yellow("3. Git commit:")) if skip_commit do IO.puts(" SKIPPED (--no-commit)") else print_commit_messages(config, prompt) end IO.puts("") end defp print_commit_messages(config, prompt) do case prompt.target_repos do nil -> print_single_commit_message(config, prompt) repos when is_list(repos) -> {resolved_repos, errors} = RepoTargets.expand(repos, config.repo_groups) Enum.each(errors, fn error -> IO.puts(" #{UI.red("ERR")} #{RepoTargets.format_error(error)}") end) Enum.each(resolved_repos, &print_repo_commit_message(config, prompt, &1)) end end defp print_single_commit_message(config, prompt) do case CommitMessages.get_message(config, prompt.num) do nil -> IO.puts(" #{UI.red("COMMIT MESSAGE NOT FOUND")} for #{prompt.num}") msg -> show_commit_message_preview(msg, nil) end end defp print_repo_commit_message(config, prompt, repo_name) do IO.puts(" #{UI.bold(repo_name <> ":")}") case CommitMessages.get_message(config, prompt.num, repo_name) do nil -> IO.puts(" #{UI.red("COMMIT MESSAGE NOT FOUND")} for #{prompt.num}:#{repo_name}") msg -> show_commit_message_preview(msg, " ") end IO.puts("") end defp show_commit_message_preview(msg, indent) do prefix = indent || " " msg |> String.split("\n") |> Enum.take(10) |> Enum.each(&IO.puts("#{prefix}#{&1}")) total_lines = msg |> String.split("\n") |> length() if total_lines > 10 do IO.puts("#{prefix}... (#{total_lines - 10} more lines)") end end defp run_prompt(config, num, skip_commit) do case Prompts.get(config, num) do nil -> {:error, {:prompt_not_found, num}} prompt -> prompt_path = Path.join(config.config_dir, prompt.file) llm = Config.llm_for_prompt(config, prompt) with :ok <- ensure_prompt_file(prompt_path), {:ok, sdk_info} <- preflight_llm_dependency(llm) do execute_prompt_stream(config, num, prompt, prompt_path, llm, sdk_info, skip_commit) else {:error, reason} -> return_error(config, num, reason) end end end defp ensure_prompt_file(prompt_path) do if File.exists?(prompt_path), do: :ok, else: {:error, {:prompt_file_not_found, prompt_path}} end defp execute_prompt_stream(config, num, prompt, prompt_path, llm, sdk_info, skip_commit) do File.mkdir_p!(config.log_dir) timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S") log_file = Path.join(config.log_dir, "prompt-#{num}-#{timestamp}.log") events_file = Path.join(config.log_dir, "prompt-#{num}-#{timestamp}.events.jsonl") print_prompt_header(config, prompt, llm, log_file) maybe_print_sdk_preflight(sdk_info) if config.log_mode != :studio do IO.puts("Events: #{events_file}") IO.puts("") end prompt_content = File.read!(prompt_path) IO.puts(UI.yellow("Prompt preview (first #{@prompt_preview_lines} lines):")) prompt_content |> String.split("\n") |> Enum.take(@prompt_preview_lines) |> Enum.each(&IO.puts(" #{&1}")) IO.puts(" ...") IO.puts("") if config.log_mode != :studio do IO.puts(UI.yellow("Starting #{llm.sdk} session...")) IO.puts("") end {:ok, log_io} = File.open(log_file, [:write, :utf8]) llm_module().start_stream(llm, prompt_content) |> handle_stream_result( config, prompt, prompt_path, llm, {log_io, events_file}, skip_commit ) end defp handle_stream_result( {:ok, stream, close_llm, llm_meta}, config, prompt, prompt_path, llm, {log_io, events_file}, skip_commit ) do write_session_header(log_io, config, llm, llm_meta, prompt_path) initialize_cli_confirmation_tracking(llm, config, log_io) renderer = renderer_for_config(config) sinks = build_sinks(config, log_io, events_file, llm) render_result = safe_render_stream(stream, renderer, sinks) callback_result = Process.get(:prompt_runner_stream_result, :ok) Process.delete(:prompt_runner_stream_result) result = resolve_stream_result(render_result, callback_result) result = maybe_finalize_cli_confirmation(result, llm, config, log_io) Process.delete(:prompt_runner_cli_confirmation_printed) Process.delete(:prompt_runner_cli_confirmation_audit) IO.puts("") finalize_stream_result(result, config, prompt, llm, skip_commit) after close_llm.() File.close(log_io) end defp handle_stream_result( {:error, reason}, config, prompt, _prompt_path, _llm, {log_io, _events_file}, _skip_commit ) do File.close(log_io) return_error(config, prompt.num, {:start_failed, reason}) end defp return_error(config, num, reason) do {summary, stderr_detail, stderr_truncated?} = format_error_reason(reason, config) IO.puts(UI.red("ERROR: #{summary}")) if is_binary(stderr_detail) and stderr_detail != "" do IO.puts(UI.red("stderr:")) stderr_detail |> String.split("\n", trim: false) |> Enum.each(&IO.puts(" #{&1}")) if stderr_truncated? do IO.puts(" [truncated]") end end Progress.mark_failed(config, num) {:error, reason} end defp safe_render_stream(stream, renderer, sinks) do Rendering.stream(stream, renderer: renderer, sinks: sinks) rescue exception -> {:error, {:stream_failed, Exception.message(exception)}} catch kind, reason -> {:error, {:stream_failed, "#{kind}: #{inspect(reason)}"}} end defp resolve_stream_result(:ok, callback_result), do: callback_result defp resolve_stream_result({:error, reason}, _callback_result), do: {:error, reason} defp renderer_for_config(config) do case config.log_mode do :studio -> {StudioRenderer, studio_opts(config)} :verbose -> {VerboseRenderer, []} _ -> {CompactRenderer, []} end end defp studio_opts(config) do opts = [tool_output: config.tool_output] if config.log_mode == :studio, do: opts, else: [] end defp print_prompt_header(config, prompt, llm, log_file) do IO.puts("") if config.log_mode == :studio do bar = UI.dim(String.duplicate("━", 60)) IO.puts(bar) IO.puts(UI.bold(" Prompt #{prompt.num}: #{prompt.name}")) IO.puts(bar) IO.puts("") IO.puts(" #{UI.dim("Prompt")} #{Path.join(config.config_dir, prompt.file)}") IO.puts(" #{UI.dim("Project")} #{llm.cwd}") IO.puts(" #{UI.dim("LLM")} #{llm.sdk} / #{llm.model}") IO.puts(" #{UI.dim("Log")} #{log_file}") IO.puts("") else IO.puts(UI.blue(String.duplicate("=", 60))) IO.puts(UI.bold("Prompt #{prompt.num}: #{prompt.name}")) IO.puts(UI.blue(String.duplicate("=", 60))) IO.puts("") IO.puts("Prompt: #{Path.join(config.config_dir, prompt.file)}") IO.puts("Project: #{llm.cwd}") IO.puts("LLM: #{llm_summary(llm)}") IO.puts("") IO.puts("Log: #{log_file}") end end defp build_sinks(config, log_io, events_file, llm) do sinks = [ {TTYSink, []}, {FileSink, [io: log_io]}, {CallbackSink, [callback: fn event, iodata -> stream_tracking_callback(event, iodata, llm, log_io) end]} ] if config.events_mode != :off do sinks ++ [{JSONLSink, [path: events_file, mode: config.events_mode]}] else sinks end end defp stream_tracking_callback(event, _iodata, llm, log_io) do maybe_print_cli_confirmation(event, llm, log_io) error_tracking_callback(event) end defp error_tracking_callback(event) do case event.type do type when type in [:error_occurred, :run_failed] -> data = map_get(event, :data) || %{} provider_error = map_get(data, :provider_error) details = map_get(data, :details) summary = map_get(provider_error, :message) || map_get(data, :error_message) || "unknown error" result = if is_map(provider_error) do {:error, %{message: summary, provider_error: provider_error, details: details}} else {:error, summary} end Process.put(:prompt_runner_stream_result, result) _ -> :ok end end defp format_error_reason(reason, config) do provider_error = extract_provider_error(reason) summary = map_get(reason, :message) || map_get(provider_error, :message) || if(is_binary(reason), do: reason, else: inspect(reason)) stderr_detail = if show_provider_stderr?(config) do map_get(provider_error, :stderr) else nil end {summary, stderr_detail, truthy?(map_get(provider_error, :truncated?))} end defp extract_provider_error(reason) do case map_get(reason, :provider_error) do value when is_map(value) -> value _ -> %{} end end defp show_provider_stderr?(%{log_meta: :full}), do: true defp show_provider_stderr?(_config), do: false defp truthy?(value) when value in [true, "true", "TRUE", "1", 1], do: true defp truthy?(_), do: false defp maybe_print_cli_confirmation(%{type: :run_started} = event, %{sdk: :codex}, log_io) do case extract_codex_cli_confirmation(event) do %{model: model, reasoning_effort: reasoning_effort} = confirmation when is_binary(model) and model != "" and not is_nil(reasoning_effort) -> IO.puts("\nLLM confirmed (codex_cli): model=#{model} reasoning=#{reasoning_effort}") audit = Process.get(:prompt_runner_cli_confirmation_audit, %{}) |> Map.put(:confirmed_model, model) |> Map.put(:confirmed_reasoning_effort, to_string(reasoning_effort)) |> Map.put(:confirmation_source, confirmation.source || "codex_cli.run_started") Process.put(:prompt_runner_cli_confirmation_printed, true) Process.put(:prompt_runner_cli_confirmation_audit, audit) IO.binwrite( log_io, "LLM_AUDIT_CONFIRMED source=#{audit.confirmation_source} confirmed_model=#{audit.confirmed_model} confirmed_reasoning=#{audit.confirmed_reasoning_effort}\n" ) :ok %{model: model} = confirmation when is_binary(model) and model != "" -> audit = Process.get(:prompt_runner_cli_confirmation_audit, %{}) |> Map.put(:confirmed_model, model) |> Map.put(:confirmation_source, confirmation.source || "codex_cli.run_started") Process.put(:prompt_runner_cli_confirmation_audit, audit) IO.binwrite( log_io, "LLM_AUDIT_CONFIRMED source=#{audit.confirmation_source} confirmed_model=#{audit.confirmed_model} confirmed_reasoning=n/a\n" ) :ok _ -> :ok end end defp maybe_print_cli_confirmation(_event, _llm, _log_io), do: :ok defp extract_codex_cli_confirmation(%{data: data}) when is_map(data) do metadata = map_get(data, :metadata) %{ model: confirmation_model(data, metadata), reasoning_effort: confirmation_reasoning_effort(data, metadata) |> stringify_or_nil(), source: confirmation_source(data) } end defp extract_codex_cli_confirmation(_), do: nil defp confirmation_source(data) do map_get(data, :confirmation_source) || "codex_cli.run_started" end defp confirmation_model(data, metadata) do map_get(data, :confirmed_model) || map_get(data, :model) || map_get(metadata, :model) end defp confirmation_reasoning_effort(data, metadata) do map_get(data, :reasoning_effort) || map_get(data, :confirmed_reasoning_effort) || map_get(metadata, :reasoning_effort) || map_get(metadata, :reasoningEffort) || metadata_config_reasoning(metadata) end defp metadata_config_reasoning(metadata) do case map_get(metadata, :config) do config when is_map(config) -> map_get(config, :model_reasoning_effort) || map_get(config, :reasoning_effort) _ -> nil end end defp llm_summary(%{sdk: :codex} = llm) do case configured_codex_reasoning(llm) do nil -> "codex model=#{llm.model}" reasoning -> "codex model=#{llm.model} reasoning=#{reasoning} (configured)" end end defp llm_summary(llm), do: "#{llm.sdk} model=#{llm.model}" defp configured_codex_reasoning(llm) do case llm[:codex_thread_opts] do opts when is_map(opts) -> map_get(opts, :reasoning_effort) _ -> nil end end defp map_get(map, key) when is_map(map) do Map.get(map, key) || Map.get(map, Atom.to_string(key)) end defp map_get(_map, _key), do: nil defp mismatch?(configured, confirmed) when is_binary(configured) and configured != "" and is_binary(confirmed) and confirmed != "" do configured != confirmed end defp mismatch?(_configured, _confirmed), do: false defp cli_confirmation_mode(llm, config) do llm[:cli_confirmation] |> Kernel.||(config_cli_confirmation(config)) |> Kernel.||(:warn) |> normalize_cli_confirmation_mode() end defp config_cli_confirmation(%{cli_confirmation: value}), do: value defp normalize_cli_confirmation_mode(value) when value in [:off, :warn, :require], do: value defp normalize_cli_confirmation_mode(value) when is_binary(value) do value |> String.downcase() |> normalize_cli_confirmation_mode_from_string() end defp normalize_cli_confirmation_mode(_), do: :warn defp normalize_cli_confirmation_mode_from_string("off"), do: :off defp normalize_cli_confirmation_mode_from_string("warn"), do: :warn defp normalize_cli_confirmation_mode_from_string("require"), do: :require defp normalize_cli_confirmation_mode_from_string(_), do: :warn defp maybe_print_sdk_preflight(nil), do: :ok defp maybe_print_sdk_preflight(%{package: package, version: version, module: module}) do IO.puts( "LLM SDK preflight: package=#{package} version=#{version} module=#{module} loaded=true" ) end defp preflight_llm_dependency(%{sdk: provider} = llm) do # In tests and custom integrations, a custom llm_module may fully own provider setup. if llm_module() != PromptRunner.LLMFacade do {:ok, nil} else do_preflight(provider, llm) end end defp preflight_llm_dependency(_), do: {:ok, nil} defp do_preflight(provider, _llm) do case Map.fetch(@provider_deps, provider) do {:ok, {otp_app, primary_mod, extra_mods}} -> all_mods = [primary_mod | extra_mods] if Enum.all?(all_mods, &Code.ensure_loaded?/1) do {:ok, %{ package: Atom.to_string(otp_app), version: app_vsn(otp_app), module: inspect(primary_mod) }} else missing = all_mods |> Enum.reject(&Code.ensure_loaded?/1) |> Enum.map_join(", ", &inspect/1) {:error, {:provider_dependency_missing, %{ provider: provider, package: otp_app, missing_module: missing, hint: dep_hint(otp_app) }}} end :error -> {:ok, nil} end end defp dep_hint(otp_app) do version_spec = case app_vsn(otp_app) do "unknown" -> dep_fallback_spec(otp_app) vsn -> "~> #{vsn}" end "Add {#{inspect(otp_app)}, #{inspect(version_spec)}} to the deps in your mix.exs." end defp dep_fallback_spec(:codex_sdk), do: "~> 0.9.0" defp dep_fallback_spec(:claude_agent_sdk), do: "~> 0.13.0" defp dep_fallback_spec(:amp_sdk), do: "~> 0.3" defp dep_fallback_spec(_), do: ">= 0.0.0" @doc false @spec check_provider_dependency(atom()) :: {:ok, map() | nil} | {:error, term()} def check_provider_dependency(provider), do: do_preflight(provider, %{}) defp app_vsn(app) do case Application.spec(app, :vsn) do nil -> "unknown" vsn when is_list(vsn) -> List.to_string(vsn) vsn -> to_string(vsn) end end defp maybe_print_cli_mismatch(audit, log_io) do if cli_confirmation_mismatch?(audit) do warning_line = cli_mismatch_warning_line(audit) IO.puts(warning_line) IO.binwrite(log_io, "LLM_AUDIT_MISMATCH #{warning_line}\n") end end defp cli_confirmation_mismatch?(audit) do mismatch?(Map.get(audit, :configured_model), Map.get(audit, :confirmed_model)) or mismatch?( Map.get(audit, :configured_reasoning_effort), Map.get(audit, :confirmed_reasoning_effort) ) end defp cli_mismatch_warning_line(audit) do "WARNING: codex_cli confirmation mismatch configured_model=#{Map.get(audit, :configured_model) || "n/a"} configured_reasoning=#{Map.get(audit, :configured_reasoning_effort) || "n/a"} confirmed_model=#{Map.get(audit, :confirmed_model) || "n/a"} confirmed_reasoning=#{Map.get(audit, :confirmed_reasoning_effort) || "n/a"}" end defp initialize_cli_confirmation_tracking(%{sdk: :codex} = llm, config, log_io) do configured_reasoning = configured_codex_reasoning(llm) cli_confirmation = cli_confirmation_mode(llm, config) audit = %{ configured_model: llm.model, configured_reasoning_effort: if(is_nil(configured_reasoning), do: nil, else: to_string(configured_reasoning)), confirmed_model: nil, confirmed_reasoning_effort: nil, confirmation_source: nil, cli_confirmation: cli_confirmation } Process.put(:prompt_runner_cli_confirmation_printed, false) Process.put(:prompt_runner_cli_confirmation_audit, audit) IO.binwrite( log_io, "LLM_AUDIT configured_model=#{audit.configured_model || "n/a"} configured_reasoning=#{audit.configured_reasoning_effort || "n/a"} cli_confirmation=#{audit.cli_confirmation}\n" ) end defp initialize_cli_confirmation_tracking(_llm, _config, _log_io) do Process.put(:prompt_runner_cli_confirmation_printed, false) Process.put(:prompt_runner_cli_confirmation_audit, %{}) end defp maybe_finalize_cli_confirmation(result, %{sdk: :codex}, config, log_io) do audit = Process.get(:prompt_runner_cli_confirmation_audit, %{}) cli_confirmation = Map.get(audit, :cli_confirmation, cli_confirmation_mode(%{}, config)) details = cli_confirmation_details(audit) status = cli_confirmation_status(details) result = apply_cli_confirmation_policy(result, status, cli_confirmation, details, audit, log_io) write_cli_audit_result(log_io, status, details, cli_confirmation) result end defp maybe_finalize_cli_confirmation(result, _llm, _config, _log_io), do: result defp cli_confirmation_details(audit) do %{ configured_model: Map.get(audit, :configured_model), configured_reasoning: Map.get(audit, :configured_reasoning_effort), confirmed_model: Map.get(audit, :confirmed_model), confirmed_reasoning: Map.get(audit, :confirmed_reasoning_effort), confirmation_source: Map.get(audit, :confirmation_source) } end defp cli_confirmation_status(%{ configured_model: configured_model, configured_reasoning: configured_reasoning, confirmed_model: confirmed_model, confirmed_reasoning: confirmed_reasoning }) do cond do mismatch?(configured_model, confirmed_model) or mismatch?(configured_reasoning, confirmed_reasoning) -> :mismatch configured_reasoning != nil and confirmed_reasoning in [nil, ""] -> :missing true -> :matched end end defp apply_cli_confirmation_policy(result, status, cli_confirmation, details, audit, log_io) do case {status, cli_confirmation} do {:mismatch, :require} -> {:error, {:cli_confirmation_mismatch, details}} {:missing, :require} -> {:error, {:cli_confirmation_missing, details}} {:mismatch, :warn} -> maybe_print_cli_mismatch(audit, log_io) result {:missing, :warn} -> maybe_print_cli_missing_warning(details, log_io) result _ -> result end end defp maybe_print_cli_missing_warning(details, log_io) do warning_line = "WARNING: codex_cli confirmation missing reasoning_effort configured_model=#{details.configured_model || "n/a"} configured_reasoning=#{details.configured_reasoning || "n/a"}" IO.puts(warning_line) IO.binwrite(log_io, "LLM_AUDIT_WARNING #{warning_line}\n") end defp write_cli_audit_result(log_io, status, details, cli_confirmation) do IO.binwrite( log_io, "LLM_AUDIT_RESULT status=#{status} configured_model=#{details.configured_model || "n/a"} configured_reasoning=#{details.configured_reasoning || "n/a"} confirmed_model=#{details.confirmed_model || "n/a"} confirmed_reasoning=#{details.confirmed_reasoning || "n/a"} source=#{details.confirmation_source || "n/a"} cli_confirmation=#{cli_confirmation}\n" ) end defp stringify_or_nil(nil), do: nil defp stringify_or_nil(value), do: to_string(value) defp write_session_header(log_io, config, llm, llm_meta, prompt_path) do header = if config.log_mode == :compact do tools_label = if is_list(llm.allowed_tools) and llm.allowed_tools != [] do Enum.join(llm.allowed_tools, ",") else "n/a" end "Session: sdk=#{llm_meta.sdk} model=#{llm_meta.model} tools=[#{tools_label}] cwd=#{llm_meta.cwd}\n" else "Session: #{inspect(llm_meta)}\n" end header = header <> "Prompt: #{prompt_path}\nProject: #{llm.cwd}\n\n" IO.binwrite(log_io, header) end defp finalize_stream_result(:ok, config, prompt, llm, skip_commit) do commit_info = if skip_commit do {:skip, :no_commit} else commit_prompt(config, prompt, llm) end Progress.mark_completed(config, prompt.num, commit_info) if config.log_mode == :studio do IO.puts(UI.green(" ✓ Prompt #{prompt.num} completed")) else IO.puts(UI.green("LLM completed successfully")) IO.puts(UI.green("Prompt #{prompt.num} completed")) end :ok end defp finalize_stream_result({:error, reason}, config, prompt, _llm, _skip_commit) do return_error(config, prompt.num, reason) end defp commit_prompt(config, prompt, _llm) do target_repos = resolve_target_repos(config, prompt) if length(target_repos) > 1 do Git.commit_multi_repo(config, prompt.num, target_repos) else [{repo_name, repo_path}] = target_repos Git.commit_single_repo(config, prompt.num, repo_name, repo_path) end end defp resolve_target_repos(config, prompt) do case prompt.target_repos do nil -> case get_default_repo(config) do %{name: name, path: path} -> [{name, path}] _ -> [{"default", config.project_dir}] end repos when is_list(repos) -> unless is_list(config.target_repos) do raise "Prompt #{prompt.num} defines target_repos but config.target_repos is not configured" end resolved_repos = repos |> RepoTargets.expand!(config.repo_groups) |> List.wrap() if resolved_repos == [] do raise "Prompt #{prompt.num} did not resolve any target repos from: #{Enum.join(repos, ", ")}" end Enum.map(resolved_repos, &resolve_repo_name_path(&1, config, prompt)) end end defp resolve_repo_name_path(repo_name, config, prompt) do case get_repo_path(config, repo_name) do nil -> raise "Repo not configured for prompt #{prompt.num}: #{repo_name}" path -> {repo_name, path} end end defp get_repo_path(config, repo_name) do case config.target_repos do repos when is_list(repos) -> case Enum.find(repos, &(&1.name == repo_name)) do %{path: path} -> path _ -> nil end _ -> nil end end defp get_default_repo(config) do case config.target_repos do repos when is_list(repos) -> Enum.find(repos, &(&1.default == true)) || List.first(repos) _ -> nil end end defp format_commit_suffix(nil), do: "" defp format_commit_suffix("no_commit"), do: " (no_commit)" defp format_commit_suffix("no_changes"), do: " (no_changes)" defp format_commit_suffix(commit) when is_binary(commit) do if String.contains?(commit, "=") do count = commit |> String.split(",") |> length() " (#{count} repos)" else short = String.slice(commit, 0, 8) " (sha #{short})" end end defp format_repos_suffix(nil), do: "" defp format_repos_suffix([]), do: "" defp format_repos_suffix(repos) when is_list(repos) do " #{UI.dim("[#{Enum.join(repos, ",")}]")}" end defp llm_module do Application.get_env(:prompt_runner, :llm_module, PromptRunner.LLMFacade) end end