Packages
fnord
0.9.40
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/cmd/ask.ex
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/<project_id>/<slug>.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 <dir>.")
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 <conv-path>`) won't be on fnord-<conv_id>.
# 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 <path>` 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