Skip to content

Agent mode, plus two safety fixes and the 1.3.0 bump - #37

Merged
baldurpan merged 31 commits into
mainfrom
feature/add-agent-mode
Sep 6, 2026
Merged

Agent mode, plus two safety fixes and the 1.3.0 bump#37
baldurpan merged 31 commits into
mainfrom
feature/add-agent-mode

Conversation

@baldurpan

Copy link
Copy Markdown
Contributor

Hands a worktree straight to a coding agent, and carries two earlier safety
fixes that were finished on this branch. Version moves 1.2.8 → 1.3.0.

The branch name undersells it — three features shipped here, each with its own
archived plan under context/archive/.

Agent mode

branch and checkout take --agent, dispatching a coding agent with cwd
set to the new worktree, so no nested .claude/worktrees/ appears inside it.
The command it runs comes from the new agent.command config value.

list --agents names the session living in each worktree, joined by working
directory, and cleanup now refuses to sweep a worktree an agent is living in.

fix: uncommitted work no longer disqualified from removal checks

isSafeToRemove called a worktree with a deleted remote safe while it held
uncommitted work
, and cleanup force-removed it — data loss with no prompt.
The hazard test is hoisted above every remote-branch check, and cleanup
reports what it held back rather than staying silent.

fix: subprocess calls no longer go through a shell

Every subprocess call was a shell string, so a path containing a space failed
and a config value containing a backtick executed. cmd() and both exec
sites are gone, replaced by an argv-array run() with an explicit cwd.

Also here

The planning overlay updates from 0.4.0 to 0.6.0, and /onboard closes the two
gaps that update named: context/git.md (this project's answer is that the user
commits, matching what .github/agents/ already required) and a Documentation
index in context/stack.md that every future plan's §7 starts from.

That last part is repo tooling rather than product, and it is here because it
was done on this branch — worth a separate look, or a separate PR, if you would
rather keep it out.

Verification

pnpm check, pnpm typecheck, pnpm build, pnpm test, pnpm docs:test — all
five run from the repo root, each exiting 0, at 322 and 49 tests.

https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g

Planning output from /roadmap and /feature-plan, previously uncommitted
in the working tree.

- agent-mode: promote the maintainer brief in drafts/ to a 7-phase plan
  and repoint its roadmap Doc field; the draft it replaces is removed.
- cleanup-data-loss: new roadmap entry plus a 5-phase plan for the
  isSafeToRemove data-loss path.
- shell-argv-safety: new roadmap entry and draft covering the shell
  interpolation call-site inventory.

All three entries stay `pending`; no feature is activated by this commit.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
cleanup-data-loss Phase 1. Preparation only — no verdict changes.

isSafeToRemove had no return type and fell off the end when no branch
matched, so it returned boolean | undefined and safeToRemove was
undefined rather than false for every unsafe worktree. Add an explicit
: boolean and a final return false so the compiler enforces that every
branch decides (plan D5), and export it so the predicate can be unit
tested directly instead of through four mocked git calls (D6).

No branch is reordered and no condition is touched. The only verdict
transition is undefined -> false; both consumers already treated those
identically (cleanup.ts:27 compares === true, remove.ts:36-37,81 test
truthiness), and nothing in the tree distinguishes them.

Adds the first coverage for the predicate: the four current outcomes,
plus a characterization test pinning the defect this plan exists to fix
— a deleted-remote worktree holding uncommitted changes is still
classified safe. That assertion is deliberately wrong and carries a
comment naming Phase 2 as what overturns it (plan R4).

Gate 1: check, typecheck, build, test, docs:test all exit 0 (186 src
tests, up from 181; no existing assertion changed).
Gate 2: PASS from the reviewer subagent, no blocking findings.
F-001 (P3) raised against Phase 2 for an unpinned input whose verdict
the Phase 2 rewrite will silently flip.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
isSafeToRemove returned true for a worktree tracking a deleted remote
branch no matter how much uncommitted work sat in it: the remote branch
returned before the only clause that consulted uncommittedChanges was
reached. Both cleanup and remove trusted that verdict and force-removed
through it.

Hoist the uncommitted-changes test above the remote branch so it cannot
be bypassed, and drop the now-redundant `uncommittedChanges === 0` clause
from the no-remote branch. The test is a truthiness check, not a
comparison, because the field is optional and undefined must not read as
"has changes".

Two verdicts move, both intended. The fix itself, and the no-remote entry
with an unknown count, which F-001 pre-registered against this phase and
which the plan's §4.1 specifies; it is unreachable from
gitGetWorktreeList, which always assigns a number.

Verified against a real repository as well as in unit tests: a worktree
whose remote branch was deleted while holding a tracked modification and
an untracked file is no longer a candidate for `cleanup` or for
`cleanup --force`, and the work survives both.

CLEANUP-DATA-LOSS-PLAN Phase 2 -> done. F-001 closed, F-002 opened.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Phase 2 made `isSafeToRemove` decline a stale worktree that holds
uncommitted changes, which silently shrank what `cleanup` sweeps. Nothing
told the user why. `--force` printed nothing at all.

`cleanup` now names the worktrees it declined, with the reason drawn from
the entry's own fields via `worktreeListEntryToListName`, in both the
default and the `--force` path: `--force` means "do not ask me", not "do
not tell me". The no-candidates path distinguishes "nothing found" from
"everything was skipped", so declining three worktrees with work in them
can no longer read as a clean sweep.

"Declined" is read narrowly: only worktrees that would have been swept but
for their work. Taken literally, plan §4.2's "the worktrees it declined" is
every entry that is not `safeToRemove`, which would print every active
worktree in the repository on every run. The set is computed by asking
`isSafeToRemove` about a zeroed copy rather than restating the rule, so the
safety verdict stays in one place per §3 D1.

cleanup-data-loss Phase 3. Gate 1: `pnpm check`, `pnpm typecheck`,
`pnpm build`, `pnpm test` (12 files, 193 tests), `pnpm docs:test` (6 files,
49 tests) — all exit 0. Gate 2: PASS WITH NOTES, reviewer subagent, no
blocking findings; F-003 (P3) records a latent double-listing.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Phase 4 of CLEANUP-DATA-LOSS-PLAN. §4.3 predicted `remove` needs no code
change once the Phase 2 predicate fix landed: a deleted-remote worktree
holding uncommitted work stops being grouped under "Inactive branches
(Safe to delete)" and starts tripping the not-safe confirmation instead.
The tests confirm it, so no source file moved.

The fixture takes `safeToRemove` from the real `isSafeToRemove` rather
than hand-setting it as every other fixture in the file does — otherwise
these cases would keep passing even if the classification regressed.

F-004 (P3) records two assertions that are looser than the claim they
pin; both are non-blocking and Gate 2 passed on this diff.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
The cleanup page listed "worktrees whose remote branch no longer exists"
as an unconditional target, which documented the defect this feature
fixed as if it were the design. Qualify it, name `git status -s` as the
source of the count so untracked files are not a surprise, and say that
held-back worktrees are reported.

Closes cleanup-data-loss Phase 5. Gate 1: `pnpm check` exit 0 (docs-only,
so Typecheck/Build/Test skipped per context/verify.md). Gate 2: PASS WITH
NOTES. F-005 (P3) raised for README.md:160, which carries the same
unqualified claim outside this phase's Files.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
All five phases done and both gates passed on each, so the feature leaves
roadmap.md for a history.md row and its plan moves to context/archive/
with git mv, keeping the history of how it was actually built.

The plan's header is repointed at the history row rather than stamped
with an outcome, and now names the five source comments that cite its
sections by number — those citations carry no path, so they survived the
move untouched and must not be rewritten into paths that would rot.

The sweep found exactly one path reference, roadmap.md:53, which the
entry removal deleted outright; no link needed rewriting, since plans/
and archive/ sit at the same depth from context/.

F-001 (closed) moves into the archived plan's §10 log. F-002 through
F-005 stay open in findings.md — all P3, none gating. The archive
records them, and records that §7's by-hand verification was never run.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Promotes the shell-argv-safety draft to a plan with a phase ledger and takes
the one active-feature slot with it.

It goes ahead of agent-mode because both plans' open questions argue for it
from opposite sides: agent-mode Phase 1 pushes `agent.command` — a value that
contains spaces — through the `gitSetConfigValue` interpolation that this
plan's Phase 4 replaces with argv.

Settles two of the four open questions:

- Q1, sequencing: this plan holds the slot. Phase 6 therefore creates
  `src/lib/base-command.test.ts` and agent-mode Phase 2 merges into it.
- Q4, the helper's name: `run`, confirmed against the tree. No
  `src/commands/*.ts` imports from `cli.js`, so the only file that sees both
  it and oclif's inherited method is `base-command.ts` after Phase 6, where
  the two read as `run()` and `this.run()`.

Q2 and Q3 stay deferred — no phase touches either.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
shell-argv-safety Phase 1. Adds `run(file, args, { cwd })` on top of
`execFile`, so no value passed to a subprocess is parsed as shell syntax and
`cwd` reaches the child directly instead of through a `cd` prefix.

Nothing migrates in this phase. `cmd()` is untouched, all 18 of its call sites
still use it, and no existing assertion changed — `run` is imported only by its
own test. This is the additive half of D1: add alongside, migrate in batches,
delete `cmd()` when the last caller is gone.

D7 is a sequencing constraint, not a preference: the global `vi.mock` factory
in `src/test-setup.ts` returns an explicit object, so an export missing from it
is `undefined` at call time and a migrated call site would fail with "run is
not a function" rather than a useful assertion. `run` goes in now, before the
first caller.

`src/lib/cli.test.ts` is new and covers the real helper — it opts out of that
global mock with `vi.unmock`, and drives `process.execPath` so the suite needs
no fixture binary. Two of its nine cases pin the reasons this work exists: a
`cwd` whose path contains a space, and an argument carrying `"`, a backtick,
`$(…)` and `;` that arrives intact while its embedded `touch` creates nothing.

Gate 2 (reviewer subagent) returned PASS WITH NOTES with no blocking findings,
and re-verified R5 by measurement rather than inference: `exec` and `execFile`
both fail at 1048576 bytes with ERR_CHILD_PROCESS_STDIO_MAXBUFFER, so the
buffer limit is genuinely unchanged.

Its notes are recorded rather than left in the transcript:

- F-006 (P2) — the `afterEach` guard watches `mockCmd` only, so R3's mitigation
  goes vacuous as call sites move to `run`. Tied to Phase 2, which migrates the
  first three, and named in that phase's scope so it cannot be missed.
- F-007 (P3) — this suite's `afterAll` would mask a `beforeAll` failure.
- R6 — `execFile` defaults to `shell: false` and cannot launch a Windows
  `.cmd`/`.bat` shim, which lands on Phase 6's `codeEditor`. Unverified from
  macOS and flagged as a question, not a defect.
- R7 — two caller-observable error deltas: the message prefix shrinks at
  Phase 3, and `code` goes from numeric 127 to "ENOENT" for a missing file.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
…dopted

Two corrections to project-owned context files, neither reachable by
`create-ai-workflow update` — both belong here, not upstream, because the
tool ships `verify.md` and `stack.md` as stubs and their content is this
repository's own.

verify.md said `pnpm test` covers 181 tests; it runs 206. The drift came from
the cleanup-data-loss commits and this branch's Phase 1, not from any one
change. Rather than restate a number that goes stale on the next test added,
the count is now dated and labelled as a snapshot for recognising a suite that
did not run — not a figure to assert against.

stack.md claimed that editing any file under `standards/` flips the tree to
project-owned so `update` stops restoring it. That is true, but the note did
not say what keeps it true, and the answer is narrower than it reads. From the
updater's own source (dist/commands/update.js:37-41, v0.4.0) the "edited" test
is `onDisk === null ? false : hash(onDisk) !== recorded` — a deleted file
counts as unedited. So none of the ten paths this project removed contribute
anything, and the whole tree stays adopted on the strength of exactly two
files that still exist with a changed hash: standards/README.md and
standards/philosophy/ai-agent-behavior.md, both altered by the PHP removal.

That matters because restoring either to its bundled content re-arms the
updater against the whole directory, and update.js:49-52 then treats every
deleted-but-recorded path as a restore — bringing back
standards/templates/biome.json, which verify.md records as breaking `pnpm
check`, `pnpm lint` and `pnpm format` together.

Verified against context/.state/manifest.json: of 78 standards/ paths, 66
match their recorded hash, 10 are deleted, 2 are modified.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
shell-argv-safety Phase 2. gitGetCommitsAheadCount,
gitGetCommitsBehindCount and gitGetUncommittedChangesCount now call
run("git", [...], { cwd: branchPath }); the `cd ${branchPath} &&` prefix
is deleted rather than quoted, per D3.

Extends the R3 unexpected-call guard to cover run as well as cmd, which
closes F-006. describeRunCall renders a run call as its argv joined plus
the cwd when one is given, so expectedCommands stays a string[] and a
call site moving to run stays inside the guard. Proved non-vacuous by
deleting a declared entry and watching it fire. The warning text is now
"Unexpected subprocess calls detected"; §7 case 4 and §5 R3 updated.

Raises F-008: gitGetWorktrees splits the `git worktree list` line on
single spaces, so a repo under a spaced path lists nothing. Pre-existing
and outside this phase's scope, but it is what stopped §7 case 1 from
being run as written. Phase 2's claim was verified against the same real
repo instead — dist/lib/git.js with the spaced worktree path returned
ahead 1 / behind 0 / uncommitted 1, where the old exec form failed on the
identical path.

Gate 1: check, typecheck, build, test (207) and docs:test (49) all 0,
zero guard warnings. Gate 2: PASS WITH NOTES (reviewer subagent), which
independently reproduced both results on its own fixture; its P3 notes on
the F-008 transcript, the stale §1/§10 citations, the Closed-section
boilerplate and R7's phase attribution are applied here.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
…alls

The `cd ${root} && git fetch && git worktree add … && cd ${PWD}` chain
becomes two sequential `run()` calls under `{ cwd: gitRootPath }`. The
`cd` halves are deleted rather than quoted (D3), and the awaits preserve
the `&&` short-circuit exactly (D5). `process.env.PWD` — its only read in
the codebase — goes with `gotoBack`, which also removes a latent failure:
`PWD` is absent from a non-shell parent's environment, and `cd undefined`
exits non-zero *after* the worktree has already been created.

`git worktree add` still receives the relative worktree path, and the
comment saying why survives (R1). §7 case 2 shows that reason no longer
holds on git 2.38.1 — relative, absolute and the pre-change shell form
record byte-identical absolute links, and all three break alike when the
project moves — so the behaviour is unchanged and the claim is now F-010.

Phase 3 of shell-argv-safety. Gate 1: check, typecheck, build, test
(211 passed) and docs:test all exit 0, with no unexpected-call warning.
Gate 2: PASS WITH NOTES from the reviewer subagent; F-009 and F-010
raised, neither blocking.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
shell-argv-safety Phase 4. gitGetConfigValue and gitSetConfigValue drop the
interpolated `git config … "${value}"` string for run("git", ["config", …]),
and gitNukeWorktreeCmd's three-command `&&` chain becomes three sequential
awaits (D5) — so it is now async and returns void, which neither caller reads.

The config value was the live injection vector in §1. Verified both ways
against a scratch repo with the built dist (§7 case 3): the argv form stores
all 72 bytes of `x"; touch <marker>; #`whoami`` literally and creates no file,
while a reproduction of the exact pre-change shell string exits 0, stores `x`
and does create the marker.

Settles the phase's open rendering question: describeRunCall now quotes
whitespace-bearing argv elements, so one spaced argument no longer reads as
two in the R3 guard. Two declarations moved with it, and the guard was proved
non-vacuous by deleting them and watching it fire.

jira.test.ts and github.test.ts move their config mocks from cmd to run —
beyond the phase's stated Files list, but forced by §2's green-at-every-
boundary rule; Phase 5's Files list already names both.

Gate 1: check, typecheck, build, test (216) and docs:test (49) all exit 0,
zero R3 warnings. Gate 2 (reviewer subagent): PASS WITH NOTES, no blocking
findings. F-011 raised for the unpinned third await.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Phase 5 of shell-argv-safety. The last ten `cmd()` call sites move onto the
argv-based `run()`, and the string-shaped subprocess contract goes with them:
`cmd()`, `CmdOptions.debug` (D4), the `exec` import in `cli.ts`, and the `cmd`
entry in the global mock factory are all gone. `src/lib/base-command.ts:56` is
now the only `exec(` left in `src/`, which is Phase 6.

Two argument shapes changed, both because a shell was doing the work before:

- `--format='%(refname:short) <- %(upstream:short)'` loses its single quotes.
  The shell stripped them before git saw them, so as one argv element they must
  not be there. Verified byte-for-byte — `sh -c` with the old string and
  `execFile` with the new argv produce identical output.
- `git rev-parse  --show-toplevel`'s double space collapses, for the same reason.

`gitGetRootPath` moving onto `run` makes `gitCreateWorktree` record three calls
where its tests counted two; `vi.spyOn` returns the existing mock when the
property is already one, so the queued values across each migrated test now sit
in a single ordered queue. Every count and nth-index was re-derived rather than
adjusted until green.

`commandExists` gains its first tests: a command on PATH resolves true, an
absent one false, and only the head of a command line is looked up — the
contract D6 relies on at Phase 6. The win32 branch stays untested, per R6.

Closes F-007: the `afterAll` in `cli.test.ts` is guarded against an unset
`tempPath`. In scope because Phase 5's Files names that file and the new PATH
restore had to edit that exact block. The restore deletes the key rather than
assigning `undefined`, which would write the literal string "undefined".

Gate 1: pnpm check, typecheck, build, test (219 passed) and docs:test (49
passed) all exit 0, with zero "Unexpected" in the test output. The R3 guard was
re-proved non-vacuous after `cmd`'s removal — deleting one declared entry made
it print the offending `run` call; restoring returned the run to zero.

Gate 2: PASS WITH NOTES, then PASS on a confirmation pass over the three notes
it raised (a comment that denied R7's measured error-message changes, the PATH
restore, and an unverified Windows claim). Findings citations shifted by this
diff were corrected in the same commit.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
shell-argv-safety Phase 6. The editor launch was the one call that bypassed
cmd(), interpolating both the codeEditor config value and the worktree path
into a shell string. It now splits codeEditor on whitespace — head as the
program, tail as leading arguments — and passes the worktree path as one argv
element, so a path containing a space opens instead of being torn into
fragments. `grep -rn --include='*.ts' 'exec(' src/` is now empty.

The launch stays fire-and-forget: exec's callback was never awaited, so
run().then(succeed, fail) is not either, and openWorktreePath still resolves as
soon as the child is spawned. Measured at 43 ms against a 900 ms editor.

R6 is answered from Node's own source rather than by assertion: the JS layer
carries no batch-file handling at all, process_wrap.cc returns UV_EINVAL for
IsWindowsBatchFile because batch arguments cannot be unambiguously escaped, and
libuv's path search appends only .com and .exe. So no Windows shell branch is
added — that would reinstate the exact hazard this feature removes, at the one
site that takes a user-supplied value. The contract is documented instead.

src/lib/base-command.test.ts is new: six cases covering the argv shape, a
spaced path, the argument tail, whitespace collapse, both spinner outcomes and
the no-editor log path.

Gate 1: pnpm check, typecheck, build, test (225 passed) and docs:test all
exit 0, with zero R3 warnings. Gate 2: PASS WITH NOTES from the reviewer
subagent, twice — the second pass covering the follow-up docs corrections.
Findings raised: F-012 (P2, the Windows .cmd shim, unobserved on Windows) and
F-013 (P2, the fire-and-forget property is unpinned by any test).

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
All six phases done and both gates passed on each. The plan moves to
context/archive/, its header repoints at history.md, and one shipped row lands
in history.md. F-006 and F-007 fold into the archived plan's new section 11, so
findings.md does not grow.

Sweep: the only reference to the old path was the roadmap entry this removes,
and no source comment cites the document by section. One link inside the moved
document broke on the move -- section 0's sibling link to AGENT-MODE-PLAN.md --
and is repointed at ../plans/. All six of its relative links resolve.

Six findings stay open in findings.md, all P2 or P3, none gating. F-008 has no
home: no phase of this plan touches gitGetWorktrees, so it needs a roadmap entry
of its own.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
agent-mode Phase 1. Adds the config key a coding agent will be dispatched
from, and nothing that reads it yet — the --agent flag and dispatchAgent
are Phase 2.

agent.command holds a full command line and is validated on its argv head
alone, via a new isValidCommandLine. Reusing isValidCommand would have
passed "claude --bg" too, since commandExists already splits on whitespace,
but its error quotes the whole value back — "Command not found: claude --bg"
names the flag rather than the program that is actually missing. See
AGENT-MODE-PLAN §3 D1.

No agent runtime is named anywhere in the code: the prompt offers no
fallback default, unlike codeEditor's "code". Naming one would make this
tool depend on a particular CLI, which §2 rules out. The tests mock
commandExists throughout, so none of them needs an agent binary installed.

Also carries the §8 resolutions the maintainer settled before this phase:
Q2 (list --agents shows both session kinds, marking interactive ones —
D5 amended and Phase 5's scope widened to match), Q5 (an unset key prints
a message and exits 0, unchanged), and Q4, which the shipped and archived
shell-argv-safety work already answered — no shell interpolation remains
in src/, so R1 is closed.

Gate 1: check, typecheck, build, test (242) and docs:test (49) all exit 0.
Gate 2: reviewer subagent, PASS WITH NOTES. Its four non-blocking notes are
filed as F-014 (codeEditor keeps the whole-line error D1 rejected), F-015
(a rejected config value exits 0 — pre-existing, repo-wide) and F-016 (the
docs describe dispatch semantics Phase 2 makes true), all P3. The fourth
was the ledger row, updated here.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
…gent

agent-mode Phase 2. --agent "<prompt>" starts the command configured as
agent.command inside the worktree that was just created, on both branch and
checkout. checkout gains its first flags block (§3 D9).

Dispatch never touches a shell. spawnDetached takes an argv array, sets cwd
to the worktree, and detaches, ignores stdio and unrefs so the CLI exits
while the agent keeps working — none of which execFile can express, which is
why it is a sibling of run() rather than a flag on it. The prompt is appended
as one argument however many quotes it holds; a shell string here would be an
injection hole, not merely a quoting bug (§3 D2).

It lives in cli.ts rather than importing node:child_process into
base-command.ts, which is the one place this diff departs from the plan's
Files list. cli.ts stays the only module that spawns anything, and the global
mock in test-setup.ts — which exists "to prevent actual command execution" —
now covers agent dispatch too, instead of leaving a real spawn behind any
future command test that does not stub dispatchAgent. Gate 2 reviewed the
deviation and kept it.

Order is create → copy env → agent → editor (§3 D3). The env edge is
load-bearing: the agent starts working immediately, so it has to find a
complete worktree. The editor stays last and still fires, since the two are
independent.

An unset agent.command prints a pointer and returns, leaving the worktree
created and the editor open, per §8 Q5. The guard runs on the parsed head, so
a hand-edited whitespace-only value degrades the same way instead of reaching
spawn(""), which throws synchronously and would have taken the editor launch
with it.

§7's manual run was performed and both checks hold. With agent.command set to
a recorder script, the prompt `fix the 'login' bug in "auth.ts"; don't stop`
arrived as one argv element with cwd set to the worktree. With it set to
`claude --bg`, claude agents --json showed a background session whose cwd was
exactly that worktree, and find returned no .claude directory anywhere in the
repo or its worktrees. That settles §8 Q1, which was the maintainer's claim
until now, and confirms R2 before Phases 3-7 build on it.

Gate 1: check, typecheck, build, test (261, from 242) and docs:test (49) all
exit 0. Gate 2: reviewer subagent, FAIL then PASS WITH NOTES after one
loopback. The P1 — the no-agent hint named `worktree config agent.command`,
which reads the key and discards the result, a dead end — was written to
findings.md as F-017 before the loopback and is closed here, along with F-016.
spawnDetached also gained real-process tests: deleting its error-handler
registration now fails the suite, where before it crashed the CLI with every
test green. Surviving notes are F-018 and F-019, both P3.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Churn is diff statistics with no relationship to an agent session, Phase 5 was
its only consumer, and it was the fourth serial subprocess per worktree on the
list path. Re-filed as the worktree-churn-stats roadmap entry, with D7 retained
as the design it should start from.

Also records what re-measuring `claude agents --json` on 2.1.263 showed: the
default listing already excludes completed sessions, so D4/D6 now turn on never
passing --all, and interactive entries carry no status or state at all, which
§4.1 pins as the limit of what isSessionWaiting can say.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Adds src/lib/agent.ts, the one module that knows an agent runtime's session
JSON exists. getAgentSessions runs the configured command's head as
`<agent> agents --json` and returns [] on every failure — no agent configured,
a missing binary, a non-zero exit, output that is not JSON, or JSON that is not
an array — so this never becomes a hard dependency on any particular runtime.

The invocation never passes --all. The default listing already excludes
completed sessions, and asking for them back is what would wedge a worktree
behind a session that has finished, so a test pins the argv rather than trusting
the call site.

findSessionForPath matches a session whose cwd is the worktree or anything under
it, comparing segment-wise so a sibling worktree sharing a name prefix does not
match and the main checkout does not claim every worktree's sessions. Among
matches a live session wins over a finished one: two sessions in one worktree is
the ordinary --agent case, and handing back a finished one would report the
worktree as free while somebody is still working in it.

isSessionLive fails safe — an absent or unrecognised state counts as live.
isSessionWaiting reads status by exact equality, never truthiness, because the
runtime writes null rather than omitting the field on a finished session.

Phase 3 of AGENT-MODE-PLAN. Gate 2 raised F-020 against the first-match lookup;
it is fixed and closed here. F-021 through F-024 are filed as P3 notes.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Phase 5 of agent-mode. `list` gains its first flag: `--agents` / `-a` joins
the agent runtime's session listing onto the worktree list and names the
session found in each one.

The join lives in `gitGetWorktreeList` behind an `includeAgents` option, per
the plan's §4 — one lookup for the whole run rather than one per worktree
(D4, R4), and none at all for a caller that never renders agents, so plain
`worktree list` costs exactly what it did before. `worktreeListEntryToListName`
takes an options argument so `cleanup`, which shares the renderer, is
untouched (D8).

A session renders as `Agent: <name>`, qualified by `[interactive]` for a
human's own terminal and `[waiting]` for a background agent that is live but
not progressing. The two are mutually exclusive by construction (§4.1). No
raw `kind`, `state` or `status` value crosses out of `agent.ts`: the markers
come from its predicates.

The entry carries one session, so the rendering names it — a worktree holding
both a dispatched agent and a terminal is described by the session named, not
ambiguously. That closes F-024, which recorded the ambiguity against this
phase; the plan section and the docs page both say so now.

Gate 1 green: check, typecheck, build, test (307, was 294) and docs:test (49)
all exit 0. Gate 2 `PASS WITH NOTES` from the reviewer subagent, no loopbacks;
notes filed as F-025 (P2, tied to Phase 6 — this change makes `git.ts` and
`agent.ts` import each other) and F-026/F-027/F-028, all P3.

Phase 5's original **Files** line omitted `src/lib/git.ts`, disagreeing with
§4; §4 was followed and the line now records the deviation and why. R4's
subprocess count is corrected in passing: the constant is two, not one.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
`cleanup` swept on remote and uncommitted state alone, so a worktree with a
running agent session in it was an ordinary candidate — removing the directory
out from under the agent, which D5 calls the worst failure mode in this flow.

`isSafeToRemove` now declines a worktree holding a live session, and `cleanup`
names it as skipped alongside the session rather than dropping it silently.
Liveness reaches the predicate as a third derived field on `WorktreeAgent`, set
from agent.ts's `isSessionLive`, so no raw `state` crosses that boundary. An
absent marker counts as live, matching D6's fail-safe direction.

`--ignore-agents` is the override, and it works by not asking for the session
join at all, so the overridden path costs what cleanup cost before. It is
deliberately not `--force`, which means "do not ask me", not "overrule a safety
verdict", and deliberately has no short alias.

The two skip reports are disjoint by construction: the uncommitted probe keeps
the agent and so declines anything an agent holds, the agent probe drops both,
and both are gated on the removal verdict. That gate is also what closes F-003,
where a worktree whose directory was already gone could be reported as held back
and then removed; F-002's missing ordering case is pinned in the same file.

agent-mode Phase 6. Gate 1 green (322 tests, was 307); Gate 2 PASS WITH NOTES,
no loopbacks. §7's manual run passed through the built bin/run.js: a plain stale
worktree was swept while the one holding a live session survived and was named,
--ignore-agents then removed it, --force alone did not, and a session flipped to
state "done" stopped blocking. F-002, F-003 and F-026 closed. F-025 deferred
with its dangling close condition amended. Notes filed as F-029 through F-035.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
The skill file's frontmatter enumerates every command and config value, so it
went stale the moment `agent.command` landed. Sweep it, the README feature
list, and `skill_tree.yaml` — which ships in the package and carries a
near-copy of that same frontmatter.

`skills/_artifacts/domain_map.yaml` and `skill_spec.md` are left alone. Both
`scripts/sync-intent-version.mjs:49-50` and `.github/workflows/ci.yml:33-34`
draw the maintained/input line in the same place, and `domain_map.yaml` is a
stamped 1.2.0 discovery record besides. Their stale counts are F-036.

Closes agent-mode Phase 7. Gate 1 green (docs-only: Lint plus a read of the
diff); Gate 2 PASS WITH NOTES, no loopbacks, notes filed as F-037/F-038.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Three features have accumulated on this branch unreleased: cleanup-data-loss,
shell-argv-safety and agent-mode. A minor covers all three — the payload is
new backward-compatible surface (`agent.command`, `--agent` on branch and
checkout, `list --agents`, `cleanup --ignore-agents`) plus fixes.

`pnpm sync-version` run and its three generated files committed alongside, per
ci.yml:31,34.

This bump releases nothing on its own. tag-on-version-change.yml fires only on
push to main, and publish.yml only on a published GitHub Release — both still
to do, and F-012 (Windows .cmd editor launch, never observed on a Windows host)
is still open against this payload.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
…numerated

The agent-mode sweep updated the five pages that document a command it
changed, and missed the two that merely enumerate config keys: neither
documents a command, so nothing in the plan pointed at them. Nothing on either
page was false — "Common values" and a set of examples are both hedged — but
the site and the README disagreed about what setup involves, which is the
disagreement that sweep existed to remove.

Adds the key to getting-started's list, an example to the config command page,
and an Agent Key section there — the counterpart the GitHub keys already had.

The general lesson is in stack.md: a version bump has to verify docs/ describes
what is shipping, because docs-deploy.yml republishes the site off package.json
whether or not anyone updated the pages, and enumerating pages go stale
silently. Plan §9 now records how its list was derived and why these two fell
outside it.

Raised and closed as F-039.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Bumping is too late to plan for, so a check there catches the miss only once
it is already too late to have planned around it. The durable fix belongs to
planning and is going to the shared AI-workflow repository, where it binds
every project rather than this one.

Worth recording alongside it: "plans must include docs" would not have caught
this feature's miss either. §9 existed and was incomplete. What is needed is a
step that derives the set — every page enumerating a config key or flag, not
only the pages for the commands a feature touches.

stack.md returns byte-identical to its pre-78e1301 state; the sync-version
guidance it already carried is untouched. §9 and F-039's close note are
redirected so neither points at a rule that no longer exists.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Every phase done or cut, no open P0 or P1 tied to the feature. Phase 4 is
`cut` rather than `done` — its work was re-filed as the worktree-churn-stats
roadmap entry — which is retired work, not unfinished work.

The entry leaves roadmap.md for a one-line history.md row, and the plan moves
by `git mv` so its build history follows it. Its header now points at that row
instead of claiming a status of its own, and notes that its section numbers are
cited from 17 source comments and must not be renumbered.

Sweep: two path-bearing references rewritten, both inside context/archive/ —
shell-argv-safety's link becomes a sibling, cleanup-data-loss's code span named
a directory the file no longer sits in. The 17 source citations are path-free
section references and are deliberately untouched. No relative link under
context/ broke; the three that fail predate this change.

F-016, F-017, F-020, F-024, F-026 and F-039 move into the plan's own §11
findings log, leaving findings.md 158 lines lighter.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
Replaces the tool-owned half of the overlay: the skill and agent trees,
the AGENTS.md block, workflow.md, README.md and the plan templates.

Two things arrive with it. /prototype is a new command for throwaway
HTML/CSS mockups under prototypes/ — no gates, no ledger, no application
code. And the workflow gains two standing rules: nothing commits unless
git.md says so, and documentation is part of the change rather than a
follow-up.

Both rules point at project-owned files the updater cannot write, so it
names the gaps and stops. Closing them is the next commit.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
/onboard, re-run against what 0.6.0 expects.

git.md did not exist, while AGENTS.md, workflow.md and README.md all
linked to it. It records that the user commits: a phase ends verified
with its ledger row updated, left in the working tree. That is the
shipped default, but it is also what this repository already said —
.github/agents/fix-format-and-lint.agent.md requires its agent to be
suggest-only and to not auto-commit or push — so it is adopted rather
than inherited. Granularity is one commit per phase, Conventional
Commits with a scope, as the shape a proposed message takes.

stack.md gains the Documentation section every plan's §7 now starts
from: six surfaces with who reads each and what has to reach it. The
seven command pages under docs/ match the seven files in src/commands/
exactly, and nothing is published outside this repository.

Two surfaces are not ordinary docs. .github/agents/ is unmaintained —
nothing executes it and its proposed runner was never written — so a
feature should not propose updates to it. skills/ is the opposite of
what it looks like: sync-intent-version.mjs rewrites only the
library_version: and version: lines, so the prose around them is
hand-written and is a real surface a command change can make untrue.

verify.md: all five commands re-run from the repo root today, each
exiting 0, at 322 and 49 tests — matching the recorded snapshot, so
only the date moved.

Claude-Session: https://claude.ai/code/session_01Sjmawm7wirBuTSTAuKEx7g
@baldurpan
baldurpan merged commit 8fd80c3 into main Sep 6, 2026
1 check passed
@baldurpan
baldurpan deleted the feature/add-agent-mode branch September 6, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant