defmodule Cmd.Ask do # Universal default: auto-deny after 180 seconds if no explicit auto flag @default_auto_policy {:deny, 180_000} @behaviour Cmd @impl Cmd def requires_project?(), do: true @impl Cmd def spec() do [ ask: [ name: "ask", about: "Ask the AI a question about the project", options: [ project: Cmd.project_arg(), question: [ value_name: "QUESTION", long: "--question", short: "-q", help: "The prompt to ask the AI", required: true ], auto_approve_after: [ value_name: "SECONDS", long: "--auto-approve-after", short: "-A", help: "After notification, auto-APPROVE in SECONDS if no input. SECONDS must be a positive integer. Default: auto-DENY after 180 seconds.", parser: :integer, required: false ], auto_deny_after: [ value_name: "SECONDS", long: "--auto-deny-after", short: "-D", help: "After notification, auto-DENY in SECONDS if no input. SECONDS must be a positive integer. Default: auto-DENY after 180 seconds.", parser: :integer, required: false ], worktree: [ value_name: "WORKTREE", long: "--worktree", short: "-W", help: "Override project source root for this run", parser: :string, required: false ], follow: [ value_name: "UUID", long: "--follow", short: "-f", help: "Continue an existing conversation by UUID", required: false ], fork: [ value_name: "UUID", long: "--fork", short: "-F", help: "Fork (branch) an existing conversation by UUID", required: false ], frippery: [ value_name: "LEVEL", long: "--frippery", short: "-V", help: "Set the model verbosity level (low, medium, high)", required: false ], reasoning: [ value_name: "LEVEL", long: "--reasoning", short: "-R", help: "Set the AI's reasoning level (minimal, low, medium, high)", required: false ], tee: [ value_name: "FILE", long: "--tee", short: "-t", help: "Write a clean (no ANSI) transcript to FILE. " <> "Prompts before overwriting an existing file (fails non-interactively).", parser: :string, required: false ], tee_force: [ value_name: "FILE", long: "--TEE", short: "-T", help: "Like --tee, but truncates an existing file without prompting.", parser: :string, required: false ] ], flags: [ quiet: Cmd.quiet_flag(), save: [ long: "--save", short: "-S", help: """ Saves the response to ~/fnord/outputs//.md """, default: false ], replay: [ long: "--replay", short: "-r", help: "Replay a conversation (with --follow)", default: false ], edit: [ long: "--edit", short: "-e", help: "Permit the AI to edit files in the project", default: false ], yes: [ long: "--yes", short: "-y", help: "Automatically approve edit/manage prompts (requires --edit)", default: false, multiple: true ], fonz: [ long: "--eyyyy", help: "", default: false ], smart: [ long: "--smart", short: "-s", default: false, help: """ Use a pricier model, trading speed and cash for improved accuracy on large, complex tasks """ ] ] ] ] end @impl Cmd def run(opts, _subcommands, _unknown) do unless UI.quiet?() do UI.info("fnord version", Util.get_running_version()) end # Start transcript tee before any output we want to capture maybe_start_tee(opts) opts = if opts[:edit] do UI.warning_banner("EDITING MODE ENABLED! THE AI CAN MODIFY FILES. YOU MUST BE NUTS.") # They obviously already know if they are using --worktree unless opts[:worktree] do UI.warn("You can work from a git worktree with --worktree .") end opts else Map.put(opts, :edit, false) end opts = maybe_enable_fonz_mode(opts) # Handle --yes auto-approval flag if opts[:yes] == true or (is_integer(opts[:yes]) and opts[:yes] > 0) do if opts[:edit] do UI.warning_banner("AUTO-CONFIRMATION ENABLED FOR CODE EDIT PROMPTS.") UI.warning_banner("ALL YOU'VE *REALLY* AUTO-CONFIRMED IS THAT YOU ARE INDEED NUTS.") UI.warning_banner("TERMINATOR WAS A WARNING, NOT A SUGGESTION.") else UI.warn("--yes has no effect unless you also pass --edit; ignoring") end end # Handle --auto-approve-after warning unless is_nil(opts[:auto_approve_after]) do UI.warning_banner("APPROVALS WILL BE GRANTED AFTER #{opts[:auto_approve_after]} SECONDS.") UI.warning_banner("MAY YOUR FUTURE SELF FORGIVE YOU FOR THIS DECISION.") UI.warning_banner("...AND MAY THE ON-CALL HAVE MERCY ON YOUR SOUL.") end # Start the local embedding model pool. Must happen before indexers since # they depend on AI.Embeddings.Pool for embedding generation. AI.Embeddings.Pool.ensure_started() AI.Embeddings.Migration.maybe_migrate(:ask) # Start silent background indexers. This must happen BEFORE any project # root override is applied, so that the indexers use the correct root. # MemoryIndexer and ConversationIndexer are independent: the memory # indexer self-scans for unprocessed session memories rather than relying # on the conversation indexer to feed it work. file_indexer_pid = start_file_indexer() commit_indexer_pid = start_commit_indexer() conversation_indexer_pid = start_conversation_indexer() start_memory_indexer() start_time = System.monotonic_time(:second) try do with {:ok, opts} <- validate(opts), :ok <- set_auto_policy(opts), :ok <- cache_original_source_root(), :ok <- set_worktree(opts), {:ok, opts} <- maybe_fork_conversation(opts), {:ok, pid} <- Services.Conversation.start_link(opts[:follow]), {:ok, _pid} <- Services.Task.start_link(conversation_pid: pid), :ok <- ( Services.Globals.put_env(:fnord, :current_conversation, pid) :ok ), :ok <- Memory.init(), {:ok, worktree_path} <- prepare_conversation_worktree(opts, pid), {:ok, usage, context, response} <- get_response(opts, pid), {:ok, conversation_id} <- save_conversation(pid) do end_time = System.monotonic_time(:second) # Re-read the worktree path: it may have been created by the # coordinator during the session via git_worktree_tool, after # prepare_conversation_worktree returned nil. effective_worktree_path = Settings.get_project_root_override() || worktree_path # Auto-commit any uncommitted state in the worktree first, so the # subsequent has_changes_to_merge? check sees a complete picture # (uncommitted -> committed -> branch ahead of base). edit_mode? = opts[:edit] == true maybe_auto_commit(effective_worktree_path, edit_mode?, pid) # Discard empty worktrees (no edits made). The coordinator may have # speculatively created one even though no file changes were needed. # A fresh worktree can be created on the next --follow. effective_worktree_path = maybe_discard_empty_worktree(effective_worktree_path, pid) # Source-of-truth gate for the merge flow: edit mode is on AND the # worktree actually has work to merge (uncommitted changes or commits # ahead of base). This replaces the prior editing_tools_used heuristic, # which was a proxy that missed cmd_tool heredocs, frob writes, and # any tool the heuristic didn't enumerate. needs_merge? = edit_mode? and worktree_has_changes_to_merge?(effective_worktree_path, pid) UI.debug( "worktree", "post-ask: path=#{inspect(effective_worktree_path)} needs_merge=#{needs_merge?}" ) # Run worktree review (merge prompt) BEFORE printing the final # response so the diff + prompts don't push the response off screen. auto_merge? = opts[:yes] == true or (is_integer(opts[:yes]) and opts[:yes] > 0) worktree_status = maybe_worktree_review(effective_worktree_path, needs_merge?, pid, auto_merge?) final_worktree_path = final_worktree_path(effective_worktree_path, worktree_status) if is_nil(final_worktree_path) do Settings.set_project_root_override(nil) end maybe_save_output(opts, conversation_id, response) copied_to_clipboard? = copied_to_clipboard?(Util.Clipboard.copy(conversation_id), conversation_id) print_result( start_time, end_time, response, usage, context, conversation_id, final_worktree_path, copied_to_clipboard?, worktree_status ) unless UI.quiet?() do Notifier.notify("Fnord response ready", opts.question) end :ok else {:error, :testing} -> :ok {:error, :invalid_worktree} -> UI.error("--worktree must be an existing directory") {:error, :invalid_worktree} {:error, :auto_approval_mutually_exclusive} -> UI.error("--auto-approve-after and --auto-deny-after are mutually exclusive") {:error, :auto_approval_mutually_exclusive} {:error, :invalid_auto_approve_after} -> UI.error("--auto-approve-after must be a positive integer") {:error, :invalid_auto_approve_after} {:error, :invalid_auto_deny_after} -> UI.error("--auto-deny-after must be a positive integer") {:error, :invalid_auto_deny_after} {:error, :conversation_not_found} -> missing_id = opts[:fork] || opts[:follow] || opts[:conversation] UI.error("Conversation ID #{missing_id} not found") {:error, :conversation_not_found} {:error, {:conversation_worktree_exists, path}} -> UI.error("This conversation already has an associated worktree at #{path}") {:error, {:conversation_worktree_exists, path}} {:error, other} -> UI.error("An error occurred while generating the response:\n\n#{other}") {:error, other} end after # stop background indexers if still running; memory indexer is left to # run until the BEAM exits so it can complete light/deep sleep passes stop_file_indexer(file_indexer_pid) stop_commit_indexer(commit_indexer_pid) stop_conversation_indexer(conversation_indexer_pid) Services.BackupFile.offer_cleanup() # Flush in-session facts (ingested via user-msg and tool-call casts) to # disk as a `# NEW NOTES (unconsolidated)` section before we release the # session. Consolidation on the next session turns these into the main # notes body. Without this flush, new facts live only in the GenServer's # in-memory state and vanish when the BEAM exits, leaving projects that # have never had a notes.md stuck in a permanent "prime me" loop. UI.spin(build_notes_spinner_label(), fn -> Services.Notes.save() Services.Notes.join() {"Notes finalized", :ok} end) # Flush and close the tee file last, after all other output UI.Tee.stop() end end # ---------------------------------------------------------------------------- # Tee (transcript) setup # ---------------------------------------------------------------------------- # Resolve --tee / --TEE into {path, force?}, then guard against # overwriting an existing file. --tee prompts interactively (fails # non-interactively); --TEE truncates without asking. defp maybe_start_tee(%{tee: path}) when is_binary(path), do: start_tee(path, false) defp maybe_start_tee(%{tee_force: path}) when is_binary(path), do: start_tee(path, true) defp maybe_start_tee(_opts), do: :ok defp start_tee(path, force?) do with :ok <- guard_existing_tee_file(path, force?) do case UI.Tee.start_link(path) do {:ok, _pid} -> # Use Elixir's Logger.Formatter (not Erlang's :logger_formatter) # with colors disabled so the tee file gets plain text. ANSI # codes from UI formatting are stripped by UI.Tee.write/1. {_mod, formatter_config} = Logger.Formatter.new( format: "[$level] $message\n", colors: [enabled: false] ) :logger.add_handler(:tee, UI.Tee.LoggerHandler, %{ level: :all, formatter: {Logger.Formatter, formatter_config} }) UI.info("Tee", "Transcript will be written to #{path}") {:error, reason} -> UI.error("Failed to open tee file #{path}: #{inspect(reason)}") end end end # If the file exists and has content, either prompt (interactive) or bail # (non-interactive). Force mode skips this entirely. defp guard_existing_tee_file(path, true = _force) do case File.stat(path) do {:ok, %{size: size}} when size > 0 -> UI.warn("Truncating existing tee file #{path} (#{size} bytes)") _ -> :ok end end defp guard_existing_tee_file(path, false) do case File.stat(path) do {:ok, %{size: size}} when size > 0 -> if UI.is_tty?() do if UI.confirm("Tee file #{path} already exists (#{size} bytes). Overwrite?") do :ok else UI.info("Tee", "Skipping transcript (user declined overwrite)") :skip end else UI.error("Tee file #{path} already exists. Use --TEE / -T to overwrite.") :skip end _ -> :ok end end # ---------------------------------------------------------------------------- # Validation # ---------------------------------------------------------------------------- defp validate(opts) do with :ok <- validate_conversation(opts), :ok <- validate_auto(opts), :ok <- validate_worktree(opts) do {:ok, opts} end end # Validate mutual exclusion and positivity of auto flags @spec validate_auto(map) :: :ok | {:error, atom | binary} def validate_auto(opts) do case {opts[:auto_approve_after], opts[:auto_deny_after]} do {a, d} when not is_nil(a) and not is_nil(d) -> {:error, :auto_approval_mutually_exclusive} {a, _} when not is_nil(a) and a <= 0 -> {:error, :invalid_auto_approve_after} {_, d} when not is_nil(d) and d <= 0 -> {:error, :invalid_auto_deny_after} _ -> :ok end end @spec validate_worktree(map) :: :ok | {:error, :invalid_worktree} defp validate_worktree(%{worktree: dir}) when is_binary(dir) do path = Path.expand(dir) if File.dir?(path) do :ok else {:error, :invalid_worktree} end end defp validate_worktree(_), do: :ok @spec validate_conversation(map) :: :ok | {:error, :conversation_not_found} defp validate_conversation(%{follow: id}) when is_binary(id) do id |> Store.Project.Conversation.new() |> Store.Project.Conversation.exists?() |> case do true -> :ok false -> {:error, :conversation_not_found} end end defp validate_conversation(_opts), do: :ok # ---------------------------------------------------------------------------- # Services # ---------------------------------------------------------------------------- defp start_file_indexer() do case Services.BackgroundIndexer.start_link() do {:ok, pid} -> pid _ -> nil end end defp start_commit_indexer() do if GitCli.is_git_repo?() do case Services.CommitIndexer.start_link() do {:ok, pid} -> pid _ -> nil end else nil end end defp start_conversation_indexer() do case Services.ConversationIndexer.start_link() do {:ok, pid} -> pid _ -> nil end end defp start_memory_indexer() do case Services.Instance.whereis(Services.MemoryIndexer) do nil -> try do Services.MemoryIndexer.start_link([]) rescue e -> UI.debug("ask", "MemoryIndexer start failed: #{Exception.message(e)}") :ok end _ -> :ok end end defp stop_file_indexer(pid) do if is_pid(pid) && Process.alive?(pid) do Services.BackgroundIndexer.stop(pid) end end defp stop_commit_indexer(pid) do if is_pid(pid) && Process.alive?(pid) do Services.CommitIndexer.stop(pid) end end defp stop_conversation_indexer(pid) do if is_pid(pid) && Process.alive?(pid) do Services.ConversationIndexer.stop(pid) end end # ---------------------------------------------------------------------------- # Original source root caching # ---------------------------------------------------------------------------- # Caches the project's source root (as configured in settings.json) into # Services.Globals. Tools that need to access files outside the worktree # (e.g. file_contents_tool's source-fallback for gitignored files) read it # via Store.Project.original_source_root/0. # # Reads settings directly rather than going through Store.get_project/0 so # that any worktree override already installed by an external caller (e.g. # a test fixture, or `--worktree`) does not poison the cached value. @spec cache_original_source_root() :: :ok defp cache_original_source_root do with {:ok, project} <- Store.get_project(), data when is_map(data) <- Settings.get_project_data(Settings.new(), project.name), root when is_binary(root) <- Map.get(data, "root") do Services.Globals.put_env(:fnord, :original_project_root, Path.expand(root)) :ok else _ -> :ok end end # ---------------------------------------------------------------------------- # Worktree setting # ---------------------------------------------------------------------------- # 1) explicit override via --worktree/-W defp set_worktree(%{worktree: dir}) when is_binary(dir) do Settings.set_project_root_override(dir) UI.info("Project root overridden for session", dir) end # 2) no explicit override: detect a mismatched git worktree defp set_worktree(opts) do case Store.get_project() do {:ok, project} -> if opts[:project] do UI.info("Skipping worktree detection due to explicit project flag") :ok else if GitCli.is_worktree?() do wt_root = GitCli.worktree_root() if wt_root && wt_root != project.source_root do if UI.is_tty?() && UI.stdout_tty?() do msg = """ You are working on project "#{project.name}", which is rooted at: #{project.source_root} Your current directory is inside a git worktree at: #{wt_root} Would you like to set the project root to #{wt_root} for this run? (equivalent to passing: --worktree #{wt_root}) """ if UI.confirm(msg, false) do Settings.set_project_root_override(wt_root) UI.info("Project root overridden for session", wt_root) end else UI.warn(""" WARNING: Detected git worktree at #{wt_root} which differs from the configured project root: #{project.source_root} To operate from this worktree, re-run with: fnord ask --edit --worktree #{wt_root} """) end end end :ok end _ -> :ok end end # Restores the hidden `--eyyyy` ask flag by mapping it onto the current # repeated-`--yes` trigger for fonz mode, so the conversation service still # selects the Fonz persona. @spec maybe_enable_fonz_mode(map) :: map defp maybe_enable_fonz_mode(%{fonz: true, edit: true} = opts) do yes_count = case Map.get(opts, :yes, false) do n when is_integer(n) and n > 1 -> n _ -> 2 end Settings.set_auto_approve(true) Settings.set_yes_count(yes_count) Map.put(opts, :yes, yes_count) end defp maybe_enable_fonz_mode(%{fonz: true} = opts) do UI.warn("--yes has no effect unless you also pass --edit; ignoring") opts end defp maybe_enable_fonz_mode(opts), do: opts # ----------------------------------------------------------------------------- # Auto-approval policy # ----------------------------------------------------------------------------- def set_auto_policy(%{auto_approve_after: seconds}) when is_integer(seconds) do Settings.set_auto_policy({:approve, seconds * 1_000}) end def set_auto_policy(%{auto_deny_after: seconds}) when is_integer(seconds) do Settings.set_auto_policy({:deny, seconds * 1_000}) end def set_auto_policy(_opts) do # No explicit auto flags: apply universal default auto-deny policy Settings.set_auto_policy(@default_auto_policy) end # ----------------------------------------------------------------------------- # Forking a conversation # ----------------------------------------------------------------------------- defp maybe_fork_conversation(%{fork: fork_id} = opts) when is_binary(fork_id) do fork_conv = Store.Project.Conversation.new(fork_id) if Store.Project.Conversation.exists?(fork_conv) do with {:ok, new_conv} <- Store.Project.Conversation.fork(fork_conv) do UI.info("Conversation #{fork_id} forked as #{new_conv.id}") maybe_handle_forked_worktree(new_conv, opts) {:ok, Map.put(opts, :follow, new_conv.id)} end else {:error, :conversation_not_found} end end defp maybe_fork_conversation(opts), do: {:ok, opts} @reuse_worktree "Reuse existing worktree" @duplicate_worktree "Duplicate worktree (independent branch from same start point)" @new_worktree "Create new worktree" @no_worktree "No worktree" # When a forked conversation inherits worktree metadata from its source, we # may prompt (TTY only) for how to proceed with the inherited worktree: # - Reuse existing worktree: keep metadata as-is # - Duplicate worktree: create an independent branch + path, replace metadata # - Create new worktree / No worktree: strip metadata entirely so bootstrap can # offer a fresh worktree on next session # # This mutation (or deliberate removal) of metadata occurs before the # conversation server starts so the coordinator naturally observes the correct # state in worktree_context_msg/1 without needing out-of-band fixes. defp maybe_handle_forked_worktree(new_conv, opts) do # Also covers the auto-detect case: set_worktree/1 runs before fork handling # and sets the override when the user confirms the "you're in a worktree" # prompt, but does not update opts. Falling back to the override here means # both -W and confirmed auto-detect suppress the fork worktree menu. explicit_path = explicit_worktree_path(opts) || Settings.get_project_root_override() with {:ok, data} <- Store.Project.Conversation.read(new_conv), meta when is_map(meta) <- extract_forked_worktree_meta(data.metadata), # Skip the prompt when a worktree path is already decided: either -W # was explicit or the user confirmed the auto-detect prompt above. # resolve_conversation_worktree handles path matching/reuse downstream. false <- is_binary(explicit_path), true <- UI.is_tty?() do case UI.choose( "Source conversation has a worktree at #{meta.path}. What would you like to do?", [@reuse_worktree, @duplicate_worktree, @new_worktree, @no_worktree] ) do @reuse_worktree -> :ok @duplicate_worktree -> duplicate_forked_worktree(new_conv, data, meta) choice when choice in [@new_worktree, @no_worktree] -> strip_worktree_metadata(new_conv, data) {:error, :no_tty} -> :ok end else _ -> :ok end end # Duplicates the source worktree onto a new branch bound to the forked # conversation. On success, replaces inherited metadata with the new # worktree's metadata. If duplication fails, we warn and strip metadata so the # coordinator can offer a fresh worktree on the next session. This explicitly # avoids silently falling back to "reuse" (which could race the source and the # fork against the same files). defp duplicate_forked_worktree(new_conv, data, source_meta) do with {:ok, project} <- Store.get_project(), {:ok, entry} <- GitCli.Worktree.duplicate(project.name, source_meta, new_conv.id) do new_meta = %{ path: entry.path, branch: entry.branch, base_branch: entry.base_branch } updated_metadata = data.metadata |> Map.delete("worktree") |> Map.put(:worktree, new_meta) Store.Project.Conversation.write(new_conv, %{data | metadata: updated_metadata}) UI.info("Duplicated worktree", "#{entry.path} (branch #{entry.branch})") :ok else {:error, reason} -> UI.warn("Failed to duplicate worktree: #{inspect(reason)}") strip_worktree_metadata(new_conv, data) end end defp extract_forked_worktree_meta(metadata) when is_map(metadata) do meta = metadata |> GitCli.Worktree.normalize_worktree_meta_in_parent() |> Map.get(:worktree) case meta do %{path: path} when is_binary(path) -> meta _ -> nil end end defp strip_worktree_metadata(conv, data) do updated_metadata = data.metadata |> Map.delete(:worktree) |> Map.delete("worktree") Store.Project.Conversation.write(conv, %{ data | metadata: updated_metadata }) :ok end # ---------------------------------------------------------------------------- # Agent response # ---------------------------------------------------------------------------- @spec get_response(map, pid) :: {:ok, non_neg_integer, non_neg_integer, binary} | {:error, any} defp get_response(opts, conversation_server) do opts |> get_agent_response(conversation_server) |> case do {:ok, %{usage: usage, context: context, last_response: res}} -> {:ok, usage, context, res} {:error, reason} -> {:error, reason} end end @spec get_agent_response(map, pid) :: {:ok, map} | {:error, any} defp get_agent_response(opts, conversation_server) do Services.Conversation.get_response(conversation_server, edit: opts.edit, question: opts.question, replay: Map.get(opts, :replay, false), yes: Map.get(opts, :yes, false), verbosity: Map.get(opts, :frippery, nil), fonz: Map.get(opts, :fonz, false) ) end # ---------------------------------------------------------------------------- # Output # ---------------------------------------------------------------------------- defp print_result( start_time, end_time, response, usage, context, conversation_id, worktree_path, copied_to_clipboard?, worktree_status ) do time_taken = end_time - start_time duration = Util.Duration.format(time_taken) pct_context_used = if usage > 0 and context > 0 do Float.round(usage / context * 100, 2) else 0.0 end usage_str = Util.format_number(usage) context_str = Util.format_number(context) {:ok, project} = Store.get_project() %{new: new, stale: stale, deleted: deleted} = Store.Project.index_status(project) worktree_summary = format_worktree_summary(worktree_path) UI.say(""" #{response} ----- ### Response Summary: - Response generated in #{duration} - Tokens used: #{usage_str} | #{pct_context_used}% of context window (#{context_str}) - Conversation saved with ID #{conversation_id}#{clipboard_summary(copied_to_clipboard?)}#{worktree_summary} ### Index Status: - Stale: #{Enum.count(stale)} - New: #{Enum.count(new)} - Deleted: #{Enum.count(deleted)} - Memory: #{count_memories(:session)} session; #{count_memories(:project)} project; #{count_memories(:global)} global#{format_search_stats()} """) print_worktree_status(worktree_status, project.source_root) UI.flush() end defp print_worktree_status({:merged, range, mode}, root) do label = case mode do :interactive -> "Worktree changes merged successfully" :auto -> "Worktree changes auto-merged because --yes was specified" end header = IO.ANSI.format([:green, :bright, "✓ #{label}", :reset], true) commits = merged_commit_lines(range, root) output = ["\n", header, "\n", commits, "\n"] IO.write(:stderr, output) UI.Tee.write(output) end defp print_worktree_status(:unmerged, _root) do line = IO.ANSI.format( [:yellow, :bright, "⚠ Worktree changes were not merged", :reset], true ) IO.puts(:stderr, "\n#{line}\n") UI.Tee.write(["\n", line, "\n\n"]) end defp print_worktree_status(:no_changes, _root) do line = IO.ANSI.format( [:light_black, :italic, "(no file changes during this session)", :reset], true ) IO.puts(:stderr, "\n#{line}\n") UI.Tee.write(["\n", line, "\n\n"]) end # Formats the list of commits included in a worktree merge as indented # one-line summaries. defp merged_commit_lines({from, to}, root) when is_binary(from) and is_binary(to) do case GitCli.Worktree.log_oneline(root, from, to) do [] -> "" lines -> Enum.map_join(lines, "\n", &" #{&1}") <> "\n" end end defp merged_commit_lines(_, _), do: "" defp copied_to_clipboard?(copied_value, conversation_id) when is_binary(conversation_id) do IO.iodata_to_binary(copied_value) == conversation_id rescue ArgumentError -> false end defp clipboard_summary(true), do: " (_copied to clipboard_)" defp clipboard_summary(false), do: "" @spec prepare_conversation_worktree(map, pid) :: {:ok, String.t() | nil} | {:error, term} defp prepare_conversation_worktree(opts, conversation_pid) do metadata = Services.Conversation.get_conversation_meta(conversation_pid) explicit_path = explicit_worktree_path(opts) stored_meta = worktree_meta(metadata) with {:ok, project} <- Store.get_project(), {:ok, path} <- resolve_conversation_worktree(project, conversation_pid, explicit_path, stored_meta) do maybe_auto_create_worktree(opts, project, conversation_pid, path) end end # In edit mode, auto-create the worktree before the coordinator bootstraps. # This ensures the GIT_INFO templated into the coordinator prompt reflects # the worktree branch and root, not the source repo's. Without this, # the LLM sees stale "current branch" info that contradicts the worktree # context message. @spec maybe_auto_create_worktree(map, any, pid, String.t() | nil) :: {:ok, String.t() | nil} | {:error, term} defp maybe_auto_create_worktree(_opts, _project, _pid, path) when is_binary(path) do {:ok, path} end defp maybe_auto_create_worktree(opts, project, conversation_pid, nil) do if opts[:edit] == true and GitCli.is_git_repo?() do conversation_id = Services.Conversation.get_id(conversation_pid) case GitCli.Worktree.create(project.name, conversation_id, nil) do {:ok, result} -> meta = %{ path: result.path, branch: result.branch, base_branch: result.base_branch } with :ok <- Services.Conversation.upsert_conversation_meta(conversation_pid, %{ worktree: meta }) do Settings.set_project_root_override(result.path) UI.info("Auto-created worktree", result.path) {:ok, result.path} end {:error, reason} -> UI.warn("Failed to auto-create worktree: #{inspect(reason)}") {:ok, nil} end else {:ok, nil} end end @type worktree_meta :: %{ path: String.t(), branch: String.t() | nil, base_branch: String.t() | nil } @spec resolve_conversation_worktree( any, pid, String.t() | nil, worktree_meta | nil ) :: {:ok, String.t() | nil} | {:error, {:conversation_worktree_exists, String.t() | nil}} # No explicit path and no stored metadata. Check whether a worktree exists # on disk at the default location for this conversation (handles the case # where a prior session created the worktree but metadata was lost, e.g., # due to SIGTERM before conversation save). defp resolve_conversation_worktree(project, conversation_pid, nil, nil) do conv_id = Services.Conversation.get_id(conversation_pid) path = GitCli.Worktree.conversation_path(project.name, conv_id) if File.dir?(path) do UI.info("Adopting orphaned worktree", path) # default_base_branch/1 is guarded on is_binary(root). GitCli.repo_root/0 # returns nil when cwd is not inside any git repo (e.g. `ask` run from # a non-repo cwd with an explicit --project flag). Use the cached # original source root instead so adoption stays anchored to the # project, not the shell's cwd. base = case Store.Project.original_source_root() do root when is_binary(root) -> GitCli.Worktree.default_base_branch(root) _ -> nil end # A user who created the worktree out-of-band (e.g. via a manual # `git worktree add -b feat `) won't be on fnord-. # Honor whatever branch HEAD actually points at so the metadata matches # reality; fall back to the synthesized name only if we can't read HEAD. branch = GitCli.Worktree.current_branch(path) || "fnord-#{conv_id}" meta = %{path: path, branch: branch, base_branch: base} with :ok <- Services.Conversation.upsert_conversation_meta(conversation_pid, %{worktree: meta}) do Settings.set_project_root_override(path) {:ok, path} end else {:ok, nil} end end # Explicit path provided but no stored metadata: adopt the explicit path by # creating minimal worktree metadata and setting the project root override. defp resolve_conversation_worktree(_project, conversation_pid, path, nil) when is_binary(path) do meta = %{path: path, branch: nil, base_branch: nil} with :ok <- Services.Conversation.upsert_conversation_meta(conversation_pid, %{worktree: meta}) do Settings.set_project_root_override(path) {:ok, path} end end # Explicit path conflicts with stored metadata: prefer the stored association # by setting the override to the stored path and signal the conflict to the # caller so they can surface appropriate UX. defp resolve_conversation_worktree(project, conversation_pid, path, stored_meta) when is_binary(path) and is_map(stored_meta) do stored_path = worktree_path(stored_meta) if Path.expand(path) == Path.expand(stored_path) do # Re-specifying the same worktree that's already stored (e.g. `-W .` on # continuation). Not a conflict - just reuse. recreate_or_reuse_worktree(project, conversation_pid, stored_path, stored_meta) else Settings.set_project_root_override(stored_path) {:error, {:conversation_worktree_exists, stored_path}} end end defp resolve_conversation_worktree(project, conversation_pid, nil, stored_meta) when is_map(stored_meta) do stored_path = worktree_path(stored_meta) recreate_or_reuse_worktree(project, conversation_pid, stored_path, stored_meta) end @spec recreate_or_reuse_worktree(any, pid, String.t(), worktree_meta) :: {:ok, String.t()} | {:error, atom() | term()} defp recreate_or_reuse_worktree(project, conversation_pid, path, stored_meta) when is_binary(path) do case File.dir?(path) do true -> Settings.set_project_root_override(path) {:ok, path} false -> recreate_conversation_worktree(project, conversation_pid, stored_meta) end end @spec recreate_conversation_worktree(any, pid, worktree_meta) :: {:ok, String.t()} | {:error, atom() | term()} defp recreate_conversation_worktree(project, conversation_pid, stored_meta) do case GitCli.Worktree.recreate_conversation_worktree( project.name, Services.Conversation.get_id(conversation_pid), GitCli.Worktree.normalize_worktree_meta(stored_meta) ) do {:ok, meta} -> :ok = Services.Conversation.upsert_conversation_meta(conversation_pid, %{worktree: meta}) Settings.set_project_root_override(meta.path) {:ok, meta.path} other -> other end end defp explicit_worktree_path(%{worktree: path}) when is_binary(path), do: path defp explicit_worktree_path(_), do: nil # Extracts and normalizes worktree metadata from conversation metadata. # Conversation metadata may arrive with string keys (from JSON deserialization) # or atom keys (from in-memory state), so we normalize before returning. @spec worktree_meta(map) :: worktree_meta | nil defp worktree_meta(meta) when is_map(meta) do raw = Map.get(meta, :worktree) || Map.get(meta, "worktree") case raw do nil -> nil m when is_map(m) -> GitCli.Worktree.normalize_worktree_meta(m) end end @spec worktree_path(worktree_meta) :: String.t() defp worktree_path(%{path: path}) when is_binary(path), do: path @spec final_worktree_path(String.t() | nil, worktree_status()) :: String.t() | nil defp final_worktree_path(_path, {:merged, _sha, _mode}), do: nil defp final_worktree_path(path, _status), do: path defp format_worktree_summary(nil), do: "" defp format_worktree_summary(path), do: "\n- Worktree path: #{path}" defp count_memories(scope) do case Memory.list(scope) do {:ok, titles} -> length(titles) _ -> 0 end end defp format_search_stats do case Memory.search_stats() do nil -> "" {count, avg_ms} -> "\n - Recall: #{count} searches, avg #{avg_ms} ms" end end defp save_conversation(pid) do with {:ok, conversation} <- Services.Conversation.save(pid) do UI.debug("Conversation saved to file", conversation.store_path) UI.report_step("Conversation saved", conversation.id) {:ok, conversation.id} end end defp build_notes_spinner_label() do base = if AI.Notes.has_new_facts?() do "Consolidating notes..." else "Finalizing notes..." end if Services.Notes.pending?() do count = Services.Notes.pending_count() suffix = if count > 0 do " (" <> Integer.to_string(count) <> " remaining)" else "" end base <> suffix else base end end # ---------------------------------------------------------------------------- # Worktree auto-commit and review # ---------------------------------------------------------------------------- # Automatically commits any uncommitted changes in a fnord-managed worktree # after the coordinator finishes a session. Only fires when edit mode is on # and a worktree exists; otherwise no-op. Always attempts the commit when # gated through - GitCli.Worktree.commit_all returns :nothing_to_commit # cleanly when there is nothing to do, so we no longer need an external # "did the agent edit?" flag to gate the call. @spec maybe_auto_commit(String.t() | nil, boolean, pid) :: :ok defp maybe_auto_commit(nil, _edit_mode?, _conversation_pid), do: :ok defp maybe_auto_commit(_path, false, _conversation_pid), do: :ok defp maybe_auto_commit(path, true, _conversation_pid) do with {:ok, project} <- Store.get_project(), true <- GitCli.Worktree.fnord_managed?(project.name, path) do UI.debug("worktree", "Auto-committing in #{path}") case GitCli.Worktree.commit_all(path, "auto-commit") do {:ok, :ok} -> UI.info("Auto-committed changes in worktree", path) {:error, :nothing_to_commit} -> UI.debug("worktree", "Nothing to commit") {:error, reason} -> UI.warn("Auto-commit failed: #{reason}") end else other -> UI.debug("worktree", "Skipping auto-commit: #{inspect(other)}") end :ok end # Source-of-truth check for "is there work in this worktree to merge?" # Combines uncommitted-changes and branch-ahead-of-base into one query # using GitCli.Worktree.has_changes_to_merge?/4. Returns false for non-git # projects, missing worktrees, or worktrees whose metadata cannot be # resolved into project_root + branch + base_branch. @spec worktree_has_changes_to_merge?(String.t() | nil, pid) :: boolean() defp worktree_has_changes_to_merge?(nil, _conversation_pid), do: false defp worktree_has_changes_to_merge?(path, conversation_pid) do with {:ok, project} <- Store.get_project(), true <- GitCli.Worktree.fnord_managed?(project.name, path), conv_meta = Services.Conversation.get_conversation_meta(conversation_pid), meta when is_map(meta) <- worktree_meta(conv_meta), repo_root = Store.Project.original_source_root() do git_changes = GitCli.Worktree.has_changes_to_merge?( repo_root, path, Map.get(meta, :branch), Map.get(meta, :base_branch) ) git_changes or has_ignored_writes?(path, conv_meta) else _ -> false end end # Checks for tracked gitignored/excluded files that still exist on disk. defp has_ignored_writes?(worktree_path, conv_meta) do ignored_files_to_copy(worktree_path, conv_meta) != [] end # Returns the list of tracked gitignored paths that still exist on disk. # Only includes files explicitly recorded by the write-time tracking in # file_edit_tool - no broad scan of gitignored dirs, which would pick up # build artifacts like _build/. defp ignored_files_to_copy(worktree_path, conv_meta) do conv_meta |> Map.get(:gitignored_writes, []) |> Enum.filter(&File.exists?(Path.join(worktree_path, &1))) end # Copies gitignored/excluded files from the worktree back to the source repo. # Returns the list of successfully copied relative paths (for display in the # review section). Logs warnings for failures immediately since they won't # appear in the review section. @spec copy_ignored_writes(String.t(), map(), String.t()) :: [String.t()] defp copy_ignored_writes(worktree_path, conv_meta, source_root) do files = ignored_files_to_copy(worktree_path, conv_meta) if files != [] do results = GitCli.Worktree.copy_ignored_files(source_root, worktree_path, files) Enum.each(results, fn {:ok, _rel} -> :ok {:error, rel, reason} -> UI.warn("Failed to copy #{rel}: #{reason}") end) for {:ok, rel} <- results, do: rel else [] end end # If the worktree exists but has no commits beyond its base and no # uncommitted changes, delete it. The coordinator may have speculatively # created a worktree that turned out to be unused. Returns the (possibly # nilled) worktree path. @spec maybe_discard_empty_worktree(String.t() | nil, pid) :: String.t() | nil defp maybe_discard_empty_worktree(nil, _conversation_pid), do: nil defp maybe_discard_empty_worktree(path, conversation_pid) do with {:ok, project} <- Store.get_project(), true <- GitCli.Worktree.fnord_managed?(project.name, path), false <- GitCli.Worktree.has_uncommitted_changes?(path), conv_meta = Services.Conversation.get_conversation_meta(conversation_pid), meta when is_map(meta) <- worktree_meta(conv_meta), # A path-only worktree record (e.g. from `--worktree ` without a # fork) carries nil branch/base_branch. Without those we cannot # compute a diff from the fork point, so we cannot know if the # worktree is empty - treat as non-empty and preserve the path. true <- is_binary(meta.branch) and is_binary(meta.base_branch), repo_root = Store.Project.original_source_root(), {:ok, diff} <- GitCli.Worktree.diff_from_fork_point(repo_root, meta.branch, meta.base_branch), true <- byte_size(diff) == 0, # Don't discard if there are gitignored files to preserve false <- has_ignored_writes?(path, conv_meta) do UI.info("Discarding empty worktree", path) case GitCli.Worktree.delete(repo_root, path) do {:ok, _} -> :ok {:error, reason} -> UI.warn("Failed to delete empty worktree: #{reason}") end _ = case GitCli.Worktree.delete_branch(repo_root, meta.branch) do {:ok, _} -> :ok {:error, _} -> case GitCli.Worktree.force_delete_branch(repo_root, meta.branch) do {:ok, _} -> :ok _ -> :ok end end conv_id = Services.Conversation.get_id(conversation_pid) Cmd.WorktreeLifecycle.clear_worktree_from_conversation(conv_id) Settings.set_project_root_override(nil) nil else _ -> path end end # Offers an interactive review/merge/cleanup flow when there is real work to # merge in a fnord-managed worktree and the user's cwd is outside it. The # `needs_merge?` flag is computed in run/3 from git state via # GitCli.Worktree.has_changes_to_merge?/4 - it is the source of truth and # has replaced the prior editing_tools_used proxy. On validation failure, # retries up to @max_merge_attempts times by invoking the coordinator to fix # the issues in the worktree. @max_merge_attempts 3 @type worktree_status :: :no_changes | :unmerged | {:merged, GitCli.Worktree.Review.merge_range(), :interactive | :auto} @spec maybe_worktree_review(String.t() | nil, boolean, pid, boolean, non_neg_integer) :: worktree_status() defp maybe_worktree_review(path, needs_merge?, pid, auto_merge?, attempt \\ 1) defp maybe_worktree_review(nil, _needs_merge?, _pid, _auto?, _attempt), do: :no_changes defp maybe_worktree_review(_path, false, _pid, _auto?, _attempt), do: :no_changes defp maybe_worktree_review(path, true, conversation_pid, _auto?, attempt) when attempt > @max_merge_attempts do UI.error("Merge failed after #{@max_merge_attempts} validation attempts") show_worktree_hints(path, conversation_pid) :unmerged end defp maybe_worktree_review(path, true, conversation_pid, auto_merge?, attempt) do cwd = File.cwd!() # Preempt the review flow when the shell is cwd'd inside the worktree. # Both the interactive dialog and --yes auto-merge finish by deleting the # worktree, which would yank the cwd out from under the user's shell. # Instead, print instructions for finishing the merge after they cd out, # and leave the worktree intact. if cwd_inside_worktree?(cwd, path) do show_cwd_inside_worktree_hints(path, conversation_pid) :unmerged else do_worktree_review(path, conversation_pid, auto_merge?, attempt) end end @spec do_worktree_review(String.t(), pid, boolean, non_neg_integer) :: worktree_status() defp do_worktree_review(path, conversation_pid, auto_merge?, attempt) do with {:ok, project} <- Store.get_project(), true <- GitCli.Worktree.fnord_managed?(project.name, path) do conv_meta = Services.Conversation.get_conversation_meta(conversation_pid) meta = worktree_meta(conv_meta) if meta do repo_root = Store.Project.original_source_root() # Copy gitignored/excluded files back to the source repo before the # review flow potentially deletes the worktree. Safe to do eagerly: # if the merge fails and the worktree survives, the copies in the # source repo are harmless (they're gitignored there too). copied_files = copy_ignored_writes(path, conv_meta, repo_root) review_opts = [ignored_files: copied_files] result = if auto_merge? do GitCli.Worktree.Review.auto_merge(repo_root, meta, review_opts) else GitCli.Worktree.Review.interactive_review(repo_root, meta, review_opts) end conv_id = Services.Conversation.get_id(conversation_pid) case result do {:cleaned_up, sha, mode} -> # clear_worktree_from_conversation also clears :gitignored_writes Cmd.WorktreeLifecycle.clear_worktree_from_conversation(conv_id) {:merged, sha, mode} {:validation_failed, phase, summary} -> UI.warn("Validation failed (#{phase}, attempt #{attempt}/#{@max_merge_attempts})") # In interactive mode, a pre_merge failure only propagates here when # the user answered "no" to the "Merge anyway despite validation # failure?" prompt. Respect that decline: do not kick off an # unsolicited auto-approved fix cycle. The worktree is left intact # so the user can inspect/fix it at their own pace. if phase == :pre_merge and not auto_merge? do show_worktree_hints(path, conversation_pid) :unmerged else run_validation_fix(conversation_pid, path, summary, auto_merge?) maybe_auto_commit(path, true, conversation_pid) maybe_worktree_review(path, true, conversation_pid, auto_merge?, attempt + 1) end {:merge_failed, _reason} -> show_worktree_hints(path, conversation_pid) :unmerged :ok -> show_worktree_hints(path, conversation_pid) :unmerged end else :unmerged end else _ -> :unmerged end end defp show_worktree_hints(path, conversation_pid) do conv_id = Services.Conversation.get_id(conversation_pid) UI.say(""" The worktree at `#{path}` still contains your changes. - Continue working: `fnord ask -f #{conv_id} -ey -q "..."` - Review the diff: `fnord worktrees view -c #{conv_id}` - Merge later: `fnord worktrees merge -c #{conv_id}` - Discard: `fnord worktrees delete -c #{conv_id}` """) end # True when the shell's cwd is the worktree directory or any descendant of it. # Path.expand on both sides normalizes trailing slashes and relative segments # so symlink-free setups compare cleanly. @spec cwd_inside_worktree?(String.t(), String.t()) :: boolean() defp cwd_inside_worktree?(cwd, path) do expanded_path = Path.expand(path) expanded_cwd = Path.expand(cwd) expanded_cwd == expanded_path or String.starts_with?(expanded_cwd, expanded_path <> "/") end # Emitted in place of the merge dialog when the user's shell is inside the # worktree that would be merged (and deleted) at session end. Surfaces the # same set of follow-up commands as show_worktree_hints but leads with the # reason the automatic flow was skipped, so the user isn't left wondering # why nothing merged. defp show_cwd_inside_worktree_hints(path, conversation_pid) do conv_id = Services.Conversation.get_id(conversation_pid) UI.say(""" Your CWD is the fnord-managed worktree for this conversation (#{path}). Ergo, the merge step was skipped: merging would delete the worktree out from under this shell. When you are ready, cd somewhere else and run one of: - Continue working: `fnord ask -f #{conv_id} -ey -q "..."` - Review the diff: `fnord worktrees view -c #{conv_id}` - Merge: `fnord worktrees merge -c #{conv_id}` - Discard: `fnord worktrees delete -c #{conv_id}` """) end # Invokes the coordinator for one completion cycle to fix validation failures. # Injects the failure context as a system message, then runs get_response # with the fix instructions as the question. The `auto_approve?` argument is # threaded from the session's own --yes/--cowboy flag so the fix cycle does # not silently elevate approval policy: interactive sessions keep prompting, # auto-merge sessions keep auto-approving. defp run_validation_fix(conversation_pid, worktree_path, validation_output, auto_approve?) do UI.begin_step("Fixing validation failures") """ # Validation Failed The following validation output was produced when attempting to merge your worktree changes. Fix the issues in the worktree, then commit your changes. ``` #{validation_output} ``` The worktree is at: #{worktree_path} """ |> AI.Util.system_msg() |> Services.Conversation.append_msg(conversation_pid) Services.Conversation.get_response(conversation_pid, edit: true, question: "Fix the validation failures shown above and commit the fixes.", replay: false, yes: auto_approve? ) end # Persists the final assistant response when `--save` is enabled. # This is the integration point between the ask workflow and output storage, # so it translates storage failures into command-level errors instead of # letting pattern matches crash the command after the response has already # been generated. @spec maybe_save_output(map(), String.t(), String.t()) :: :ok | {:error, {:save_failed, term()}} defp maybe_save_output(opts, conversation_id, response) do case opts[:save] do true -> save_output(conversation_id, response) _ -> :ok end end # Saves an ask response for the current project and reports the saved # filename. @spec save_output(String.t(), String.t()) :: :ok | {:error, {:save_failed, term()}} defp save_output(conversation_id, response) do with {:ok, project} <- Store.get_project(), {:ok, path} <- Outputs.save(project.name, response, conversation_id: conversation_id) do UI.report_step("Output saved", Path.basename(path)) :ok else {:error, reason} -> UI.error("Failed to save output: #{reason}") {:error, {:save_failed, reason}} end end end