diff --git a/.agents/skills/feature-close/SKILL.md b/.agents/skills/feature-close/SKILL.md index d046e56..4f03e80 100644 --- a/.agents/skills/feature-close/SKILL.md +++ b/.agents/skills/feature-close/SKILL.md @@ -79,6 +79,11 @@ grep -rn "\|" --include='*.md' . | grep -v node_modules Move every **closed** finding tied to this feature out of `context/findings.md` and into the archived plan's own log. `findings.md` must not grow for the life of the project. +Then read [`context/git.md`](../../../context/git.md) before committing anything. `git mv` stages a rename +and writes no history, so it is safe under either answer — but the commit that carries it is the agent's to +make only where that file says so. If it does not exist, the answer is *the user commits*: show the whole +retirement as one reviewable change and hand it over. + ## Mode 2 — `--dropped` For an entry that will not be built. **There is no ledger check in this mode** — unfinished phases are @@ -97,6 +102,6 @@ expected. - **Never delete a plan document.** Archiving keeps the reasoning; deleting throws away the record of a decision someone will otherwise re-litigate. - **Never leave `roadmap.md` and `history.md` inconsistent.** An entry is in exactly one of them. -- **Never commit the sweep unreviewed.** +- **Never commit the sweep unreviewed** — and never commit it at all unless `git.md` says the agent commits. - **Never mark a phase `done` to get past the refusal.** If phases are unfinished, the feature is unfinished. diff --git a/.agents/skills/feature-implement/SKILL.md b/.agents/skills/feature-implement/SKILL.md index 9f4aaec..a70c5cf 100644 --- a/.agents/skills/feature-implement/SKILL.md +++ b/.agents/skills/feature-implement/SKILL.md @@ -38,7 +38,8 @@ detailed it looks. This is the step that used to be structural: `/feature-plan` stopped and you typed a second command. It is explicit here now, or it is lost. -1. **Surface the plan's §8 Open questions and require an acknowledgement.** Do not proceed on silence. +1. **Surface the plan's Open questions section and require an acknowledgement.** Do not proceed on + silence. Cite it by name — a plan written against an earlier template numbers its sections differently. 2. **Re-check that the files the plan cites still exist.** A plan drafted a month ago against a since-changed tree is a state that can now exist and could not before. Name anything that has moved. 3. **Check the one-active-feature rule** in [`context/workflow.md`](../../../context/workflow.md). If @@ -65,10 +66,31 @@ If the ledger's claim contradicts the repo — a phase marked `done` whose **Fil plainly in the tree under a phase marked `not started` — **say so and stop.** Never silently re-do or skip a phase on a stale ledger. -## 6. Do the work +## 6. Open the ledger row + +Set the phase's Status to `in progress` and write a Note naming what is underway — **before any code.** + +This row is what a *later* session reads. A phase interrupted here — context exhausted, session closed, run +cancelled — leaves a working tree with half a phase in it. A row still reading `not started` sends the next +run into step 5's disagreement stop, or into redoing work that is already there. + +One token and one Note, in the row that is already there — do not move the entry, restructure the table, or +write a summary anywhere else. If the row is already `in progress` because you are resuming it, leave it +alone; step 11 rewrites the Note. + +**This write is not a change of its own.** Leave it in the working tree — it lands with the phase's work +under either answer in [`context/git.md`](../../../context/git.md). Never commit it on its own. + +## 7. Do the work Read the phase's §6.2 sub-section: its scope, its **Files:**, and what `done` means for it. +**The plan's Documentation rows assigned to this phase are part of this phase**, not a follow-up — their +paths are on the same **Files:** line as the code. Per the standing rule in +[`context/workflow.md`](../../../context/workflow.md), whatever this phase makes untrue is fixed by this +phase. If the work turned out differently from the plan and made something *else* untrue — a README the +plan never listed — fix that too and say so; the sweep happened before the code existed. + Delegate to a coder per [`context/executors.md`](../../../context/executors.md) if one is configured; otherwise implement in-host. The coder's system prompt is [`context/roles/coder.md`](../../../context/roles/coder.md). @@ -79,7 +101,7 @@ this repository can open them, and a brief that inlines them is a brief that goe Describe **what** needs to happen, never **how** to code it. Scope each delegated task to specific files. -## 7. Gate 1 — verification +## 8. Gate 1 — verification Per the gate contract in [`context/workflow.md`](../../../context/workflow.md): read [`context/verify.md`](../../../context/verify.md) and run its sections in order — Lint → Typecheck → Build → @@ -89,9 +111,9 @@ Test. so, never faked. Exit 0 is the verdict regardless of summary text. If `verify.md` does not exist or has no filled-in section, stop and say so. Docs-only changes run Lint plus a read of the diff. -A failure is the verdict — go to step 9 with the failing output verbatim as the feedback. +A failure is the verdict — go to step 10 with the failing output verbatim as the feedback. -## 8. Gate 2 — review +## 9. Gate 2 — review Dispatch per [`context/executors.md`](../../../context/executors.md). With no independent reviewer configured, review the diff yourself against the plan's review expectations and the standards — weaker, and @@ -100,13 +122,13 @@ configured, review the diff yourself against the plan's review expectations and Require concrete evidence — file paths, command output — for every verdict, and a `P0`–`P3` severity on every blocking finding. -- `PASS` or `PASS WITH NOTES` → the phase's work is done; go to step 10. -- `FAIL` → **write it to [`context/findings.md`](../../../context/findings.md) first, then** go to step 9. +- `PASS` or `PASS WITH NOTES` → the phase's work is done; go to step 11. +- `FAIL` → **write it to [`context/findings.md`](../../../context/findings.md) first, then** go to step 10. **Write the finding before the loopback, not after it.** A verdict that lives only in this session's transcript evaporates when the conversation ends — including a `P0` the cap never got to. -## 9. Loopback +## 10. Loopback Cap: **two loops per gate, per phase.** @@ -118,20 +140,34 @@ At the cap: **write a finding** (`P1` for a Gate 1 cap-out — a phase whose ver blocked by definition), then escalate to the user with the current state and the last feedback. **Escalating is not a substitute for recording.** -## 10. Close out the ledger row +## 11. Close out the ledger row -In the same commit as the work: +The row is part of the same change as the work — never a separate step afterwards: -- **All of the phase's scope landed and both gates passed** → `done`. +- **All of the phase's scope landed and both gates passed** → `done`. Its documentation rows are part of + that scope: a phase whose doc update has not landed has not landed. - **Some landed** → stays `in progress`, Note rewritten to name exactly what remains. - **A gate hit its cap, or something external blocks it** → `blocked`, with the blocker in the Note. **Never mark `done` on a coder's self-report** — the gate output is the evidence. **Refuse `done` while an open `P0` or `P1` is tied to this phase**; leave it `in progress` and name the finding. -## 11. Report +`done` is a verdict about the gates, not about git. Whether the change is committed at all is the next step. + +## 12. Land it — read [`context/git.md`](../../../context/git.md) + +**Do not commit until you have read that file, and do not commit at all unless it says the agent does.** +It is the only place this project's answer lives, the same way `verify.md` is the only place its commands +live. If it does not exist — an install from before it shipped — the answer is *the user commits*: say so +once, and name `/onboard`. + +- **The user commits** → leave the change in the working tree, ledger row and all. Report it, hand it over, + and stop. Do not stage-and-commit "to be helpful", and do not push or branch under either answer. +- **The agent commits** → the code and the ledger row in one commit, at the granularity that file names. + +## 13. Report -- What changed, and which files. +- What changed, and which files — and whether it is committed or waiting in the tree. - Gate 1 output, and Gate 2's verdict. - Loopback counts, if any. - Findings written or closed, by id. diff --git a/.agents/skills/feature-plan/SKILL.md b/.agents/skills/feature-plan/SKILL.md index a720534..d8bc584 100644 --- a/.agents/skills/feature-plan/SKILL.md +++ b/.agents/skills/feature-plan/SKILL.md @@ -89,11 +89,22 @@ inline. Either way the brief is the same, and the output contract is the templat planner's own default shape: - The roadmap entry verbatim, and the full content of its draft if there was one. +- **If `prototypes//` exists at the repository root**, its `NOTES.md` and the mockups beside it. A + sketch someone has already looked at settles a design question that a paragraph would only argue. Carry + what it settled into §4 Design and cite the folder; treat anything it marked invented as a proposal, not + a fact. **No folder, no step** — this is a conditional read, not a prerequisite. - **The full section list from `context/plan-template.md`, stated as required output**, in order, with the ledger's exact column set. A general-purpose planner will otherwise emit implementation-steps-and- acceptance-criteria — a per-phase artifact, not a plan — and you will throw it away. - Pointers to `context/stack.md`, `context/standards/README.md` (load per its conditional table) and `context/verify.md`. Cite the paths; do not paste the files in. Anything reading this repo can open them. +- **Where this project documents itself, and what this feature makes untrue there.** Start from the + Documentation section of `context/stack.md`. **If that section is empty, missing, or names less than the + tree plainly holds, sweep for it** — the root `README`, a `README` in each package, `docs/`, a docs site + or landing page in the repository, an API reference or OpenAPI document, a changelog, help text and + format comments that live in the code. Ask the user about anything hosted elsewhere: a wiki, a docs site + built from another repo, a published reference. **An index nobody filled in is not evidence that there + are no docs**, and a plan that assumes it is ships the drift. - **Cite file paths and command output for every claim about the current codebase.** Anything unverified is an open question, not an assertion. - Phases are **commit-sized units with checkable outcomes**, each with a real `Depends on` value and a @@ -107,7 +118,11 @@ Fill in the template's shape. Then: - Date it and point its header at the roadmap entry. - **No `**Status:**` header.** Feature status lives in `roadmap.md`, phase status in the ledger. A document that claims its own status is a copy that goes stale. -- Fill in **§8 Open questions** honestly. An honest gap is worth more than an invented decision. +- Fill in **§7 Documentation** from what the sweep found: one row per surface the feature changes, each + assigned to the phase that carries it, **and that phase's `Files:` line names the same path.** A + documentation row with no phase is a follow-up nobody does. If nothing changes, say which surfaces you + checked and why none of them describe this — that is an answer, and leaving the section blank is not. +- Fill in **§9 Open questions** honestly. An honest gap is worth more than an invented decision. - Every phase is `not started`. ### 6. Update the roadmap entry @@ -125,7 +140,8 @@ own; discarding it over a marker would undo the point of the split. ### 7. Report and stop -State the document path, the phase count, and the open questions. Then say plainly that **what you produced +State the document path, the phase count, the documentation surfaces §7 commits to updating, and the open +questions. Then say plainly that **what you produced is a reviewable skeleton plus open questions, not a finished plan of record** — the value is the structure and the research. Name the next step: the user reviews and edits the plan, and `/feature-implement` runs it once they are satisfied. diff --git a/.agents/skills/feature-status/SKILL.md b/.agents/skills/feature-status/SKILL.md index d419921..a671603 100644 --- a/.agents/skills/feature-status/SKILL.md +++ b/.agents/skills/feature-status/SKILL.md @@ -40,8 +40,10 @@ stale. That is the exact failure this workflow exists to prevent. - Every phase `done` while the entry still reads `active` — that is the normal state before `/feature-close`. Next action 5 handles it. -- A `done` row with its changes still in the working tree — a phase finished but not committed. Name it in - the report; do not stop on it. +- A `done` row with its changes still in the working tree. Under the default policy in + [`context/git.md`](../../../context/git.md) that is the normal end state of a phase, not a discrepancy — + the user commits. Name it in the report; do not stop on it, and do not commit it: this command writes + nothing. ## 3. Report diff --git a/.agents/skills/onboard/SKILL.md b/.agents/skills/onboard/SKILL.md index 1d29b78..9f06a8c 100644 --- a/.agents/skills/onboard/SKILL.md +++ b/.agents/skills/onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: onboard -description: "Fill in this project's own workflow stubs — context/verify.md, context/executors.md and context/stack.md — by adopting what the repository already documented, asking wherever a claim's destination is unclear, and running each candidate verification command so only the ones that pass are written down. Explicit invocation only — run this when the user types /onboard. Do NOT match on 'set up the project', 'get started', or general setup requests." +description: "Fill in this project's own workflow stubs — context/verify.md, context/executors.md, context/git.md and context/stack.md — by adopting what the repository already documented, asking wherever a claim's destination is unclear, and running each candidate verification command so only the ones that pass are written down. Explicit invocation only — run this when the user types /onboard. Do NOT match on 'set up the project', 'get started', or general setup requests." --- # /onboard @@ -9,6 +9,11 @@ Fills the project-owned stubs the installer deliberately left empty, and folds w already documented into them. **Re-runnable** — run it again after the stack changes, and it re-proposes against what is there now. +**Run it after an `update`, too.** The updater replaces tool-owned files only; the stubs are project-owned +and it cannot reach them, so a section a new version's stub gained arrives only through this command. The +update prints the gaps it found under **Next** — every one of them is this command's work. Until it runs, +a command can be pointed at a section of a file that does not have it. + **Asking is not guessing.** The installer could have detected a test command and written it in; that is exactly how a file ends up naming a command that has never run. This command asks, and where it can, it *checks*. @@ -21,8 +26,9 @@ Read [`context/workflow.md`](../../../context/workflow.md) for the tier model. |---|---| | [`context/verify.md`](../../../context/verify.md) | the real Lint / Typecheck / Build / Test commands — **only ones that exited 0** | | [`context/executors.md`](../../../context/executors.md) | how this project dispatches a coder and a reviewer | -| [`context/stack.md`](../../../context/stack.md) | runtime, layout, conventions | -| [`AGENTS.md`](../../../AGENTS.md) | pruned, on confirmation, of what moved into those three. The region between the `ai-workflow` markers is never touched | +| [`context/git.md`](../../../context/git.md) | who commits the work, and at what granularity | +| [`context/stack.md`](../../../context/stack.md) | runtime, layout, conventions, and the index of where this project documents itself | +| [`AGENTS.md`](../../../AGENTS.md) | pruned, on confirmation, of what moved into those four. The region between the `ai-workflow` markers is never touched | Show every proposed edit before writing it, and **do not commit.** The user reviews and commits. @@ -44,8 +50,10 @@ Break what you read into claims — a paragraph, a table row, a bullet — and p | A claim about | Goes to | |---|---| | what the project is, its layout, its conventions | `context/stack.md` | -| a lint, typecheck, build or test command | a **candidate** for `context/verify.md` — Step 5 still has to run it | +| where the project's own documentation lives, or how it is published | `context/stack.md`, its Documentation section | +| a lint, typecheck, build or test command | a **candidate** for `context/verify.md` — Step 6 still has to run it | | how a coder or a reviewer is dispatched | `context/executors.md` | +| a rule about who commits, or when work is committed | `context/git.md` | | a rule the bundled standards already state | nowhere — the standards own it. Ask before dropping | | planning or review process this workflow now owns | nowhere — superseded. Ask before dropping | | anything else — ownership, contacts, external links | stays in `AGENTS.md`, outside the block | @@ -63,10 +71,15 @@ destination. Two kinds of row are never decided alone: An adopted claim is an *input* to the steps below, not a substitute for them. A command lifted out of the old file is a candidate like any other and still has to run. -**Nothing is deleted here.** Pruning is Step 7, after the destination files exist. +**Nothing is deleted here.** Pruning is Step 8, after the destination files exist. On a re-run, a claim its destination file already states is already adopted. Say so in a line and move on. +**If an `update` named a missing section** — `context/stack.md` with no Documentation section, a stub that +is not there at all — add the heading in the shipped stub's order, then fill it through the step below that +owns it. Do not rewrite what is already there to match a newer stub: the section is the part that is new, +not the prose someone wrote about their own project. + ## Step 2 — Coder dispatch Ask whether implementation runs **in-host** or is **offloaded** to an external coder CLI. @@ -87,7 +100,7 @@ on that being true, and it is true of some executors and not others. 4. If it comes back empty, guessed, or refused, record that **this executor needs content inline** — the one case where a brief carries file contents instead of paths. -Never write down an invocation you have not run. This is the same rule as Step 5, for the same reason. +Never write down an invocation you have not run. This is the same rule as Step 6, for the same reason. ## Step 3 — Reviewer dispatch @@ -107,7 +120,33 @@ winner: it differs per host and changes underneath you. What ships is the contra Whatever is chosen, that contract stands: a review happens, every blocking finding carries a `P0`–`P3` severity, and a `FAIL` writes a finding before the loopback. -## Step 4 — Standards source +## Step 4 — Who commits + +**Ask this one. It is the question the workflow used to answer by inference.** + +Every command that lands code closes out by updating a ledger row *as part of the same change as the work*, +and `done` has always meant the scope landed and the gates passed. Given no policy, an agent resolves that +the only way it can — by committing, every phase, in someone else's repository. That is a call for the +project to make, so ask it and write the answer to [`context/git.md`](../../../context/git.md). + +Ask, using your runtime's question mechanism if it has one: + +- **The user commits** — the default, and what the stub ships saying. A phase ends verified, with its + ledger row updated, left in the working tree. The agent reports and stops. +- **The agent commits** — one commit per phase, the code and its ledger row together. + +Then confirm the granularity if the agent commits, and **say plainly what is not being decided**: nothing in +this workflow branches, pushes, or opens a pull request under either answer, and choosing one does not start +that. + +Write the answer as the surviving line under **Who commits**, and delete the other. If `context/git.md` does +not exist — an install from before it shipped — create it, with a `## Who commits` section holding the +chosen line, a `## Granularity` section, and a line recording that branches and pushes are out of scope. + +If Step 1 turned up an existing rule about committing, quote it here and let it win unless the user says +otherwise. Prose someone wrote about their own repository beats a default. + +## Step 5 — Standards source `context/standards/` ships with a bundled default. Ask whether that is right for this project. @@ -121,7 +160,7 @@ Say plainly what the default is and that a wrong set is not inert — agents loa conditional table unprompted, on every task. If Step 1 turned up house rules that the bundled set already covers, this is the moment that matters: keeping both means the project has two answers. -## Step 5 — Verification commands +## Step 6 — Verification commands **This is the most valuable step in this command.** Do it properly. @@ -143,7 +182,7 @@ covers, this is the moment that matters: keeping both means the project has two Explain what you are doing: this turns `verify.md` from someone's guess into something verified at install time, which is the one moment it is cheap to catch. -## Step 6 — Stack +## Step 7 — Stack Start from what Step 1 routed here, show it back as a draft, and ask only for the gaps: @@ -155,12 +194,29 @@ Start from what Step 1 routed here, show it back as a draft, and ask only for th that earns its keep; the rest is discoverable. Inherited prose is usually strongest here and weakest at describing layout, which drifts. +Then fill in the **Documentation** section, which is the one every later plan reads: + +1. **Sweep the repository.** The root `README`, a `README` in every package, `docs/`, a docs site or + landing page built from this repo, an API reference or OpenAPI document, a changelog, a `man` page or + `--help` text that lives in the code, a comment that is the only description of a file format. +2. **Ask what is published elsewhere** — a wiki, a hosted docs site built from another repository, a + support centre, a public API reference. Nothing in the tree can reveal those, and they are the surfaces + that rot longest without anyone noticing. +3. **Ask which of them are actually maintained.** A directory nobody has touched in two years is worth + recording as exactly that; a plan can then say so instead of proposing an update to a dead file. +4. **Write "none" if there is none.** An empty section reads as "nobody checked", and `/feature-plan` + cannot tell those apart — it sweeps the tree itself when the section is empty, which finds files but + never finds the docs site nobody mentioned. + +Say what this is for: every plan's §7 starts from this list, and whatever a feature makes untrue there is +fixed by the phase that makes it untrue. + Point out that anything else added under `context/` should be indexed in `stack.md`, not in `context/README.md`, which is tool-owned and replaced on update. -## Step 7 — Prune the sources +## Step 8 — Prune the sources -Only now, with the three stubs written, remove from `AGENTS.md` and `CLAUDE.md` what has landed elsewhere. +Only now, with the four stubs written, remove from `AGENTS.md` and `CLAUDE.md` what has landed elsewhere. Duplication left standing is the failure this step exists to prevent: two statements of one fact drift, and the stale copy is indistinguishable from the live one. diff --git a/.agents/skills/orchestrate/SKILL.md b/.agents/skills/orchestrate/SKILL.md index 7d43917..eef5994 100644 --- a/.agents/skills/orchestrate/SKILL.md +++ b/.agents/skills/orchestrate/SKILL.md @@ -45,6 +45,11 @@ A refusal here is the workflow working. Read `context/stack.md` and load `context/standards/README.md` per its conditional table. +**Check that file's Documentation section, and the tree if it is empty.** If this change makes something +there wrong — a README, a docs page, a changelog, help text in the code — the fix is part of this change, +per the standing rule in [`context/workflow.md`](../../../context/workflow.md). A one-shot change is where +that gets skipped most, because there is no plan holding the row. + Delegate to a coder per [`context/executors.md`](../../../context/executors.md) if one is configured; otherwise implement in-host. The coder's system prompt is [`context/roles/coder.md`](../../../context/roles/coder.md). The brief **cites paths, it does not paste @@ -79,10 +84,18 @@ expand no scope. At the cap: write a finding (`P1` for a Gate 1 cap-out), then escalate with the current state and the last feedback. **Escalating is not a substitute for recording.** -## 6. Report +## 6. Land it — read [`context/git.md`](../../../context/git.md) + +**Do not commit unless that file says the agent does.** If it does not exist, the answer is *the user +commits*: say so once, and name `/onboard`. Nothing branches or pushes under either answer. + +- **The user commits** → leave the change in the working tree and hand it over. +- **The agent commits** → one commit, at the granularity that file names. + +## 7. Report -What changed, the Gate 1 output, the Gate 2 verdict, any loopbacks, and any findings written, closed or -swept — by id. +What changed, whether it is committed or waiting in the tree, the Gate 1 output, the Gate 2 verdict, any +loopbacks, and any findings written, closed or swept — by id. ## Rules diff --git a/.agents/skills/prototype/SKILL.md b/.agents/skills/prototype/SKILL.md new file mode 100644 index 0000000..980c143 --- /dev/null +++ b/.agents/skills/prototype/SKILL.md @@ -0,0 +1,115 @@ +--- +name: prototype +description: "Mock one screen or interaction as throwaway HTML and CSS under prototypes/, to settle a layout or look-and-feel question before a plan commits to it. Writes no application code and runs no gates. Explicit invocation only — run this when the user types /prototype. Do NOT match on 'mock this up', 'try a layout', 'what should this look like', or any request to build, style or restyle real UI." +--- + +# /prototype + +A cheap, disposable sketch of something that will be built properly later. **No roadmap entry, no ledger, +no gates, no tier boundary crossed** — and no application source is touched. + +It exists because some questions are only answerable by looking at the thing. Arguing about a layout in a +plan document is slow and inconclusive; two static files answer it in minutes, and the answer then goes +into the plan as a decision rather than a paragraph of hedging. **Nothing in the workflow requires this +command** — skip it and every other command behaves identically. + +Read [`context/workflow.md`](../../../context/workflow.md) for the tier model and the feature/task rule. + +## Usage + +``` +/prototype "" # a screen, a flow, an interaction +/prototype "" # mock what a roadmap entry describes +``` + +## 0. Refuse, before anything else + +1. **Refuse to build the real thing.** This command produces throwaway files outside the application. The + moment the request is "now wire it up" or "put this in the app", it belongs to `/orchestrate` if it is + commit-sized, or to `/feature-plan` and `/feature-implement` if it is a feature. Name the one that + applies and stop. +2. **Refuse to sketch the whole application.** A screen or two, chosen because something about them is + actually in question. If the answer is "all of them", ask which decision is being made and mock the two + screens that decide it. + +Say which question the mockup exists to answer, in one line, before writing anything. + +## 1. Read what this project already looks like + +**Borrow before inventing.** This repository exists already, so most of the look is decided — and a mockup +built from values the project does not have is a picture of an app nobody is going to build. + +Read `context/stack.md`, then find the real thing: the stylesheet or token file, the design system or +component library, and two or three existing screens close to what is being mocked. Copy the actual token +names, spacing scale, radii and type stack out of them. + +Invent only where there is genuinely nothing to borrow — a new surface, or a project with no styling of its +own yet. **Mark every invented value**, so the plan that reads this knows which parts are proposals and +which are already true. + +## 2. Agree the sketch, then stop + +Ask a short set of questions — which screens, which states, and what specifically is undecided. Use your +runtime's question mechanism if it has one, or a plain numbered question if it does not. + +Then propose, and **wait**: + +- the screens, one line each on what each one shows, +- the states each will exercise, and +- which values are borrowed and which are invented. + +**Write nothing until the user approves.** Adjust the proposal if they push back. Generating five files +before anyone has agreed what is being asked is how a fast tool becomes a slow one. + +## 3. Write the mockups + +Everything goes under `prototypes//` at the repository root, where `` is the kebab-case subject +— matching the roadmap entry's name when there is one. + +| File | Holds | +|---|---| +| `theme.css` | every value the mockups use, in one place — the project's real tokens copied in, and invented ones marked with a comment | +| `.html` | one self-contained page per screen, linking `theme.css` | +| `NOTES.md` | the question this exists to answer, what it settled, what is still open, and which values were invented | + +- **Plain HTML and CSS. No framework, no build step, no dependencies.** A few lines of inline script for a + view toggle is fine; anything more means this stopped being a sketch. +- **Every colour, font and spacing value comes from a `theme.css` variable**, never hard-coded — that is + what makes tweaking one file restyle every screen at once. +- **Realistic content and the states that matter** — the empty list, the error, the mid-action moment, the + name that is too long. An empty shell full of lorem ipsum answers nothing. Desktop-first is enough unless + the question is about small screens. +- Never transcribe a credential. Placeholder content is invented, and a real key is never realistic detail. + +`NOTES.md` is the durable part. The HTML is scaffolding for a conversation; the notes are what a plan reads. + +## 4. It gets committed + +`prototypes/` is yours, not the tool's — nothing installs it, nothing updates it, nothing validates it. +**It gets committed anyway.** Until a plan absorbs `NOTES.md` its conclusions live nowhere else, and neither +the notes nor the mockups survive a cleared context or a second machine if they were never tracked. + +Who does the committing is [`context/git.md`](../../../context/git.md)'s answer, not this command's. Where +the user commits, say plainly that these files are worth tracking rather than leaving them to be mistaken +for scratch output. + +## 5. Hand off and stop + +Report the folder path, and tell the user to open the files in a browser and iterate on the look — that +iteration is the point, and it costs nothing. + +Then name exactly one next step, and **do not run it**: + +- **No roadmap entry yet** → `/roadmap ""`, citing `prototypes//`. +- **An entry exists** → `/feature-plan ""`, which reads the folder and carries what it settled into + the plan's Design section. + +## Rules + +- **Nothing outside `prototypes/` is written.** Not application source, not `context/`, not `roadmap.md`. + This command creates no entry, marks nothing active, and touches no ledger row. +- **No gates.** Nothing here is verified or reviewed, because there is nothing to verify — no test covers + a throwaway file, and review time spent on one is time wasted. Work that needs a gate is not a mockup. +- **Throwaway means throwaway.** When the feature ships, delete `prototypes//` and the citation that + pointed at it. A mockup that no longer matches the app is worse than no mockup, because someone will + believe it. diff --git a/.claude/agents/planner.agent.md b/.claude/agents/planner.agent.md index 45474ee..6a12bcd 100644 --- a/.claude/agents/planner.agent.md +++ b/.claude/agents/planner.agent.md @@ -33,9 +33,13 @@ The default shape below applies only when the brief names none. generic principles. 2. **Read the project.** `context/stack.md` for what this repo is; `context/verify.md` for how it proves itself. Search the codebase and find the patterns that already exist. -3. **Verify externals.** Check documentation for every library and API involved. Cite what you confirmed. -4. **Consider.** Edge cases, error states, and implicit requirements the request did not mention. -5. **Draft.** Say *what* needs to happen, not *how* to code it. +3. **Find the documentation.** `context/stack.md`'s Documentation section says where this project explains + itself. If it is empty, sweep for it — READMEs, `docs/`, a docs site in the tree, an API reference, a + changelog, help text in the code. Report what the change would make untrue there, per surface. An empty + index is not evidence that there are none. +4. **Verify externals.** Check documentation for every library and API involved. Cite what you confirmed. +5. **Consider.** Edge cases, error states, and implicit requirements the request did not mention. +6. **Draft.** Say *what* needs to happen, not *how* to code it. ## Default output shape diff --git a/.claude/skills/feature-close/SKILL.md b/.claude/skills/feature-close/SKILL.md index f9fc7d0..abab675 100644 --- a/.claude/skills/feature-close/SKILL.md +++ b/.claude/skills/feature-close/SKILL.md @@ -80,6 +80,11 @@ grep -rn "\|" --include='*.md' . | grep -v node_modules Move every **closed** finding tied to this feature out of `context/findings.md` and into the archived plan's own log. `findings.md` must not grow for the life of the project. +Then read [`context/git.md`](../../../context/git.md) before committing anything. `git mv` stages a rename +and writes no history, so it is safe under either answer — but the commit that carries it is the agent's to +make only where that file says so. If it does not exist, the answer is *the user commits*: show the whole +retirement as one reviewable change and hand it over. + ## Mode 2 — `--dropped` For an entry that will not be built. **There is no ledger check in this mode** — unfinished phases are @@ -98,6 +103,6 @@ expected. - **Never delete a plan document.** Archiving keeps the reasoning; deleting throws away the record of a decision someone will otherwise re-litigate. - **Never leave `roadmap.md` and `history.md` inconsistent.** An entry is in exactly one of them. -- **Never commit the sweep unreviewed.** +- **Never commit the sweep unreviewed** — and never commit it at all unless `git.md` says the agent commits. - **Never mark a phase `done` to get past the refusal.** If phases are unfinished, the feature is unfinished. diff --git a/.claude/skills/feature-implement/SKILL.md b/.claude/skills/feature-implement/SKILL.md index 99ed27f..348d5e9 100644 --- a/.claude/skills/feature-implement/SKILL.md +++ b/.claude/skills/feature-implement/SKILL.md @@ -39,7 +39,8 @@ detailed it looks. This is the step that used to be structural: `/feature-plan` stopped and you typed a second command. It is explicit here now, or it is lost. -1. **Surface the plan's §8 Open questions and require an acknowledgement.** Do not proceed on silence. +1. **Surface the plan's Open questions section and require an acknowledgement.** Do not proceed on + silence. Cite it by name — a plan written against an earlier template numbers its sections differently. 2. **Re-check that the files the plan cites still exist.** A plan drafted a month ago against a since-changed tree is a state that can now exist and could not before. Name anything that has moved. 3. **Check the one-active-feature rule** in [`context/workflow.md`](../../../context/workflow.md). If @@ -66,10 +67,31 @@ If the ledger's claim contradicts the repo — a phase marked `done` whose **Fil plainly in the tree under a phase marked `not started` — **say so and stop.** Never silently re-do or skip a phase on a stale ledger. -## 6. Do the work +## 6. Open the ledger row + +Set the phase's Status to `in progress` and write a Note naming what is underway — **before any code.** + +This row is what a *later* session reads. A phase interrupted here — context exhausted, session closed, run +cancelled — leaves a working tree with half a phase in it. A row still reading `not started` sends the next +run into step 5's disagreement stop, or into redoing work that is already there. + +One token and one Note, in the row that is already there — do not move the entry, restructure the table, or +write a summary anywhere else. If the row is already `in progress` because you are resuming it, leave it +alone; step 11 rewrites the Note. + +**This write is not a change of its own.** Leave it in the working tree — it lands with the phase's work +under either answer in [`context/git.md`](../../../context/git.md). Never commit it on its own. + +## 7. Do the work Read the phase's §6.2 sub-section: its scope, its **Files:**, and what `done` means for it. +**The plan's Documentation rows assigned to this phase are part of this phase**, not a follow-up — their +paths are on the same **Files:** line as the code. Per the standing rule in +[`context/workflow.md`](../../../context/workflow.md), whatever this phase makes untrue is fixed by this +phase. If the work turned out differently from the plan and made something *else* untrue — a README the +plan never listed — fix that too and say so; the sweep happened before the code existed. + Delegate to a coder per [`context/executors.md`](../../../context/executors.md) if one is configured; otherwise implement in-host. The coder's system prompt is [`context/roles/coder.md`](../../../context/roles/coder.md). @@ -80,7 +102,7 @@ this repository can open them, and a brief that inlines them is a brief that goe Describe **what** needs to happen, never **how** to code it. Scope each delegated task to specific files. -## 7. Gate 1 — verification +## 8. Gate 1 — verification Per the gate contract in [`context/workflow.md`](../../../context/workflow.md): read [`context/verify.md`](../../../context/verify.md) and run its sections in order — Lint → Typecheck → Build → @@ -90,9 +112,9 @@ Test. so, never faked. Exit 0 is the verdict regardless of summary text. If `verify.md` does not exist or has no filled-in section, stop and say so. Docs-only changes run Lint plus a read of the diff. -A failure is the verdict — go to step 9 with the failing output verbatim as the feedback. +A failure is the verdict — go to step 10 with the failing output verbatim as the feedback. -## 8. Gate 2 — review +## 9. Gate 2 — review Dispatch per [`context/executors.md`](../../../context/executors.md). With no independent reviewer configured, review the diff yourself against the plan's review expectations and the standards — weaker, and @@ -101,13 +123,13 @@ configured, review the diff yourself against the plan's review expectations and Require concrete evidence — file paths, command output — for every verdict, and a `P0`–`P3` severity on every blocking finding. -- `PASS` or `PASS WITH NOTES` → the phase's work is done; go to step 10. -- `FAIL` → **write it to [`context/findings.md`](../../../context/findings.md) first, then** go to step 9. +- `PASS` or `PASS WITH NOTES` → the phase's work is done; go to step 11. +- `FAIL` → **write it to [`context/findings.md`](../../../context/findings.md) first, then** go to step 10. **Write the finding before the loopback, not after it.** A verdict that lives only in this session's transcript evaporates when the conversation ends — including a `P0` the cap never got to. -## 9. Loopback +## 10. Loopback Cap: **two loops per gate, per phase.** @@ -119,20 +141,34 @@ At the cap: **write a finding** (`P1` for a Gate 1 cap-out — a phase whose ver blocked by definition), then escalate to the user with the current state and the last feedback. **Escalating is not a substitute for recording.** -## 10. Close out the ledger row +## 11. Close out the ledger row -In the same commit as the work: +The row is part of the same change as the work — never a separate step afterwards: -- **All of the phase's scope landed and both gates passed** → `done`. +- **All of the phase's scope landed and both gates passed** → `done`. Its documentation rows are part of + that scope: a phase whose doc update has not landed has not landed. - **Some landed** → stays `in progress`, Note rewritten to name exactly what remains. - **A gate hit its cap, or something external blocks it** → `blocked`, with the blocker in the Note. **Never mark `done` on a coder's self-report** — the gate output is the evidence. **Refuse `done` while an open `P0` or `P1` is tied to this phase**; leave it `in progress` and name the finding. -## 11. Report +`done` is a verdict about the gates, not about git. Whether the change is committed at all is the next step. + +## 12. Land it — read [`context/git.md`](../../../context/git.md) + +**Do not commit until you have read that file, and do not commit at all unless it says the agent does.** +It is the only place this project's answer lives, the same way `verify.md` is the only place its commands +live. If it does not exist — an install from before it shipped — the answer is *the user commits*: say so +once, and name `/onboard`. + +- **The user commits** → leave the change in the working tree, ledger row and all. Report it, hand it over, + and stop. Do not stage-and-commit "to be helpful", and do not push or branch under either answer. +- **The agent commits** → the code and the ledger row in one commit, at the granularity that file names. + +## 13. Report -- What changed, and which files. +- What changed, and which files — and whether it is committed or waiting in the tree. - Gate 1 output, and Gate 2's verdict. - Loopback counts, if any. - Findings written or closed, by id. diff --git a/.claude/skills/feature-plan/SKILL.md b/.claude/skills/feature-plan/SKILL.md index 230ef6b..8ad1778 100644 --- a/.claude/skills/feature-plan/SKILL.md +++ b/.claude/skills/feature-plan/SKILL.md @@ -90,11 +90,22 @@ inline. Either way the brief is the same, and the output contract is the templat planner's own default shape: - The roadmap entry verbatim, and the full content of its draft if there was one. +- **If `prototypes//` exists at the repository root**, its `NOTES.md` and the mockups beside it. A + sketch someone has already looked at settles a design question that a paragraph would only argue. Carry + what it settled into §4 Design and cite the folder; treat anything it marked invented as a proposal, not + a fact. **No folder, no step** — this is a conditional read, not a prerequisite. - **The full section list from `context/plan-template.md`, stated as required output**, in order, with the ledger's exact column set. A general-purpose planner will otherwise emit implementation-steps-and- acceptance-criteria — a per-phase artifact, not a plan — and you will throw it away. - Pointers to `context/stack.md`, `context/standards/README.md` (load per its conditional table) and `context/verify.md`. Cite the paths; do not paste the files in. Anything reading this repo can open them. +- **Where this project documents itself, and what this feature makes untrue there.** Start from the + Documentation section of `context/stack.md`. **If that section is empty, missing, or names less than the + tree plainly holds, sweep for it** — the root `README`, a `README` in each package, `docs/`, a docs site + or landing page in the repository, an API reference or OpenAPI document, a changelog, help text and + format comments that live in the code. Ask the user about anything hosted elsewhere: a wiki, a docs site + built from another repo, a published reference. **An index nobody filled in is not evidence that there + are no docs**, and a plan that assumes it is ships the drift. - **Cite file paths and command output for every claim about the current codebase.** Anything unverified is an open question, not an assertion. - Phases are **commit-sized units with checkable outcomes**, each with a real `Depends on` value and a @@ -108,7 +119,11 @@ Fill in the template's shape. Then: - Date it and point its header at the roadmap entry. - **No `**Status:**` header.** Feature status lives in `roadmap.md`, phase status in the ledger. A document that claims its own status is a copy that goes stale. -- Fill in **§8 Open questions** honestly. An honest gap is worth more than an invented decision. +- Fill in **§7 Documentation** from what the sweep found: one row per surface the feature changes, each + assigned to the phase that carries it, **and that phase's `Files:` line names the same path.** A + documentation row with no phase is a follow-up nobody does. If nothing changes, say which surfaces you + checked and why none of them describe this — that is an answer, and leaving the section blank is not. +- Fill in **§9 Open questions** honestly. An honest gap is worth more than an invented decision. - Every phase is `not started`. ### 6. Update the roadmap entry @@ -126,7 +141,8 @@ own; discarding it over a marker would undo the point of the split. ### 7. Report and stop -State the document path, the phase count, and the open questions. Then say plainly that **what you produced +State the document path, the phase count, the documentation surfaces §7 commits to updating, and the open +questions. Then say plainly that **what you produced is a reviewable skeleton plus open questions, not a finished plan of record** — the value is the structure and the research. Name the next step: the user reviews and edits the plan, and `/feature-implement` runs it once they are satisfied. diff --git a/.claude/skills/feature-status/SKILL.md b/.claude/skills/feature-status/SKILL.md index b24a8d4..cce561e 100644 --- a/.claude/skills/feature-status/SKILL.md +++ b/.claude/skills/feature-status/SKILL.md @@ -41,8 +41,10 @@ stale. That is the exact failure this workflow exists to prevent. - Every phase `done` while the entry still reads `active` — that is the normal state before `/feature-close`. Next action 5 handles it. -- A `done` row with its changes still in the working tree — a phase finished but not committed. Name it in - the report; do not stop on it. +- A `done` row with its changes still in the working tree. Under the default policy in + [`context/git.md`](../../../context/git.md) that is the normal end state of a phase, not a discrepancy — + the user commits. Name it in the report; do not stop on it, and do not commit it: this command writes + nothing. ## 3. Report diff --git a/.claude/skills/onboard/SKILL.md b/.claude/skills/onboard/SKILL.md index a6dac71..fd89a82 100644 --- a/.claude/skills/onboard/SKILL.md +++ b/.claude/skills/onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: onboard -description: "Fill in this project's own workflow stubs — context/verify.md, context/executors.md and context/stack.md — by adopting what the repository already documented, asking wherever a claim's destination is unclear, and running each candidate verification command so only the ones that pass are written down. Explicit invocation only — run this when the user types /onboard. Do NOT match on 'set up the project', 'get started', or general setup requests." +description: "Fill in this project's own workflow stubs — context/verify.md, context/executors.md, context/git.md and context/stack.md — by adopting what the repository already documented, asking wherever a claim's destination is unclear, and running each candidate verification command so only the ones that pass are written down. Explicit invocation only — run this when the user types /onboard. Do NOT match on 'set up the project', 'get started', or general setup requests." disable-model-invocation: true --- @@ -10,6 +10,11 @@ Fills the project-owned stubs the installer deliberately left empty, and folds w already documented into them. **Re-runnable** — run it again after the stack changes, and it re-proposes against what is there now. +**Run it after an `update`, too.** The updater replaces tool-owned files only; the stubs are project-owned +and it cannot reach them, so a section a new version's stub gained arrives only through this command. The +update prints the gaps it found under **Next** — every one of them is this command's work. Until it runs, +a command can be pointed at a section of a file that does not have it. + **Asking is not guessing.** The installer could have detected a test command and written it in; that is exactly how a file ends up naming a command that has never run. This command asks, and where it can, it *checks*. @@ -22,8 +27,9 @@ Read [`context/workflow.md`](../../../context/workflow.md) for the tier model. |---|---| | [`context/verify.md`](../../../context/verify.md) | the real Lint / Typecheck / Build / Test commands — **only ones that exited 0** | | [`context/executors.md`](../../../context/executors.md) | how this project dispatches a coder and a reviewer | -| [`context/stack.md`](../../../context/stack.md) | runtime, layout, conventions | -| [`AGENTS.md`](../../../AGENTS.md) | pruned, on confirmation, of what moved into those three. The region between the `ai-workflow` markers is never touched | +| [`context/git.md`](../../../context/git.md) | who commits the work, and at what granularity | +| [`context/stack.md`](../../../context/stack.md) | runtime, layout, conventions, and the index of where this project documents itself | +| [`AGENTS.md`](../../../AGENTS.md) | pruned, on confirmation, of what moved into those four. The region between the `ai-workflow` markers is never touched | Show every proposed edit before writing it, and **do not commit.** The user reviews and commits. @@ -45,8 +51,10 @@ Break what you read into claims — a paragraph, a table row, a bullet — and p | A claim about | Goes to | |---|---| | what the project is, its layout, its conventions | `context/stack.md` | -| a lint, typecheck, build or test command | a **candidate** for `context/verify.md` — Step 5 still has to run it | +| where the project's own documentation lives, or how it is published | `context/stack.md`, its Documentation section | +| a lint, typecheck, build or test command | a **candidate** for `context/verify.md` — Step 6 still has to run it | | how a coder or a reviewer is dispatched | `context/executors.md` | +| a rule about who commits, or when work is committed | `context/git.md` | | a rule the bundled standards already state | nowhere — the standards own it. Ask before dropping | | planning or review process this workflow now owns | nowhere — superseded. Ask before dropping | | anything else — ownership, contacts, external links | stays in `AGENTS.md`, outside the block | @@ -64,10 +72,15 @@ destination. Two kinds of row are never decided alone: An adopted claim is an *input* to the steps below, not a substitute for them. A command lifted out of the old file is a candidate like any other and still has to run. -**Nothing is deleted here.** Pruning is Step 7, after the destination files exist. +**Nothing is deleted here.** Pruning is Step 8, after the destination files exist. On a re-run, a claim its destination file already states is already adopted. Say so in a line and move on. +**If an `update` named a missing section** — `context/stack.md` with no Documentation section, a stub that +is not there at all — add the heading in the shipped stub's order, then fill it through the step below that +owns it. Do not rewrite what is already there to match a newer stub: the section is the part that is new, +not the prose someone wrote about their own project. + ## Step 2 — Coder dispatch Ask whether implementation runs **in-host** or is **offloaded** to an external coder CLI. @@ -88,7 +101,7 @@ on that being true, and it is true of some executors and not others. 4. If it comes back empty, guessed, or refused, record that **this executor needs content inline** — the one case where a brief carries file contents instead of paths. -Never write down an invocation you have not run. This is the same rule as Step 5, for the same reason. +Never write down an invocation you have not run. This is the same rule as Step 6, for the same reason. ## Step 3 — Reviewer dispatch @@ -108,7 +121,33 @@ winner: it differs per host and changes underneath you. What ships is the contra Whatever is chosen, that contract stands: a review happens, every blocking finding carries a `P0`–`P3` severity, and a `FAIL` writes a finding before the loopback. -## Step 4 — Standards source +## Step 4 — Who commits + +**Ask this one. It is the question the workflow used to answer by inference.** + +Every command that lands code closes out by updating a ledger row *as part of the same change as the work*, +and `done` has always meant the scope landed and the gates passed. Given no policy, an agent resolves that +the only way it can — by committing, every phase, in someone else's repository. That is a call for the +project to make, so ask it and write the answer to [`context/git.md`](../../../context/git.md). + +Ask, using your runtime's question mechanism if it has one: + +- **The user commits** — the default, and what the stub ships saying. A phase ends verified, with its + ledger row updated, left in the working tree. The agent reports and stops. +- **The agent commits** — one commit per phase, the code and its ledger row together. + +Then confirm the granularity if the agent commits, and **say plainly what is not being decided**: nothing in +this workflow branches, pushes, or opens a pull request under either answer, and choosing one does not start +that. + +Write the answer as the surviving line under **Who commits**, and delete the other. If `context/git.md` does +not exist — an install from before it shipped — create it, with a `## Who commits` section holding the +chosen line, a `## Granularity` section, and a line recording that branches and pushes are out of scope. + +If Step 1 turned up an existing rule about committing, quote it here and let it win unless the user says +otherwise. Prose someone wrote about their own repository beats a default. + +## Step 5 — Standards source `context/standards/` ships with a bundled default. Ask whether that is right for this project. @@ -122,7 +161,7 @@ Say plainly what the default is and that a wrong set is not inert — agents loa conditional table unprompted, on every task. If Step 1 turned up house rules that the bundled set already covers, this is the moment that matters: keeping both means the project has two answers. -## Step 5 — Verification commands +## Step 6 — Verification commands **This is the most valuable step in this command.** Do it properly. @@ -144,7 +183,7 @@ covers, this is the moment that matters: keeping both means the project has two Explain what you are doing: this turns `verify.md` from someone's guess into something verified at install time, which is the one moment it is cheap to catch. -## Step 6 — Stack +## Step 7 — Stack Start from what Step 1 routed here, show it back as a draft, and ask only for the gaps: @@ -156,12 +195,29 @@ Start from what Step 1 routed here, show it back as a draft, and ask only for th that earns its keep; the rest is discoverable. Inherited prose is usually strongest here and weakest at describing layout, which drifts. +Then fill in the **Documentation** section, which is the one every later plan reads: + +1. **Sweep the repository.** The root `README`, a `README` in every package, `docs/`, a docs site or + landing page built from this repo, an API reference or OpenAPI document, a changelog, a `man` page or + `--help` text that lives in the code, a comment that is the only description of a file format. +2. **Ask what is published elsewhere** — a wiki, a hosted docs site built from another repository, a + support centre, a public API reference. Nothing in the tree can reveal those, and they are the surfaces + that rot longest without anyone noticing. +3. **Ask which of them are actually maintained.** A directory nobody has touched in two years is worth + recording as exactly that; a plan can then say so instead of proposing an update to a dead file. +4. **Write "none" if there is none.** An empty section reads as "nobody checked", and `/feature-plan` + cannot tell those apart — it sweeps the tree itself when the section is empty, which finds files but + never finds the docs site nobody mentioned. + +Say what this is for: every plan's §7 starts from this list, and whatever a feature makes untrue there is +fixed by the phase that makes it untrue. + Point out that anything else added under `context/` should be indexed in `stack.md`, not in `context/README.md`, which is tool-owned and replaced on update. -## Step 7 — Prune the sources +## Step 8 — Prune the sources -Only now, with the three stubs written, remove from `AGENTS.md` and `CLAUDE.md` what has landed elsewhere. +Only now, with the four stubs written, remove from `AGENTS.md` and `CLAUDE.md` what has landed elsewhere. Duplication left standing is the failure this step exists to prevent: two statements of one fact drift, and the stale copy is indistinguishable from the live one. diff --git a/.claude/skills/orchestrate/SKILL.md b/.claude/skills/orchestrate/SKILL.md index dcf4106..30add1e 100644 --- a/.claude/skills/orchestrate/SKILL.md +++ b/.claude/skills/orchestrate/SKILL.md @@ -46,6 +46,11 @@ A refusal here is the workflow working. Read `context/stack.md` and load `context/standards/README.md` per its conditional table. +**Check that file's Documentation section, and the tree if it is empty.** If this change makes something +there wrong — a README, a docs page, a changelog, help text in the code — the fix is part of this change, +per the standing rule in [`context/workflow.md`](../../../context/workflow.md). A one-shot change is where +that gets skipped most, because there is no plan holding the row. + Delegate to a coder per [`context/executors.md`](../../../context/executors.md) if one is configured; otherwise implement in-host. The coder's system prompt is [`context/roles/coder.md`](../../../context/roles/coder.md). The brief **cites paths, it does not paste @@ -80,10 +85,18 @@ expand no scope. At the cap: write a finding (`P1` for a Gate 1 cap-out), then escalate with the current state and the last feedback. **Escalating is not a substitute for recording.** -## 6. Report +## 6. Land it — read [`context/git.md`](../../../context/git.md) + +**Do not commit unless that file says the agent does.** If it does not exist, the answer is *the user +commits*: say so once, and name `/onboard`. Nothing branches or pushes under either answer. + +- **The user commits** → leave the change in the working tree and hand it over. +- **The agent commits** → one commit, at the granularity that file names. + +## 7. Report -What changed, the Gate 1 output, the Gate 2 verdict, any loopbacks, and any findings written, closed or -swept — by id. +What changed, whether it is committed or waiting in the tree, the Gate 1 output, the Gate 2 verdict, any +loopbacks, and any findings written, closed or swept — by id. ## Rules diff --git a/.claude/skills/prototype/SKILL.md b/.claude/skills/prototype/SKILL.md new file mode 100644 index 0000000..1efe068 --- /dev/null +++ b/.claude/skills/prototype/SKILL.md @@ -0,0 +1,116 @@ +--- +name: prototype +description: "Mock one screen or interaction as throwaway HTML and CSS under prototypes/, to settle a layout or look-and-feel question before a plan commits to it. Writes no application code and runs no gates. Explicit invocation only — run this when the user types /prototype. Do NOT match on 'mock this up', 'try a layout', 'what should this look like', or any request to build, style or restyle real UI." +disable-model-invocation: true +--- + +# /prototype + +A cheap, disposable sketch of something that will be built properly later. **No roadmap entry, no ledger, +no gates, no tier boundary crossed** — and no application source is touched. + +It exists because some questions are only answerable by looking at the thing. Arguing about a layout in a +plan document is slow and inconclusive; two static files answer it in minutes, and the answer then goes +into the plan as a decision rather than a paragraph of hedging. **Nothing in the workflow requires this +command** — skip it and every other command behaves identically. + +Read [`context/workflow.md`](../../../context/workflow.md) for the tier model and the feature/task rule. + +## Usage + +``` +/prototype "" # a screen, a flow, an interaction +/prototype "" # mock what a roadmap entry describes +``` + +## 0. Refuse, before anything else + +1. **Refuse to build the real thing.** This command produces throwaway files outside the application. The + moment the request is "now wire it up" or "put this in the app", it belongs to `/orchestrate` if it is + commit-sized, or to `/feature-plan` and `/feature-implement` if it is a feature. Name the one that + applies and stop. +2. **Refuse to sketch the whole application.** A screen or two, chosen because something about them is + actually in question. If the answer is "all of them", ask which decision is being made and mock the two + screens that decide it. + +Say which question the mockup exists to answer, in one line, before writing anything. + +## 1. Read what this project already looks like + +**Borrow before inventing.** This repository exists already, so most of the look is decided — and a mockup +built from values the project does not have is a picture of an app nobody is going to build. + +Read `context/stack.md`, then find the real thing: the stylesheet or token file, the design system or +component library, and two or three existing screens close to what is being mocked. Copy the actual token +names, spacing scale, radii and type stack out of them. + +Invent only where there is genuinely nothing to borrow — a new surface, or a project with no styling of its +own yet. **Mark every invented value**, so the plan that reads this knows which parts are proposals and +which are already true. + +## 2. Agree the sketch, then stop + +Ask a short set of questions — which screens, which states, and what specifically is undecided. Use your +runtime's question mechanism if it has one, or a plain numbered question if it does not. + +Then propose, and **wait**: + +- the screens, one line each on what each one shows, +- the states each will exercise, and +- which values are borrowed and which are invented. + +**Write nothing until the user approves.** Adjust the proposal if they push back. Generating five files +before anyone has agreed what is being asked is how a fast tool becomes a slow one. + +## 3. Write the mockups + +Everything goes under `prototypes//` at the repository root, where `` is the kebab-case subject +— matching the roadmap entry's name when there is one. + +| File | Holds | +|---|---| +| `theme.css` | every value the mockups use, in one place — the project's real tokens copied in, and invented ones marked with a comment | +| `.html` | one self-contained page per screen, linking `theme.css` | +| `NOTES.md` | the question this exists to answer, what it settled, what is still open, and which values were invented | + +- **Plain HTML and CSS. No framework, no build step, no dependencies.** A few lines of inline script for a + view toggle is fine; anything more means this stopped being a sketch. +- **Every colour, font and spacing value comes from a `theme.css` variable**, never hard-coded — that is + what makes tweaking one file restyle every screen at once. +- **Realistic content and the states that matter** — the empty list, the error, the mid-action moment, the + name that is too long. An empty shell full of lorem ipsum answers nothing. Desktop-first is enough unless + the question is about small screens. +- Never transcribe a credential. Placeholder content is invented, and a real key is never realistic detail. + +`NOTES.md` is the durable part. The HTML is scaffolding for a conversation; the notes are what a plan reads. + +## 4. It gets committed + +`prototypes/` is yours, not the tool's — nothing installs it, nothing updates it, nothing validates it. +**It gets committed anyway.** Until a plan absorbs `NOTES.md` its conclusions live nowhere else, and neither +the notes nor the mockups survive a cleared context or a second machine if they were never tracked. + +Who does the committing is [`context/git.md`](../../../context/git.md)'s answer, not this command's. Where +the user commits, say plainly that these files are worth tracking rather than leaving them to be mistaken +for scratch output. + +## 5. Hand off and stop + +Report the folder path, and tell the user to open the files in a browser and iterate on the look — that +iteration is the point, and it costs nothing. + +Then name exactly one next step, and **do not run it**: + +- **No roadmap entry yet** → `/roadmap ""`, citing `prototypes//`. +- **An entry exists** → `/feature-plan ""`, which reads the folder and carries what it settled into + the plan's Design section. + +## Rules + +- **Nothing outside `prototypes/` is written.** Not application source, not `context/`, not `roadmap.md`. + This command creates no entry, marks nothing active, and touches no ledger row. +- **No gates.** Nothing here is verified or reviewed, because there is nothing to verify — no test covers + a throwaway file, and review time spent on one is time wasted. Work that needs a gate is not a mockup. +- **Throwaway means throwaway.** When the feature ships, delete `prototypes//` and the citation that + pointed at it. A mockup that no longer matches the app is worse than no mockup, because someone will + believe it. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 13dd520..0ef21ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,3 +33,13 @@ jobs: - name: Publish prerelease if: github.event.release.prerelease == true run: npm publish --tag next + + - name: Send Discord Notification + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + with: + args: | + 🚀 Version ${{ github.event.release.tag_name }} of @northguild/worktree is now live on npm! + 📑 **What's New:** + ${{ github.event.release.html_url }} diff --git a/AGENTS.md b/AGENTS.md index 23b763e..a3965f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,20 +26,28 @@ the standing rules, and every command cites it rather than restating it. | `/feature-status` | read-only "where do things stand". **Never a prerequisite** for anything | | `/feature-close` | retires a finished or abandoned feature into `context/archive/` | | `/orchestrate` | one ad-hoc, gated, commit-sized change — no roadmap entry, no ledger | -| `/onboard` | fills in this project's own stubs — `verify.md`, `executors.md`, `stack.md` — adopting what an existing `AGENTS.md` already said | +| `/prototype` | a throwaway HTML/CSS mockup under `prototypes/` — no gates, no ledger, no application code | +| `/onboard` | fills in this project's own stubs — `verify.md`, `executors.md`, `git.md`, `stack.md` — adopting what an existing `AGENTS.md` already said | | Read | For | |---|---| -| [`context/stack.md`](context/stack.md) | runtime, layout, conventions | +| [`context/stack.md`](context/stack.md) | runtime, layout, conventions, and where this project documents itself | | [`context/standards/README.md`](context/standards/README.md) | engineering standards — load per its conditional table | | [`context/verify.md`](context/verify.md) | the real lint / typecheck / build / test commands — the only file that names one | +| [`context/git.md`](context/git.md) | who commits, and at what granularity — read it before closing out any change | **Phase status lives in the active plan's status ledger and nowhere else.** Work the lowest-numbered phase that is not `done` and whose `Depends on` are all `done`; state which you picked before starting; update -the row in the same commit as the work. **If the ledger disagrees with the repo, stop and say so.** +the row as part of the same change as the work. **If the ledger disagrees with the repo, stop and say so.** + +**Nothing commits unless [`context/git.md`](context/git.md) says so.** Absent or silent, the work is left in +the working tree and the user commits it. **An open `P0` or `P1` in [`context/findings.md`](context/findings.md) blocks its phase from being `done`.** +**Documentation is part of the change.** Whatever a change makes untrue in this project's own docs is fixed +by the phase that makes it untrue, not by a follow-up. + **Require evidence, not assertion.** A claim about what a file contains needs the file read, not recalled — yours as much as a subagent's. diff --git a/README.md b/README.md index 8d57d3c..f12e39d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ It wraps the most common worktree tasks into a small workflow-oriented tool: - check out an existing remote branch into its own worktree - copy local env files into the new worktree - open the result in your editor automatically +- hand the new worktree straight to a coding agent - list, reopen, remove, and clean up worktrees later This README focuses on the fast path. The documentation website will cover deeper examples, advanced workflows, integrations, and troubleshooting. @@ -77,6 +78,7 @@ The setup flow can configure: - `defaultSourceBranch` for new worktrees, such as `origin/main` - `codeEditor` for automatically opening a worktree, such as `code` +- `agent.command` for handing a worktree to a coding agent, such as `claude --bg` Then create your first worktree: @@ -119,12 +121,28 @@ worktree checkout origin/feature/fix-login-timeout This creates a local tracking branch in a dedicated worktree. +### Hand a new worktree to a coding agent + +```bash +worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" +``` + +The agent starts with the new worktree as its working directory and the flag's value as its prompt, so it works inside `.worktrees` alongside everything else. `worktree checkout` takes the same flag. + +This is independent of the editor: with `codeEditor` set as well, the worktree still opens there. It needs `agent.command` configured — without it the worktree is created and opened as usual and only the agent is skipped. + ### See what worktrees already exist ```bash worktree list ``` +To name the agent session living in each worktree: + +```bash +worktree list --agents +``` + ### Reopen a worktree in your editor ```bash @@ -159,6 +177,8 @@ worktree cleanup The cleanup command targets worktrees that are considered safe to remove, for example branches whose remote no longer exists or local worktrees with no tracked remote and no pending work. +A worktree that an agent session is living in is held back and reported as skipped. `--force` does not override that, because it answers the confirmation prompt rather than the safety verdict; `--ignore-agents` is the flag that does. + ## Commands | Command | What it does | @@ -187,6 +207,7 @@ Examples: ```bash worktree config defaultSourceBranch origin/main worktree config codeEditor code +worktree config agent.command "claude --bg" worktree config --list worktree config --missing ``` diff --git a/context/.state/manifest.json b/context/.state/manifest.json index 4ff2d56..e693a41 100644 --- a/context/.state/manifest.json +++ b/context/.state/manifest.json @@ -1,110 +1,34 @@ { "schemaVersion": 1, - "version": "0.4.0", + "version": "0.6.0", "adapters": [ "claude", "agents" ], "managedFiles": { - ".agents/skills/feature-close/SKILL.md": "41f8159e855255bd043e00d7accdc956eaeac38d81d8434123d191df723a95ce", - ".agents/skills/feature-implement/SKILL.md": "a9098df80d1ecbf5fe936ebda0d9b0c597379948a0042e502a2b0fe8c56bf3bc", - ".agents/skills/feature-plan/SKILL.md": "721fa0b15bd2cc6aa9ba16f9222106703b15b04d5af3660d46754b17c89c5e2a", - ".agents/skills/feature-status/SKILL.md": "82989af6ba9f99e749b6062bfd0b965186097431afbc544d8edbdf320adb4a87", - ".agents/skills/onboard/SKILL.md": "ba6500eed804403880da169f4d5dd6210122004afc63467b08c1ee91dad37373", - ".agents/skills/orchestrate/SKILL.md": "08ce20d5a4c54443e7ec7e8afbdad2aea7d0fc925752242763e9df807d604a41", + ".agents/skills/feature-close/SKILL.md": "685135e03ad7873038cb6da39c6aa78faf47d007e94a2490a256c7af190ed045", + ".agents/skills/feature-implement/SKILL.md": "164f92535b487dbed0ab2d991aa0aea8989d9d53b750e352974acd763f7d033e", + ".agents/skills/feature-plan/SKILL.md": "53d86bc71ada6e15199ae10ae37cd8525b4a14e4859e0c9d088bbfe64b4f2ebd", + ".agents/skills/feature-status/SKILL.md": "307cceeecf11be2489d336de3eaecc6bdc510f3ccb38d3305e30e110a7f8a8b8", + ".agents/skills/onboard/SKILL.md": "30ce958014ae13242c86cf45323af0bd83e9df451e7fc8d41b089a429e0bda49", + ".agents/skills/orchestrate/SKILL.md": "a929c7ff96000f045145ddaa8f320b3e3dd0075a3fd2f617369bcb9ef52e8c55", + ".agents/skills/prototype/SKILL.md": "5a0a80d8a34604af24ac6c398ceb5e747bccd9b7dea73f8994626ae3d4939d7a", ".agents/skills/roadmap/SKILL.md": "509c942f17d286fe40e7b52119d06348f13037e0fe0a9f365a17032f67334f90", - ".claude/agents/planner.agent.md": "33beae63f4a76ee15919edfd14e54ce56ad20ef8fa8c0cfa4c73ad2ffa6a2ba1", + ".claude/agents/planner.agent.md": "9e55199eaf1209d99a6287b95f7966589bc9f7e42d02d7a42a9662a4f6ad157a", ".claude/agents/reviewer.agent.md": "9067dc8216c8951ef04e5f871b834a6f25c82bfc34eef997d880baadfd4b9b30", - ".claude/skills/feature-close/SKILL.md": "7bd0ba128e5dcbd2e65e4dfbdd93cbb1f0db791f216e29a7606357b0a7dcfb1c", - ".claude/skills/feature-implement/SKILL.md": "71dd4d584bec6385a9e59b03e824049ff11516d2de993ad0a0dd248c9ce5360c", - ".claude/skills/feature-plan/SKILL.md": "a47c32647e6066c543140737f821f64c51b2e2279f29e7f312cefbd9ec5221fb", - ".claude/skills/feature-status/SKILL.md": "46c493733bdc6048204a73d0b9b49939587736937efd8b17822d87f7e3b9d687", - ".claude/skills/onboard/SKILL.md": "852275c2f2fd6b72d2bcc406245cf091af64a25229e587f4f3daf1f43c858432", - ".claude/skills/orchestrate/SKILL.md": "b3a3f4165d807bae6cb6ae80cca70c862b67e5f3e527597cdb3191457daf196a", + ".claude/skills/feature-close/SKILL.md": "2809e1c59caeb2107a86d6a164000b6363bd1d063289311bca253d4d7c1599ed", + ".claude/skills/feature-implement/SKILL.md": "13d3a0fb920e37ad11c5bd53796bcc3ffaf7e5e8bb1aff202b0232b830389ebb", + ".claude/skills/feature-plan/SKILL.md": "ca611cd8feff64c55271b5714ff14801b521ccdfe3215ac78b0afb1109ccca49", + ".claude/skills/feature-status/SKILL.md": "bda78bab6baa59ec4b1d7766d39455efd0c3bea826ebf0721fde3d3bf452a5a3", + ".claude/skills/onboard/SKILL.md": "6cfe79b0c967c69646e133a238e443bff6817ec5b6d996c3bbfdfddc97c044da", + ".claude/skills/orchestrate/SKILL.md": "699d9a3dba8b3bead6bc07dd10bf6dab564bf616e846191f50a4add7027fac44", + ".claude/skills/prototype/SKILL.md": "84de8183a8415b129838fdf68cc3ecb74ce50e150454ec29cc80c8e2b46c7ff3", ".claude/skills/roadmap/SKILL.md": "c3bfbf9879257019f73bfc506b187e4d75bb56d4b3dd073e8c1fea2043995b9d", - "AGENTS.md#ai-workflow": "06a1d5e387db2c8f91768db94172dd33a4af4c0a2c0338655c4dbf3bad8c7636", - "context/README.md": "154925c26f7e1e1f83ecaeaaa26bd560e4a426b5f4e485d479d93e779fb11363", - "context/plan-template.md": "9e5c7f5180990f0a2b57f46511919ce54a8aa7e675de168d74ef2652befb0320", - "context/plan-template.notes.md": "08508c48716730fd282c97ed82c7e62934a9b3f368421ec56793bcd44807784f", + "AGENTS.md#ai-workflow": "4d314ed50dd544890ff78f7c8928f86a5a72de9b957b658ee0d5a7159db959f7", + "context/README.md": "ac4e8de53d5c702139473de44e41afd8abb493dc797da4d3a1d2fd81f762f908", + "context/plan-template.md": "22cb445c58fc595d9370f6e4c105ff3c93f1dcabc4b71adaedc53cead06e7f54", + "context/plan-template.notes.md": "4a0f233c973a84a86bc45508d05e51e7c9c4ea889e01da85bfadc11587d99779", "context/roles/coder.md": "becf9c143e4e6f1b718736f65d3906a574deb279968f235202ab0bd4127cb3cd", - "context/standards/.source": "c167c40b9e9785d24b2edf153f13d9db54baebca3cced4d7720afe010d94d55d", - "context/standards/README.md": "693927e50ca0e77519926e386338b28a961a5ff4a6e2923f7e70929dd57e998d", - "context/standards/architecture/api-design.md": "feca65b88aff4274833efff56391224ded460d4fbfe330eefeef91f1c772a780", - "context/standards/architecture/dependency-boundaries.md": "a739e24bee6365686669e6b8fd9c49edc0a530f601df5bd274cc62c072a8d904", - "context/standards/architecture/feature-driven.md": "63fc6199c4301582c0157605c3b1b19d57b8af830f3b6a9f3c69348764663aae", - "context/standards/architecture/folder-structure.md": "9fecf274deb4d0bfee0daa2dfd7d396faa8ebac44f9c97d3a4de2737fb9a49d3", - "context/standards/architecture/monorepos.md": "bab5673cc8d30e8c390b4102012008a53e76ac0b3fa0df7b86fc48b6729a5260", - "context/standards/architecture/refactoring.md": "c53dd5cfb3d7428c2e029d34a7b15a882fce772e576bd815b354a7805bbae924", - "context/standards/architecture/shared-code.md": "ea54cdc4c6e0412376f86fccec706c8ad9fbb88be408fac6cbae32f4a9feb17f", - "context/standards/docs/PHP-SPEC.md": "ccc90a83d7abbe28c187cf1e38728c16382b32b9cdf4efee1d76d410b41634b7", - "context/standards/docs/SPEC.md": "527ea04ded36900d32000ced9c708e8e6595e3ca8e78e83d8a6f557c45b5af56", - "context/standards/examples/bad/data-fetching.tsx": "71fa16fced0e72deb4de4378f493834ff48210dd1a9b510a77519db6b47bdeeb", - "context/standards/examples/bad/derived-state.tsx": "30cd3233fc6f8fcd28bc65853b329b71a927fe19a6212902f5ced0b11463b6bd", - "context/standards/examples/bad/feature-component.tsx": "9bf29f948deb7346b6a9c78a3ca842a969d0bf5ef52bc962ed8277f66046bdf5", - "context/standards/examples/bad/prop-drilling.tsx": "79f28a520c68e6f0332ebe4564661e0eb19bbba0048b8ab23a7d0d5618cc2d0b", - "context/standards/examples/good/data-fetching.tsx": "f0a0b381b77f5f2595d8eb852832c45247f1723c25ea306a5e5d469bc6d3e373", - "context/standards/examples/good/derived-state.tsx": "909d31b72e185d2515666f1e55e7a2556e80e8ef9480a1ee80184d7256722707", - "context/standards/examples/good/feature-component.tsx": "36fb18c40c884ead5e29c18976436c48e8ea7b3399ad3d0f9ca4c763f555db14", - "context/standards/examples/good/invoice.schema.ts": "2428b9bea98efe701bb804b526f4c0be9cd8edc3dbc433322ff5865f5b600598", - "context/standards/examples/good/prop-drilling.tsx": "722ebc6adf1749e5eb98b53ef818885a1539ae76ceeb62cd8d311f81baeb3222", - "context/standards/philosophy/ai-agent-behavior.md": "f958f582ed9d9c0517aefa0cb171f08ef93fe9764a1cba60dfdde22df677010b", - "context/standards/philosophy/core-principles.md": "06d98a6735acd7175a15ba8b6acb031062d3cd2e03040274b90f0ce2134fa64f", - "context/standards/philosophy/incremental-abstraction.md": "fe8d7e2649390c26c15b963fd9928074b4cccfd9c33451dc09e52de4e9d0a97a", - "context/standards/philosophy/maintainability.md": "86285e66624cdf7c21bd4e87e981fc9e286d937fa780939bb94e1295cfb1c5e2", - "context/standards/philosophy/readability.md": "7baca19254a9d457bbc32a6c331af71417d95420737f988ed2eacd549ade5ea1", - "context/standards/php/README.md": "b180abc62005e9a8ba440cd55602ecbd77bd5b3b68d9bb75417a965dd6ece430", - "context/standards/php/anti-patterns.md": "734d5c5c64bfadc29fde706fcde5acdd07f4c1fef87e740983ca2d4b1319594e", - "context/standards/php/architecture.md": "3112acd56156f26a80bee0b8b33f221a977e0b6e8eca710026580f02cf23a524", - "context/standards/php/frameworks.md": "e5447150a9ecee92a0eb799a08d0378530d578ccd54ff12598d1b114e97102fc", - "context/standards/php/rules.md": "346d41cf0174e4ea510bb813cd04a0e2ac1c3c7a117f9d60966f3aa297129725", - "context/standards/php/security.md": "1b603c721e7a943852302631a6ab5322b9eea14625174b463cf4e03595550bf6", - "context/standards/php/testing.md": "97df1fccc313c17cddd16b82e5f42f2a2b00658793f722c4dd90b12706b3c9f2", - "context/standards/php/tooling.md": "bf2db2c7b4fb8418d7df7858bfeabe917502545b2a5b355e9509c1afbd109232", - "context/standards/react/accessibility.md": "906791467df80e6dc2833961c81178ac061cc7d5caa9a2390431ff956082662c", - "context/standards/react/anti-patterns.md": "f3f9dbad35506732d6ed4dc01db36ca0d51214368d46072c7fb1bbca94e980a4", - "context/standards/react/component-design.md": "4c32c2a511c1200bfa6da01694455e68c1d00f1cf0ebb17ac17470203c1199be", - "context/standards/react/error-boundaries.md": "1b87938961a681bc5ac818a050778930c55204f12d57636ceaa1bcb074611671", - "context/standards/react/forms.md": "702a9050ce95e05e05ffa017eec3981de5c7ca84886a792a627ec31d1f5a9e50", - "context/standards/react/hooks.md": "037057fe079ade95915dd1e9719f3d2b748483f052ac94478944902faabdcdb9", - "context/standards/react/memoization.md": "cee82cb80230ed80aac3e9cbf115ecdc469c0f9366150b2b45e05c8c2e2d9595", - "context/standards/react/state-management.md": "753a0b5b96635469fb7b80144c4ac87f9380bd298df4bc4b7ebc7dec62d0c129", - "context/standards/react/testing.md": "286ea0fe481ccb1976045770693cb775ac26e36b41df3c9f9be2f009d8e8771b", - "context/standards/react/use-effect.md": "922bd0156149a9b939561805ead3d4f92025cc9a2ad6be2ef5fa2a182a2c06b4", - "context/standards/security/api-security.md": "49a8e2f51032d66c0f6bf6c4dc3774b9b904e82400bbb1ac982cf70dd6c3eea9", - "context/standards/security/auth.md": "1fe0b5a32515234eabca743545a8484d68dd45f0555397c3b341d176de2d421c", - "context/standards/security/secrets.md": "d86188052b2c492976609ec13faeb9770f31944b6cc44ef08f7d8a2736840dd5", - "context/standards/security/validation.md": "67017dec5962ec07b6b8c0ff431d35191392c97928e07734a8308fd6f8d09048", - "context/standards/templates/.editorconfig": "c00104e890c599d71dfecf939be1a1420897c2acc42385758ed58d457817d4d9", - "context/standards/templates/.gitignore": "8960def4458546712f82ee252f193ead8eeec44330257527e6245e13afaa6721", - "context/standards/templates/.nvmrc": "f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56", - "context/standards/templates/AGENTS.md": "b6a794f7cb00c27ba35256c0250e7ed182c8ab70139b6f114d3cbc8b8c2dec46", - "context/standards/templates/CLAUDE.md": "6cad5a915a4eca889930756f8c6cf033f9c756f0bd96d1bc528e2fe80c09f1be", - "context/standards/templates/biome.json": "f70832429597c1653a792641f0af311660333f6b7408752afb78578897e91096", - "context/standards/templates/eslint.config.js": "39881e3ab71f259b730be3392d0d85c58b163a4ac801590ce3030bfd1e0b0fd1", - "context/standards/templates/project-readme.md": "63d6978b3a694a0587a0bb295783f2b85d9394bd47b1bce90ce51aaffa1a32f1", - "context/standards/templates/pull-request-template.md": "9609db4174253d718c640a3a237a5f8be79948908a2f89938fae76c4da12ed28", - "context/standards/templates/tsconfig.json": "ba737dee4c097d665df9aa87c4cdf1a53d25d7aaaeb81855b2b2991038c6965f", - "context/standards/tooling/biome.md": "9efc3eaa34bd89f34c1dcb2b501c103f6ddc48f3650886040f56eb7b18133baa", - "context/standards/tooling/ci.md": "9f1f06fe631624a0b23c82db180e0bc92656058905f6bb441b24069e3bf27fbc", - "context/standards/tooling/dates.md": "aa58a8e3d72418ccd85ab18506d8e2d3f5fa5b458d59ee5552e329ac3c81031b", - "context/standards/tooling/dependencies.md": "43ee419eef3f3d1200b4ac0bb3eeaa939c6f5a95d18c6ea3743caaa2a032be43", - "context/standards/tooling/nx.md": "2b465c603057310749499effc943c4cbdf2d651660d6697707db4bf50ebf2181", - "context/standards/tooling/observability.md": "2c61950f3ae2f54332d7cca0ab7dc9d5a895a2f0cee5b6014948f12e33ee6ed9", - "context/standards/tooling/prisma.md": "d9ee1aebadb9fa965658f213861532a06ad6c1c786564884e3f495167254281e", - "context/standards/tooling/shadcn.md": "b93b919a1e8f213ac3f922e8ac89d3021f44e8be8793d94ae71fcb88dfe0f047", - "context/standards/tooling/tailwind.md": "b0dcd7499546d6958e1aefe2c74d9e406988c03983265993e216343b87acce5b", - "context/standards/tooling/tanstack-query.md": "1430896fbd2e966fc9d08741700b32d97d0db51c4916fc16d0260211445b8476", - "context/standards/tooling/tanstack-router.md": "acc0801e4c75293c0b1a9cad8fabb34bdad7a163578e0585ae1d47c926d29681", - "context/standards/tooling/tanstack.md": "e3517c3adc5e3b04cc7393c8510a4b9ffa79926279f4f4ba71fac77ff69775b7", - "context/standards/tooling/vite.md": "1d61d725f6999b24caa68894b8d76172b5583b90d76986abac10df3693609117", - "context/standards/typescript/anti-patterns.md": "24b72a5fd91f4b2858ae2cbfd267603059a5edd6e2ed16de322587cdfc4b5733", - "context/standards/typescript/error-handling.md": "af5b347965376c855a390b73581d98033304604600ecfd33d8152e6a2b1fcbd1", - "context/standards/typescript/naming.md": "c1afe5e179ba932548bdfc80a5b8e94b996003d178e77b77569e01fc796c4c95", - "context/standards/typescript/rules.md": "4fa17c5d86a87719754c4ae3104a6c6a71a55c25bbe3ce37d2e8aa3d52cb7b4f", - "context/standards/typescript/tsconfig/base.json": "5ed7251002b4c8dca0f7b58a083bd3a67d7ba8783e7efaa77062a1e919b2a24d", - "context/standards/typescript/tsconfig/nextjs.json": "c99b090c161cc32609d00b9d0f759a2c27d39520436035aa46b9f2a0e88865c8", - "context/standards/typescript/tsconfig/react.json": "ae1e5d86ceb00439dd572cc4fb59dc0cc3c437abc7c8fef3dfa260d94a2aaeda", - "context/standards/typescript/validation.md": "e93433db3fde939380a0d069dc4315d8034d0c95d051d0e59dc84c684e325a89", - "context/workflow.md": "ed58742816e57bb2757518cd2cdb01dab7315bd34f95723c840b44297aa9d26d" + "context/workflow.md": "31eb6add4c334e21aa50da9998b9e81a58790e682c9462ec61f09ffaf4620f3c" } } diff --git a/context/README.md b/context/README.md index f3c781d..e54d8cf 100644 --- a/context/README.md +++ b/context/README.md @@ -32,9 +32,10 @@ generated "current state" file — hand-editing a ledger row changes every comma | [`plan-template.notes.md`](plan-template.notes.md) | what goes in each section of that skeleton | tool | | [`roles/coder.md`](roles/coder.md) | the coder system prompt — names no commands | tool | | [`standards/`](standards/README.md) | engineering standards, loaded per that README's conditional table | tool* | -| [`stack.md`](stack.md) | this project's runtime, layout, conventions, and an index of your own files | project | +| [`stack.md`](stack.md) | this project's runtime, layout, conventions, where it documents itself, and an index of your own files | project | | [`verify.md`](verify.md) | this project's real lint / typecheck / build / test commands | project | | [`executors.md`](executors.md) | how this project dispatches a coder and a reviewer | project | +| [`git.md`](git.md) | who commits the work an agent produces, and at what granularity | project | | [`roadmap.md`](roadmap.md) | Tier 1 — the backlog. `pending` and `active` entries only | project | | [`history.md`](history.md) | index of retired features, newest last | project | | [`findings.md`](findings.md) | open findings that gate a phase from being marked `done` | project | @@ -64,12 +65,17 @@ hash, `update` walks that manifest, and a project-owned file is not in it — so | Tool-owned — replaced on `update` | Project-owned — unreachable by the updater | |---|---| -| `README.md`, `workflow.md`, `plan-template*.md`, `roles/` | `stack.md`, `verify.md`, `executors.md` | +| `README.md`, `workflow.md`, `plan-template*.md`, `roles/` | `stack.md`, `verify.md`, `executors.md`, `git.md` | | the skill and agent trees, the `AGENTS.md` block | `roadmap.md`, `history.md`, `findings.md` | | `standards/` while it is ours and unmodified | `drafts/`, `plans/`, `archive/`, `CLAUDE.md` | `update` prints both columns when it runs. A visible boundary beats a documented one. +The boundary has a cost, and `update` prints that too: a new version's tool-owned files can expect +something of a project-owned one — a section of [`stack.md`](stack.md), a `git.md` that predates the +file — and nothing in the updater may write it. So it names each gap under **Next** and stops there. +Closing them is `/onboard`, which is re-runnable for exactly this reason. + **Anything else you add under `context/` is yours forever**, by the same property — `context/decisions.md`, `context/glossary.md`, `context/ops-notes.md` all survive by default, with no feature required to protect them. Index your own additions in [`stack.md`](stack.md), not here: this file is replaced on every update, @@ -79,3 +85,7 @@ so a line you add to it is a line you lose. Planning artifacts live in `context/`, wherever else your docs live. Product specs, API references and anything else written for humans or library consumers stay where this project already keeps them. + +They are not out of scope for being elsewhere, though. [`stack.md`](stack.md)'s Documentation section +indexes where "elsewhere" is, so a plan can name what a feature makes untrue there and a phase can carry +the fix — the standing rule is in [`workflow.md`](workflow.md). diff --git a/context/archive/AGENT-MODE-PLAN.md b/context/archive/AGENT-MODE-PLAN.md new file mode 100644 index 0000000..7481e7d --- /dev/null +++ b/context/archive/AGENT-MODE-PLAN.md @@ -0,0 +1,680 @@ +# agent-mode Plan + +Retired — its outcome and date are in [`../history.md`](../history.md). + +Hands a freshly created worktree to a coding agent, and makes the other worktree commands aware that an +agent may be living inside one: `branch --agent` and `checkout --agent` dispatch one into the new worktree, +`list --agents` names the session living in each, and `cleanup` refuses to sweep a worktree one is working +in. + +Built on the maintainer brief captured by `/roadmap` on 2026-09-05, which this document replaces. That +brief's provenance is carried forward in §0 so nothing it recorded is lost. + +**Its §-numbers are cited from source comments** — `grep -rn 'AGENT-MODE-PLAN' src/` — so the section +numbering is load-bearing and must not be renumbered. + +--- + +## 0. Provenance of the source material + +- **Source:** maintainer brief pasted into `/roadmap`, 2026-09-05. +- **External citation in the brief:** — the maintainer's claim + about background-session worktree isolation is attributed to that page. **The page was never fetched; the + claim it carries was instead verified by observation at Phase 2** — see §8 Q1. +- **The `claude agents --json` join was marked "I verified this join works" by the maintainer.** That claim + **is now verified here** — see §1 and D4. +- The brief's closing instruction was "do not commit or push, leave the work in the tree and summarise." + That was addressed to a direct implementation run and is **superseded** by the roadmap flow: + `/feature-implement` commits per phase and updates the ledger in the same commit. + +## 1. Why + +Claude Code background sessions isolate themselves into `/.claude/worktrees/`, which fights this +tool's `.worktrees/` layout. Per the brief, that isolation is **skipped when the session's cwd +is already inside a linked git worktree** — so if this CLI creates the worktree and dispatches the agent +with `cwd` set to it, the agent works inside our layout and no nested worktree appears. There is a +`worktree.location` setting in Claude Code, but per the brief its own schema says the CLI does not read it +yet, so dispatching into an existing worktree is the only way to control placement today. **That is why this +belongs in this tool rather than in agent configuration.** + +Verified on 2026-09-05, on this machine, `claude` 2.1.261: + +``` +$ claude agents --json | head +[ + { "pid": 9187, "cwd": "/Users/baldur/Documents/Job seeker", "kind": "interactive", + "startedAt": 1788600791123, "sessionId": "53fac4da-…", "name": "job-seeker-1f" }, + … + { "pid": 33471, "id": "23f50fae", + "cwd": "/Users/baldur/Development/northguild/worktree/worktree.worktrees/feature/add-agent-mode", + "kind": "background", … } ] +``` + +`claude agents --help` documents `--json` as *"Print active sessions (interactive and background) as a JSON +array and exit (for scripting; does not require a TTY)"*, plus `--cwd ` and `--all`. The join the +brief wanted is real, and the last entry above is a background session whose `cwd` is a worktree in this +repo's own layout — with no `.claude/worktrees/` anywhere under +`/Users/baldur/Development/northguild/worktree` (`find … -name worktrees -path '*.claude*'` returns +nothing). + +**Three things the live JSON shows that the brief did not**, each of which forces a decision below: + +1. A `kind` field separating `"interactive"` from `"background"`. Interactive sessions include the user's + own terminal — a naive join reports "an agent is here" when a human simply has Claude open. (D5) +2. `status` and `state` fields on background sessions — and **on background sessions only**: an + interactive entry carries just `pid, cwd, kind, startedAt, sessionId, name`. **Completed sessions are + excluded from the default listing**; `--all` is what includes them, per `claude agents --help`: *"With + --json: also include completed background sessions"*. Re-measured 2026-09-06 on `claude` 2.1.263: the + default returns 7 sessions, 1 of them background with `"state": "working"`; `--all` returns 10, adding + three with `"state": "done"` and `"status": null`. So the invocation must never pass `--all`. (D4, D6) +3. `pid`, `sessionId` and a short `id` are all present. The brief asked for `{ name, pid }`; that is + satisfiable exactly as written. + +## 2. Constraints + +From the brief, unchanged: + +- **Runtime-neutral.** Never hardcode `claude`. The command is a config string, so Codex or anything of the + same shape works. **Tests must not depend on Claude Code being installed.** +- **No TUI, no monitor.** `claude agents` already is one. `list --agents` prints and exits. +- **No orchestration.** This tool decides *where* work happens, never *what* the work is. No task + assignment, no queue, no prompt templating. +- **Degrade silently.** A missing agent command, a non-zero exit, or non-JSON output yields no agent data + and no error. This must never become a hard dependency on Claude Code. + +From this repository ([`../stack.md`](../stack.md)): + +- ESM throughout; relative imports keep the `.js` extension. `export default` only in `src/commands/*.ts`. +- Tests colocated as `*.test.ts`, vitest. +- Console output is chalk-styled and TTY-dependent; assertions on printed text rely on the `FORCE_COLOR: "0"` + pin in `vitest.config.ts`. +- Never add a file named `biome.json` or `biome.jsonc` anywhere in the tree. + +## 3. Decisions + +**D1. `agent.command` holds a full command line, validated on its argv head only.** A new +`isValidCommandLine` in `src/lib/validators.ts` splits on whitespace and checks the first token, wired into +`isValidConfigValue`'s switch. *Rejected:* reusing `isValidCommand` — `commandExists` (`src/lib/cli.ts:27-39`) +does already take `command.split(" ")[0]`, so `claude --bg` would in fact pass, but `isValidCommand`'s error +message quotes the whole string back (`Command not found: claude --bg`), which is a misleading thing to show +a user. *Rejected:* no validation at all — a typo would then fail silently at dispatch time, long after the +config command exited 0. + +**D2. Dispatch uses `spawn` with an argv array, never `exec`.** `dispatchAgent(path, prompt)` splits +`agent.command` into argv, appends the prompt as **one** argument, sets `cwd` to the worktree path, and uses +`detached: true` + `unref()` so the CLI exits cleanly. *Rejected:* mirroring `openWorktreePath`'s +`exec(\`${cmd} ${path}\`)` — a prompt is arbitrary user text full of quotes and apostrophes, and passing it +through a shell string is an injection hole, not merely a quoting bug. See §5 R1. + +**D3. `--agent` and the editor are independent; both fire.** Order in `run()` becomes: create worktree → +copy env files → dispatch agent → open editor. **The env-before-agent edge is load-bearing** (the brief says +so explicitly); editor-last simply preserves the existing final call and keeps `openWorktreePath`'s +`✔ Worktree created in …` as the last line when no editor is configured. *Rejected:* making `--agent` +suppress the editor — the maintainer's stated preference is independence. + +**D4. One `claude agents --json` invocation per command run, joined in-process on `cwd`. The invocation is +bare `--json`; `--all` is never passed (D6).** *Rejected:* `claude agents --cwd ` per worktree — two +reasons, and the second is the disqualifying one. `gitGetWorktreeList()` (`src/lib/git.ts:168-211`) is +already a serial loop doing 3 subprocess calls per worktree, so a fourth spawn per iteration for data one +call returns whole is the wrong trade; and `--cwd` is documented as *"Show only background sessions started +under ``"*, so it silently drops exactly the interactive sessions D5 exists to protect. + +**D5. The join keeps `kind`, and both consumers see every session.** `cleanup` blocks on **any** session, +interactive included: its job is not to delete a directory a human is sitting in. `list --agents` reports +both kinds too, **marking interactive ones** so a dispatched agent is still distinguishable from a human's +own terminal. *Rejected:* filtering to background everywhere — that makes `cleanup` delete the worktree out +from under an open editor session, which is the failure mode the brief calls the worst in the whole flow. +*Rejected:* background-only in `list` while `cleanup` blocks on both — the original wording, **superseded by +the Q2 resolution in §8**: it made `list --agents` silent about exactly the worktrees `cleanup` then refused +to remove, which is the inconsistency Q2 asked about. + +**D6. The invocation omits `--all`, and liveness is a belt-and-braces `state !== "done"` on top of that.** +The default listing already excludes completed sessions (§1.2), so the primary guard against wedging a +worktree is simply not asking for them. The `state` check is the second layer, for a session that is listed +but finished. An absent or unrecognised `state` counts as live — which is also what makes an *interactive* +session live, since those carry no `state` at all (§1.2), exactly as D5 requires. *Rejected:* passing +`--all` for completeness — that imports exactly the wedging problem this decision exists to prevent, and +nothing in either consumer wants a finished session. + +**D7. ~~Churn's merge-base comes from `defaultSourceBranch`, falling back to `origin/main`.~~ — CUT with +Phase 4, 2026-09-06.** Kept rather than deleted, because it is the one part of the churn design that was +actually settled and the `worktree-churn-stats` roadmap entry should not have to re-derive it: a +`WorktreeListEntry` has no record of what it was branched from (`gitGetWorktreeList` tracks `remote`, not +origin-of-branch), and `branch.ts:81` already uses exactly this fallback chain. If `git merge-base` fails, +churn is **omitted**, not defaulted to zero — an absent number and a genuine zero are different facts. + +**D8. `list --agents` extends the existing bullet-list output, and does not become a table.** The brief says +"output stays a printed table, consistent with current `list` formatting" — **the brief is wrong about the +current formatting.** `src/commands/list.ts:16-18` prints `- ${worktreeListEntryToListName(wt)}`, a bullet +list, and `worktreeListEntryToListName` (`src/lib/utils.ts:24-49`) builds a parenthesised details string. +Agent facts append to that same `details` array. **`worktreeListEntryToListName` is shared with +`src/commands/cleanup.ts:40`**, so it takes an options argument to control which details render; without one, +this change silently rewrites `cleanup`'s output too and breaks `cleanup.test.ts`. + +**D9. `checkout --agent` is in scope.** The brief marked it "only if it falls out cheaply — explicitly +skippable." It does fall out cheaply: `dispatchAgent` lives on `BaseCommand`, and `checkout.run()` +(`src/commands/checkout.ts:65-71`) already has the identical create → copy env → open sequence. The only +real cost is that `checkout` has no `static override flags` block today and needs one. + +## 4. Design + +**Config.** `agent.command` joins `CONFIG_NAMES` (`src/lib/constants.ts`). `config.ts` gates it behind a +`maybePrompt` confirm exactly as `codeEditor` is gated at `src/commands/config.ts:211-224`. Unset is a +normal state: `dispatchAgent` prints a line pointing at `worktree config` and returns, rather than erroring. + +**Dispatch.** `dispatchAgent(path, prompt)` on `BaseCommand`, sibling to `openWorktreePath`: + +``` +agent.command ──split──▶ [bin, ...args] + spawn(bin, [...args, prompt], { cwd: path, detached: true, stdio: "ignore" }) + .unref() +``` + +**Session join.** A new `src/lib/agent.ts` owns everything that knows an agent CLI exists: + +- `getAgentSessions()` — returns `AgentSession[]` (`{ name, pid, cwd, kind, status?, state? }`), or `[]` + on any failure. Gated on `agent.command` being configured; parses stdout as JSON inside a `try`. Invoked + as bare `--json` — never `--all` (D4, D6). +- `findSessionForPath(sessions, path)` — the `cwd` join. +- `isSessionLive(session)` — D6. +- `isSessionWaiting(session)` — true when the session is live but not progressing (`state === "blocked"`, + or `status === "idle"`). Derived here so the JSON shape stays inside this module, per the rule below. + +Nothing outside this file knows the JSON shape, so swapping runtimes touches one module. + +**4.1 — what `isSessionWaiting` can and cannot say.** Two measured constraints, both from §1.2. `status` is +`null`, not absent, on a finished session, so the test is `status === "idle"` and never a truthiness check. +And interactive sessions carry no `status` or `state` at all, so `waiting` is **only ever true for a +background session** — which is the intended reading, since a human's own terminal should be marked +*interactive*, not *waiting*, but it is invisible from the field name and so is written down here. Both +fields are undocumented (R3); an unrecognised vocabulary degrades `waiting` to "no marker", which is +cosmetic — unlike D6, where the same degradation goes the safe way and counts the session live. + +**List.** `WorktreeListEntry` (`src/lib/types.ts:12-19`) gains **one** optional field: + +```ts +agent?: { name: string; pid: number; interactive?: boolean; waiting?: boolean } +``` + +`interactive` is required by the Q2 resolution — Phase 5 must mark a human's own terminal, and the brief's +bare `{ name, pid }` has nowhere to put that. `waiting` is §4.1. Both are **derived** in `agent.ts`; no raw +`kind`, `state` or `status` value crosses this boundary, per the encapsulation rule above. +`gitGetWorktreeList` takes an options flag so the join happens only when `list --agents` asks for it — +`list` today pays for no session lookup and must not start. + +The brief's `filesChanged?` / `insertions?` / `deletions?` are **not** part of this feature. See the cut +Phase 4 in §6.1. + +**Cleanup.** `isSafeToRemove` (`src/lib/git.ts:153-166`) gains a live-agent clause. `cleanup` gets an +override flag; `--force` alone must **not** be it, since `--force` today means "skip the confirmation +prompt", not "override a safety verdict". + +## 5. Risks + +**R1 — the shell-interpolation prerequisite is wider than the brief says.** The brief cites +`src/lib/base-command.ts:56` and calls it an `/orchestrate` task to be fixed separately and first. Verified +present, and **it is not the only one**: `src/lib/git.ts` interpolates unquoted paths at lines 86, 94, 103, +231 and 239 (`cd ${branchPath} && …`), and `gitSetConfigValue` at `src/lib/git.ts:23-25` interpolates a +config *value* into `git config … "${value}"` — which `agent.command` will now flow through. Any worktree +under a path containing a space is already broken today. **This plan does not depend on that fix** (D2 keeps +dispatch off the shell entirely), but shipping `agent.command` through `gitSetConfigValue` adds a value with +spaces in it to a code path that quotes badly. See §8 Q4. + +**R2 — the isolation mechanic is load-bearing and second-hand.** If the cwd-inside-a-worktree exemption does +not hold, `--agent` produces a nested `.claude/worktrees/` and the feature's premise fails. It shows up +immediately as a directory appearing inside the repo. Response: this is the first thing Phase 2's manual +check looks for (§7), before any of Phases 3–7 build on it. + +**R3 — the agent JSON shape is undocumented.** `kind`, `state` and `status` appear in output but not in +`claude agents --help`. A future version could rename them. Response: D6 fails safe, `getAgentSessions` +returns `[]` on any parse failure, and every field the code reads is optional. + +**R4 — cost on the `list` path.** `gitGetWorktreeList` is serial and already runs 3 subprocess calls per +worktree, and one real repo on this machine carries **51 registered worktrees** (`git worktree list` in +`~/Development/corivo/corivo`, 2026-09-06) — so a per-worktree cost is not hypothetical here. Response: +cutting churn (Phase 4) removes the proposed fourth per-worktree call outright, and the session join is +**one** invocation for the whole run (D4), gated behind `--agents` (§4). Default `list` is unchanged, and +`list --agents` adds a **constant** cost regardless of worktree count. Measured at Phase 5, that constant is +**two** subprocesses, not one: `getAgentSessions` reads `agent.command` through `gitGetConfigValue` before +it invokes the runtime (`src/lib/agent.ts:66,78`). R4's substance is that the cost does not scale with the +worktree count, and that holds exactly; the earlier "exactly one" was a miscount of the same constant. + +**R5 — a stale PID.** A session's process can die between the JSON call and the removal. The window is +small and the consequence is a spurious block, not data loss. Accepted; not mitigated. + +## 6. Phases + +### 6.1 Status ledger + +| # | Phase | Status | Depends on | Note | +|---|---|---|---|---| +| 1 | `agent.command` config value | done | — | Gate 1 green; Gate 2 `PASS WITH NOTES`. Notes filed as F-014/F-015/F-016, all `P3`. | +| 2 | `dispatchAgent` + `--agent` on `branch` and `checkout` | done | 1 | Gate 1 green; Gate 2 `PASS WITH NOTES` after one loopback. F-017 (`P1`) raised and closed in the same commit; F-016 closed. §7's manual run passed, including step 3 — see Q1. Notes filed as F-018/F-019, both `P3`. | +| 3 | Agent session join module | done | 1 | Gate 1 green; Gate 2 `PASS` after one loopback. F-020 (`P2`) raised and closed in the same commit. Notes filed as F-021/F-022/F-023, all `P3`, plus F-024 against Phase 5. | +| 4 | ~~Churn stats on the worktree entry~~ | cut | — | Cut 2026-09-06: unrelated to agents, Phase 5 was its only consumer, and it was the fourth per-worktree subprocess (R4). Re-filed as `worktree-churn-stats`. | +| 5 | `list --agents` | done | 3 | Gate 1 green (307 tests, was 294). Gate 2 `PASS WITH NOTES`, no loopbacks. F-024 closed. Notes filed as F-025 (`P2`, tied to Phase 6) and F-026/F-027/F-028, all `P3`. §4's flag on `gitGetWorktreeList` was followed over this phase's original **Files** line — see Phase 5 below. | +| 6 | Agent-aware `cleanup` | done | 3 | Gate 1 green (322 tests, was 307). Gate 2 `PASS WITH NOTES`, no loopbacks. §7's Phase 6 manual run passed, including the override and the finished-session case. F-002, F-003 and F-026 closed; F-025 deferred, with its dangling close condition amended in place. Notes filed as F-029 through F-035, all `P3`. `src/lib/types.ts` added to **Files** — see Phase 6 below. | +| 7 | Generated-surface sweep | done | 2, 5, 6 | Gate 1 green — docs-only per `verify.md`, so Lint (`pnpm check`, exit 0) plus a read of the diff, and `pnpm sync-version` left no further diff, which is this phase's own **Done when**. Gate 2 `PASS WITH NOTES`, no loopbacks. `skills/_artifacts/skill_tree.yaml` added to **Files** — see Phase 7 below. F-036 filed by hand during the sweep. Of Gate 2's ten notes, the six that were inaccuracies or stale bookkeeping in this phase's own text were corrected before the commit; the two that would have rewritten shipped prose are filed as F-037/F-038, and two were judged not defects. All `P3`. | + +Status is one of `not started`, `in progress`, `blocked`, `done`, `cut`. `done` only when committed and +verified, and whoever finishes a phase updates the row in the same commit. `cut` means the phase will not be +built and nothing depends on it; **the row stays so the numbering never shifts** — +[`../findings.md`](../findings.md) F-002 and F-003 both cite "agent-mode Phase 6" by number, and renumbering +would silently break those references. + +**Exactly one table in this document has these columns.** Do not add a second phase table — a +differently-shaped one nearby is a decoy that gets read by mistake. + +### 6.2 The phases + +#### Phase 1 — `agent.command` config value + +**Files:** `src/lib/constants.ts`, `src/lib/validators.ts`, `src/lib/validators.test.ts`, +`src/commands/config.ts`, `src/commands/config.test.ts`, `docs/src/app/docs/configuration/page.mdx` + +**Scope:** Add `agent.command` to `CONFIG_NAMES`. Add `isValidCommandLine` and wire it into +`isValidConfigValue`'s switch (D1). Add a `maybePrompt`-gated prompt in `renderInput`, following the +`codeEditor` block at `src/commands/config.ts:211-224`. Document the value. + +**Done when:** `worktree config agent.command "claude --bg"` stores it and `worktree config --list` shows it; +`worktree config agent.command "nope-not-a-binary"` is rejected; `config.test.ts` covers both. + +#### Phase 2 — `dispatchAgent` + `--agent` on `branch` and `checkout` + +**Files:** `src/lib/base-command.ts`, `src/commands/branch.ts`, `src/commands/branch.test.ts`, +`src/commands/checkout.ts`, `src/commands/checkout.test.ts`, +`docs/src/app/docs/commands/branch/page.mdx`, `docs/src/app/docs/commands/checkout/page.mdx` + +**Scope:** `dispatchAgent(path, prompt)` on `BaseCommand` per D2 — `spawn`, argv array, prompt as one +argument, `cwd` set, `detached` + `unref()`. Unset `agent.command` prints a pointer at `worktree config` and +returns. Add the `--agent` / `-a` string flag to `branch` and `checkout`, called after +`copyEnvFilesFromRootPath` and before `openWorktreePath` (D3). `checkout` needs a new `static override flags` +block (D9). **`src/lib/base-command.test.ts` now exists** (added by `shell-argv-safety`, 6 tests on +`openWorktreePath`) — this phase extends it rather than creating it. + +**Done when:** `worktree branch --github 47 --agent "implement the issue"` creates the worktree, copies env +files, and starts the agent with cwd set to the worktree; tests assert the spawn argv — including a prompt +containing a single quote and a double quote — without invoking a real agent binary. + +#### Phase 3 — Agent session join module + +**Files:** `src/lib/agent.ts` (new), `src/lib/agent.test.ts` (new), `src/lib/types.ts` + +**Scope:** `getAgentSessions`, `findSessionForPath`, `isSessionLive`, `isSessionWaiting` per §4/§4.1 and +D4/D5/D6. Add the `AgentSession` type and the widened `agent?` field (§4) to `WorktreeListEntry`. Every +failure path returns `[]`. + +**Done when:** tests cover a well-formed array, a non-zero exit, non-JSON stdout, an unset `agent.command`, +and a session with no `state` — all without Claude Code installed, per §2; **and the invocation is asserted +to omit `--all`** (D6). That assertion is the primary guard, not a detail: a regression to `--all` would +pass every other test in the file and only show up as a worktree nobody can delete. + +#### Phase 4 — ~~Churn stats on the worktree entry~~ — CUT 2026-09-06 + +Not built. 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, in a tool whose largest real +installation here has 51 of them (R4). It is also what would have made Phase 5 a dashboard rather than a +read surface — see Phase 5 below. + +Re-filed as the `worktree-churn-stats` entry in [`../roadmap.md`](../roadmap.md); **D7 above is retained as +the design that entry should start from.** The phase number is retained and never reused — see §6.1. + +#### Phase 5 — `list --agents` + +**Files:** `src/commands/list.ts`, `src/commands/list.test.ts`, `src/lib/utils.ts`, +`src/lib/utils.test.ts`, `docs/src/app/docs/commands/list/page.mdx`, and — **added at implementation, +2026-09-06** — `src/lib/git.ts`, `src/lib/git.test.ts`. This line originally omitted the two `git.ts` files +and disagreed with §4, which puts the `includeAgents` flag on `gitGetWorktreeList` itself. §4 is the +normative design and was followed: the join has to happen inside the builder, because that is where +`isSafeToRemove` is called (`src/lib/git.ts:265`) and Phase 6 needs it to see the agent. Phase 6's own +**Files** line already names both, so this moves in-feature work earlier rather than widening the feature. + +**Scope:** Add the `--agents` flag — `list`'s first (`src/commands/list.ts:6-19` has no flags today). When +set, perform the session join and render it in the existing bullet-list details string per D8, via a new +options argument to `worktreeListEntryToListName` so `cleanup`'s output is untouched. **Both session kinds +are listed, and an interactive one is marked as such** — this is what the §8 Q2 resolution requires and the +reason D5 was amended; without the marker a human's own terminal is indistinguishable from an agent this +tool dispatched. A *waiting* session is marked too (§4.1). + +**The marker describes one session, not the worktree.** `WorktreeListEntry.agent` is singular (§4), so a +worktree holding several live sessions at once — a dispatched agent and a human's own terminal, which is the +ordinary `--agent`-then-editor case — is rendered from whichever `findSessionForPath` picked, and the +picked one may differ between runs. That is why the rendering **names** the session: `Agent: `, with +`[interactive]` or `[waiting]` qualifying that named session and nothing else. This is the resolution of +[`../findings.md`](../findings.md) F-024, which recorded the ambiguity against this phase; the docs page +says the same thing in the reader's words. + +**Why this phase survives the "is it just a dashboard?" question.** It is the read surface for Phase 6: +once `cleanup` refuses a worktree because a session lives in it, the only other way to find out which +worktrees those are is to trigger the refusal. Cutting churn (Phase 4) is what keeps this a read surface +rather than a dashboard, and §2's "no TUI, no monitor" still holds — the flag prints and exits. + +**Done when:** `worktree list --agents` prints the agent name per worktree; **an interactive session renders +with its marker and a background one without it**; **a worktree whose session is blocked or idle renders +distinguishably from one that is actively working**; `worktree list` output is byte-identical to today's; +`cleanup.test.ts` passes unmodified. + +#### Phase 6 — Agent-aware `cleanup` + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts`, `src/commands/cleanup.ts`, +`src/commands/cleanup.test.ts`, `docs/src/app/docs/commands/cleanup/page.mdx`, and — **added at +implementation, 2026-09-06** — `src/lib/types.ts`. Liveness has to reach `isSafeToRemove`, and §4's +encapsulation rule says no raw `state` may cross out of `agent.ts`, so `WorktreeAgent` carries a third +derived field — `live?: boolean`, alongside `interactive` and `waiting` — set in `toWorktreeAgent` from +`isSessionLive`. That is one line on a type Phase 3 already created rather than new scope. It is **optional** +like its two siblings, because `src/commands/list.test.ts` and `src/lib/utils.test.ts` build `WorktreeAgent` +literals without it and both are Phase 5's files; the cost is that every reader must say what an absent +marker means, and `hasLiveAgent` says *live*, which is the direction D6 fails in. + +**Scope:** Teach `isSafeToRemove` about a live agent (D5, D6) and add the explicit override flag to +`cleanup` — distinct from `--force` (§4). A worktree hosting a live session is excluded from the sweep and +named as skipped rather than silently dropped. + +**The override is `--ignore-agents`, and it works by not looking.** It has no short alias: `--force` has +`-f` because it answers a prompt, and this one overrules a safety verdict, which is worth spelling out. +Rather than gathering sessions and discounting them, it turns the join off (`includeAgents: +!flags["ignore-agents"]`), so the overridden path also costs exactly what `cleanup` cost before this phase — +R4's constant, off. The one consequence is that the override reports nothing about what it swept past, which +is a departure from this command's own "do not ask me, not do not tell me" principle and is recorded as +F-030. + +**Each held-back worktree is named under exactly one heading.** A worktree an agent is working in almost +always holds uncommitted work too, so the two skip reports are made disjoint by construction: the uncommitted +probe keeps the agent (and so declines anything an agent holds), the agent probe drops both. Both are gated +on `safeToRemove !== true`, which is also what closes F-003 — without it a worktree whose directory is gone +is reported as held back *and* removed. + +**Done when:** a worktree with a live session in its `cwd` is excluded from `cleanup` and reported as +skipped; the override includes it; a session with `state: "done"` does not block; tests cover all three +without a real agent binary. + +#### Phase 7 — Generated-surface sweep + +**Files:** `skills/core/SKILL.md`, `README.md`, and — **added at implementation, 2026-09-06** — +`skills/_artifacts/skill_tree.yaml`. That file carries a near-copy of `SKILL.md`'s frontmatter — a +`description` enumerating the config keys and commands, and the same `sources` list — and it ships: the +`files` field in `package.json` publishes the whole `skills/` tree. Leaving it would have put two +disagreeing descriptions of the config surface in the published package, which is the drift this phase +exists to close. + +`skills/_artifacts/domain_map.yaml` and `skills/_artifacts/skill_spec.md` are **deliberately not touched**, +and the line between them and `skill_tree.yaml` is checkable rather than a matter of taste. Two independent +things draw it in the same place: `scripts/sync-intent-version.mjs:49-50` writes exactly two files, +`SKILL.md` and `skill_tree.yaml`, and `.github/workflows/ci.yml:33-34` gates on that same pair — +`git diff --exit-code -- skills/core/SKILL.md skills/_artifacts/skill_tree.yaml`. Those two are the +maintained artifacts; the other two are generator inputs nothing consumes at build or run time. + +`domain_map.yaml` is additionally a stamped record: `:4-6` carries `Version: 1.2.0`, `Date: 2026-04-06` and +`Status: reviewed` against a package now at 1.2.8, so hand-editing it would claim a discovery run and a +review that never happened. `skill_spec.md` carries no such stamp and rests on the sync/CI criterion alone. +Both files' stale enumerations are recorded as [`../findings.md`](../findings.md) F-036 instead. + +**Scope:** Update `SKILL.md`'s frontmatter `description` (it enumerates every command and config value) and +its `sources` list, plus the body. Update `README.md` if the feature list changed. **No new command is +added, so `docs/src/app/docs/commands/_meta.ts` is not touched.** + +**Done when:** `SKILL.md` names `agent.command`, `--agent` and `list --agents`; `pnpm sync-version` leaves +no diff (`ci.yml` hard-fails on drift via `git diff --exit-code`). + +## 7. Verification + +[`../verify.md`](../verify.md) names the commands — this file does not repeat them. Beyond Gate 1: + +**The manual run, from the brief's own definition of done.** Required at Phase 2, before later phases build +on the premise (R2): + +1. `worktree branch --agent ""` in a repo with `agent.command` set. +2. Confirm the agent starts with cwd set to the new worktree — `claude agents --json` shows a session whose + `cwd` is that path. +3. **Confirm no `.claude/worktrees/` directory appears inside the repo.** This is the load-bearing check. + +**At Phase 6**, confirm by hand that a worktree with a live agent survives `cleanup` and is reported as +skipped. Removing a worktree out from under a running agent is the worst failure mode in this flow, and it +is not one to discover from a unit test alone. + +## 8. Open questions + +- **Q1 — the isolation mechanic — RESOLVED 2026-09-06: it holds, verified first-hand.** §7's manual run + was performed at Phase 2 against the built `dist` in a throwaway repo. With `agent.command` set to + `claude --bg`, `worktree branch feature/claude-check --agent "…"` produced a `"kind": "background"` + session whose `cwd` was exactly the new worktree (`claude agents --json`), and `find` over the repository + and its worktrees returned **no `.claude` directory at all**. R2's premise is confirmed by observation + rather than by the maintainer's claim, and did not need to be + fetched to settle it. Phases 3–7 build on a checked foundation. +- **Q2 — should `list --agents` show interactive sessions? — RESOLVED 2026-09-06: yes, with a marker.** + The maintainer chose the fix the question itself named, so `list` and `cleanup` now agree on what counts + as "an agent is here". **D5 is amended accordingly** and Phase 5 renders the marker. +- **Q3 — is `state: "done"` a stable field?** It is absent from `claude agents --help`. D6 fails safe, so a + rename degrades to "everything blocks cleanup" rather than "nothing does" — annoying, not dangerous. +- **Q4 — does the `/orchestrate` shell fix land before or after this? — RESOLVED 2026-09-06: before.** + It shipped as the `shell-argv-safety` feature and is archived (`acf0774` … `43cca67`). Verified on + 2026-09-06: `grep -rn 'exec(\|cd \${' src/` matches nothing but the `execFile` import in + `src/lib/cli.ts:1`, and `gitSetConfigValue` (`src/lib/git.ts:25-27`) now passes the value as an argv + element. **R1 is closed**, and the code path `agent.command` flows through is clean. The conflict this + question worried about cannot now occur. +- **Q5 — what should `--agent` with no `agent.command` configured do on a *scripted* run? — RESOLVED + 2026-09-06: the brief's behaviour, unchanged.** A message and exit 0; the worktree is still created and + the editor still opens, only the dispatch is skipped. The maintainer accepted the CI caveat rather than + adding a TTY branch. Phase 2 implements exactly this. + +## 9. Surfaces to update — all verified to exist + +**How this list was derived — and the gap that left.** Every entry below was checked to exist. The *set* +was never derived from anything; it is the pages someone thought of. That is exactly what let +`getting-started/page.mdx` and `commands/config/page.mdx` through: both enumerate config keys, neither +documents a command this feature changed, so nothing in the plan pointed at them and no phase's **Files** +could have caught them. Both were fixed after the fact — see F-039. + +**The lesson is not local to this repository, so it is not written down as a local rule.** Note that a docs +section is not itself the fix: this plan had one, and it still missed two pages. What was missing is a step +that *derives* the set — every page enumerating a config key or a flag, not only the pages for the commands +a feature touches. The maintainer is taking that to the shared AI-workflow repository on 2026-09-06 so it +binds every plan, rather than pinning a bump-time check here that would catch it only once it was already +too late to plan for. + +- `docs/src/app/docs/commands/branch/page.mdx`, `checkout/`, `list/`, `cleanup/` — all present. +- `docs/src/app/docs/configuration/page.mdx` — for `agent.command`. +- `docs/src/app/docs/commands/_meta.ts` — **not touched**; no new command is added. +- `skills/core/SKILL.md` — frontmatter `description` enumerates every command and config value; `sources` + listed `src/commands/branch.ts`, `src/lib/git.ts`, `src/lib/validators.ts` when this plan was written, and + Phase 7 added `src/lib/agent.ts` and `src/lib/base-command.ts` to it. +- `skills/_artifacts/skill_tree.yaml` — **added at Phase 7**; it ships and duplicates that frontmatter. Its + two sibling artifacts are deliberately left alone; see Phase 7's **Files** line for where the line falls. +- `docs/src/app/docs/getting-started/page.mdx` and `docs/src/app/docs/commands/config/page.mdx` — + **added after the fact.** Both list config keys; neither was in this list or in any phase's **Files**. +- `README.md` — if the feature list changes. + +## 10. What already holds in this repo + +Read, not recalled — checked 2026-09-05 on `feature/add-agent-mode`, with the last four rows added +2026-09-06 by the audit that cut Phase 4. The first seven rows are the brief's own table, re-verified; the +rest were found while writing this plan or auditing it. + +| Claim | Status | +|---|---| +| Unquoted shell interpolation of the path in `openWorktreePath()` | confirmed, `src/lib/base-command.ts:51-66` | +| `CONFIG_NAMES` has `codeEditor`, no agent entry | confirmed, `src/lib/constants.ts:1-12` | +| `WorktreeListEntry` carries `ahead`/`behind`/`uncommittedChanges`/`safeToRemove` | confirmed, `src/lib/types.ts:12-19` | +| `branch.run()` already orders create → copy env → open editor | confirmed, `src/commands/branch.ts:182-184` | +| `safeToRemove` reasons only about remote / commits / uncommitted | confirmed, `isSafeToRemove()` at `src/lib/git.ts:153-166` | +| `cleanup` filters on `safeToRemove === true` and has only `--force` | confirmed, `src/commands/cleanup.ts:16-27` | +| `list` has no flags at all today | confirmed, `src/commands/list.ts:6-19` — `--agents` is the first | +| `claude agents --json` exists and emits `cwd` + `name` per session | **confirmed by running it**, `claude` 2.1.261 — see §1 | +| `list` output is a bullet list, **not** a table as the brief states | confirmed, `src/commands/list.ts:16-18` + `src/lib/utils.ts:24-49` | +| `worktreeListEntryToListName` is shared by `list` and `cleanup` | confirmed, `src/commands/cleanup.ts:40` | +| `checkout` has no `flags` block at all | confirmed, `src/commands/checkout.ts:12-20` | +| `commandExists` already splits on whitespace and checks only the head | confirmed, `src/lib/cli.ts:27-39` | +| `config.ts` gates `codeEditor` behind a `maybePrompt` confirm | confirmed, `src/commands/config.ts:211-224` | +| `gitGetWorktreeList()` does 3 serial subprocess calls per worktree | confirmed, `src/lib/git.ts:168-211` | +| No `src/lib/base-command.test.ts` exists | confirmed, `ls src/lib/` — Phase 2 creates it | +| `gitSetConfigValue` interpolates the value into a shell string | confirmed, `src/lib/git.ts:23-25` — see R1 | +| Unquoted `cd ${branchPath}` in five more places | confirmed, `src/lib/git.ts:86,94,103,231,239` — see R1 | +| No `.claude/worktrees/` exists under this repo today | confirmed, `find` returned nothing — the §7 baseline | +| `claude agents --json` **excludes** completed sessions unless `--all` is passed | **confirmed by running both**, 2.1.263 on 2026-09-06 — 7 sessions vs 10; see §1 and D6 | +| Interactive sessions carry no `status` or `state` field at all | **confirmed by running it** — keys are `pid, cwd, kind, startedAt, sessionId, name`; see §4.1 | +| `--cwd` filters to background sessions only | confirmed, `claude agents --help` — the disqualifying reason in D4 | +| A real repo on this machine has 51 registered worktrees | confirmed, `git worktree list` in `~/Development/corivo/corivo`, 2026-09-06 — see R4 | + +## 11. Findings log + +Closed findings tied to this feature, moved here from [`../findings.md`](../findings.md) at +`/feature-close` so that file does not grow for the life of the project. The findings still open against +this feature stay there; all are `P2` or `P3`, none blocked this close. + +### F-016 — P3 — the configuration docs describe dispatch semantics that no code reads yet + +**Tied to:** Phase 2 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 1) + +`docs/src/app/docs/configuration/page.mdx` documents `agent.command` and says the tail is "passed to that +program as leading arguments". That is true of the `spawn` in §3 D2 — which Phase 2 adds. Today nothing +reads the key, so a reader who sets it gets no behaviour. The page never names a dispatching command, so it +does not document a phantom flag; the gap is that the key is inert until Phase 2. + +Not fixed in Phase 1, and deliberately not hedged with a temporary "no command reads this yet" line: that +sentence would have to be removed again in Phase 2, and Phase 2's **Files** do not include this page, so the +note would strand. `docs-deploy.yml:3-7` deploys only on push to `main`, so the intermediate state never +reaches readers — the branch merges as a unit. + +**Closes when:** Phase 2's Gate 1 run passes with `dispatchAgent` reading `agent.command`, making the page's +description true. + +**Closed:** 2026-09-06 by agent-mode Phase 2's Gate 1 run (`pnpm check`, `pnpm typecheck`, `pnpm build`, +`pnpm test` 261 passed, `pnpm docs:test` 49 passed — all exit 0). `dispatchAgent` reads `agent.command` and +passes its tail as leading arguments, so the page's description is now true of shipped code, confirmed +end-to-end against the built `dist`. + +### F-017 — P1 — the "no agent configured" hint points at a command that does nothing + +**Tied to:** Phase 2 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 2) + +`dispatchAgent` tells a user with no `agent.command` set to run `worktree config agent.command` +(`src/lib/base-command.ts:79`). That form does nothing: `config.ts:273-274` calls `gitGetConfigValue(args.name)` +and **discards the result** when no value argument follows, so the command prints nothing, writes nothing and +exits 0. Verified by hand on 2026-09-06 in a throwaway repo — zero output, and only `has-called-config` written. + +The one new user-facing string this phase adds therefore sends the reader to a dead end, on exactly the path +§8 Q5 resolved to keep friendly. The repository's four existing hints of this shape all name the bare, +working form — `src/integrations/jira.ts:80,86,92,100`, `Run "worktree config" to configure …` — and +`context/standards/typescript/error-handling.md:172-176` requires a user-facing message to name an +actionable next step. + +**Closes when:** a Gate 1 run passes with the message naming a form that works — the bare `worktree config`, +or the `worktree config agent.command ""` shape the docs already show at +`docs/src/app/docs/configuration/page.mdx:52-54` — and the assertion at `src/lib/base-command.test.ts:187` +updated to match. + +**Closed:** 2026-09-06 by the same Gate 1 run, after one Gate 2 loopback. The message now names +`worktree config agent.command ""`, and that form was verified by hand to set the value +(exit 0, `git config --get` returns it); the unset path still prints the message, creates the worktree and +exits 0. + +### F-020 — P2 — `findSessionForPath` returns the first match, so a finished session can mask a live one + +**Tied to:** Phase 3 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 3) + +`findSessionForPath` (`src/lib/agent.ts`) returns `sessions.find(...)` — the first session whose `cwd` sits +in the worktree, in whatever order the runtime listed them. D5 requires that `cleanup` block on **any** +session; a single-match lookup delivers "block on the first one". + +Two sessions in one worktree is the ordinary case for `--agent`, which dispatches an agent and then opens +the editor. If the first listed entry carries `state: "done"`, Phase 6 sees `isSessionLive` false and +removes the worktree out from under the live second session — a live session treated as dead, which is the +direction D6 exists to fail away from and the failure mode §7 calls the worst in the flow. D6's own premise +is that a finished session can appear in the default listing, so the shadowing case is inside the plan's +model rather than hypothetical. + +**Closes when:** Gate 2 re-passes on Phase 3 with a live match preferred over a finished one, and a test +covering two matching sessions whose first is `done`. + +**Closed:** 2026-09-06 by agent-mode Phase 3's Gate 2 re-run (reviewer subagent, `PASS`, no blocking +findings), after one loopback. `findSessionForPath` prefers a live match and falls back to any match, so the +set of paths yielding no session is unchanged; `src/lib/agent.test.ts` pins two matching sessions with the +finished one listed first, and a lone finished session still being returned. The re-run followed a Gate 1 +pass — `pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` 294 passed, `pnpm docs:test` 49 passed, all +exit 0. + +### F-024 — P3 — among several *live* sessions in one worktree the choice is arbitrary, which Phase 5's marker inherits + +**Tied to:** Phase 5 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 3) + +`findSessionForPath` (`src/lib/agent.ts`) now prefers a live session over a finished one, but among two live +ones it returns whichever the runtime listed first. With a human's interactive terminal and a dispatched +background agent both live in one worktree — the ordinary `--agent`-then-editor case — either may win. + +Phase 6 is unaffected: both are live, so `cleanup` blocks either way, which is what F-020 set out to +guarantee. Phase 5 is affected. Its **Done when** says "an interactive session renders with its marker and a +background one without it", and the marker will describe whichever session was picked rather than the +worktree as a whole — so a worktree holding both may render either way between runs. The singular signature +is what §4 specifies, so this is a limit to state, not a defect to fix blindly. + +**Closes when:** Phase 5 either renders a marker that does not depend on which of several live sessions was +picked, or its docs and plan section say the marker describes one session — folded into the plan's log at +`/feature-close`. + +**Closed 2026-09-06** by the Phase 5 Gate 2 run (reviewer subagent, `PASS WITH NOTES`), which assessed this +finding as "addressed, not worsened". The second branch of the condition above is met in full: the plan's +§6.2 Phase 5 section now carries a paragraph headed *"The marker describes one session, not the worktree"*, +and `docs/src/app/docs/commands/list/page.mdx:43-45` says the same in the reader's words. The rendering +**names** the session it describes — `Agent: `, with `[interactive]` or `[waiting]` qualifying that +named session — so an ambiguous worktree is never rendered ambiguously, only partially. Observed live at +Phase 5: this repo's own worktree held two live sessions at once, one background and one interactive, and +the background one was named. + +### F-026 — P3 — the `list` docs page describes a `cleanup` refusal that Phase 6 has not built + +**Tied to:** Phase 5 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 5) + +`docs/src/app/docs/commands/list/page.mdx:40-41` explains the `[interactive]` marker with *"It is listed +because `cleanup` will refuse to remove a worktree somebody is sitting in."* `grep -n 'agent\|Agent' +src/commands/cleanup.ts` returns nothing; that refusal is Phase 6's scope. + +Same shape as the closed F-016, and accepted on the same grounds: `docs-deploy.yml` deploys only on push to +`main`, the branch merges as a unit, and Phase 6 gates both Phase 7 and `/feature-close`, so the sentence +will be true before any reader sees it. Recorded so it is not lost if Phase 6 changes shape. + +**Closes when:** `cleanup` excludes a worktree holding a live session, proven by a Gate 1 run on Phase 6 — +or the sentence is cut. + +**Closed:** 2026-09-06 by agent-mode Phase 6's Gate 1 run (`pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` 322 +passed, `pnpm docs:test` 49 passed — all exit 0). `cleanup` excludes a worktree holding a live session and reports it by +name, so the sentence at `docs/src/app/docs/commands/list/page.mdx:40-41` describes shipped behaviour. Both +the unit suite and §7's manual run through the built `bin/run.js` confirm the refusal. + +### F-039 — P3 — two docs pages carrying the setup surface were in no phase's Files, so the site and the README now differ + +**Tied to:** Phase 7 · **Raised:** 2026-09-06 (hand, prompted by the maintainer asking whether +the docs site was swept) + +The site was updated — `git diff --stat ebb23b8~1..HEAD -- docs/` shows +134 lines across +`commands/branch`, `commands/checkout`, `commands/list`, `commands/cleanup` and `configuration`. Two pages +that carry the same setup enumeration were missed: + +- `docs/src/app/docs/getting-started/page.mdx:56-59` lists what `worktree config` is for — `Common values:` + then `defaultSourceBranch` and `codeEditor`. This is the site's twin of `README.md:79-81`, which Phase 7 + updated to name `agent.command` as a third. +- `docs/src/app/docs/commands/config/page.mdx:31-33` gives example invocations for `defaultSourceBranch` + and `codeEditor` and none for `agent.command`, and its only key list is a **GitHub Integration Keys** + section with no agent equivalent. + +**Nothing on either page is false** — `Common values` and a set of examples are both hedged, and +`configuration/page.mdx:45-66` documents the key properly. The defect is that the docs site and the README +now give different answers to "what does setup involve", which is the disagreement Phase 7 existed to +remove. + +**The cause is structural and worth more than the fix.** §9 of the plan is headed *Surfaces to update — all +verified to exist*: it verified that the pages it named exist, never that the list was complete. No step +asked which pages enumerate the setup surface, so these two were outside every phase's **Files** from the +start and no gate could have caught them. + +**Closes when:** a Lint gate run passes with `getting-started/page.mdx` naming `agent.command` alongside +the other two, `commands/config/page.mdx` carrying an `agent.command` example, and §9 amended to say how +its list was derived. + +**Closed:** 2026-09-06 by a Lint gate run (`pnpm check` exit 0, `pnpm docs:test` 49 passed, exit 0) over the fix. `docs/src/app/docs/getting-started/page.mdx:60` now names `agent.command` beside the other two; `docs/src/app/docs/commands/config/page.mdx:33` carries an `agent.command` example and `:50-55` a new **Agent Key** section, the counterpart the GitHub keys already had; and the plan's §9 now states how its list was derived and why these two fell outside it. + +**No local rule was added, deliberately.** A bump-time docs check was written into `stack.md` and then +removed the same day: bumping is too late to plan for, and the durable fix belongs to planning. Note that +"plans must include docs" would not have caught this either — §9 existed and was incomplete. What is needed +is a step that derives the set, naming every page that enumerates a config key or flag rather than only the +pages for the commands a feature touches, and the maintainer is taking that to the shared AI-workflow +repository so it binds every plan in every project. diff --git a/context/archive/CLEANUP-DATA-LOSS-PLAN.md b/context/archive/CLEANUP-DATA-LOSS-PLAN.md new file mode 100644 index 0000000..1a43b8a --- /dev/null +++ b/context/archive/CLEANUP-DATA-LOSS-PLAN.md @@ -0,0 +1,471 @@ +# Cleanup Data Loss Plan + +Retired — its outcome and date are in [`../history.md`](../history.md). + +Made `safeToRemove` mean what its name says, so `worktree cleanup` stops force-removing worktrees that hold +uncommitted work. + +**Its section numbers are cited from source comments.** `src/lib/git.ts:160`, `src/commands/cleanup.ts:16,77`, +`src/commands/cleanup.test.ts:324` and `src/commands/remove.test.ts:313` reference `CLEANUP-DATA-LOSS-PLAN` +by section and without a path, so they survived this move untouched — but renumbering or deleting a section +below breaks them. + +--- + +## 1. Why + +`isSafeToRemove` (`src/lib/git.ts:153-166`) has three branches and returns on the first that matches: + +```ts +if (!wt.pathExists) return true; // 154-157 +if (wt.remote && !wt.remoteExists) return true; // 158-161 ← returns before line 162 +if (!wt.remote && !wt.ahead && !wt.behind && wt.uncommittedChanges === 0) return true; // 162-165 +``` + +**The second branch returns before the third is ever reached, and the third is the only one that consults +`uncommittedChanges`.** A worktree tracking a deleted remote branch is therefore classified `safeToRemove` +no matter how much uncommitted work sits in it. + +Both removal surfaces then act on that verdict without re-checking: + +- `cleanup` filters on `safeToRemove === true` (`src/commands/cleanup.ts:27`) and removes every match via + `gitNukeWorktreeCmd(wt.branchName, { force: true })` (`src/lib/git.ts:354`). That `force: true` is + unconditional — it is not derived from the flag. +- `remove`'s multi-select path sorts the same field into an "Inactive branches (Safe to delete)" group + (`src/commands/remove.ts:36-48`) and skips its are-you-sure prompt entirely when every selection is + `safeToRemove` (`src/commands/remove.ts:81`), then removes through the same forced sweep. + +`git worktree remove --force` does not stage, stash or back anything up. The work is gone. + +**Reproduction** + +1. Create a worktree from a remote branch (`worktree checkout some-branch`). +2. Get its PR merged so the remote branch is deleted, or delete it on the remote by hand. +3. Keep editing in the worktree. Do not commit. +4. Run `worktree cleanup`. + +**Who is exposed.** `remote` is only non-empty when the branch has an upstream (`src/lib/git.ts:178` — +`tracking.find(...)?.remote ?? ""`). `branch` creates worktrees with `--no-track` (`src/lib/git.ts:237`), so +those have no `remote` and fall through to the safe third branch. `checkout` creates them with `--track` +(`src/lib/git.ts:236`). **Worktrees made by `worktree checkout` are the exposed ones** — exactly the ones +most likely to have had a PR merged and their remote branch deleted. This is a mainline path, not an exotic +one. + +**What the user sees today.** The information is not hidden, but it is easy to miss. In the default path +`cleanup.ts:39-41` prints each candidate through `worktreeListEntryToListName`, which appends an +uncommitted-changes count (`src/lib/utils.ts:38-42`): + +``` +- feature/thing (Remote removed, 3 uncommitted changes) +Are you sure you want to delete them? +``` + +That is one bulk yes/no covering every candidate at once, so a single worktree with work in it is easy to +miss in a list. `--force` skips the display entirely (`cleanup.ts:34,46-48`) — it takes the `else` branch +and never prints the candidates. + +**The deeper problem is the classification.** `safeToRemove` is the name of a safety verdict, and it returns +`true` for a worktree that is not safe to remove. Anything that trusts the field inherits the bug, which is +why the fix belongs in the predicate rather than in either caller. + +**There is no test coverage for any of this.** `src/lib/git.test.ts` contains four `describe` blocks — git +branch parsing, git config, git root path, git status and tracking helpers — and none of them exercise +`isSafeToRemove` or `gitGetWorktreeList`. `cleanup.test.ts` and `remove.test.ts` hand-write `safeToRemove` +into their fixtures (`cleanup.test.ts:44,56,68`), so they assert against a verdict they supply themselves +and never run the predicate. + +**The precedent for what correct looks like** is already in the file. `gitRemoveWorktree`'s interactive +single-worktree path (`src/lib/git.ts:303-326`) prompts specifically on `ahead`, then specifically on +`uncommittedChanges`, each defaulting to `false`, and only passes `force` once the user has confirmed +against that specific hazard. The two paths should agree about what "safe" means. + +## 2. Constraints + +- **`safeToRemove` has two consumers, not one** — `cleanup.ts:27` and `remove.ts:36-48,81`. Any fix must + leave both correct; a patch applied in `cleanup` alone would leave `remove` removing the same worktrees + without a prompt, and would put two copies of one safety rule in the tree. +- **`WorktreeListEntry.safeToRemove` is an exported interface member** (`src/lib/types.ts:18`, + `safeToRemove?: boolean`) in a published npm package. Changing its type is a breaking change to anything + importing it; see D4. +- **`ahead` and `behind` are only computed when `pathExists && remoteExists`** (`src/lib/git.ts:180-187`), + so for exactly the worktrees this plan is about, `ahead` is `undefined`. This is not an oversight to + route around: `gitGetCommitsAheadCount` compares against `@{u}` (`src/lib/git.ts:84-91`), which cannot + resolve once the upstream branch is deleted. Any check for unpushed commits on a deleted-remote branch + needs a different comparison base, which is why D3 scopes it out rather than folding it in. +- **Must not change what `branch` or `checkout` do at creation time.** The `--track` / `--no-track` + difference is the reason the exposure is shaped as it is, but it is correct behaviour and out of scope. +- **Repository conventions** apply as written in [`stack.md`](../stack.md): ESM with `.js` extensions on + relative imports, named exports outside `src/commands/`, tests colocated as `*.test.ts`, Biome owns + formatting. Verification commands come from [`verify.md`](../verify.md) and nowhere else. +- **Land this before `agent-mode` Phase 6.** That phase adds a live-agent clause to this same + `isSafeToRemove` and rewrites `cleanup`'s reporting, touching `src/lib/git.ts`, + `src/commands/cleanup.ts` and both test files. Running the two concurrently would conflict; running this + one second would mean Phase 6 had already encoded the current behaviour into `cleanup.test.ts` as if it + were intended. + +## 3. Decisions + +**D1.** **Fix the predicate, not the callers.** `isSafeToRemove` is corrected so that its verdict can be +trusted by anything that reads it. Rejected: re-checking `uncommittedChanges` inside `cleanup` before the +sweep, because `remove.ts:81` inherits the identical bug and would need the identical patch — two +independently-worded copies of one safety rule, which is the drift this repository's own workflow exists to +prevent. + +**D2.** **Uncommitted changes disqualify a worktree from `safeToRemove`, regardless of remote state.** The +uncommitted-changes test is hoisted above the remote-branch branch so it cannot be skipped. Rejected: +prompting per-worktree inside the sweep, because `cleanup` is the bulk path — per-item prompts defeat its +purpose, and `--force` would skip them anyway, leaving the destructive path exactly as destructive as it is +today. + +**D3.** **Unpushed commits on a deleted-remote branch are out of scope for this plan**, and recorded as an +open question (§8, Q1) rather than silently fixed. Branch 2 swallows that case too — a worktree that is +`ahead` with a deleted remote is also classified safe — but detecting it is not a predicate change: `ahead` +is `undefined` for these worktrees by construction (§2), and `gitGetCommitsAheadCount`'s `@{u}` cannot +resolve without an upstream. Fixing it means choosing a new comparison base and changing what +`gitGetWorktreeList` gathers. That is a larger, separately-reviewable change; folding it in here would grow +a `small` entry into a `medium` one and delay the data-loss fix behind an unsettled design question. +Rejected: doing both at once, for that reason. + +**D4.** **`safeToRemove` stays `boolean`.** A skipped worktree's *reason* is derived at the point of +reporting from the fields already on the entry (`uncommittedChanges`, `remote`, `remoteExists`), exactly as +`worktreeListEntryToListName` already does (`src/lib/utils.ts:29-42`). Rejected: turning `safeToRemove` into +a reason enum or a `{ safe, reason }` object. It is a published interface member (§2) with two consumers +that both treat it as a boolean — `remove.ts` groups on its truthiness — so the change would ripple through +both commands and their tests for no gain this plan needs. + +**D5.** **`isSafeToRemove` becomes a total function with an explicit return type.** It currently declares no +return type and falls off the end when no branch matches, so it returns `boolean | undefined` and +`safeToRemove` is `undefined` rather than `false` for every unsafe worktree. `cleanup.ts:27` compares +`=== true` and `remove.ts:36-37` tests truthiness, so both happen to behave — but the next caller to write +`!== false` inherits a trap. Adding `: boolean` and a final `return false` is a correctness fix in its own +right and makes the compiler enforce that every branch decides. + +**D6.** **`isSafeToRemove` is exported as a named export** so it can be unit-tested directly. It is a pure +function over a `WorktreeListEntry`, and testing it through `gitGetWorktreeList` would require mocking four +git calls to assert one predicate. Named export matches the `src/lib/` convention in +[`stack.md`](../stack.md). Rejected: testing it indirectly through `gitGetWorktreeList`, because the mock +scaffolding would exceed the code under test and would couple the predicate's tests to the list builder's +implementation. + +**D7.** **`cleanup` reports what it skipped**, naming each worktree it declined to remove and why, in both +the default and `--force` paths. Without this the fix is silent: a user whose worktree is no longer swept +gets no signal that anything changed, and `--force` prints nothing at all today. This is the reporting half +of the roadmap entry. + +**D8.** **`gitRemoveWorktreesWithProgress` keeps its unconditional `force: true`** (`src/lib/git.ts:354`). +Once D1/D2 land, everything `cleanup` sends it is genuinely safe, and `remove` sends user-selected +worktrees that have already passed a confirmation prompt, where `--force` is the point. Rejected: deriving +`force` per worktree from its hazards, because the sweep would then fail partway on precisely the +worktrees a user had just confirmed. Recorded as a residual risk (R3) rather than a change. + +## 4. Design + +### 4.1 The predicate + +`isSafeToRemove` is reordered so the hazard test cannot be bypassed, gains an explicit return type, and +becomes total: + +```ts +export function isSafeToRemove(wt: WorktreeListEntry): boolean { + // A worktree whose directory is gone holds nothing to lose. + if (!wt.pathExists) return true; + + // Uncommitted work disqualifies a worktree whatever its remote looks like. See plan §3 D2. + if (wt.uncommittedChanges) return false; + + // Tracking a remote branch that no longer exists. + if (wt.remote && !wt.remoteExists) return true; + + // No remote, and nothing pending. + if (!wt.remote && !wt.ahead && !wt.behind) return true; + + return false; +} +``` + +Three things to note about the shape: + +- **The `!wt.pathExists` branch stays first.** If the directory is gone there is nothing to lose, and + `uncommittedChanges` is hardcoded to `0` for that case anyway (`src/lib/git.ts:188-190`), so testing it + first would be meaningless as well as wrong. +- **The uncommitted test is `if (wt.uncommittedChanges)`, not `=== 0`.** The field is optional + (`src/lib/types.ts:17`), so `undefined` must not read as "has changes". Truthiness gives `undefined` and + `0` the same, correct answer. +- **The third branch loses its `wt.uncommittedChanges === 0` clause**, which is now redundant — the hoisted + test has already returned `false` for that case. Leaving it in would be a second copy of the same rule. + +Source comments cite `§3 D2` by section number, not line number, per +[`plan-template.notes.md`](../plan-template.notes.md). + +### 4.2 Reporting in `cleanup` + +`cleanup` currently derives its candidate list and discards everything else (`src/commands/cleanup.ts:27`). +It instead keeps both halves — the removable set and the worktrees it declined — and prints the declined +ones with the reason drawn from the entry's own fields, reusing `worktreeListEntryToListName` +(`src/lib/utils.ts:24-48`), which already renders "Remote removed" and "N uncommitted changes". + +The skipped list prints in **both** paths. Today `--force` takes the `else` branch and prints nothing +(`cleanup.ts:46-48`); after this change it still skips the *confirmation* but still says what it left +alone. `--force` means "do not ask me", not "do not tell me". + +The existing "No stale worktree branches found." early return (`cleanup.ts:29-32`) needs to account for the +case where there are no removable worktrees but there *are* skipped ones — reporting "none found" while +silently declining three worktrees with work in them would reintroduce the invisibility this fix exists to +remove. + +### 4.3 What `remove` needs + +Nothing, in the command itself. Once the predicate is correct, a deleted-remote worktree holding uncommitted +changes stops being sorted into the "Safe to delete" group (`remove.ts:39-47`) and starts tripping the +`selected.some((wt) => !wt.safeToRemove)` confirmation at `remove.ts:81`. The phase for `remove` is +regression coverage proving that, not a code change — and if it turns out a change is needed, that is the +finding the phase exists to surface. + +### 4.4 Documentation + +`docs/src/app/docs/commands/cleanup/page.mdx:16` lists "worktrees whose remote branch no longer exists" as a +cleanup target with no qualification, which documents the defect as if it were the design. It gains the +uncommitted-work exception. + +## 5. Risks + +**R1 — `cleanup` becomes less useful because `git status -s` counts untracked files.** +`gitGetUncommittedChangesCount` shells out to `git status -s` (`src/lib/git.ts:102-105`), whose short format +includes untracked entries (`??`) by default. A merged worktree holding only stray cruft — an unignored +`.env.local`, a scratch file, a stale build output — will now be declined rather than swept. **How it shows +up:** users report that `cleanup` stopped cleaning anything. **Response:** this is the correct default — +untracked files are unrecoverable in exactly the way this plan is about, and D7's skip reporting names the +worktree and its count so the user can act. If it proves too noisy in practice, the lever is a flag or a +`-uno` variant, recorded as Q2 rather than pre-emptively built. + +**R2 — the `agent-mode` collision.** `archive/AGENT-MODE-PLAN.md` Phase 6 edits the same function and the same +`cleanup` reporting. **How it shows up:** merge conflicts in `src/lib/git.ts` and `src/commands/cleanup.ts`, +or a silent revert of this fix if Phase 6 rewrites the predicate from its own plan text. **Response:** land +this first (§2), and have Phase 6 add its live-agent clause to the corrected predicate. + +**R3 — the unconditional `force: true` in the shared sweep remains** (`src/lib/git.ts:354`, D8). This fix +removes the way unsafe worktrees currently *reach* that sweep from `cleanup`, but the sweep itself stays +maximally destructive for whatever is handed to it. **How it shows up:** a future caller that builds its own +worktree list and calls `gitRemoveWorktreesWithProgress` gets force-removal with no prompt and no verdict +check. **Response:** accepted for this plan; the mitigation is that the one remaining unguarded caller path +(`remove`, after an explicit confirmation) is intentional. + +**R4 — characterization tests briefly assert the defect.** Phase 1 pins current behaviour, including the +wrong verdict, so that Phase 2's diff shows the behaviour change. **How it shows up:** someone reads the +Phase 1 commit in isolation and believes the project intends that behaviour. **Response:** the assertion +carries a comment naming this plan and the phase that overturns it, and Phase 2 is the immediately following +phase. + +## 6. Phases + +### 6.1 Status ledger + +| # | Phase | Status | Depends on | Note | +|---|---|---|---|---| +| 1 | Make `isSafeToRemove` testable and total | done | — | Verdicts unchanged; only the fall-through moved `undefined` → `false`. F-001 (P3) raised against Phase 2 | +| 2 | Uncommitted work disqualifies removal | done | 1 | Two verdicts moved, both intended: the fix, and the no-remote `undefined` case F-001 pre-registered. F-001 closed. §4.1 cites `git.ts:188-190`; that assignment is now at `194-196` | +| 3 | `cleanup` reports what it skipped | done | 2 | Skipped = held back *only* by uncommitted work, asked of `isSafeToRemove` on a zeroed copy (D1); §4.2's literal "declined" would print every active worktree. F-003 (P3) raised. `verify.md:63` cites `cleanup.ts:37` and `cleanup.test.ts:176-178`; now `69` and `188-190` | +| 4 | `remove` multi-select regression coverage | done | 2 | §4.3 confirmed — `remove.ts` needed no change. The fixture takes `safeToRemove` from the real predicate, unlike the hand-set ones at `remove.test.ts:37,49`. F-004 (P3) raised. `verify.md:68` says `pnpm test` covers 181 tests; now 197 | +| 5 | Document the exception | done | 2 | Docs-only — Lint gate only. §4.4's `page.mdx:16` bullet was at 17. Gate 2 `PASS WITH NOTES`; its N1/N2 tightened the new prose before commit — "every worktree it skipped" was broader than `cleanup.ts:17-21`, and "instead of silently removed" repeated the overstatement §9.2 corrects. F-005 (P3) raised: `README.md:160` still carries the unqualified claim, outside this phase's **Files** | + +Status is one of `not started`, `in progress`, `blocked`, `done`. `done` only when committed and verified, +and whoever finishes a phase updates the row in the same commit. + +**Exactly one table in this document has these columns.** Do not add a second phase table — a +differently-shaped one nearby is a decoy that gets read by mistake. + +### 6.2 The phases + +#### Phase 1 — Make `isSafeToRemove` testable and total + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts` + +**Scope:** Export `isSafeToRemove` (D6), give it an explicit `: boolean` return type and a final +`return false` (D5). **No branch is reordered and no verdict changes.** Add a `describe("isSafeToRemove")` +block to `src/lib/git.test.ts` covering all four current outcomes: missing path, deleted remote, no-remote +clean, and the fall-through. The deleted-remote-with-uncommitted-changes case is asserted as `true` — the +current, wrong answer — with a comment citing this plan §1 and naming Phase 2 as the phase that overturns +it (R4). + +**Done when:** `isSafeToRemove` is exported with a `boolean` return type, `git.test.ts` has a +`describe("isSafeToRemove")` block whose cases include a deleted-remote worktree with non-zero +`uncommittedChanges`, and `pnpm test` exits 0 with no change to any existing assertion. + +#### Phase 2 — Uncommitted work disqualifies removal + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts` + +**Scope:** Hoist the uncommitted-changes test above the remote branch and drop the now-redundant +`uncommittedChanges === 0` clause from the third branch, per §4.1 (D2). Flip the Phase 1 characterization +assertion to `false` and remove its R4 comment. Add cases pinning that the fix does not over-reach: a +deleted-remote worktree with `uncommittedChanges: 0` is still `true`, and one with `uncommittedChanges: +undefined` is still `true`. + +**Done when:** `isSafeToRemove` returns `false` for `{ pathExists: true, remote: "origin/x", remoteExists: +false, uncommittedChanges: 3 }`, still returns `true` for the same entry with `uncommittedChanges` of `0` or +`undefined`, and `pnpm test` exits 0. + +#### Phase 3 — `cleanup` reports what it skipped + +**Files:** `src/commands/cleanup.ts`, `src/commands/cleanup.test.ts` + +**Scope:** Partition the list rather than filtering it, and print the declined worktrees through +`worktreeListEntryToListName` in both the default and `--force` paths (§4.2, D7). Handle the +nothing-removable-but-something-skipped case so it no longer reports "No stale worktree branches found." +while silently declining worktrees that hold work. Existing fixtures at `cleanup.test.ts:44,56,68` hand-set +`safeToRemove`; add one with `safeToRemove: false` and non-zero `uncommittedChanges` to drive the new +output. + +**Done when:** running `cleanup` against a list containing a `safeToRemove: false` worktree prints that +worktree and its reason, with and without `--force`; the no-candidates path distinguishes "nothing found" +from "everything was skipped"; `pnpm test` exits 0. + +**Note:** assert unstyled strings. `vitest.config.ts` pins `FORCE_COLOR: "0"` and this is load-bearing for +this exact file — see [`verify.md`](../verify.md). + +#### Phase 4 — `remove` multi-select regression coverage + +**Files:** `src/commands/remove.test.ts`, and `src/commands/remove.ts` only if the test proves a change is +needed + +**Scope:** Prove the claim in §4.3 — that a deleted-remote worktree with uncommitted changes now reaches the +`selected.some((wt) => !wt.safeToRemove)` confirmation at `remove.ts:81` instead of being grouped under +"Inactive branches (Safe to delete)" at `remove.ts:39-47`. If it does not, fix `remove.ts` and say so in the +ledger Note. + +**Done when:** `remove.test.ts` contains a case selecting such a worktree that asserts the confirmation is +requested and that declining it performs no removal; `pnpm test` exits 0. + +#### Phase 5 — Document the exception + +**Files:** `docs/src/app/docs/commands/cleanup/page.mdx` + +**Scope:** Qualify the "worktrees whose remote branch no longer exists" bullet (line 16) with the +uncommitted-work exception, and state that skipped worktrees are reported (§4.4). + +**Done when:** the page no longer claims deleted-remote worktrees are removed unconditionally, and `pnpm +check` exits 0. Docs-only, so per [`verify.md`](../verify.md) this phase runs the Lint gate plus a read of +the diff. + +## 7. Verification + +Beyond [`verify.md`](../verify.md) passing, the defect in §1 is proved gone by hand, against a real +repository — the unit tests use synthetic `WorktreeListEntry` objects and cannot prove that the real +`gitGetWorktreeList` produces the field values the predicate now depends on. + +1. In a scratch repository with a remote, `worktree checkout some-branch`. +2. Delete the remote branch (`git push origin --delete some-branch`), then `git fetch --prune`. +3. Write an uncommitted change in the worktree — do both a modification to a tracked file and an untracked + new file, since `git status -s` counts both (R1). +4. `worktree list` — the entry should show `Remote removed` and the uncommitted count. +5. `worktree cleanup` — the worktree must **not** appear in the removal candidates, and must appear in the + skipped report with its reason. +6. `worktree cleanup --force` — same: not removed, and still reported as skipped. +7. Commit the change, leaving the branch `ahead` with no remote. `worktree cleanup` **will** remove it — + this is Q1, the known remaining gap, and confirming it here is what keeps the open question honest + rather than forgotten. +8. `worktree remove`, select the worktree from step 3 — it must be listed outside the "Safe to delete" + group and must trigger the not-safe confirmation. + +## 8. Open questions + +- **Q1 — unpushed commits on a deleted-remote branch.** Scoped out by D3 and still a live data-loss path, + narrower than the one this plan closes: a worktree that is `ahead` with a deleted remote is classified + safe. It cannot be fixed in the predicate alone, because `ahead` is `undefined` for these worktrees by + construction and `gitGetCommitsAheadCount` relies on `@{u}`, which no longer resolves (§2). Settling it + means choosing a comparison base — the repository's default branch is the obvious candidate, but that is + a design decision with its own edge cases, not a detail. **Should this become its own roadmap entry once + this one lands?** +- **Q2 — should untracked files count?** R1's regression rests on `git status -s` including untracked + entries. Treating an unignored scratch file as work worth protecting is the safe default and this plan + adopts it, but it is a judgement call that will shape how `cleanup` feels day to day. No evidence either + way was available while planning; the honest answer is to ship the safe default and revisit if it + annoys. +- **Q3 — the `pathExists: false` branch is unverified.** It returns `true` immediately, and + `uncommittedChanges` is hardcoded to `0` for that case (`src/lib/git.ts:188-190`), so a worktree whose + directory was deleted out from under git is always swept. That is almost certainly right — there is + nothing left to lose — but it was not tested against a real repository while planning, only read. +- **Q4 — is `--force` printing skipped worktrees the right call?** D7 says `--force` should still report. + A user piping `cleanup --force` in a script gets new output on stdout. Nothing in the repository suggests + that path is scripted, but nothing rules it out either. +- **Q5 — sequencing against `agent-mode`.** This plan assumes it lands before `agent-mode` Phase 6 (§2, + R2). `agent-mode` is `pending` and holds no active slot, so there is no conflict today — but if + `agent-mode` is activated first, this plan's §4.1 and §4.2 need re-reading against whatever Phase 6 left + behind. + +## 9. Reference material carried from the draft + +Captured by `/roadmap` on 2026-09-05 from maintainer-supplied material, which originated as analysis done +while planning `agent-mode` in the same session — it was found by reading `isSafeToRemove` to work out what +a live-agent check would have to attach to. Retained here because it is the provenance of §1. + +### 9.1 Verified against the tree + +Read, not recalled. Every row checked on 2026-09-05 on branch `feature/add-agent-mode`, once when the draft +was written and again while writing this plan. + +| Claim | Status | +|---|---| +| `isSafeToRemove` returns at line 158 before reaching the `uncommittedChanges` branch at 162 | confirmed, `src/lib/git.ts:153-166` | +| `cleanup` filters on `safeToRemove === true` | confirmed, `src/commands/cleanup.ts:27` | +| The sweep removes with an unconditional `force: true` | confirmed, `src/lib/git.ts:354` | +| `cleanup --force` only skips the confirmation prompt | confirmed, `src/commands/cleanup.ts:34-48` | +| The candidate list already prints uncommitted counts | confirmed, `src/commands/cleanup.ts:40` → `src/lib/utils.ts:38-42` | +| The single-worktree path prompts separately on uncommitted changes | confirmed, `src/lib/git.ts:310-315` | +| `remote` is only non-empty when the branch has an upstream | confirmed, `src/lib/git.ts:178` | +| `remove` also consumes `safeToRemove`, to group choices and to gate its confirmation | confirmed, `src/commands/remove.ts:36-48,81` | +| `ahead`/`behind` are computed only when `pathExists && remoteExists` | confirmed, `src/lib/git.ts:180-187` | +| `gitGetCommitsAheadCount` compares against `@{u}` | confirmed, `src/lib/git.ts:84-91` | +| `gitGetUncommittedChangesCount` uses `git status -s`, which counts untracked files | confirmed, `src/lib/git.ts:102-105` | +| `isSafeToRemove` declares no return type and falls through to `undefined` | confirmed, `src/lib/git.ts:153-166` | +| No test exercises `isSafeToRemove` or `gitGetWorktreeList` | confirmed, `src/lib/git.test.ts` — four `describe` blocks, none covering either | +| The docs page lists deleted-remote worktrees as targets without qualification | confirmed, `docs/src/app/docs/commands/cleanup/page.mdx:16` | + +### 9.2 Correction the draft made to the supplied material + +The original brief said cleanup shows "only a bulk progress bar". That overstates it — the default path does +print each candidate with its uncommitted count. The accurate, narrower statement is carried in §1, and it +matters because it changes what the fix has to do: the information is displayed but bundled into one bulk +yes/no, `--force` skips the display entirely, and the real defect is the classification rather than the +reporting. + +## 10. Findings log + +Closed findings tied to this feature, moved here from [`../findings.md`](../findings.md) at +`/feature-close` so that file does not grow for the life of the project. + +### F-001 — P3 — `uncommittedChanges: undefined` with no remote is unpinned, and Phase 2 flips it + +**Tied to:** Phase 2 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 1) · +**Closed:** 2026-09-05 (Gate 1, Phase 2) + +Phase 2 dropped the `wt.uncommittedChanges === 0` clause per §4.1 and pinned the resulting verdict: the +no-remote entry with an unknown count is `true`, asserted at `src/lib/git.test.ts:321-323` against +`entry()`'s defaults of `pathExists: true, remote: ""` (`src/lib/git.test.ts:245-253`). Gate 1 re-passed on +that run — `pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` (12 files, 189 tests) and +`pnpm docs:test` (6 files, 49 tests) all exit 0. + +### Still open at retirement + +Four `P3` findings tied to this feature were open when it was retired, and **stay in +[`../findings.md`](../findings.md)** — only closed findings move here. None gated the close; `P3` blocks +nothing. They are recorded here so the archive does not read as if the feature retired clean: + +| Id | Phase | What it is | +|---|---|---| +| F-002 | 2 | §4.1's `pathExists`-first ordering is argued but unpinned by any test | +| F-003 | 3 | `{ pathExists: false, uncommittedChanges: 3 }` would be listed as skipped *and* removed | +| F-004 | 4 | the `remove` regression cases are looser than the §4.3 claim they pin | +| F-005 | 5 | `README.md:160` still carries the unqualified claim §4.4 corrected on the docs page | + +F-002 and F-003 are both the `pathExists: false` ordering question, latent because +`gitGetWorktreeList` hardcodes the count to `0` when the path is missing. `agent-mode` Phase 6 adds a +live-agent clause to this same predicate (§2, R2) and is the natural place to settle them. + +### §7 was not performed + +**The by-hand verification in §7 was never run.** Every phase passed both gates on unit tests over +synthetic `WorktreeListEntry` objects, which is exactly what §7 says is insufficient: no test in this +feature exercised `gitGetWorktreeList` against a real repository, so nothing proved that the real list +builder produces the field values the corrected predicate depends on. Step 7 of that walkthrough — which +confirms Q1's remaining gap rather than fixing it — is likewise unconfirmed. diff --git a/context/archive/SHELL-ARGV-SAFETY-PLAN.md b/context/archive/SHELL-ARGV-SAFETY-PLAN.md new file mode 100644 index 0000000..ec6d896 --- /dev/null +++ b/context/archive/SHELL-ARGV-SAFETY-PLAN.md @@ -0,0 +1,678 @@ +# shell-argv-safety Plan + +Retired — its outcome and date are in [`../history.md`](../history.md). + +Removed the shell from this CLI's subprocess calls, replacing an interpolated command-string contract with +an argv-array one. `cmd()`, the `exec` inside it, and the one `exec` that bypassed it are all gone; every +subprocess call is now an argv array with an explicit `cwd`. + +**Nothing cites this document by section.** `grep -rn "SHELL-ARGV-SAFETY" .` at retirement returned exactly +one hit — the `roadmap.md` entry this close removed — so no source comment depends on the numbering below. +That is the opposite of [`CLEANUP-DATA-LOSS-PLAN.md`](CLEANUP-DATA-LOSS-PLAN.md), whose sections *are* cited +from source, and it is why renumbering here breaks nothing. + +Built on the reference material captured by `/roadmap` on 2026-09-05, which this document replaced. That +material's provenance is carried forward in §0, and its inventory — re-verified and corrected — is §10. + +--- + +## 0. Provenance of the source material + +- **Source:** maintainer, pasted into `/roadmap` on 2026-09-05. Originated as analysis done while planning + `agent-mode`, where it is recorded as risk R1 in [`AGENT-MODE-PLAN.md`](AGENT-MODE-PLAN.md). +- The **original** `agent-mode` brief called this a single-site, commit-sized `/orchestrate` task citing + only `src/lib/base-command.ts:56`. The inventory is why it was filed as its own roadmap entry instead. +- The draft's two corrections to the supplied material (`git.ts:94` → `:95`, and "every subprocess call + builds a shell string" being too broad) **both still hold** and are carried into §10. +- **Every line number in the draft was re-verified against the tree on 2026-09-05 while writing this plan, + and four had drifted.** See §10's "Corrections to the draft" — the draft is now three commits stale, and + the phases below cite the current numbers. + +## 1. Why + +`cmd()` (`src/lib/cli.ts:7-25`) runs every git call through `child_process.exec`, which means a shell, +which means every value interpolated into it is parsed as shell syntax. Two consequences, one live today +and one latent: + +**A path containing a space fails.** Re-demonstrated first-hand on 2026-09-05, not inherited from the +draft — the exact pattern the CLI builds at `src/lib/git.ts:103`, against a directory whose path contains +a space: + +``` +today's pattern -> FAILS: Command failed: cd /…/tmp/space demo && git status -s +argv + cwd -> ok +``` + +The second line is `execFile("git", ["status", "-s"], { cwd })` — the same work, no shell, no quoting. + +**A config value is an injection vector.** `gitSetConfigValue` (`src/lib/git.ts:23-25`) interpolates an +arbitrary user-supplied value into `git config … "${value}"` with nothing but double quotes around it. A +value containing `"` closes the quote; a backtick or `$(…)` executes. + +The shape of the problem is better than the raw count suggests. +`grep -rn --include='*.ts' 'exec(\|execSync\|spawn(\|execFile' src/` over non-test sources returns exactly +**two** hits: + +| Site | What it is | +|---|---| +| `src/lib/cli.ts:17` | `exec(cmd, …)` inside `cmd()` — **the single shell boundary every git call funnels through** | +| `src/lib/base-command.ts:56` | `exec(\`${codeEditor} ${path}\`, …)` — the one call that **bypasses `cmd()`** | + +So this is not eighteen independent bugs. It is **one helper with a string-shaped contract, one caller that +skipped the helper, and eight call sites that interpolate into that contract.** Ten further call sites pass +static strings and are already safe. + +**Five of the eight interpolations exist only to work around a missing option.** `CmdOptions` is +`{ debug?: boolean }` (`src/lib/cli.ts:3-5`) — there is no `cwd`. So five sites shell out to `cd` to reach a +worktree: + +``` +src/lib/git.ts:86 `cd ${branchPath} && git rev-list --count @{u}..HEAD` ─┐ deleted by Phase 2 +src/lib/git.ts:95 `cd ${branchPath} && git rev-list --count HEAD..@{u}` │ +src/lib/git.ts:103 `cd ${branchPath} && git status -s` ─┘ +src/lib/git.ts:237 `cd ${gitRootPath}` ─┐ composed at :247 into one chained +src/lib/git.ts:245 `cd ${currentPath}` ─┘ `${cdRoot} && ${gitFetch} && ${addWorktree} && ${gotoBack}` +``` + +The first three are historical from Phase 2 onward; the line numbers above are the pre-Phase-2 tree. + +**Adding a `cwd` option deletes these rather than escaping them.** That is the load-bearing consequence for +how this plan is phased: the fix is mostly subtraction, and `execFile` already takes `{ cwd }`. + +## 2. Constraints + +- **No behaviour change that a user can see**, except the two that are the point: paths with spaces start + working, and hostile config values stop executing. Command output, error text and exit behaviour stay as + they are. +- **The suite must stay green at every phase boundary.** This is a refactor of a contract that 18 call + sites and 16 test assertions depend on; a phase that leaves the tree red is not commit-sized. +- **No new runtime dependency.** `node:child_process` already provides `execFile`. Adding a shell-quoting + library would be the wrong direction — it keeps the shell. +- From [`../stack.md`](../stack.md): ESM throughout, relative imports keep the `.js` extension; `export + default` only in `src/commands/*.ts`; tests colocated as `*.test.ts`, vitest. Never add a file named + `biome.json` or `biome.jsonc` anywhere in the tree. +- Console output is chalk-styled and TTY-dependent; assertions on printed text rely on the + `FORCE_COLOR: "0"` pin in `vitest.config.ts`. + +## 3. Decisions + +**D1. A new `run(file, args, opts)` lands alongside `cmd()`, call sites migrate in batches, and `cmd()` is +deleted when its last caller is gone.** *Rejected:* changing `cmd()`'s signature in place — that moves 18 +call sites, 16 assertions and the global mock in one commit, which is not a commit-sized unit and hands the +review gate one undifferentiated diff. *Rejected:* an overload on `cmd()` — a union signature keeps the +string path reachable and reviewable-as-normal forever, and the goal here is to **remove** that path, not to +park a safer one next to it. + +**D2. `run` is built on `execFile`, not `spawn`.** `execFile` buffers stdout and hands it back, which is +exactly what all 18 call sites want — every one of them parses stdout. `spawn` would push chunk +accumulation into each caller. *Note:* `agent-mode` D2 chooses `spawn` for agent dispatch, and that is not +in tension — it dispatches a detached, long-lived process whose output is deliberately not collected. + +**D3. `run` takes `{ cwd }`, and the five `cd` sites are deleted rather than quoted.** This is the whole +reason the change is mostly subtraction (§1). A per-call `cwd` also never mutates the process's own working +directory, which is what makes the `cd`-back halves unnecessary (D5). + +**D4. `CmdOptions.debug` is dropped, not carried onto `run`.** Verified dead: it is defined at +`src/lib/cli.ts:4,9,12` and **passed by no caller** — `grep -rn --include='*.ts' 'debug' src/` returns only +those three lines. *Rejected:* preserving it — it is a branch that logs, resolves `""` and skips execution +entirely, so setting it on a migrated call site would silently no-op the command rather than run it. + +**D5. Chained `&&` commands become sequential `await run(…)` calls.** Short-circuit semantics are preserved +exactly: an `await` that rejects stops the sequence, which is what `&&` did. The `cd`-back halves disappear +under D3. + +**D6. `openWorktreePath` splits `codeEditor` on whitespace — head as the file, tail as leading args.** This +is the contract `commandExists` (`src/lib/cli.ts:27-39`) **already** applies, since it does +`command.split(" ")[0]` and checks only the head. Today validation and execution disagree: `commandExists` +validates `code`, the shell then runs the whole string. After this, they agree. *Rejected:* a shell-quoting +library, per §2. + +**D7. `src/test-setup.ts` gains a `run` mock in the same commit that adds `run` to `cli.ts`.** The global +`vi.mock("./lib/cli.js", …)` factory returns an **explicit object** (`{ cmd, commandExists }`), so any export +missing from it is `undefined` at call time. A migrated call site would not fail with a useful assertion — +it would throw "run is not a function". This is a sequencing constraint, not a preference. + +**D8. Migration order is read-only sites first, filesystem-mutating sites last.** Ahead/behind/uncommitted +counts are pure reads (Phase 2). `gitCreateWorktree` and `gitNukeWorktree` create and destroy directories +(Phases 3, 4), and go after the pattern is established and covered. + +## 4. Design + +### 4.1 The helper + +```ts +// src/lib/cli.ts +interface RunOptions { cwd?: string } + +export function run(file: string, args: string[] = [], { cwd }: RunOptions = {}): Promise +``` + +`execFile(file, args, { cwd })`, resolving `stdout?.trim() ?? ""` and rejecting on error — the same +resolve/reject shape `cmd()` has today, so no caller's error handling changes. `cmd()` stays untouched +until Phase 5. + +### 4.2 What each call-site group becomes + +| Group | Today | After | +|---|---|---| +| three `cd ${branchPath}` reads | `cmd(\`cd ${p} && git status -s\`)` | `run("git", ["status", "-s"], { cwd: p })` | +| `gitCreateWorktree`'s chain | one `cmd()` with four `&&`-joined commands | two sequential `run`s, both `{ cwd: gitRootPath }` | +| `gitNukeWorktreeCmd`'s chain | one `cmd()` with three `&&`-joined commands | three sequential `run`s | +| config get/set | `cmd(\`git config … "${value}"\`)` | `run("git", ["config", name, value])` | +| ten static sites | `cmd("git --no-pager branch")` | `run("git", ["--no-pager", "branch"])` | +| `openWorktreePath` | `exec(\`${codeEditor} ${path}\`)` | argv-split head + `[...tail, path]` | + +### 4.3 What disappears + +- The `cd …` prefix at five sites, and the `&&` chaining at two. +- `const currentPath = process.env.PWD` (`src/lib/git.ts:230`) and the `gotoBack` it feeds — the only + `process.env.PWD` read in the codebase. **That deletion removes a latent failure, not only dead code** + (found at Phase 3's Gate 2): `PWD` is not guaranteed to be in the environment of a non-shell parent, and + when it was unset the chain ended in `cd undefined`, which exits non-zero — so `cmd()` rejected *after* + the worktree had already been created successfully. +- `CmdOptions.debug` (D4), and eventually `cmd()` and the `exec` import in `cli.ts`. + +### 4.4 The test surface + +This is the part the draft does not cover, and it is what sizes the work. `src/test-setup.ts` mocks +`./lib/cli.js` **globally** and its `afterEach` maps `mockCmd.mock.calls` to `call[0]` — a command string. +Sixteen assertions across `src/lib/git.test.ts` (23 mock references), `src/integrations/github.test.ts` (7) +and `src/integrations/jira.test.ts` (2) assert on that string. Each migrated call site moves its assertion +from `toHaveBeenCalledWith("git …")` to `toHaveBeenCalledWith("git", […], { cwd })`, in the same commit as +the source change. + +## 5. Risks + +**R1 — `gitCreateWorktree` is the highest-consequence migration.** The comment at `src/lib/git.ts:235-236` +says the `cd` to the git root is deliberate: it makes `git worktree add` receive a **relative** +`worktreePath` so the layout survives the project being moved on disk. Getting `cwd` wrong there does not +error — it creates a worktree in the wrong place. Response: Phase 3 asserts both the `cwd` and the relative +path shape, and §7 case 2 checks the resulting `.git` file by hand. + +**The premise behind that comment does not hold on git 2.38.1, measured at Phase 3 rather than assumed.** +`git worktree add` resolves the path it is given and records an **absolute** one in both link files, so a +relative argument, an absolute argument and the pre-change shell form produce byte-identical +`.git` and `.git/worktrees//gitdir` contents; after moving the containing directory all three fail +alike with `fatal: not a git repository`. What R1 asked of Phase 3 is unaffected — the argument stays +relative and the behaviour is unchanged — but the comment now states a benefit this git version does not +deliver. Recorded as F-010, which is where the decision belongs: correct the comment, or reach for +`git worktree add --relative-paths` (git 2.48+) and actually deliver it. + +**R2 — a `codeEditor` value with quoted arguments regresses.** `code -n` splits correctly on whitespace. +`open -a "Visual Studio Code"` does not — today the shell parses those quotes, and after D6 it becomes three +argv entries. Narrow but real, and it is a behaviour change a user could see, against §2. Response: Phase 6 +documents the contract on the configuration page. See §8 Q2 — whether to reject such values at config time +is not settled here. + +**R3 — the global mock warns instead of failing.** `src/test-setup.ts`'s `afterEach` `console.warn`s on +unexpected calls; it does not fail the test. So a migration that changes *which* commands are issued +can pass a green suite while printing a warning nobody reads. Response: each phase's **Done when** names the +assertion, not just the green run, and §7 case 4 greps the captured output for the warning. **Extended at +Phase 2 to cover `run` as well as `cmd`** (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. The warning text is now `Unexpected subprocess calls detected`. The guard was proved +non-vacuous by deleting a declared entry and watching it fire, not by assuming it would. + +**R4 — overlap with two other roadmap entries.** `agent-mode` Phase 2 edits `src/lib/base-command.ts` and +creates `src/lib/base-command.test.ts` — the same file this plan's Phase 6 creates. `cleanup-data-loss` +touched `src/lib/git.ts`'s `isSafeToRemove`, and `gitNukeWorktreeCmd` is in this plan's Phase 4. Response: +this is a sequencing question, not a design one — see §8 Q1. + +**R5 — `execFile` has a default `maxBuffer`.** `exec` and `execFile` both default to 1 MB of stdout in +current Node. `git worktree list` or `git --no-pager branch -r` in a very large repository could exceed it, +and the failure mode is a rejected promise, not truncation. Unchanged from today — `exec` has the same +default — so this is not a regression, but it is now worth knowing. Not mitigated. **Re-verified at +Phase 1's Gate 2** by running both against a 1 MB stdout on Node 24: identical +`ERR_CHILD_PROCESS_STDIO_MAXBUFFER` at 1 048 576 bytes. The claim is measured, not inferred. + +**R6 — `execFile` cannot launch a Windows `.cmd` or `.bat` shim.** Found at Phase 1's Gate 2 and **not in +the original inventory.** `exec` always goes through a shell; `execFile` defaults to `shell: false`, and +since the Node 18.20/20.12 spawn hardening a `.cmd`/`.bat` shim on Windows needs `shell: true` to launch at +all. This barely touches git — `git.exe` is a real binary — but it lands squarely on **Phase 6**, where a +`codeEditor` of `code` is `code.cmd` on Windows, and on `commandExists` in Phase 5, which already branches +on `win32` (`src/lib/cli.ts:57`). **Unverified from macOS**: the restriction lives in libuv's Windows +`uv_spawn`, not in the JS layer, so this is a question Phase 6's design must answer rather than a +demonstrated defect. Do not close it by assertion. + +**Answered at Phase 6 from Node's own source, not by assertion.** Three things, each read rather than +recalled, on 2026-09-05: + +- **Node 24.19.0's JavaScript layer contains no batch-file handling at all.** All 371 builtin module + sources — every key of `process.binding("natives")` bar the non-string `configs` — were read and scanned. + `.bat` and `IsWindowsBatchFile` return **zero** lines; the 16 `.cmd` lines, across 5 modules, are all + property reads in the child-process and cluster IPC code — `message.cmd` twelve times, `msg.cmd` three + and `ex.cmd` once — not batch-file handling. So there is no JS branch to take, and nothing there that can be probed from macOS. (Stated + loosely as "zero matches for the pair" when first written, and corrected at Gate 2, which reproduced the + scan.) +- **The rejection is native and deliberate.** `src/process_wrap.cc` on `v24.x` — the async spawn path + `execFile` uses — sets `err = UV_EINVAL` when `IsWindowsBatchFile(options.file)`, under the comment that + spawning batch files directly "is potentially insecure because arguments are not escaped (and sometimes + cannot be unambiguously escaped), hence why they are rejected here." `src/spawn_sync.cc` carries the same + guard. +- **An extension-less `code` never even reaches that guard.** libuv's `path_search_walk_ext` + (`deps/uv/src/win/process.c`) appends only `.com` and `.exe` — "Since CreateProcess can start only .com + and .exe files" — so `code` fails `ENOENT` before `code.cmd` is considered. + +**The design answer is no Windows branch.** The only way to launch a `.cmd` shim is `shell: true`, and Node +refuses that path for exactly the argument-escaping hazard this plan exists to remove; re-adding a shell at +the one site that interpolates a user-supplied config value would undo the change at its own last call +site. The contract is documented on the configuration page instead. The consequence is **recorded as F-012, +not closed here** — it is measured from Node's source, never observed on a Windows host. + +**R7 — two caller-observable error differences that §2 should acknowledge.** §4.1's "no caller's error +handling changes" is very slightly overstated, in two ways found by measurement at Phase 1's Gate 2: + +- **The message prefix shrinks.** `src/lib/git.ts:253` surfaces a rejection with + `spinner.fail(error.message)`. Both forms carry `Command failed: …` plus stderr, so nothing is lost, but + at **Phase 3** the prefix goes from `cd /x && git fetch && git worktree add …` to `git worktree add …`. + That is an improvement and still a user-visible text change. Phase 3 should state it deliberately rather + than let it happen. **Measured at Phase 3, not predicted:** the same failing `worktree add` gave + `Command failed: cd /…/proj && git fetch && git worktree add --no-track -b feature/abs + ../proj.worktrees/feature/abs main` before and `Command failed: git worktree add --no-track -b + feature/abs ../proj.worktrees/feature/abs main` after, with the stderr detail retained in both. **It happened at Phase 2 as well**, found at that phase's Gate 2 and measured: a + vanished `cwd` on the three migrated reads now rejects with `spawn git ENOENT` where the old form gave + `Command failed: cd /x && git status -s`. Narrow — `git.ts:194` gates all three behind `pathExists`, so + reaching it needs a race — and it is the same class of change this risk already accepts. **And a third + time at Phase 4**, stated here deliberately because it is the one instance that reaches a user unswallowed: + a rejection from `gitNukeWorktreeCmd` on the `gitRemoveWorktreesWithProgress` path (`git.ts:375`) is + awaited bare by `src/commands/cleanup.ts:89` and `src/commands/remove.ts:92` and surfaces at + `src/lib/base-command.ts:75`, so its text goes from + `Command failed: git worktree remove X && git worktree prune && git branch -D X` to + `Command failed: git worktree remove X`. The `gitNukeWorktree` path is unaffected — `git.ts:296-300` + catches and prints a fixed message. +- **`code` changes type when the file is missing.** `exec` rejects with a numeric `127` from the shell; + `execFile` rejects with the string `"ENOENT"`. Harmless today — `grep -rn 'error\.code|\.code ===' src/` + returns nothing, so no caller branches on it — but a future caller must not assume a number. + +## 6. Phases + +### 6.1 Status ledger + +| # | Phase | Status | Depends on | Note | +|---|---|---|---|---| +| 1 | `run()` helper, `cwd` support, and its global mock | done | — | Gate 2 `PASS WITH NOTES`; R5 re-verified, not a regression | +| 2 | The three read-only `cd` sites | done | 1 | Gate 2 `PASS WITH NOTES`; F-006 closed, F-008 raised — §7 case 1 blocked by it | +| 3 | `gitCreateWorktree`'s four-command chain | done | 1 | Gate 2 `PASS WITH NOTES`; F-009 and F-010 raised; R1's premise disproved on git 2.38.1, behaviour unchanged | +| 4 | Config get/set and `gitNukeWorktreeCmd` | done | 1 | Gate 2 `PASS WITH NOTES`; §7 case 3 run and the pre-change form proved live; F-011 raised | +| 5 | Static sites, `commandExists`, and deleting `cmd()` | done | 2, 3, 4 | Gate 2 `PASS WITH NOTES` then `PASS`; `cmd()` gone; F-007 closed; R3 guard re-proved non-vacuous | +| 6 | `openWorktreePath` — the last `exec` | done | 1 | Gate 2 `PASS WITH NOTES`; R6 answered from Node's source; F-012 and F-013 raised; `exec` gone from `src/` entirely | + +Status is one of `not started`, `in progress`, `blocked`, `done`. `done` only when committed and verified, +and whoever finishes a phase updates the row in the same commit. + +**Exactly one table in this document has these columns.** Do not add a second phase table — a +differently-shaped one nearby is a decoy that gets read by mistake. + +### 6.2 The phases + +#### Phase 1 — `run()` helper, `cwd` support, and its global mock + +**Files:** `src/lib/cli.ts`, `src/lib/cli.test.ts` (new), `src/test-setup.ts` + +**Scope:** Add `run(file, args, opts)` per §4.1 — `execFile`, `{ cwd }`, same resolve/reject shape as +`cmd()`. Add `run` to the `vi.mock` factory in `src/test-setup.ts` (D7). **No call site migrates in this +phase** and `cmd()` is untouched. `src/lib/cli.test.ts` does not exist today — this phase creates it. + +**Done when:** `run("git", ["status", "-s"], { cwd })` resolves trimmed stdout, rejects on a non-zero exit, +and passes `cwd` through, all covered in `cli.test.ts`; `pnpm test` is green with `cmd()` still present and +**every existing assertion unmodified**. + +#### Phase 2 — The three read-only `cd` sites + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts` + +**Scope:** Migrate `gitGetCommitsAheadCount` (`git.ts:85-87`), `gitGetCommitsBehindCount` (`git.ts:94-96`) +and `gitGetUncommittedChangesCount` (`git.ts:103`) to `run("git", […], { cwd: branchPath })`. The +`cd ${branchPath} &&` prefix is deleted, not quoted (D3). Move each function's assertion to the argv form +(§4.4). + +**Also in scope — F-006, and it is the reason this phase is where R3 lives or dies.** The `afterEach` +guard in `src/test-setup.ts` maps `mockCmd.mock.calls` only. These are the first call sites to move onto +`mockRun`, so from this phase on every migrated call is invisible to that guard and §7 case 4's grep goes +quietly vacuous — the opposite of what R3 asks for. Decide the shape here: either extend the guard to cover +`run` (`expectedCommands: string[]` cannot hold an argv triple unchanged, so this is a shape decision, not a +one-line edit) or retire R3's mitigation explicitly in this document. Do not leave it implicit. + +**Done when:** none of the three functions' bodies contain the string `cd `; their tests assert +`("git", [...], { cwd })`; a `branchPath` containing a space produces a correct call (§7 case 1); F-006 is +either closed or consciously retired. + +#### Phase 3 — `gitCreateWorktree`'s four-command chain + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts` + +**Scope:** Replace the `${cdRoot} && ${gitFetch} && ${addWorktree} && ${gotoBack}` chain +(`git.ts:237-247`) with two sequential `run` calls under `{ cwd: gitRootPath }` (D5). Delete `gotoBack` +and the `process.env.PWD` read at `git.ts:230`. **Preserve the relative `worktreePath`** and the comment +at `git.ts:235-236` explaining why it is relative (R1). + +**Done when:** `gitCreateWorktree` issues exactly two subprocess calls, both with `cwd` at the git root; +the `worktree add` argv still carries the **relative** path; both the `isCheckout` and non-`isCheckout` +branches are covered; `process.env.PWD` appears nowhere in `src/`. + +#### Phase 4 — Config get/set and `gitNukeWorktreeCmd` + +**Files:** `src/lib/git.ts`, `src/lib/git.test.ts`, `src/test-setup.ts`, +`src/integrations/jira.test.ts`, `src/integrations/github.test.ts` + +The last three were not in this line as written and were added at the phase, not assumed: `test-setup.ts` +is mandated by the "Also decide here" paragraph below, and the two integration test files mock the config +helpers through `cmd`, so §2's "green at every phase boundary" forces them to move in this commit rather +than in Phase 5, whose Files list already names them. + +**Scope:** Migrate `gitGetConfigValue` (`git.ts:17`) and `gitSetConfigValue` (`git.ts:24`) — the +arbitrary-value site that is `agent-mode`'s R1 — plus `gitNukeWorktreeCmd`'s three-command chain +(`git.ts:266-270`) into sequential `run` calls (D5). + +**Also decide here:** `describeRunCall` in `src/test-setup.ts` renders a `run` call by joining its argv on +spaces, so `["config", name, "a b"]` and `["config", name, "a", "b"]` read identically in the R3 guard. +Phase 2 accepted that because no call site then passed an argv element containing a space. **Phase 3 is +where that stopped being hypothetical** — its spaced-root case declares +`git worktree add … ../my project.worktrees/feature/test …`, which the guard renders exactly as two argv +entries would. `gitSetConfigValue` is the first site to pass such an element in production rather than in a +fixture, and §7 case 3's hostile value is exactly that shape. Either quote spaced elements in +the rendering or accept the collision knowingly — do not leave it unexamined. + +**Settled 2026-09-05: quoted.** `describeRunCall` (`src/test-setup.ts:31-33`) wraps any element matching +`/\s/` in double quotes, so one spaced argument no longer reads as two. Two declarations moved with it — +Phase 3's spaced-root entry and this phase's hostile value — and they are the only whitespace-bearing argv +strings in any `expectCommands` call. The `cwd` is left unquoted: it is a single labelled trailing field, +so it has no boundary to lose. The quoting does not escape an embedded `"`, which is why +`src/test-setup.ts:22-24` keeps saying the rendering is a diagnostic and `toHaveBeenCalledWith` is the +assertion. + +**Done when:** a config value containing `"`, a backtick and `;` round-trips through set-then-get unchanged +and is asserted as a single argv element; `gitNukeWorktreeCmd` issues three sequential calls that stop at +the first rejection; the `force` branch still appends `--force`. + +#### Phase 5 — Static sites, `commandExists`, and deleting `cmd()` + +**Files:** `src/lib/cli.ts`, `src/lib/cli.test.ts`, `src/lib/git.ts`, `src/lib/git.test.ts`, +`src/integrations/github.ts`, `src/integrations/github.test.ts`, `src/integrations/jira.test.ts`, +`src/test-setup.ts` + +**Scope:** Migrate the ten static call sites (`git.ts:28, 32, 36, 65, 71, 76, 108, 133` and +`github.ts:113, 147`) and `commandExists` (`cli.ts:34`). Then delete `cmd()`, `CmdOptions.debug` (D4), the +`exec` import in `cli.ts`, and the `cmd` entry in the global mock factory. + +**Done when:** `grep -rn --include='*.ts' '\bcmd(' src/` returns nothing outside `*.test.ts` history; +`grep -rn --include='*.ts' 'exec(' src/` returns only `src/lib/base-command.ts`; `pnpm test` and +`pnpm typecheck` green. + +**Recorded at the phase, 2026-09-05.** Three things this phase settled that the scope above did not +anticipate: + +- **`gitGetRootPath` moving onto `run` changes what `gitCreateWorktree`'s tests count.** Phase 3's **Done + when** said "exactly two subprocess calls", and that was true when it was written: the root lookup went + through `cmd`, a separate mock. It is now a third `run` call ahead of the fetch and the add, so that + describe asserts `3` and the fetch-failure case stops at call 2. Nothing about `gitCreateWorktree`'s own + body changed — it still issues two calls of its own — and Phase 3's claim is left as written rather than + back-dated. `vi.spyOn` returns the existing mock when the property is already one, so every + `mockResolvedValueOnce` across a migrated test now sits in a single ordered queue; that is what made the + ordering, not just the count, the thing to check in this phase's tests. +- **The `for-each-ref` format loses its quotes, and that is the correct migration.** The shell form carried + `--format='%(refname:short) <- %(upstream:short)'`, whose single quotes the shell stripped before git saw + them. As one argv element the quotes must not be there. Verified byte-for-byte rather than reasoned + about: `sh -c` with the old string and `execFile` with the new argv produce identical output on this + repository. `git rev-parse --show-toplevel`'s double space collapses for the same reason. +- **`commandExists` gained its first tests.** It had none, and this phase is what moves it off `cmd`. The + three cases in `src/lib/cli.test.ts` pin that a command on `PATH` resolves `true`, an absent one `false`, + and that only the head of a command line is looked up — the last being the contract D6 relies on at + Phase 6. `beforeAll` prepends `dirname(process.execPath)` to `PATH` so the hit does not depend on how the + suite was launched. The `win32` branch stays untested, per R6. + +#### Phase 6 — `openWorktreePath` — the last `exec` + +**Files:** `src/lib/base-command.ts`, `src/lib/base-command.test.ts` (new), +`docs/src/app/docs/configuration/page.mdx`, `docs/src/app/docs/guides/editor-integration/page.mdx` +(added at the phase — see §9) + +**Scope:** Replace `exec(\`${codeEditor} ${path}\`)` (`base-command.ts:56`) with the D6 argv split, keeping +the existing ora spinner success/fail behaviour exactly. Document on the configuration page that +`codeEditor` is a command line split on whitespace, and that quoted arguments are not supported (R2). +`src/lib/base-command.test.ts` does not exist today — this phase creates it. **See R4: `agent-mode` Phase 2 +creates the same file.** + +**Done when:** `grep -rn --include='*.ts' 'exec(' src/` returns nothing outside `*.test.ts`; a worktree path +containing a space opens; `base-command.test.ts` asserts the argv without launching a real editor; the +spinner still fails with the error message on a rejected call. + +**Recorded at the phase, 2026-09-05.** Five things this phase settled: + +- **R6 is answered in §5, from Node's and libuv's source**, and the answer is that no Windows branch is + added. The user-facing half lands on the configuration page; the unobserved half is F-012. +- **The launch stays fire-and-forget.** `exec`'s callback was never awaited, so `run(…).then(succeed, fail)` + is not either — `openWorktreePath` still returns as soon as the child is spawned, and the spinner settles + when it exits. Awaiting would have made all three callers (`branch.ts:184`, `checkout.ts:71`, + `open.ts:46`) block until the editor process closed, which is a behaviour change §2 does not allow. + Biome's `noFloatingPromises`, enabled in `biome.json`, accepts the two-argument `.then`; `pnpm check` is + green. +- **R7, a fourth time, and stated deliberately.** An editor that cannot be launched used to fail with + `Command failed: ` plus the shell's own `command not found` line, and now fails with + `spawn ENOENT`. Narrow: `isValidConfigValue` routes `codeEditor` through `commandExists` + (`validators.ts:66-67`), and both config paths validate — `config.ts:221` interactively, `config.ts:250` + for `worktree config ` — so an unfound editor is rejected at config time. **That mitigation + inverts on Windows**, where `commandExists` runs `where` (`cli.ts:31`), which resolves `PATHEXT` and so + finds `code.cmd`, while launching the stored value `code` fails `ENOENT` — libuv's path search tries only + `.com` and `.exe` (the `UV_EINVAL` refusal needs the value to name the batch file outright). Carried in + F-012, not here. +- **The split is `trim().split(/\s+/)`, not `split(" ")`.** `commandExists` looks up + `command.split(" ")[0]` (`cli.ts:28`), and the two agree on the head for every value that passes that + validation; the stricter form additionally stops a doubled space from becoming an empty argv element. + Pinned by the third case in `src/lib/base-command.test.ts`. +- **The fire-and-forget property itself is not pinned**, and Gate 2 measured that: adding `await` at + `src/lib/base-command.ts:65` leaves all six cases passing. Recorded as F-013 rather than fixed, for the + reason F-009 and F-011 record — the same shape of gap, one phase on. + +## 7. Verification + +[`../verify.md`](../verify.md) names the commands — this file does not repeat them. Beyond Gate 1: + +1. **The space-path case, by hand, at Phase 2. Run 2026-09-05 — blocked by F-008, and verified another + way.** Create a worktree under a path containing a space and run `worktree list`. Ahead/behind/uncommitted + counts must be real numbers, not blanks. **`worktree list` prints no rows at all under a spaced repo + path**, for a reason that has nothing to do with this plan: `gitGetWorktrees` splits the `git worktree + list` line on single spaces and truncates the path, so every entry fails its own filter before the three + migrated functions are reached. Recorded as F-008. Phase 2's claim was verified against the same real + repository instead, by calling the built `dist/lib/git.js` directly with the spaced worktree path — + `ahead: 1`, `behind: 0`, `uncommitted: 1`, the fixture's exact values, where the pre-change + `exec("cd /…/space demo/… && git status -s")` form failed on the identical path. **Re-run this case as + written once F-008 is fixed.** +2. **The relative-path check, by hand, at Phase 3. Run 2026-09-05 — first half confirmed, second half + disproved for both forms.** After `worktree branch `, read the `.git` file in the new worktree and + confirm it points at a path of the same shape as before this change (R1). Then move the repository + directory and confirm the worktree still resolves — that is what the relative path buys. Run against a + scratch repo by calling the built `dist/lib/git.js` directly, alongside a shell reproduction of the exact + pre-change command in the same repository: **the recorded links are byte-identical between the two + forms** — worktree `.git` holds `gitdir: /.git/worktrees/` and `.git/worktrees//gitdir` holds + `//.git` in both — so the first half passes. An absolute argument produces identical links + too, and after moving the containing directory **all three forms fail alike** with + `fatal: not a git repository`, so the second half's expectation is false on git 2.38.1 for reasons that + predate this change. See R1 and F-010. +3. **The hostile-value case, at Phase 4. Run 2026-09-05 — passed, and the pre-change form proved live.** + `worktree config codeEditor 'x"; touch /tmp/pwned; #'` must store the literal string and create no file. + Run against a scratch repo by calling the built `dist/lib/git.js` directly, with the marker path inside a + scratch directory rather than `/tmp`. The value carried a quote, a semicolon, a `#` and a backtick pair; + `gitSetConfigValue` then `gitGetConfigValue` round-tripped all of it byte-for-byte, `git config` held the + whole string, and **no file was created**. Then the *exact* pre-change shell string + (`git config northguild.worktree.codeEditor "${value}"` through `exec`) was reproduced in the same + repository: it exited 0, stored `x`, and **created the marker file** — so the injection this phase closes + was live rather than theoretical, and the check is not vacuous. Re-verified independently at Gate 2. +4. **Grep the test output for the R3 warning** at every phase: a run that prints + `Unexpected subprocess calls detected` is a failure even when vitest is green. The string was + `Unexpected cmd calls detected` until Phase 2 extended the guard to `run` (R3, F-006) — grep for the + current one. +5. **The spaced-path editor launch, at Phase 6. Run 2026-09-05 — passed, and the pre-change form proved + broken.** Set `codeEditor` in a scratch repo to a recorder that appends its own `process.argv.slice(2)` + to a file, then drive the built `dist/lib/base-command.js` through a concrete subclass with a worktree + path containing two spaces (`…/space demo/proj.worktrees/feature/my branch`). The recorder received + **one** argument, the whole path, and the spinner succeeded. The *exact* pre-change form + (``exec(`${codeEditor} ${path}`)``) reproduced in the same repository handed the recorder **three** + fragments — `…/space`, `demo/proj.worktrees/feature/my`, `branch` — and reported no error, so the defect + was live rather than theoretical and this check is not vacuous. The recorder doubles as the + leading-argument case: `codeEditor` was `node `, which is a two-element command line. + +## 8. Open questions + +- **Q1 — sequencing against `agent-mode` and `cleanup-data-loss`. Settled 2026-09-05: this plan holds the + slot.** Activated ahead of `agent-mode` on the argument it and `agent-mode`'s §8 Q4 both make — that + `agent-mode` Phase 1 pushes `agent.command`, a value containing spaces, through the `gitSetConfigValue` + quoting this plan's Phase 4 fixes. The consequence for Phase 6 stands: it **creates** + `src/lib/base-command.test.ts`, and `agent-mode` Phase 2 merges into it rather than creating it. +- **Q2 — should a `codeEditor` value with quotes be rejected at config time?** R2 makes such a value + silently misbehave after D6. `isValidConfigValue` (`src/lib/validators.ts:57-70`) is where a check would + go, and `agent-mode` D1 proposes an `isValidCommandLine` for exactly this shape. Deferring: adding the + validator here would collide with that plan's Phase 1. +- **Q3 — do branch names need validation as well as argv-safety?** Carried from the draft, unresolved. + `isValidBranchName` (`src/lib/validators.ts:22-55`) already rejects spaces and several metacharacters, but + it is **not applied on every path a branch name reaches a subprocess call by** — `gitNukeWorktreeCmd` takes + whatever it is handed. Argv-safety makes this non-exploitable, so it is now a correctness question rather + than a security one. **Sharpened at Phase 4's Gate 2:** the concrete residue is that `branchName` reaches + `git worktree remove` and `git branch -D` (`git.ts:278-285`) with no `--` end-of-options separator, so a + name beginning with `-` is still read as a flag. Unchanged from the shell form and not introduced by the + migration — but it is what this question is actually about now, and `--` is the one-line answer if it is + taken up. +- **Q4 — is `run` the right name? Settled 2026-09-05: yes, `run`.** Confirmed against the tree rather than + waved through: no `src/commands/*.ts` imports from `cli.js` today, so the only file that will see both + names is `src/lib/base-command.ts` after Phase 6, where the inherited oclif method is reached as + `this.run()` and the helper as `run()` — distinct to TypeScript and to a reader. `execCmd` was the + considered alternative and was rejected for costing a rename across §4.1, all six phases and §10 to buy + nothing. `sh` was rejected as actively misleading: the point of the change is that no shell is involved. + **Do not revisit.** + +## 9. Surfaces to update — all verified to exist + +- `docs/src/app/docs/configuration/page.mdx` — the `codeEditor` contract note (Phase 6). It documents the + value at lines 12 and 28 with no mention of arguments today. +- `docs/src/app/docs/guides/editor-integration/page.mdx` — **missed by this sweep and found at Phase 6's + Gate 2.** Its closing line told the reader to set `codeEditor` to "the matching shell command", which + Phase 6 makes false. Corrected there, after the Gate 2 diff, and re-reviewed. +- **No generated-surface sweep is needed, and this was checked rather than assumed.** `skills/core/SKILL.md` + lists `src/lib/git.ts` and `src/lib/validators.ts` in `sources`, and both paths survive; its frontmatter + `description` enumerates commands and config values, none of which change. No command is added, so + `docs/src/app/docs/commands/_meta.ts` is untouched. +- `docs/src/app/docs/changelog/page.mdx` — **not touched.** It explicitly records a "single latest-docs + strategy" with no per-release notes, so there is no entry to add. +- `README.md` — no feature-list change; nothing in it describes subprocess behaviour. + +## 10. What already holds in this repo + +Read, not recalled — checked 2026-09-05 on `feature/add-agent-mode`. The first nine rows are the draft's +own table, re-verified; the rest were found while writing this plan. + +| Claim | Status | +|---|---| +| `cmd()` is the only shell boundary for git calls | confirmed, `src/lib/cli.ts:7-25` | +| `base-command.ts:56` bypasses `cmd()` and calls `exec` directly | confirmed | +| `CmdOptions` has no `cwd` | confirmed, `src/lib/cli.ts:3-5` | +| Five interpolation sites exist only to work around that | confirmed, `git.ts:86, 95, 103, 237, 245` — the first three deleted by Phase 2, leaving two | +| The create-worktree call chains four commands with `&&` | confirmed, `git.ts:247` | +| `commandExists` splits on whitespace and checks only the head | confirmed, `src/lib/cli.ts:27-39` | +| No `src/lib/base-command.test.ts` exists | confirmed, `ls src/lib/` — Phase 6 creates it | +| `src/integrations/` contains no `exec`/`spawn` of its own | confirmed | +| A path containing a space fails today | **re-demonstrated 2026-09-05** — see §1 | +| Exactly 18 non-test `cmd()` call sites; 10 static, 8 interpolating | confirmed by `grep -rn --include='*.ts' '\bcmd(' src/` | +| `CmdOptions.debug` is passed by no caller | confirmed — `grep` returns only `cli.ts:4,9,12` | +| No `src/lib/cli.test.ts` exists | confirmed, `ls src/lib/` — Phase 1 creates it | +| `src/test-setup.ts` mocks `./lib/cli.js` globally with an explicit factory | confirmed, `src/test-setup.ts:5-8` — the D7 constraint | +| Its `afterEach` warns, and does not fail, on unexpected calls | confirmed, `src/test-setup.ts:19-31` — R3; still warns, now at `:39-56` and covering `run` too after Phase 2 | +| 16 assertions across 3 test files assert on the command string | confirmed, `git.test.ts` (23 mock refs), `github.test.ts` (7), `jira.test.ts` (2) | +| `process.env.PWD` is read exactly once in `src/` | confirmed, `git.ts:230` — deleted by Phase 3 | +| The relative worktree path is deliberate, with a comment saying why | confirmed, `git.ts:235-236` — R1 | +| The changelog page keeps no per-release notes | confirmed, `docs/src/app/docs/changelog/page.mdx` | + +### Corrections to the draft + +The draft's line numbers were correct when written and have since drifted. Four are restated here so the +phases are not read against stale citations: + +| Draft said | Actually | What it is | +|---|---|---| +| `git.ts:231` | `git.ts:237` | `const cdRoot = \`cd ${gitRootPath}\`` | +| `git.ts:239` | `git.ts:245` | `const gotoBack = \`cd ${currentPath}\`` | +| `git.ts:241` | `git.ts:247` | the four-command chained `cmd()` call | +| `git.ts:261-263` | `git.ts:266-270` | `gitNukeWorktreeCmd`'s three-command chain | + +The draft's own two corrections to the material it was given — `git.ts:94` → `:95`, and "every subprocess +call builds a shell string" being too broad — both still hold and are carried into §1 and the table above. + +## 11. Findings log + +Closed findings tied to this feature, moved here from [`../findings.md`](../findings.md) at +`/feature-close` so that file does not grow for the life of the project. + +### F-006 — P2 — the unexpected-call guard watches `cmd` only, so it goes vacuous as call sites migrate + +**Tied to:** Phase 2 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 1) · +**Closed:** 2026-09-05 (Gate 1, Phase 2) + +`src/test-setup.ts:23` built its unexpected-call list from `mockCmd.mock.calls` alone, so every call site +moving to `run` would have left that guard's field of view. + +**Fixed in Phase 2.** The `afterEach` at `src/test-setup.ts:39-56` now folds both mocks into one list, with +`describeRunCall` (`src/test-setup.ts:23-31`) rendering a `run` call as its argv joined plus the cwd when +one is given — the same information the `cd ${path} && …` prefix carried. The shape decision F-006 asked +for is that `expectedCommands` stays a `string[]`: the rendering is a diagnostic, and what each call site +passes is asserted by `toHaveBeenCalledWith` in the tests themselves. The warning text is now +`Unexpected subprocess calls detected`, and the plan's §7 case 4 was updated to grep for that string. + +**Proved non-vacuous rather than assumed:** deleting one declared entry from `git.test.ts`'s +`expectCommands` made the guard print +`Unexpected subprocess calls detected:\n - git status -s (cwd: /repo/project.worktrees/test)`; restoring +it returned the run to zero warnings. Gate 1 on the Phase 2 commit: `pnpm check`, `pnpm typecheck`, +`pnpm build`, `pnpm test` (207 passed) and `pnpm docs:test` (49 passed) all exit 0, with zero occurrences +of `Unexpected` in the captured test output. + +`cleanup-data-loss`'s F-001 moved to [`CLEANUP-DATA-LOSS-PLAN.md`](CLEANUP-DATA-LOSS-PLAN.md) §10 on +2026-09-05. + +### F-007 — P3 — `cli.test.ts`'s `afterAll` would mask a failure in its own `beforeAll` + +**Tied to:** Phase 1 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 1) · +**Closed:** 2026-09-05 (Gate 1, Phase 5) + +`src/lib/cli.test.ts`'s `afterAll` called `rmSync(tempPath, …)` unconditionally, so a throwing +`mkdtempSync` in `beforeAll` would have left `tempPath` undefined and reported an +`ERR_INVALID_ARG_TYPE` from the cleanup instead of the real failure. + +**Fixed in Phase 5**, and in scope rather than swept in: Phase 5's **Files** names `src/lib/cli.test.ts`, +and the phase already had to edit that exact `afterAll` block to restore the `PATH` its new `commandExists` +cases mutate. The guard is `if (tempPath)` at `src/lib/cli.test.ts:47-49`; `tempPath` is declared +`let tempPath: string` with no initializer, so it is genuinely `undefined` on that path. + +The PATH restore next to it raised the same class of defect one line earlier and was fixed with it: +assigning a `string | undefined` back would have written the literal string `"undefined"` onto `PATH`, so +an unset `PATH` is restored by `delete process.env.PATH` (`src/lib/cli.test.ts:43-51`). Verified both +directions rather than assumed — after `delete`, `'PATH' in process.env` is `false`, which is the real +pre-`beforeAll` state. + +Gate 1 on the Phase 5 commit: `pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` (219 passed) and +`pnpm docs:test` (49 passed) all exit 0, with zero occurrences of `Unexpected` in the captured test output. +Gate 2 returned `PASS`, having verified the guard and the restore against the file. + +### Still open at retirement + +Six findings tied to this feature were open when it was retired, and **stay in +[`../findings.md`](../findings.md)** — only closed findings move here. None gated the close: five are `P2` +and one `P3`, and only `P0`/`P1` blocks. They are recorded here so the archive does not read as if the +feature retired clean: + +| Id | Sev | Phase | What it is | +|---|---|---|---| +| F-008 | P2 | 2 | `gitGetWorktrees` splits the `worktree list` line on a space, so a repo under a spaced path lists nothing | +| F-009 | P2 | 3 | the `worktree add` failure path is untested, so a dropped `await` would land green | +| F-010 | P3 | 3 | the relative `worktreePath` comment claims a property git 2.38.1 does not deliver | +| F-011 | P2 | 4 | the third `run` in `gitNukeWorktreeCmd` is unpinned against a dropped `await` | +| F-012 | P2 | 6 | a Windows `.cmd`/`.bat` editor can no longer be launched, and `code` is one there | +| F-013 | P2 | 6 | the editor launch is unpinned as fire-and-forget, so an added `await` would land green | + +**F-008 has no home and this close does not give it one.** Its own text says so: no row in §6.1 touches +`gitGetWorktrees`, because it is stdout parsing rather than command construction, so no phase of this plan +could ever have closed it. It is the one finding here that describes a defect a user hits today — a +repository under a path containing a space lists no worktrees at all — and the likely fix, +`git worktree list --porcelain`, is a roadmap entry rather than a loose end. **Raise one with `/roadmap`, +or it stays open against a retired feature indefinitely.** + +F-009, F-011 and F-013 are one shape three times: a sequential `run` whose `await` no test observes. Each +records the mutation that survives and the one case that would kill it. Whoever picks up any of them should +take all three — they are the same test, written three times. diff --git a/context/findings.md b/context/findings.md index bacb273..6ecac50 100644 --- a/context/findings.md +++ b/context/findings.md @@ -36,5 +36,700 @@ life of the project. ## Open +### F-004 — P3 — Phase 4's regression cases are looser than the §4.3 claim they pin + +**Tied to:** cleanup-data-loss Phase 4 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 4) + +Two assertions in `src/commands/remove.test.ts` prove less than the plan's §4.3 claims, in ways a future +edit could exploit without failing a test: + +- The confirmation case selects a single worktree (`remove.test.ts:367-368`), where `some` and `every` are + equivalent — so mutating `selected.some((wt) => !wt.safeToRemove)` at `src/commands/remove.ts:81` to + `every` passes. §4.3 names `some` specifically, and a mixed selection of `[safeWorktree, mergedWithWork]` + is the multi-select shape the phase is titled after. The pre-existing case at `remove.test.ts:173-194` + has the identical gap, so this is not introduced here. +- The grouping case asserts position only (`remove.test.ts:363`, `index > activeGroupStart`), so swapping + the two groups emitted at `src/commands/remove.ts:40-45` would still pass. The test's name promises "not + Safe to delete" and never asserts that literal. + +Non-blocking: Gate 2 returned `PASS WITH NOTES` on the diff and Phase 4's **Done when** is met — the +confirmation is asserted and declining it performs no removal. Left open rather than fixed for the reason +F-002 and F-003 record: adding assertions after Gate 2 had already passed would land unreviewed test code. + +**Closes when:** a Gate 1 run passes with the confirmation case selecting a mixed `[safe, unsafe]` list and +the grouping case asserting the entry is absent from the "Safe to delete" group by name. + +### F-005 — P3 — `README.md` still describes cleanup's pre-fix behaviour + +**Tied to:** cleanup-data-loss Phase 5 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 5) + +`README.md:160` carries the same unqualified claim Phase 5 corrected on the docs page — cleanup "targets +worktrees that are considered safe to remove, for example branches whose remote no longer exists" — with no +uncommitted-work exception. It is now the only place in the repository that documents the defect as if it +were the design; `grep -rn "remote branch no longer exists" docs README.md skills` returns the corrected +`page.mdx:17` and nothing else. Left unfixed because Phase 5's **Files** names exactly one file, +`docs/src/app/docs/commands/cleanup/page.mdx`, and editing the README would have been scope the gate did +not review. + +Adjacent and smaller: neither page documents `cleanup --force`, so D7's design point — `--force` skips the +confirmation but still prints the skipped report (`src/commands/cleanup.ts:66,78-80`) — is written down +nowhere user-facing. §4.4 did not ask for it and Q4 leaves whether `--force` *should* print unsettled, so +this is a note, not a gap to close blindly. + +**Closes when:** a Lint gate run passes with `README.md:160` carrying the same uncommitted-work exception +as `page.mdx:17`. + +### F-008 — P2 — `gitGetWorktrees` splits the worktree-list line on a space, so a repo under a spaced path lists nothing + +**Tied to:** shell-argv-safety Phase 2 · **Raised:** 2026-09-05 (hand, §7 case 1) + +`gitGetWorktrees` (`src/lib/git.ts:134-157`) parses each `git worktree list` line with +`line.replace(/\s\s+/g, " ").split(" ")`, taking `[0]` as the path and `[2]` as the branch. That collapses +runs of two-or-more spaces into one and then splits on every single space, so a repository whose own path +contains a space is truncated at that space. Measured on 2026-09-05 against a real repo at +`…/case1/space demo/proj`: + +``` +line /Users/…/case1/space demo/proj 60b9ebe [main] +parsedPath /Users/…/case1/space +branchStr 60b9ebe → branchName "0b9eb" after slice(1, -1) + +line /Users/…/case1/space demo/proj.worktrees/feature/test 9a6e0d9 [feature/test] +parsedPath /Users/…/case1/space +branchStr 9a6e0d9 → branchName "a6e0d" after slice(1, -1) +``` + +Both lines collapse to the same truncated `parsedPath`, and `branchStr` lands on the commit sha instead of +the bracketed branch, so `branchName` is five characters of that sha. + +Every entry then fails the `path.startsWith(worktreesRootPath)` filter at `src/lib/git.ts:152-155`, because +`worktreesRootPath` is the untruncated `/Users/…/case1/space demo/proj.worktrees`. `worktree list` prints +its spinner and no rows; `remove` and `cleanup` consume the same builder and see an empty list. + +**This is pre-existing and independent of the subprocess layer** — it is string parsing of stdout, not +command construction, so no phase of this plan touches it. It is recorded here because it is what stopped +§7 case 1 from being run as written: the three functions Phase 2 migrated are never reached under a spaced +repo path, so the end-to-end "counts must be real numbers, not blanks" observation cannot be made. + +Phase 2's own claim was verified by substitute evidence instead, against the same real repository — the +built `dist/lib/git.js` called directly with the spaced worktree path returned `ahead: 1`, `behind: 0`, +`uncommitted: 1`, the exact values the fixture was built to have, while the pre-change +`exec("cd /…/space demo/… && git status -s")` form failed on the identical path. + +Not fixed here: Phase 2's scope names three functions and F-006, and `gitGetWorktrees` is neither. Landing +a parser change would commit code no gate reviewed — the reasoning F-002 through F-005 record. The likely +shape is `git worktree list --porcelain`, which emits one `key value` record per line and needs no +column-splitting at all. + +**No phase of this plan will close this.** §6.1 has no row that touches `gitGetWorktrees`, so this finding +is still open at `/feature-close` unless it is given a home first — a roadmap entry is the natural one. + +**Closes when:** a Gate 1 run passes with `gitGetWorktrees` returning the correct path and branch for a +worktree-list line whose path contains a space, and `worktree list` run by hand in a repo under a spaced +path prints its rows. + +### F-009 — P2 — the `worktree add` failure path is untested, so a dropped `await` on it would land green + +**Tied to:** shell-argv-safety Phase 3 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 3) + +`gitCreateWorktree` issues two sequential `run` calls of its own (`src/lib/git.ts:245` and `:249-261`), +and D5 rests on both being awaited. Only the **first** is exercised on its rejecting path: +`grep -n "mockRejectedValue" src/lib/git.test.ts` returns `445` as the sole rejection in that describe, +and it is queued on the fetch. So dropping `await` at `src/lib/git.ts:249` passes all four tests — the +call is still recorded synchronously, `succeed` is still called, and the one rejection test stops before +reaching it. + +**Line numbers updated at Phase 5**, which moved `gitGetRootPath` onto `run` as well: `gitCreateWorktree` +now records **three** calls, the root lookup ahead of the fetch and the add, so the counts in that describe +read `3` where they read `2`, and the fetch-failure case stops at call 2 rather than call 1. + +It matters past mutation hygiene: `src/commands/branch.ts:182-184` and `src/commands/checkout.ts:65-71` +both `await gitCreateWorktree(…)` and immediately act on the returned path, so an un-awaited +`worktree add` would run them against a directory git has not created yet. This is also the one path R7 +names for Phase 3, and that prefix change is currently pinned by the hand measurement in §7 case 2 alone, +not by a test. + +The smaller half of this finding is **closed**. The spaced-root case omitted the +`toHaveBeenCalledTimes` its two sibling cases carried, so a further `run` issued only on that path would +have gone unseen; Phase 5 added it at `src/lib/git.test.ts:418`, alongside the siblings now at `:363` and +`:395`. The primary half — no rejecting test on `worktree add` — is untouched and is what keeps this +finding open. + +Left unfixed because Gate 2 had already returned `PASS WITH NOTES` on the Phase 3 diff, and adding +assertions afterwards would commit test code no gate had seen — the reasoning F-002 through F-005 record. + +**Closes when:** a Gate 1 run passes with a `git.test.ts` case that resolves the fetch, rejects the +`worktree add`, and asserts both `rejects.toThrow` and `spinnerMocks.fail` called with that message. + +### F-010 — P3 — the relative `worktreePath` does not buy what its comment says it does + +**Tied to:** shell-argv-safety Phase 3 · **Raised:** 2026-09-05 (hand, §7 case 2) + +`src/lib/git.ts:234-237` keeps the pre-change rationale for passing `git worktree add` a relative path: +"This ensures that everything stays in sync in case the project is moved in the filesystem." Measured on +2026-09-05 on **git 2.38.1**, it does not. `git worktree add` resolves the path it is given and records an +absolute one in both link files, so the relative form, an absolute form, and a shell reproduction of the +exact pre-change command are byte-identical: + +``` +worktree .git gitdir: /proj/.git/worktrees/ +.git/worktrees//gitdir /proj.worktrees/feature//.git +``` + +After renaming the containing directory, all three fail alike with +`fatal: not a git repository: /proj/.git/worktrees/`, and `git worktree list` from the root +shows every entry `prunable` at its stale absolute path. + +**Not a regression and not introduced here** — Phase 3 preserved the argument shape exactly, which is what +R1 asked of it, and §7 case 2's first half (same shape as before the change) passes. What is wrong is the +claim: the comment survives into the argv form as the stated reason for a choice that, on this git version, +has no effect. A future reader "simplifying" it to `absoluteWorktreePath` would be talked out of a harmless +edit by a false rationale — or, worse, would trust the promise. + +Two ways out, and choosing between them is the point of recording this: correct the comment to say the +path is relative for readability and that git records absolute links regardless, or reach for +`git worktree add --relative-paths` (git 2.48+) and actually deliver the property — which would need a +version floor this project does not currently state. + +**Closes when:** a Gate 1 run passes with `src/lib/git.ts`'s comment either matching measured git behaviour +or accompanied by the `--relative-paths` flag that makes the original claim true. + +### F-011 — P2 — the third `run` in `gitNukeWorktreeCmd` is unpinned, so a dropped `await` would report a removal that did not happen + +**Tied to:** shell-argv-safety Phase 4 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 4) + +`gitNukeWorktreeCmd` now issues three sequential `run` calls (`src/lib/git.ts:282`, `:288`, `:289`), and D5 +rests on every one being awaited. The **third** has no rejecting test, because no call follows it to count: +`src/lib/git.test.ts:506-516` rejects call 1 and asserts `toHaveBeenCalledTimes(1)`, and `:518-529` rejects +call 2 and asserts `2`, but nothing observes a rejection from `git branch -D`. So dropping `await` at +`src/lib/git.ts:289` passes all four tests. (Line numbers refreshed at Phase 5, which shifted this file.) + +It matters past mutation hygiene. `gitNukeWorktree` catches the rejection (`src/lib/git.ts:300-304`) and is +the only thing standing between a failed `branch -D` and a success message: un-awaited, the catch never +fires and the spinner prints `Worktree was removed.` while the branch is still there. This is +exactly the shape F-009 records for `worktree add`, one phase earlier. + +Smaller, and the same shape: the round-trip case at `src/lib/git.test.ts:137-154` asserts the **set** call's +argv and its length, but never `toHaveBeenNthCalledWith(2, …)` for the get, and its +`expect(value).toBe(hostileValue)` half only proves the helper returns what the mock was queued with. The +load-bearing evidence for the return direction is §7 case 3, which was run by hand against the built +`dist/lib/git.js` and re-run independently at Gate 2 — not this assertion. + +Left unfixed because Gate 2 had already returned `PASS WITH NOTES` on this exact diff, and adding +assertions afterwards would commit test code no gate had seen — the reasoning F-002 through F-005, F-007 +and F-009 record. + +**Closes when:** a Gate 1 run passes with a `git.test.ts` case that resolves the first two calls, rejects +`git branch -D`, and asserts both `rejects.toThrow` and that `gitNukeWorktree` fails rather than succeeds +on that path. + +### F-012 — P2 — a Windows `.cmd`/`.bat` editor can no longer be launched, and `code` is one there + +**Tied to:** shell-argv-safety Phase 6 · **Raised:** 2026-09-05 (hand, R6) + +`openWorktreePath` now launches the editor through `run` (`src/lib/base-command.ts:65`), which is +`execFile` with no shell. On Windows that cannot start a `.cmd` or `.bat` file, and the usual `codeEditor` +value — `code` — is `code.cmd` there. The pre-change `exec` form always went through `cmd.exe`, so this is +a behaviour change a Windows user would see, against §2 of the plan. + +**Measured from source rather than asserted, and the design answer is in the plan's R6:** Node 24.19.0's +JavaScript layer has no batch-file handling at all (371 builtin module sources scanned via +`process.binding("natives")`: zero `.bat` lines, and all 16 `.cmd` lines are property reads — +`message.cmd`, `msg.cmd`, `ex.cmd`); `src/process_wrap.cc` on `v24.x` returns `UV_EINVAL` for +`IsWindowsBatchFile(options.file)` because batch-file arguments "cannot be unambiguously escaped"; and +libuv's `path_search_walk_ext` appends only `.com` and `.exe`, so an extension-less `code` fails `ENOENT` +before the guard is reached. Adding `shell: true` on `win32` would restore the exact hazard this feature +removes, at the one site that interpolates a user-supplied config value, so it was rejected. + +**Config-time validation does not catch it, and on Windows it actively hides it.** The plan's R7 note +argues the new `spawn ENOENT` text is narrow because `commandExists` rejects an unfound editor +before it can be stored. On Windows that inverts: `commandExists` runs `where` (`src/lib/cli.ts:31`), which +resolves `PATHEXT` and therefore *finds* `code.cmd`, while libuv's spawn path tries only `.com` and `.exe`. +Validation passes and the launch then fails — the one configuration where the "narrow" argument does not +hold. **This half is reasoned from documented behaviour, not measured**, which is why it lives here rather +than anywhere that reads as settled. + +**Not observed on a Windows host.** This repository has no Windows CI — every workflow is `ubuntu-latest` +— and the phase was implemented and verified on macOS. The configuration page documents the limitation +(`docs/src/app/docs/configuration/page.mdx`), which is the user-facing half; this finding is the half that +is still unproved. + +**Closes when:** the behaviour is observed on a real Windows host and the configuration page's note is +made to match what was seen — corrected and removed if `code` launches anyway, or kept with the observed +error text if it does not. + +### F-013 — P2 — the editor launch is unpinned as fire-and-forget, so an added `await` would land green + +**Tied to:** shell-argv-safety Phase 6 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 6) + +`openWorktreePath` deliberately does not await the launch (`src/lib/base-command.ts:65`): the editor +outlives the command, and the spinner settles from the `.then(succeed, fail)` after `openWorktreePath` has +already resolved. **No case in `src/lib/base-command.test.ts` pins that.** Measured at Gate 2 rather than +argued: putting `await` in front of that `run(…)` call leaves all six cases passing, for two reasons — +the two cases that do assert a spinner outcome reach it through `vi.waitFor` +(`src/lib/base-command.test.ts:83` and `:93`), which tolerates either ordering, and the other four never +observe timing at all. The comment at +`src/lib/base-command.test.ts:81-82` states the property in prose and asserts nothing about it. + +It matters past mutation hygiene. All three callers await `openWorktreePath` — `src/commands/branch.ts:184`, +`src/commands/checkout.ts:71`, `src/commands/open.ts:46` — so the mutation would make `worktree branch`, +`checkout` and `open` block until the editor process exits, which for a terminal editor is until the user +closes it. This is the same shape as F-009 (`worktree add`) and F-011 (`git branch -D`), one phase on: a +dropped or added `await` that no test observes. + +The property was measured by hand instead, at Gate 2 and again independently afterwards: driving the built +`dist/lib/base-command.js` with an editor that sleeps 900 ms returns from `openWorktreePath` in **43 ms**, +while the process stays alive until the child exits at 990 ms and the spinner succeeds there. That is +evidence for the current commit, not a guard on the next one. + +Left unfixed because Gate 2 had already returned `PASS WITH NOTES` on this diff, and adding assertions +afterwards would commit test code no gate had seen — the reasoning F-002 through F-005, F-009 and F-011 +record. + +**Closes when:** a Gate 1 run passes with a `base-command.test.ts` case asserting that `succeed` has *not* +been called at the moment `openWorktreePath` resolves, and has been once the launch settles. + +### F-014 — P3 — `codeEditor` keeps the whole-line error message that D1 rejected for `agent.command` + +**Tied to:** agent-mode Phase 7 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 1) + +Phase 1 added `isValidCommandLine` (`src/lib/validators.ts:21-35`) because `isValidCommand`'s message quotes +the whole value back — `Command not found: claude --bg` names the flag rather than the program that is +actually missing. That reasoning is `AGENT-MODE-PLAN.md` §3 D1. But the switch wires only `agent.command` to +the new validator (`src/lib/validators.ts:90-91`); `codeEditor` stays on `isValidCommand` +(`src/lib/validators.ts:88-89`). + +The two keys now have **identical execution semantics and different error quality**: `openWorktreePath` +splits `codeEditor` on `/\s+/` and launches the head alone (`src/lib/base-command.ts:62`), exactly as +`agent.command` will, so `worktree config codeEditor "code -n"` with `code` absent reports +`Command not found: code -n`. The current behaviour is pinned by `src/lib/validators.test.ts:56`, which +asserts `"Command not found: bad command with args"` — so switching `codeEditor` over is a deliberate change +with a test to update, not a silent fix. + +Out of Phase 1's scope: the plan wires `agent.command` and nothing else, and editing `codeEditor`'s +validation after Gate 2 had passed on the diff would land an unreviewed behaviour change. Phase 7 is the +generated-surface sweep and the natural place to take it. + +**Closes when:** a Gate 1 run passes with `codeEditor` routed to `isValidCommandLine` and +`validators.test.ts:56` updated to expect the head alone — or the finding is closed as deliberate if the +maintainer prefers the two keys to differ. + +### F-015 — P3 — a rejected config value prints an error but exits 0, so a scripted `worktree config` cannot detect it + +**Tied to:** agent-mode Phase 1 · **Raised:** 2026-09-06 (hand, confirmed by Gate 2) + +`worktree config agent.command "nope-not-a-binary"` prints `Error: Command not found: nope-not-a-binary` and +**exits 0**. The value is correctly not stored, so Phase 1's **Done when** clause ("is rejected") does hold — +but a caller that checks the exit code sees success. `BaseCommand.catch` (`src/lib/base-command.ts:74-85`) +logs the message and returns instead of re-throwing, which is what swallows the status. + +**Pre-existing and repo-wide, not introduced by Phase 1.** Verified on 2026-09-06 against the built `dist` +in a throwaway repo: `worktree config codeEditor "also-not-a-binary"` exits 0 identically, and that path is +untouched by this phase. + +Recorded because it compounds with the §8 Q5 resolution rather than because Phase 1 caused it. Q5 accepted +that `--agent` with no `agent.command` set prints a message and exits 0, on the grounds that the CI case is +a caveat worth taking. With this, a scripted setup that both *sets* the key wrongly and *uses* `--agent` gets +two consecutive exit-0s and no agent — the failure is invisible at both ends. + +**Closes when:** the maintainer either accepts it explicitly (and it is folded into the plan's log at +`/feature-close`) or a Gate 1 run passes with `catch` re-raising a non-zero status for +`InvalidConfigValueError`. The second is a repo-wide behaviour change well outside agent-mode and should not +be taken as part of it. + +### F-018 — P3 — `✔ Agent started` is printed before a failed launch is knowable + +**Tied to:** agent-mode Phase 2 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 2) + +`dispatchAgent` logs its success line immediately after `spawnDetached` returns (`src/lib/base-command.ts`), +but the launch is fire-and-forget: an ENOENT arrives as an `error` event a tick later. Measured on +2026-09-06 against the built `dist` with `agent.command` pointing at a missing binary — the terminal shows +`✔ Agent started in …` and then a red `Error: spawn worktree-no-such-agent ENOENT`, exit 0. + +Narrow, because `isValidCommandLine` refuses to store a head that is not on PATH: the residual cases are a +binary removed after it was configured, and the Windows `.cmd` shim case [F-012] records. There is no exit +status to wait for by design (§3 D2), so the fix is wording, not sequencing. + +**Closes when:** a Gate 1 run passes with the dispatch line no longer asserting success ahead of the launch +— or the maintainer accepts the current wording, folded into the plan's log at `/feature-close`. + +### F-019 — P3 — `stdio: "ignore"` discards the output of an agent that does not background itself + +**Tied to:** agent-mode Phase 2 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 2) + +`spawnDetached` sets `stdio: "ignore"` (`src/lib/cli.ts`), which is right for the runtime the plan was +written against: `claude --bg` detaches and keeps its own transcript, reachable through `claude agents`. +§2 requires runtime neutrality, and an agent of the same shape that stays in the foreground and writes to +stdout — `codex exec`, the plan's own second example at `docs/src/app/docs/configuration/page.mdx:54` — has +its output thrown away irrecoverably: no file, no terminal, nothing to attach to. + +Inherited stdio is not the answer, since the CLI exits immediately and would be writing into a terminal that +has moved on; a log file under the worktree would be. `docs/src/app/docs/commands/branch/page.mdx` currently +tells the reader to use their agent's own tooling, which is only true of an agent that has some. + +**Closes when:** a Gate 1 run passes with dispatch writing the child's output somewhere recoverable and the +branch page saying so — or the maintainer accepts backgrounding agents as the supported shape, folded into +the plan's log at `/feature-close`. + +### F-021 — P3 — `toAgentSession` drops a session that has a usable `cwd` but no `name` or `pid` + +**Tied to:** agent-mode Phase 3 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 3) + +`toAgentSession` (`src/lib/agent.ts`) requires `name`, `pid` and `cwd`, and its comment justifies all three +with "an entry without them cannot match a worktree in the first place". That reasoning holds for `cwd`, +the join key, and not for `name` and `pid`, which are only rendering fields. A runtime that renamed either +would yield zero sessions and `cleanup` would then delete everything — the unsafe direction. + +This matches §4's type sketch literally, and §1 measured all three as present on both session kinds, so it +is defensible as written. It is in tension with R3's response, "every field the code reads is optional", +which the plan scopes to `kind`, `state` and `status` without saying so explicitly. + +**Closes when:** the plan says which fields are load-bearing and which are optional, and the code and its +comment agree with that — or the maintainer accepts the current shape, folded into the plan's log at +`/feature-close`. + +### F-022 — P3 — the `cwd` join misses on case and symlink differences, and fails toward "no session" + +**Tied to:** agent-mode Phase 3 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 3) + +`isPathInside` (`src/lib/agent.ts`) compares paths segment-wise via `path.relative`, which is +case-sensitive on posix and does not resolve symlinks. So `/Repo/…` against `/repo/…` on a case-insensitive +macOS volume, or `/tmp/wt` against `/private/tmp/wt`, both report no match. Every such miss fails toward +"no session found", which for Phase 6 means the worktree is removable. + +Low probability: `git rev-parse --show-toplevel` and `process.cwd()` both return resolved physical paths, +so the two sides of the comparison normally agree. R5 already accepts a stale PID as a spurious block; this +is the same class of imprecision pointing the other way, and it is unrecorded. + +**Closes when:** the paths are normalised on both sides before comparison, or R5 is widened to name this as +an accepted risk, folded into the plan's log at `/feature-close`. + +### F-023 — P3 — `isHere` is the only const-assigned arrow function in non-test `src/` + +**Tied to:** agent-mode Phase 3 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 3) + +`findSessionForPath` binds its predicate as `const isHere = (session) => …` (`src/lib/agent.ts`). Every +other named function in non-test `src/` is a declaration; the reviewer's grep found this to be the only +const-assigned arrow outside tests. `context/standards/typescript/rules.md:25-27` reserves arrow +expressions for anonymous callbacks and inline handlers, and its `BAD` example is module-level, so a named +local sits in a gray zone the rule does not directly address. A nested `function isHere(…)` closes over +`worktreePath` identically. + +Cosmetic, and `pnpm check` passes it. Left unfixed because Gate 2 had already passed on the diff — the same +reasoning F-002, F-003 and F-004 record: editing after the gate lands unreviewed code. + +**Closes when:** a Lint gate run passes with the predicate written as a nested function declaration — or the +maintainer accepts the arrow, folded into the plan's log at `/feature-close`. + +### F-025 — P2 — `git.ts` and `agent.ts` now import each other, the only circular import in non-test `src/` + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 5) + +Phase 5 introduced it: `src/lib/git.ts:7-12` imports four symbols from `./agent.js`, while +`src/lib/agent.ts:3` imports `gitGetConfigValue` from `./git.js`. A depth-first scan over relative imports +in non-test `src/` reports `git.ts -> agent.ts -> git.ts`; the same scan at the pre-phase commit reports no +cycle at all. `context/standards/architecture/dependency-boundaries.md:81` reads *"Circular dependencies are +always forbidden. They indicate a design problem."* Nothing catches it — there is no `madge` step in +`.github/workflows/` and Biome has no rule for it enabled in `biome.json`. + +It is safe **today** and that is exactly why it is worth recording. Neither module has a top-level binding +that references the other — `agent.ts`'s only module-scope `const` is `SESSION_ARGS` (line 15) — so every +crossing is a hoisted function declaration resolved at call time, and the build is plain `tsc`, not a +bundler that could reorder it. The hazard is latent: the first top-level `const` added to either module that +references the other becomes a temporal-dead-zone crash at CLI startup, with a stack trace naming neither +the cause nor the author. + +It is the joint consequence of two plan-directed decisions rather than an invention of Phase 5 — §4 puts the +`includeAgents` flag on `gitGetWorktreeList`, and Phase 3 already read config through `git.ts`. The fix is +to extract `gitGetConfigValue` / `gitSetConfigValue` into a `src/lib/config.ts` depending only on +`./cli.js`, leaving `git.ts -> agent.ts -> config.ts`. That touches `git.ts`, `agent.ts`, `base-command.ts`, +`src/commands/config.ts` and their tests — outside Phase 5's scope, but **Phase 6 already owns +`src/lib/git.ts`** and will have the module open, which is why it is tied there. + +**No remaining phase of this plan will close this.** It was tied to Phase 6 because that phase owns +`src/lib/git.ts`; Phase 6 has now shipped without taking it, and Phase 7's **Files** are `skills/core/SKILL.md` +and `README.md`, so no row is left that opens either module. Deferred deliberately: the extraction is `P2`, +which does not gate a phase, and the Phase 6 Gate 2 reviewer measured its real blast radius as **seven** +non-test files (`git.ts`, `agent.ts`, `base-command.ts`, `commands/config.ts`, `commands/branch.ts`, +`integrations/github.ts`, `integrations/jira.ts`) plus six test files that spy on `git.gitGetConfigValue` — +materially wider than this finding's own estimate above, and roughly twelve files against Phase 6's five. +Landing it inside Phase 6 would have been the unreviewed-scope-expansion this file records elsewhere. It is +therefore still open at `/feature-close` unless it is given a home first — a roadmap entry is the natural +one, as with F-008. + +**Closes when:** a cycle scan over non-test `src/` reports none, proven by a Gate 1 run. + +### F-027 — P3 — a new `git.test.ts` comment claims an ordering assertion the harness does not make + +**Tied to:** agent-mode Phase 5 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 5) + +`src/lib/git.test.ts:630-633` introduces its fixture as *"Every git call gitGetWorktreeList makes, in +order…"* and passes an eight-command sequence to `expectCommands`. But `src/test-setup.ts:47-53` only +`console.warn`s on an unexpected call — it asserts nothing, and it tests membership, not order. The comment +promises a guard the harness does not provide. + +Nothing this phase guarantees rests on it: the load-bearing R4 check is the `toHaveBeenCalledTimes(1)` at +`src/lib/git.test.ts:685`, which is a real assertion. The harness property is pre-existing and surfaced only +because this comment leans on it. + +**Closes when:** the comment states what `expectCommands` actually does, or `test-setup.ts` asserts rather +than warns — either proven by a Gate 1 run. + +### F-028 — P3 — `list`'s `-a` alias and flag description are pinned by no test + +**Tied to:** agent-mode Phase 5 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 5) + +`docs/src/app/docs/commands/list/page.mdx:22` documents `-a` and the exact description string, and a manual +`list --help` run confirmed both, but nothing in `src/commands/list.test.ts` reads `List.flags` — the suite +stubs `parse` wholesale (`src/commands/list.test.ts:34-37,41`). Changing `char: "a"` or the description at +`src/commands/list.ts:16-17` would diverge from the docs table with every test still green. + +Cosmetic, and consistent with how `branch` and `checkout` flags are tested in this repo — so this is a note +about a repo-wide gap that Phase 5 inherited, not a Phase 5 defect. + +**Closes when:** a test reads `List.flags.agents` and asserts its `char` and `description`, proven by a Gate +1 run — or the maintainer accepts the gap, folded into the plan's log at `/feature-close`. + +### F-029 — P3 — `hasLiveAgent`'s comment credits the fail-safe to a path that cannot reach it + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`src/lib/git.ts:168-172` says an absent `live` marker counts as live "so a hand-built entry **and a runtime +that renamed its state field** both block removal rather than being waved through". The hand-built half is +real. The renamed-`state` half never reaches the `!== false` branch: `toWorktreeAgent` always sets `live` +(`src/lib/git.ts:231`), and `isSessionLive` returns `true` for an unrecognised state (`src/lib/agent.ts:132`), +so a renamed field yields `live: true` explicitly, not an absent one. The outcome is the same and the code is +right; the comment names the wrong mechanism for it. + +The same shape as F-027 — a comment leaning on a guard that lives in another module. One line. + +**Closes when:** the comment attributes the renamed-`state` case to `isSessionLive` rather than to the absent +marker, proven by a Gate 1 run. + +### F-030 — P3 — `--ignore-agents` is "do not tell me" as well as "do not look", against this command's own stated principle + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`archive/CLEANUP-DATA-LOSS-PLAN.md:209` states *"`--force` means \"do not ask me\", not \"do not tell me\"."* +and `src/commands/cleanup.ts:147-148` carries that forward for uncommitted changes. `--ignore-agents` is +implemented as *not gathering* the sessions (`src/commands/cleanup.ts:110-112`), so +`cleanup --force --ignore-agents` sweeps agent-occupied worktrees and names none of them — "do not tell me", +for the one category D5 calls the worst failure mode in the flow. + +This is a deliberate trade, not an oversight: not looking is also what keeps R4's constant cost off the +overridden path, the docs disclose the mechanism (`docs/src/app/docs/commands/cleanup/page.mdx:55-57`), and +nothing in §4 or D5 requires a report from the override. Recorded because it is the single place the new flag +departs from a principle this command already had in writing, and someone will otherwise rediscover it as a +bug. + +**Closes when:** the override reports what it swept past — which means gathering sessions and discounting +them rather than skipping the lookup — or the maintainer accepts the trade, folded into the plan's log at +`/feature-close`. + +### F-031 — P3 — `cleanup`'s flags block is pinned by no test + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`src/commands/cleanup.test.ts` stubs `parse` wholesale and never reads `Cleanup.flags`, so deleting the +`"ignore-agents"` entry at `src/commands/cleanup.ts:58-61` leaves every test green while +`worktree cleanup --ignore-agents` fails at runtime against a flag the docs table documents +(`docs/src/app/docs/commands/cleanup/page.mdx:31`). Only §7's manual run currently proves the flag exists. + +Identical in shape to F-028, one command over, and to how `branch` and `checkout` flags are tested here — so +this is the repo-wide gap Phase 6 inherited rather than a Phase 6 defect. + +**Closes when:** a test reads `Cleanup.flags["ignore-agents"]` and asserts it, proven by a Gate 1 run — or +the maintainer accepts the gap, folded into the plan's log at `/feature-close`. Best taken together with +F-028. + +### F-032 — P3 — `remove` and `cleanup` now disagree about a worktree an agent is living in + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`src/commands/remove.ts:54` calls `gitGetWorktreeList()` with no options, so no session join happens and a +worktree holding a live agent still gets `safeToRemove: true` — landing it under *"Inactive branches (Safe to +delete)"* (`src/commands/remove.ts:40`) while `cleanup` refuses the same worktree. + +Out of scope by design: D5 names `cleanup` only, §4's **Cleanup** paragraph names `isSafeToRemove` and +`cleanup`'s override and nothing else, and `remove` is an explicit single-worktree choice rather than a +sweep, so the case for blocking it is weaker. But the label it prints is now wrong for that worktree, which +is a different thing from declining to block. + +**Closes when:** `remove` either joins sessions and stops calling such a worktree safe, or the maintainer +accepts the asymmetry — either folded into the plan's log at `/feature-close`, or re-filed as its own +roadmap entry. + +### F-033 — P3 — a finished session renders in `list --agents` as though it were working + +**Tied to:** agent-mode Phase 5 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`src/lib/utils.ts:37-44` gives a session no marker unless it is interactive or waiting, so a session with +`state: "done"` renders as bare `Agent: ` — indistinguishable from a background agent that is getting +on with its work. `docs/src/app/docs/commands/list/page.mdx` says *"**No marker** — a background agent that is +getting on with its work"*, which is now false for that case, and `cleanup` will happily remove the worktree +the reader has just been shown an agent in. + +Reachable only through D6's second layer — a finished session appearing in a listing that omits `--all` — so +low probability. Phase 5 owns both files, and Phase 6 is what made the divergence observable by acting on +`live` where `list` does not render it. + +**Closes when:** `list --agents` distinguishes a finished session from a working one, proven by a Gate 1 run +— or the maintainer accepts the gap, folded into the plan's log at `/feature-close`. + +### F-034 — P3 — the cleanup docs open with an absolute the page then qualifies twice + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`docs/src/app/docs/commands/cleanup/page.mdx:35` opens *"A worktree that an agent session is living in is +never removed."* Two exceptions exist: `--ignore-agents`, qualified three paragraphs later at `:54-57` and in +the flags table at `:31`, and a worktree whose directory is already gone, which `isSafeToRemove` answers +before it ever reaches the agent clause (`src/lib/git.ts:178-183`). + +Ordinary topic-sentence-then-qualification prose, so a note rather than a defect. *"is not removed unless you +pass `--ignore-agents`"* would remove the tension in one edit. + +**Closes when:** the sentence carries its qualification, proven by a Lint gate run — or the maintainer +accepts the prose as written. + +### F-035 — P3 — the cleanup docs flags table has a different shape from the list page's + +**Tied to:** agent-mode Phase 6 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 6) + +`docs/src/app/docs/commands/cleanup/page.mdx:28-31` uses a two-column Flag/Description table with the alias +folded into the flag cell, where `docs/src/app/docs/commands/list/page.mdx:20-22` uses three columns — +Flag/Alias/Description — and quotes each flag's `description` string verbatim. `--ignore-agents`' row does +quote the code; `--force`'s paraphrases it as "Skip the confirmation prompt" against +`src/commands/cleanup.ts:53`'s "Force cleanup without confirmation". + +Cosmetic, and the two pages were written by different phases. Recorded so the docs settle on one shape rather +than drifting page by page. + +**Closes when:** the two pages use one table shape and quote the flag descriptions the code carries, proven by +a Lint gate run — or the maintainer accepts the variation. + +### F-036 — P3 — two shipped skill artifacts still enumerate the pre-agent config and command surface + +**Tied to:** agent-mode Phase 7 · **Raised:** 2026-09-06 (hand, during the Phase 7 sweep) + +`skills/_artifacts/domain_map.yaml:38` claims `'worktree config (all 9 keys)'` and +`skills/_artifacts/skill_spec.md:23` claims `All 7 commands, 9 config keys`. There are ten user-facing keys +now — `agent.command` is the tenth (`src/lib/constants.ts:1-12`, less the internal `has-called-config`) — +and neither file mentions `--agent`, `list --agents` or `cleanup --ignore-agents`. Both ship: the `files` +field in `package.json` publishes the whole `skills/` tree. + +They were left untouched deliberately, and the reasoning is in the plan's Phase 7 **Files** line. What +draws the line is that `scripts/sync-intent-version.mjs:49-50` writes only `SKILL.md` and +`skill_tree.yaml`, and `.github/workflows/ci.yml:33-34` gates on that same pair — so those two are the +maintained artifacts and these two are inputs nothing consumes. `domain_map.yaml` is a stamped record on +top of that (`:4-6` — `Version: 1.2.0`, `Date: 2026-04-06`, `Status: reviewed`, against a package now at +1.2.8), so editing it by hand would assert a discovery run and a review that never happened. +`skill_spec.md` carries no stamp and rests on the sync/CI criterion alone. + +A second consequence worth naming: `skill_tree.yaml:8-10` declares `generated_from` these two files, and +its hand-edited `description` now enumerates a surface neither declared input describes. That is the +accepted trade — a shipped description that is correct, sourced from inputs that are not. + +`P3` because nothing reads these two at runtime: `SKILL.md`'s own frontmatter is what an agent loads, and +that is now correct. The cost is a published artifact that undercounts the surface. + +**Closes when:** the skill generator is re-run against the current tree and its output committed, restamping +both files — or the maintainer accepts that they are frozen 1.2.0 records and the claim is scoped to that +version in the files themselves. + +### F-037 — P3 — `SKILL.md` shows `list --agents` without naming the three states it renders + +**Tied to:** agent-mode Phase 7 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 7) + +`skills/core/SKILL.md:128-129` gives the invocation and its `-a` alias and stops there. The command renders +`Agent: ` qualified by `[interactive]` or `[waiting]` or neither (`src/lib/utils.ts:37-44`), and +`docs/src/app/docs/commands/list/page.mdx:36-39` documents all three. An agent reading only the skill file +sees output it has not been told how to read — and the interactive marker is the one that matters, because +it is the difference between "an agent this tool dispatched" and "somebody's own terminal". + +Not fixed in the phase that raised it: Gate 2 had already passed on the diff, and adding shipped prose +afterwards lands content no gate reviewed — the same reasoning F-002 through F-005 record. + +**Closes when:** a Lint gate run passes with `SKILL.md`'s `list --agents` text naming the no-marker, +`[waiting]` and `[interactive]` states as `page.mdx:36-39` does. + +### F-038 — P3 — `SKILL.md` inherits the cleanup docs' absolute-then-qualified opening + +**Tied to:** agent-mode Phase 7 · **Raised:** 2026-09-06 (Gate 2, reviewer subagent, Phase 7) + +`skills/core/SKILL.md:145` opens "A worktree a live agent session is sitting in is never removed by +`cleanup`", which is the same unqualified absolute F-034 records against +`docs/src/app/docs/commands/cleanup/page.mdx:35`. `src/lib/git.ts:178-183` returns `true` before the agent +clause is reached when `pathExists` is false, so "never" has an exception neither page states. + +**This one must not be fixed alone.** The two sentences agreeing is the property Phase 7 exists to +establish, so editing the skill file without the docs page would trade a wording defect for a consistency +defect. It closes with F-034 or not at all. + +**Closes when:** a Lint gate run passes with `SKILL.md:145` and `page.mdx:35` carrying the same qualified +claim — that is, jointly with F-034. + ## Closed +Closed findings leave this file — a feature's at `/feature-close`, folded into the retiring plan's own log; +an `ad-hoc` one at the start of the next `/orchestrate`. + +`shell-argv-safety`'s F-006 and F-007 left at its close on 2026-09-06, into +[`archive/SHELL-ARGV-SAFETY-PLAN.md`](archive/SHELL-ARGV-SAFETY-PLAN.md) §11. These two leave at +`agent-mode`'s. + +### F-002 — P3 — the `pathExists` ordering rationale in §4.1 is not pinned by any test + +**Tied to:** cleanup-data-loss Phase 2 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 2) + +§4.1's first bullet argues that `!wt.pathExists` must stay the first branch — a worktree whose directory is +gone holds nothing to lose. No test enforces it. `src/lib/git.test.ts:255-257` covers `{ pathExists: false }` +with the `entry()` default of `uncommittedChanges: 0`, which returns `true` under either ordering, so a +future change hoisting the uncommitted test above the path test would flip +`{ pathExists: false, uncommittedChanges: 3 }` from `true` to `false` with no test failing. + +Latent, not live: `gitGetWorktreeList` hardcodes `uncommittedChanges` to `0` when the path is missing +(`src/lib/git.ts:200-202`), so the combination is unreachable from the list builder. Left open rather than +fixed because Phase 2's **Done when** names exactly two over-reach cases and this is not one of them — +adding it would have landed an unreviewed assertion after Gate 2 had already passed on the diff. + +This matters sooner than it looks: `agent-mode` Phase 6 adds a live-agent clause to this same predicate +(§2, R2), and is the natural place to pin the ordering while the branches are being re-read anyway. + +**Closes when:** a Gate 1 run passes with a `git.test.ts` case asserting the verdict for +`{ pathExists: false, uncommittedChanges: 3 }`. + +**Closed:** 2026-09-06 by agent-mode Phase 6's Gate 1 run (`pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` 322 +passed, `pnpm docs:test` 49 passed — all exit 0). `src/lib/git.test.ts` now carries "is safe when the directory is gone +even with uncommitted work", asserting `true` for `{ pathExists: false, uncommittedChanges: 3 }` — the exact +case this finding names. The predicate was open for the live-agent clause, which is placed *after* the path +branch and pinned by a second ordering case of its own, so the argument in §4.1 is now enforced by tests on +both sides rather than by a comment. + +### F-003 — P3 — a path-less worktree with a non-zero change count would be listed as skipped *and* removed + +**Tied to:** cleanup-data-loss Phase 3 · **Raised:** 2026-09-05 (Gate 2, reviewer subagent, Phase 3) + +For `{ pathExists: false, uncommittedChanges: 3 }` both halves of `cleanup`'s split claim the entry: +`isSafeToRemove` returns `true` at `src/lib/git.ts:159-162`, so it lands in `worktrees` +(`src/commands/cleanup.ts:48`), and `isSkippedForUncommittedChanges` (`src/commands/cleanup.ts:17-21`) also +returns `true`, because its zeroed probe hits that same first branch. The command would print the worktree +as skipped and then remove it anyway. + +Latent, not live, and for the same reason as F-002: `gitGetWorktreeList` hardcodes `uncommittedChanges` +to `0` when the path is missing (`src/lib/git.ts:200-202`), and `cleanup` consumes no other source. The +one-line form is `wt.safeToRemove !== true &&` in front of the existing condition. Left open rather than +fixed because Gate 2 had already passed on the diff — the same reasoning F-002 records — and because both +findings are the `pathExists: false` ordering question that `agent-mode` Phase 6 will have this predicate +open for anyway. + +**Closes when:** a Gate 1 run passes with a `cleanup.test.ts` case proving that entry appears in at most one +of the two lists. + +**Closed:** 2026-09-06 by agent-mode Phase 6's Gate 1 run (`pnpm check`, `pnpm typecheck`, `pnpm build`, `pnpm test` 322 +passed, `pnpm docs:test` 49 passed — all exit 0). Both skip predicates in `src/commands/cleanup.ts` now open with +`wt.safeToRemove !== true`, so an entry the sweep will remove can never also be announced as held back. +`src/commands/cleanup.test.ts` pins it with "claims a worktree whose directory is gone for at most one +report". The Gate 2 reviewer went further and ran the two predicates against the built `dist/lib/git.js` +over all 48 combinations of `pathExists` × `uncommittedChanges` × four agent shapes × three remote states, +reporting **0** entries claimed by more than one list — so the defect class is closed empirically, not just +for the one case named here. diff --git a/context/git.md b/context/git.md new file mode 100644 index 0000000..e265ab3 --- /dev/null +++ b/context/git.md @@ -0,0 +1,48 @@ +# Git + +Who commits the work an agent produces, and at what granularity. **Every command that lands code reads this +file before it closes out** — the exact parallel to [`verify.md`](verify.md) for commands and +[`executors.md`](executors.md) for dispatch, and for the same reason: git etiquette differs per repository, +and a skill that assumes one project's ships one project's habits everywhere. + +Configured by `/onboard` on 2026-09-06, when `@baldurpan/create-ai-workflow` 0.6.0 introduced this file. + +## Who commits + +**The user commits.** A phase ends with the work verified and its ledger row updated, left in the working +tree. The agent reports what changed and stops there — no `git commit`, no `git push`, nothing that +rewrites history. + +**This project said so before this file existed**, which is why the shipped default stands here as a +decision rather than as an unexamined inheritance. +[`.github/agents/fix-format-and-lint.agent.md`](../.github/agents/fix-format-and-lint.agent.md) requires +its agent to be "suggest-only" and to "*not* auto-commit or push changes", and repeats it under +**Safety**. That directory is documentation-only and nothing executes it — see the Documentation section +of [`stack.md`](stack.md) — so it binds nothing on its own. This file is the executable form of the same +policy, and the two now agree. + +## Granularity + +**One commit per phase.** A phase is a commit-sized unit with one checkable outcome — that is what a plan's +ledger is a list of. Where the user commits, this describes the shape the agent leaves the tree in, not +something it carries out. + +**Conventional Commits with a scope**, which is what this repository's history uses without exception: +`feat(cleanup):`, `docs(context):`, `refactor(git):`, `chore(release):`, `test(remove):`. An agent that +proposes a commit message proposes one in that form; it still does not run the commit. + +## What this file does not decide + +**Branches, pushes and pull requests.** Nothing in this workflow creates a branch, pushes, or opens a pull +request, and the answer above does not make it start. If work here belongs on a branch, make the branch +before the phase starts. + +## The rules that hold either way + +- **The ledger row lands with the work.** Whoever makes the commit, the row and the code it describes are + one change. A row updated separately is a row that disagrees with the repository in between. +- **`done` is a verdict about the gates, not about git.** A phase is `done` when its scope landed and both + gates passed. Here, where the user commits, a `done` row whose change is still in the working tree is the + normal end state — not a discrepancy, and nothing stops on it. +- **If this file is missing, the answer is the first one.** An install from before this file existed has no + policy written down; treat it as *the user commits*, say so once, and name `/onboard`. diff --git a/context/history.md b/context/history.md index dae968e..3e98c34 100644 --- a/context/history.md +++ b/context/history.md @@ -11,3 +11,6 @@ except git history, and this row is what stops it being re-proposed. | Date | Feature | Outcome | Why | Document | |---|---|---|---|---| +| 2026-09-05 | cleanup-data-loss | shipped | `isSafeToRemove` called a deleted-remote worktree safe while it held uncommitted work, and `cleanup` force-removed it; the hazard test is now hoisted above every remote branch and `cleanup` names what it held back | [`archive/CLEANUP-DATA-LOSS-PLAN.md`](archive/CLEANUP-DATA-LOSS-PLAN.md) | +| 2026-09-06 | shell-argv-safety | shipped | 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` | [`archive/SHELL-ARGV-SAFETY-PLAN.md`](archive/SHELL-ARGV-SAFETY-PLAN.md) | +| 2026-09-06 | agent-mode | shipped | a worktree can now be handed straight to a coding agent — `branch`/`checkout --agent` dispatch one with cwd set to the new worktree, so no nested `.claude/worktrees/` appears; `list --agents` names the session living in each worktree and `cleanup` refuses to sweep one an agent is working in | [`archive/AGENT-MODE-PLAN.md`](archive/AGENT-MODE-PLAN.md) | diff --git a/context/plan-template.md b/context/plan-template.md index a854869..412f3dc 100644 --- a/context/plan-template.md +++ b/context/plan-template.md @@ -36,8 +36,11 @@ Written . The `` entry in | 1 | | not started | — | | | 2 | | not started | 1 | | -Status is one of `not started`, `in progress`, `blocked`, `done`. `done` only when committed and verified, -and whoever finishes a phase updates the row in the same commit. +Status is one of `not started`, `in progress`, `blocked`, `done`. A row goes to `in progress` when work on +the phase starts, before any code, so an interrupted run leaves a ledger that says what was underway. `done` +only when the scope landed and both gates passed, and whoever finishes a phase updates the row as part of +the same change as the work — who commits that change is [`../git.md`](../git.md)'s answer, not this +table's. **Exactly one table in this document has these columns.** Do not add a second phase table — a differently-shaped one nearby is a decoy that gets read by mistake. @@ -60,10 +63,22 @@ differently-shaped one nearby is a decoy that gets read by mistake. **Done when:** -## 7. Verification +## 7. Documentation + + + +| Surface | What changes | Phase | +|---|---|---| +| | | | + + + +## 8. Verification -## 8. Open questions +## 9. Open questions - diff --git a/context/plan-template.notes.md b/context/plan-template.notes.md index 85e53fd..b478053 100644 --- a/context/plan-template.notes.md +++ b/context/plan-template.notes.md @@ -36,11 +36,19 @@ repo match the ledger" has no answer. A phase is a **commit-sized unit of work w not a category of activity — "grammar plus container support for the new token" is a phase; "testing" is not. -**§7 Verification** — how to prove the feature works, beyond [`verify.md`](verify.md) passing. Commands to +**§7 Documentation** — every place this project explains itself that the feature makes wrong, out of date +or incomplete. The surfaces come from [`stack.md`](stack.md)'s Documentation index, and from a sweep of the +repository when that index is empty — an index nobody filled in is not evidence that there are no docs. +**Each row names the phase that carries it, and that phase's `Files:` line names the same path.** A +documentation row with no phase is a follow-up nobody does. If the feature genuinely changes nothing, the +section says which surfaces were checked and why: *"no surface describes this"* is an answer, and it is +distinguishable from silence only when it is written down. + +**§8 Verification** — how to prove the feature works, beyond [`verify.md`](verify.md) passing. Commands to run by hand, files to eyeball, numbers to compare against §1's measurements. Anything that belongs to the project's standing verification stack goes in `verify.md` instead, not here. -**§8 Open questions** — what the plan could not settle. Do not paper over them. +**§9 Open questions** — what the plan could not settle. Do not paper over them. ## Standing rules @@ -48,7 +56,8 @@ project's standing verification stack goes in `verify.md` instead, not here. [`roadmap.md`](roadmap.md), phase status in §6.1, retired outcomes in [`history.md`](history.md) — one place each. `check` fails a plan that grows one. - **`/feature-plan` produces a reviewable skeleton plus open questions**, not a finished plan of record. - §1, §3 and §6.1 are the sections research can usefully draft. §4 and §5 usually arrive as open questions. + §1, §3, §6.1 and §7 are the sections research can usefully draft — §7 in particular is a search, not a + judgement call. §4 and §5 usually arrive as open questions. - **Cite by §-number, not by line number.** Source comments cite plan sections; line numbers rot on the first edit, and a §-number survives the move into `archive/`. - **Never mark a phase `done` in a plan that has not been executed.** Every phase in a new plan is diff --git a/context/roadmap.md b/context/roadmap.md index c558f17..2beee9f 100644 --- a/context/roadmap.md +++ b/context/roadmap.md @@ -27,3 +27,20 @@ this, appended under **Features** below: ## Features +### worktree-churn-stats — `pending` + +`list` says how far ahead or behind a worktree is, but not how much has actually changed in it, so there is +no way to tell a one-line fix from a rewrite without entering the directory. + +- **Size:** small — one `git diff --shortstat` per worktree behind an opt-in flag, plus three fields on + `WorktreeListEntry` +- **Doc:** none yet — cut out of `agent-mode` on 2026-09-06 as unrelated scope; its merge-base design is + already settled in that plan's D7, and its cost concern in that plan's R4 + +### chat-input-multiline — `pending` + +The docs chatbot's message field is a single-line ``, so a longer question cannot contain newlines +and the text scrolls out of sight instead of the field growing. + +- **Size:** small — one component (`ChatInput`) plus its ref type and submit key handling in `ChatForm` +- **Doc:** none yet diff --git a/context/stack.md b/context/stack.md index 5b57d9f..bfbd4d3 100644 --- a/context/stack.md +++ b/context/stack.md @@ -67,6 +67,40 @@ context/ planning-workflow artifacts (this directory) - **Console output is styled with chalk, so it is TTY-dependent.** Any test asserting on printed text has to control colour explicitly — see the `FORCE_COLOR=0` note in [`verify.md`](verify.md). +## Documentation + +Where this project explains itself. **Every plan's §7 starts from this list**, and whatever a change makes +untrue on one of these surfaces is fixed by the phase that makes it untrue, not by a follow-up. + +| Surface | Who reads it | What has to reach it | +|---|---|---| +| [`README.md`](../README.md) | someone deciding whether to install; npm renders it as the package page | the fast path — a new command, a renamed flag, a change to what the tool is for | +| `docs/src/app/docs/` | users looking something up, at https://northguild.github.io/worktree | any user-visible CLI behaviour. One page per command under `commands/` (7), plus `configuration/`, `getting-started/`, `faq/`, and `guides/` (6) | +| [`docs/README.md`](../docs/README.md) | someone working on the docs app | how the docs app is run or built | +| [`docs/worker/README.md`](../docs/worker/README.md) | someone working on the chat proxy | the Worker's routes, secrets or deploy | +| `skills/core/SKILL.md`, `skills/_artifacts/` | the `@tanstack/intent` skill consumers; shipped in the npm package | a command or flag change that the skill tree describes — **but see the caveat below** | +| `docs/src/app/docs/changelog/page.mdx` | users looking for release notes | nothing routine — it states the "single latest-docs" policy rather than listing versions | + +**Nothing is published outside this repository.** The GitHub Pages site is built from `docs/` by +`docs-deploy.yml`, and npm's package page renders the root `README.md`. Both are already in the tree, so +sweeping the tree finds every surface. + +### The two that are not ordinary docs + +- **`.github/agents/` is not maintained.** Documentation only, nothing executes it, the `scripts/run-agents.js` + runner it proposes was never written, and no workflow references the directory. **Do not propose updates + to it as part of a feature** — treat it as a note to contributors. Its suggest-only commit rule is + adopted, and now superseded, by [`git.md`](git.md). +- **`skills/` is hand-written prose with two generated lines in it.** `pnpm sync-version` rewrites *only* + the `library_version:` field of `skills/core/SKILL.md` and the `version:` field of + `skills/_artifacts/skill_tree.yaml` — `scripts/sync-intent-version.mjs:20-45` is a two-field regex + replace, nothing more. So the version lines are never hand-edited, and everything around them is a real + documentation surface that a command change can make untrue. `skill_spec.md` and `domain_map.yaml` are + hand-written throughout. + +This project stated the standing rule itself, in `changelog/page.mdx`, before the workflow arrived: +*"Update relevant docs pages in the same change."* + ## Agent customization lives in `.claude/` and `.agents/` Project skills are in `.claude/skills/` and `.agents/skills/`; agents in `.claude/agents/`. Both trees are @@ -93,10 +127,30 @@ longer arrive automatically — refresh by re-vendoring with `standards add /`, and nothing else | +| `/onboard` | the project-owned stubs | `verify.md`, `executors.md`, `git.md`, `stack.md`, and the pruning of what they replace | ## One source of truth per fact @@ -85,6 +86,30 @@ features may hold plans at once, and that is what makes planning ahead possible. `/orchestrate` is the ad-hoc escape hatch, not the way to skip planning. It refuses anything larger than a commit-sized unit and anything an existing roadmap entry already covers. +### Nothing commits unless `git.md` says so + +> **Read [`git.md`](git.md) before closing out any command that lands code. If it does not exist, or does +> not say the agent commits, the work is left in the working tree and the user commits it.** + +This workflow has always described phases as commit-sized and `done` as landed — which an agent, given no +policy, resolves by committing on its own every phase. That is a call about someone else's repository, so +it is a written answer rather than an inference. Branching and pushing are outside it: nothing here creates +a branch, pushes, or opens a pull request under either answer. + +### Documentation is part of the change + +> **Find where this project documents itself before planning — the Documentation index in +> [`stack.md`](stack.md), and the repository itself when that index is missing or empty. Whatever a change +> makes untrue there is fixed by the phase that makes it untrue, not by a follow-up.** + +Documentation is the one output with no gate behind it. Nothing fails when a README goes on describing a +flag that was renamed, so the drift is invisible until someone follows the old instructions and it is not +invisible to them. `/feature-plan` writes the affected surfaces into the plan's §7, each assigned to a +phase, and that phase's **Files:** line carries the path like anything else it touches. + +*"Nothing here describes this feature"* is a legitimate answer, and it names the surfaces that were +checked. Saying nothing is not that answer. + ### Never transcribe a credential > **A DSN, token or key is described and pointed at the secret store, never copied into a tracked file.** @@ -114,10 +139,21 @@ To pick the next phase: take the **lowest-numbered phase that is not `done` and are all `done`.** State which one you picked before starting. If it is already `in progress`, read its Note and resume — do not restart it. -`done` means committed and verified, and whoever finishes a phase updates its row in the same commit. +**A phase's row is written twice.** It opens to `in progress` when the work starts, before any code, and +closes to `done`, `in progress` or `blocked` when the phase ends. The opening write is what makes an +interruption survivable: a run that dies mid-phase leaves a tree with half the work in it, and the row is +the only thing that can say so. + +`done` means the phase's scope landed and both gates passed — **a verdict about the gates, not about git.** +Whoever finishes a phase updates its row **as part of the same change as the work**: one commit where the +agent commits, one working tree handed over where the user does. A closing row updated separately is a row +that disagrees with the repository in between. The opening write is not a change of its own — it is left in +the tree and lands with the work it describes. If the ledger's claim disagrees with the repo — a phase marked `done` whose files do not exist, or the -reverse — **stop and say so.** Never silently re-do or skip a phase on a stale ledger. +reverse — **stop and say so.** Never silently re-do or skip a phase on a stale ledger. A `done` row whose +change is still uncommitted is not that: under the default policy in [`git.md`](git.md) it is the normal +end state. ## The gates diff --git a/docs/src/app/docs/commands/branch/page.mdx b/docs/src/app/docs/commands/branch/page.mdx index 8ed9a37..0edd9bb 100644 --- a/docs/src/app/docs/commands/branch/page.mdx +++ b/docs/src/app/docs/commands/branch/page.mdx @@ -13,6 +13,7 @@ worktree branch worktree branch --source worktree branch --github worktree branch --jira +worktree branch --agent "" ``` ## Examples @@ -24,6 +25,8 @@ worktree branch --github 42 worktree branch --github "#42" worktree branch --jira DEV-123 worktree branch --jira dev-123 +worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" +worktree branch --github 42 --agent "implement the issue" ``` ## GitHub Issue Branching @@ -64,9 +67,30 @@ Jira authentication requires three configuration keys: 2. `northguild.worktree.jira.email` - Your Jira email address 3. `northguild.worktree.jira.apiToken` - Your Jira API token +## Agent Dispatch + +Use the `--agent` flag (short: `-a`) to hand the new worktree to the coding agent configured as +[`agent.command`](/docs/configuration#agent-command). The flag's value is the prompt the agent starts with. + +```bash +worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" +``` + +- The agent runs **with the new worktree as its working directory**, so it works inside + `.worktrees/` rather than picking a directory of its own. +- The prompt is passed as a **single argument**, so quotes, apostrophes and semicolons in it reach the + agent unchanged. No shell interprets it. +- The agent is started and left running — `worktree` does not wait for it, and its output does not appear + here. Use your agent's own tooling to follow it. +- `--agent` and the editor are **independent**: if `codeEditor` is configured, the worktree still opens in + it as well. +- If `agent.command` is not configured, `worktree` says so and carries on — the worktree is still created + and the editor still opens. + ## What It Does 1. Creates a branch from your configured source branch. 2. Adds a worktree under `.worktrees/`. 3. Copies `.env` files into the new worktree. -4. Opens the worktree in your configured editor (if configured). +4. Starts the configured coding agent in it (with `--agent`). +5. Opens the worktree in your configured editor (if configured). diff --git a/docs/src/app/docs/commands/checkout/page.mdx b/docs/src/app/docs/commands/checkout/page.mdx index 7f93d42..a3d6efd 100644 --- a/docs/src/app/docs/commands/checkout/page.mdx +++ b/docs/src/app/docs/commands/checkout/page.mdx @@ -10,6 +10,7 @@ Check out an existing remote branch into its own worktree. ```bash worktree checkout +worktree checkout --agent "" ``` If omitted, `worktree checkout` will show an interactive branch picker. @@ -19,6 +20,14 @@ If omitted, `worktree checkout` will show an interactive branch picker. ```bash worktree checkout feature/fix-login-timeout worktree checkout origin/feature/fix-login-timeout +worktree checkout feature/fix-login-timeout --agent "find the cause of the timeout" ``` This creates a local tracking branch and worktree directory for that branch. + +## Agent Dispatch + +`--agent` (short: `-a`) works exactly as it does on [`worktree branch`](/docs/commands/branch#agent-dispatch): +the checked-out worktree is handed to the agent configured as +[`agent.command`](/docs/configuration#agent-command), with the flag's value as its prompt and the worktree +as its working directory. The editor still opens as well. diff --git a/docs/src/app/docs/commands/cleanup/page.mdx b/docs/src/app/docs/commands/cleanup/page.mdx index efd537a..c31d3a2 100644 --- a/docs/src/app/docs/commands/cleanup/page.mdx +++ b/docs/src/app/docs/commands/cleanup/page.mdx @@ -14,5 +14,47 @@ worktree cleanup Typical cleanup targets include: -- worktrees whose remote branch no longer exists +- worktrees whose remote branch no longer exists, as long as they hold no uncommitted changes - local worktrees with no tracked remote and no pending work + +Uncommitted changes disqualify a worktree from removal whatever its remote looks like. The +count comes from `git status -s`, so untracked files hold a worktree back too. + +`cleanup` reports every worktree it would otherwise have removed but held back for that +reason, along with its change count, so work in progress is named rather than swept up. + +## Flags + +| Flag | Description | +| --- | --- | +| `-f`, `--force` | Skip the confirmation prompt | +| `--ignore-agents` | Remove worktrees even when an agent session is living in them | + +## Agent sessions + +A worktree that an agent session is living in is never removed. `cleanup` asks the agent +runtime configured in [`agent.command`](/docs/configuration#agent-command) which sessions are running and +where, and holds back any worktree one of them is working in — naming both the worktree and +the session sitting in it: + +``` +Skipped 1 worktree branch with a live agent session: +- feature/api-retries (Remote removed, Agent: feature-api-retries-1f) +``` + +This covers your own terminal as well as an agent this tool dispatched with +[`branch --agent`](/docs/commands/branch). Both count as somebody working in the directory, +and deleting it out from under either is the failure this check exists to prevent. Use +[`list --agents`](/docs/commands/list) to see which worktrees those are before running +`cleanup`, rather than discovering them from the report. + +A session that has finished does not hold anything back — the worktree goes back to being +ordinary stale work. + +`--force` does **not** override this. It answers the confirmation prompt, not the safety +verdict. To sweep a worktree anyway, pass `--ignore-agents`, which skips the session lookup +entirely and leaves `cleanup` deciding exactly as it did before agents existed. It has no +short alias on purpose: overruling this one is worth spelling out. + +If `agent.command` is not configured, or the runtime cannot be reached, no session data is +gathered and `cleanup` behaves exactly as it always has. diff --git a/docs/src/app/docs/commands/config/page.mdx b/docs/src/app/docs/commands/config/page.mdx index 6b0fcd2..24529d2 100644 --- a/docs/src/app/docs/commands/config/page.mdx +++ b/docs/src/app/docs/commands/config/page.mdx @@ -30,6 +30,7 @@ worktree config --yes ```bash worktree config defaultSourceBranch origin/main worktree config codeEditor code +worktree config agent.command "claude --bg" worktree config --list worktree config --list --missing worktree config --missing @@ -45,3 +46,10 @@ Configuration is stored in local Git config under `northguild.worktree.*`. - `branchPrefix.feature` optional prefix used for `Feature` issues. - `branchPrefix.bugfix` optional prefix used for `Bug` issues. - `branchPrefix.chore` optional prefix used for `Task` issues. + +## Agent Key + +- `agent.command` the coding agent to hand a worktree to. Read by + [`branch --agent`](/docs/commands/branch#agent-dispatch) and `checkout --agent`, by + [`list --agents`](/docs/commands/list), and by [`cleanup`](/docs/commands/cleanup)'s agent check. See + [Configuration](/docs/configuration#agent-command). diff --git a/docs/src/app/docs/commands/list/page.mdx b/docs/src/app/docs/commands/list/page.mdx index f033a14..20591b8 100644 --- a/docs/src/app/docs/commands/list/page.mdx +++ b/docs/src/app/docs/commands/list/page.mdx @@ -10,6 +10,40 @@ List known worktrees managed for the current repository. ```bash worktree list +worktree list --agents ``` Use this before `open`, `remove`, or cleanup decisions. + +## Flags + +| Flag | Alias | Description | +| --- | --- | --- | +| `--agents` | `-a` | Show the agent session living in each worktree | + +## Showing agent sessions + +`--agents` asks the agent runtime configured in +[`agent.command`](/docs/configuration#agent-command) which sessions are open, and names the one +found in each worktree: + +```text +- feature/one (Agent: feature-one-1f) +- feature/two (Ahead: 2, Behind: 0, Agent: feature-two-1f [waiting]) +- feature/three (Agent: notes-1f [interactive]) +``` + +- **No marker** — a background agent that is getting on with its work. +- **`[waiting]`** — a background agent that is live but not progressing, so it is + probably waiting on you. +- **`[interactive]`** — somebody's own terminal session, not an agent this tool + dispatched. It is listed because `cleanup` will refuse to remove a worktree + somebody is sitting in, and this is where you find out which those are. + +A worktree with no `Agent:` detail has no session in it. Where a worktree holds +more than one — a dispatched agent and your own terminal, say — the entry names +one of them, and the marker describes the session it names. + +Without `--agents`, `list` asks the agent runtime nothing and its output is +unchanged. If `agent.command` is unset, or the runtime is not installed or +reports nothing usable, `--agents` simply lists no sessions rather than failing. diff --git a/docs/src/app/docs/configuration/page.mdx b/docs/src/app/docs/configuration/page.mdx index 6d50ff6..2013e1e 100644 --- a/docs/src/app/docs/configuration/page.mdx +++ b/docs/src/app/docs/configuration/page.mdx @@ -10,6 +10,7 @@ title: Configuration - `defaultSourceBranch` (example: `origin/main`) - `codeEditor` (example: `code`) +- `agent.command` (the coding agent to hand a new worktree to, example: `claude --bg`) - `github.token` (GitHub personal access token) - `jira.host` (Your Jira workspace URL, example: `https://company.atlassian.net`) - `jira.email` (Your Jira email address) @@ -18,6 +19,56 @@ title: Configuration - `branchPrefix.bugfix` (example: `fix/`) - `branchPrefix.chore` (example: `chore/`) +## Editor Command + +`codeEditor` is a command line, not just a program name. It is split on whitespace: the first word is the +program to launch, and anything after it is passed to that program as arguments, ahead of the worktree +path. + +```bash +worktree config codeEditor code # runs: code +worktree config codeEditor "code -n" # runs: code -n +``` + +The worktree path is always passed as a single argument, so a project or branch whose path contains a +space opens correctly. + +**Quoted arguments are not supported.** No shell parses this value, so the quotes in +`open -a "Visual Studio Code"` are never removed — the value splits into `open` plus the four arguments +`-a`, `"Visual`, `Studio` and `Code"`, quote characters and all. Point `codeEditor` at a program or at a wrapper +script instead. + +**On Windows the editor has to be a real executable.** `.cmd` and `.bat` shims — which is what `code` is +on Windows — cannot be launched without a shell, and `worktree` no longer uses one. Name the `.exe`, or a +wrapper that is one. + +## Agent Command + +`agent.command` names the coding agent that a worktree can be handed to. Like `codeEditor` it is a command +line, not just a program name, and it is split the same way: the first word is the program to launch, and +anything after it is passed to that program as leading arguments. + +```bash +worktree config agent.command claude +worktree config agent.command "claude --bg" +worktree config agent.command "codex exec" +``` + +Only the first word is checked when you set the value, so flags and subcommands do not have to resolve to +anything on their own: + +```bash +worktree config agent.command "claude --bg" # accepted — only `claude` is looked up +worktree config agent.command "nope --bg" # rejected — Command not found: nope +``` + +`worktree` does not assume a particular agent. There is no default value, and nothing about this key is +specific to one vendor — any agent CLI of the same shape works. The same caveats as +[Editor Command](#editor-command) apply: no shell parses the value, so quoted arguments are not supported +and the program has to be a real executable rather than a `.cmd` or `.bat` shim. + +The key is optional and unset by default. + ## Commands ```bash @@ -26,6 +77,7 @@ worktree config --missing worktree config --missing --names defaultSourceBranch,github.token worktree config defaultSourceBranch origin/main worktree config codeEditor code +worktree config agent.command "claude --bg" worktree config github.token worktree config jira.host https://company.atlassian.net worktree config jira.email your-email@example.com diff --git a/docs/src/app/docs/getting-started/page.mdx b/docs/src/app/docs/getting-started/page.mdx index 5e62671..62d8b15 100644 --- a/docs/src/app/docs/getting-started/page.mdx +++ b/docs/src/app/docs/getting-started/page.mdx @@ -57,6 +57,7 @@ Common values: - `defaultSourceBranch`: usually `origin/main` - `codeEditor`: usually `code` +- `agent.command`: optional, such as `claude --bg`, to hand new worktrees to a coding agent ## First Workflow diff --git a/docs/src/app/docs/guides/editor-integration/page.mdx b/docs/src/app/docs/guides/editor-integration/page.mdx index d8ae0aa..17fdd1a 100644 --- a/docs/src/app/docs/guides/editor-integration/page.mdx +++ b/docs/src/app/docs/guides/editor-integration/page.mdx @@ -20,4 +20,6 @@ If omitted, `worktree open` will show an interactive branch picker. Similarly, if omitted, `worktree checkout` will show an interactive remote branch picker. -For non-VS Code editors, set `codeEditor` to the matching shell command. +For non-VS Code editors, set `codeEditor` to the matching command. No shell is involved: the value is a +command line split on whitespace, so `code -n` works and quoted arguments do not — see +[Configuration](/docs/configuration). diff --git a/docs/src/lib/site-meta.ts b/docs/src/lib/site-meta.ts index c6eee49..8fd8ed5 100644 --- a/docs/src/lib/site-meta.ts +++ b/docs/src/lib/site-meta.ts @@ -1,7 +1,7 @@ // Generated file. Do not edit directly. import type { Metadata } from "next"; -export const cliVersion = "1.2.8"; +export const cliVersion = "1.3.0"; export const projectName = "Worktree"; export const projectDescription = "A CLI tool for managing git worktrees with enhanced workflow features"; diff --git a/package.json b/package.json index bd4e0d4..55e7ec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@northguild/worktree", - "version": "1.2.8", + "version": "1.3.0", "description": "A CLI tool for managing git worktrees with enhanced workflow features", "main": "./bin/run.js", "type": "module", diff --git a/skills/_artifacts/skill_tree.yaml b/skills/_artifacts/skill_tree.yaml index 210af2e..311a3c0 100644 --- a/skills/_artifacts/skill_tree.yaml +++ b/skills/_artifacts/skill_tree.yaml @@ -2,7 +2,7 @@ # Generated by skill-tree-generator library: name: '@northguild/worktree' - version: '1.2.8' + version: '1.3.0' repository: 'https://github.com/northguild/worktree' description: 'Workflow-oriented CLI that wraps git worktree commands into repeatable daily-use tasks.' generated_from: @@ -21,10 +21,12 @@ skills: path: 'skills/core/SKILL.md' description: > Complete usage guide for @northguild/worktree. Covers install, - worktree config (defaultSourceBranch, codeEditor, github.token, - jira.host, jira.email, jira.apiToken, branchPrefix.*), worktree branch, - checkout, list, open, remove/rm, cleanup, --github and --jira - issue-to-branch workflows, and automatic .env file copying. + worktree config (defaultSourceBranch, codeEditor, agent.command, + github.token, jira.host, jira.email, jira.apiToken, branchPrefix.*), + worktree branch, checkout, list, open, remove/rm, cleanup, --github and + --jira issue-to-branch workflows, handing a new worktree to a coding + agent with --agent, list --agents, cleanup --ignore-agents, and automatic + .env file copying. requires: [] sources: - 'northguild/worktree:README.md' @@ -40,6 +42,8 @@ skills: - 'northguild/worktree:docs/src/app/docs/guides/jira-integration/page.mdx' - 'northguild/worktree:docs/src/app/docs/guides/env-files/page.mdx' - 'northguild/worktree:src/commands/branch.ts' + - 'northguild/worktree:src/lib/agent.ts' + - 'northguild/worktree:src/lib/base-command.ts' - 'northguild/worktree:src/lib/git.ts' - 'northguild/worktree:src/lib/validators.ts' subsystems: diff --git a/skills/core/SKILL.md b/skills/core/SKILL.md index df87655..9451e5a 100644 --- a/skills/core/SKILL.md +++ b/skills/core/SKILL.md @@ -2,15 +2,16 @@ name: core description: > Complete usage guide for @northguild/worktree. Covers install, first-time - setup with worktree config (defaultSourceBranch, codeEditor, github.token, - jira.host, jira.email, jira.apiToken, branchPrefix.feature, + setup with worktree config (defaultSourceBranch, codeEditor, agent.command, + github.token, jira.host, jira.email, jira.apiToken, branchPrefix.feature, branchPrefix.bugfix, branchPrefix.chore), worktree branch, worktree checkout, worktree list, worktree open, worktree remove (alias: rm), worktree cleanup, - --github issue-to-branch, --jira issue-to-branch, and automatic .env / - .env.local copying into new worktrees. + --github issue-to-branch, --jira issue-to-branch, handing a new worktree to a + coding agent with --agent, worktree list --agents, worktree cleanup + --ignore-agents, and automatic .env / .env.local copying into new worktrees. type: core library: '@northguild/worktree' -library_version: "1.2.8" +library_version: "1.3.0" sources: - "northguild/worktree:README.md" - "northguild/worktree:docs/src/app/docs/commands/branch/page.mdx" @@ -25,6 +26,8 @@ sources: - "northguild/worktree:docs/src/app/docs/guides/jira-integration/page.mdx" - "northguild/worktree:docs/src/app/docs/guides/env-files/page.mdx" - "northguild/worktree:src/commands/branch.ts" + - "northguild/worktree:src/lib/agent.ts" + - "northguild/worktree:src/lib/base-command.ts" - "northguild/worktree:src/lib/git.ts" - "northguild/worktree:src/lib/validators.ts" --- @@ -43,7 +46,8 @@ npm install -g @northguild/worktree # Run once inside your git repository worktree config -# prompts for: defaultSourceBranch (e.g. origin/main), codeEditor (e.g. code) +# prompts for: defaultSourceBranch (e.g. origin/main), codeEditor (e.g. code), +# and agent.command (e.g. claude --bg) — the last two behind a yes/no confirm # Create your first worktree worktree branch feature/my-feature @@ -96,12 +100,34 @@ editable before confirmation. Branch prefixes are applied when configured: - `Bug` → `branchPrefix.bugfix` - `Task` → `branchPrefix.chore` +### Hand a new worktree to a coding agent + +```bash +# Requires agent.command in config, e.g. claude --bg +worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" +worktree branch --github 42 --agent "implement the issue" +worktree checkout feature/fix-login-timeout -a "find the cause of the timeout" +``` + +The agent starts with the new worktree as its working directory, so it works +inside `.worktrees/` rather than isolating itself elsewhere. The flag's +value reaches the agent as a single argument and no shell parses it, so quotes +and spaces in a prompt are safe. + +`--agent` and the editor are independent: with `codeEditor` also configured the +worktree opens there as well. The agent is started and left running, so +`worktree` does not wait for it and its output does not appear here. + ### Maintain the worktree lifecycle ```bash # See all active worktrees worktree list +# Name the agent session living in each worktree +worktree list --agents +worktree list -a # alias + # Reopen a worktree in your editor worktree open feature/add-bulk-actions @@ -113,8 +139,15 @@ worktree remove feature/add-bulk-actions --force # skip confirmation # Remove all stale worktrees (no unpushed work, remote gone, etc.) worktree cleanup worktree cleanup --force # skip confirmation +worktree cleanup --ignore-agents # sweep even worktrees an agent is in ``` +A worktree a live agent session is sitting in is never removed by `cleanup`; it +is reported as skipped instead. `--force` does not override that — it answers +the confirmation prompt, not the safety verdict — and `--ignore-agents` does, +which is why that one has no short alias. The check covers an interactive +session in your own terminal as well as an agent this tool dispatched. + ## Configuration Reference All keys are stored via `worktree config ` in git config @@ -124,6 +157,7 @@ under `northguild.worktree.*`. |---|---|---| | `defaultSourceBranch` | `origin/main` | `worktree branch` without `--source` | | `codeEditor` | `code` | auto-opening worktrees | +| `agent.command` | `claude --bg` | `--agent`, `list --agents`, `cleanup`'s agent check | | `github.token` | `ghp_...` | `--github` flag | | `jira.host` | `https://company.atlassian.net` | `--jira` flag | | `jira.email` | `you@company.com` | `--jira` flag | @@ -241,6 +275,32 @@ Source: `docs/guides/jira-integration`, `src/integrations/jira.ts` --- +### HIGH `--agent` used without `agent.command` configured + +Wrong: + +```bash +# Nothing set agent.command +worktree branch feature/x --agent "implement the issue" +``` + +Correct: + +```bash +worktree config agent.command "claude --bg" +worktree branch feature/x --agent "implement the issue" +``` + +With no `agent.command` set, `--agent` logs `No agent configured. Run worktree +config agent.command "" to set one.` and carries on: the worktree is +created, env files are copied, the editor opens, and the exit code is still `0`. Nothing fails, so in a scripted run a skipped dispatch +is indistinguishable from a successful one. Set the key first, or check +`worktree config --list`. + +Source: `src/lib/base-command.ts` — `dispatchAgent()` + +--- + ### MEDIUM `--source` flag given without `origin/` prefix Wrong: diff --git a/src/commands/branch.test.ts b/src/commands/branch.test.ts index c17c000..42d4182 100644 --- a/src/commands/branch.test.ts +++ b/src/commands/branch.test.ts @@ -39,6 +39,7 @@ vi.mock("ora", () => ({ describe("branch command", () => { let branch: Branch; let mockOpenWorktreePath: ReturnType; + let mockDispatchAgent: ReturnType; const mockInput = vi.mocked(input); const mockConfirm = vi.mocked(confirm); const mockCopyEnvFiles = vi.mocked(copyEnvFilesFromRootPath); @@ -52,6 +53,9 @@ describe("branch command", () => { mockOpenWorktreePath = vi .spyOn(branch as any, "openWorktreePath") .mockResolvedValue(undefined); + mockDispatchAgent = vi + .spyOn(branch as any, "dispatchAgent") + .mockResolvedValue(undefined); // Mock config verification to prevent first-time config prompts vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { @@ -587,4 +591,84 @@ describe("branch command", () => { ); }); }); + + describe("--agent flag", () => { + beforeEach(() => { + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { + if (key === "has-called-config") return Promise.resolve("true"); + if (key === "defaultSourceBranch") + return Promise.resolve("origin/main"); + return Promise.resolve(""); + }); + }); + + it("hands the new worktree to the agent with the prompt", async () => { + (branch as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: { agent: "implement the issue" }, + }); + + await branch.run(); + + expect(mockDispatchAgent).toHaveBeenCalledWith( + "/path/to/worktree", + "implement the issue", + ); + }); + + it("dispatches after the env files are copied and before the editor opens", async () => { + (branch as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: { agent: "implement the issue" }, + }); + + await branch.run(); + + // The agent starts working immediately, so the worktree has to be + // complete before it is handed over. + expect(mockCopyEnvFiles.mock.invocationCallOrder[0]).toBeLessThan( + mockDispatchAgent.mock.invocationCallOrder[0], + ); + expect(mockDispatchAgent.mock.invocationCallOrder[0]).toBeLessThan( + mockOpenWorktreePath.mock.invocationCallOrder[0], + ); + }); + + it("opens the editor as well, since the two are independent", async () => { + (branch as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: { agent: "implement the issue" }, + }); + + await branch.run(); + + expect(mockOpenWorktreePath).toHaveBeenCalledWith("/path/to/worktree"); + }); + + it("still dispatches when the prompt is empty, since the flag was given", async () => { + // `--agent ""` is a request for an agent with no prompt, not an absent + // flag: the prompt reaches the agent as an empty argument. + (branch as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: { agent: "" }, + }); + + await branch.run(); + + expect(mockDispatchAgent).toHaveBeenCalledWith("/path/to/worktree", ""); + }); + + it("dispatches nothing when the flag is absent", async () => { + (branch as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: {}, + }); + + await branch.run(); + + expect(mockDispatchAgent).not.toHaveBeenCalled(); + expect(mockOpenWorktreePath).toHaveBeenCalledWith("/path/to/worktree"); + }); + }); }); diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 1ac43ee..e172f7d 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -25,6 +25,7 @@ export default class Branch extends BaseCommand { "<%= config.bin %> <%= command.id %> my-new-branch --source origin/main", "<%= config.bin %> <%= command.id %> --github 42", "<%= config.bin %> <%= command.id %> --jira DEV-123", + '<%= config.bin %> <%= command.id %> --github 42 --agent "implement the issue"', ]; static override flags = { @@ -40,6 +41,11 @@ export default class Branch extends BaseCommand { char: "j", description: "Create a branch from a Jira issue (issue ID)", }), + agent: Flags.string({ + char: "a", + description: + "Start the configured coding agent in the new worktree with this prompt", + }), }; private confirmNonOriginSource() { @@ -180,7 +186,13 @@ export default class Branch extends BaseCommand { const branchName = await this.getBranchName(args.branchName, flags); const sourceBranch = await this.getSourceBranch(flags.source); const projectPath = await gitCreateWorktree(branchName, sourceBranch); + // Env files first: the agent starts working immediately, so it has to find a + // worktree that is already complete. The editor stays last so its "Worktree + // created" fallback remains the final line. await copyEnvFilesFromRootPath(projectPath); + if (flags.agent !== undefined) { + await this.dispatchAgent(projectPath, flags.agent); + } await this.openWorktreePath(projectPath); } } diff --git a/src/commands/checkout.test.ts b/src/commands/checkout.test.ts index 2417767..f8e46f7 100644 --- a/src/commands/checkout.test.ts +++ b/src/commands/checkout.test.ts @@ -24,6 +24,7 @@ vi.mock("ora", () => ({ describe("checkout command", () => { let checkout: Checkout; let mockOpenWorktreePath: ReturnType; + let mockDispatchAgent: ReturnType; const mockSelect = vi.mocked(select); const mockCopyEnvFiles = vi.mocked(copyEnvFilesFromRootPath); @@ -36,6 +37,9 @@ describe("checkout command", () => { mockOpenWorktreePath = vi .spyOn(checkout as any, "openWorktreePath") .mockResolvedValue(undefined); + mockDispatchAgent = vi + .spyOn(checkout as any, "dispatchAgent") + .mockResolvedValue(undefined); }); it("creates a worktree from a provided non-origin branch name", async () => { @@ -169,4 +173,49 @@ describe("checkout command", () => { ); expect(git.gitCreateWorktree).not.toHaveBeenCalled(); }); + + describe("--agent flag", () => { + beforeEach(() => { + vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([ + "origin/main", + "origin/feature/test", + ]); + vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue(["main"]); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + }); + + it("hands the checked-out worktree to the agent with the prompt", async () => { + (checkout as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: { agent: "review this branch" }, + }); + + await checkout.run(); + + expect(mockDispatchAgent).toHaveBeenCalledWith( + "/path/to/worktree", + "review this branch", + ); + // The worktree has to be complete before the agent sees it, and the + // editor still opens afterwards. + expect(mockCopyEnvFiles.mock.invocationCallOrder[0]).toBeLessThan( + mockDispatchAgent.mock.invocationCallOrder[0], + ); + expect(mockDispatchAgent.mock.invocationCallOrder[0]).toBeLessThan( + mockOpenWorktreePath.mock.invocationCallOrder[0], + ); + }); + + it("dispatches nothing when the flag is absent", async () => { + (checkout as any).parse = vi.fn().mockResolvedValue({ + args: { branchName: "feature/test" }, + flags: {}, + }); + + await checkout.run(); + + expect(mockDispatchAgent).not.toHaveBeenCalled(); + expect(mockOpenWorktreePath).toHaveBeenCalledWith("/path/to/worktree"); + }); + }); }); diff --git a/src/commands/checkout.ts b/src/commands/checkout.ts index 6950c03..82e5274 100644 --- a/src/commands/checkout.ts +++ b/src/commands/checkout.ts @@ -1,5 +1,5 @@ import { select } from "@inquirer/prompts"; -import { Args } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import ora from "ora"; import { BaseCommand } from "../lib/base-command.js"; import { copyEnvFilesFromRootPath } from "../lib/env.js"; @@ -17,8 +17,17 @@ export default class Checkout extends BaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> origin/feature/other-branch", + '<%= config.bin %> <%= command.id %> feature/other-branch --agent "review this branch"', ]; + static override flags = { + agent: Flags.string({ + char: "a", + description: + "Start the configured coding agent in the new worktree with this prompt", + }), + }; + private normalizeBranchNameArg(branchNameArg?: string) { if (branchNameArg) { return branchNameArg?.startsWith("origin/") @@ -42,7 +51,7 @@ export default class Checkout extends BaseCommand { } public async run(): Promise { - const { args } = await this.parse(Checkout); + const { args, flags } = await this.parse(Checkout); const spinner = ora("Fetching remote branches").start(); const remoteBranches = await gitGetRemoteBranches(); const localBranches = await gitGetLocalBranches(); @@ -67,7 +76,12 @@ export default class Checkout extends BaseCommand { sourceBranchName, { isCheckout: true }, ); + // Same order as branch: env files complete the worktree before the agent + // sees it, and the editor stays last. await copyEnvFilesFromRootPath(projectPath); + if (flags.agent !== undefined) { + await this.dispatchAgent(projectPath, flags.agent); + } await this.openWorktreePath(projectPath); } } diff --git a/src/commands/cleanup.test.ts b/src/commands/cleanup.test.ts index 4ea4e46..3ebd41d 100644 --- a/src/commands/cleanup.test.ts +++ b/src/commands/cleanup.test.ts @@ -68,6 +68,76 @@ describe("cleanup command", () => { safeToRemove: false, }; + // A stale worktree — its remote branch is gone — that holds uncommitted work. + // Phase 2 made `isSafeToRemove` decline it; this is what cleanup must report. + const staleWorktreeWithChanges = { + path: "/path/to/project.worktrees/feature/stale-dirty", + branchName: "feature/stale-dirty", + remote: "origin/feature/stale-dirty", + remoteExists: false, + pathExists: true, + uncommittedChanges: 3, + safeToRemove: false, + }; + + // A stale worktree an agent is living in. Nothing else holds it back, so it is + // the live session alone that has to keep it out of the sweep. + const agentWorktree = { + path: "/path/to/project.worktrees/feature/agent-live", + branchName: "feature/agent-live", + remote: "origin/feature/agent-live", + remoteExists: false, + pathExists: true, + uncommittedChanges: 0, + agent: { + name: "feature-agent-1f", + pid: 9187, + live: true, + interactive: false, + waiting: false, + }, + safeToRemove: false, + }; + + // The ordinary shape of a worktree an agent is working in: a live session and + // the uncommitted work it has produced so far. It belongs under one heading, + // not both. + const agentWorktreeWithChanges = { + path: "/path/to/project.worktrees/feature/agent-dirty", + branchName: "feature/agent-dirty", + remote: "origin/feature/agent-dirty", + remoteExists: false, + pathExists: true, + uncommittedChanges: 2, + agent: { + name: "feature-dirty-2a", + pid: 9188, + live: true, + interactive: false, + waiting: false, + }, + safeToRemove: false, + }; + + // The session finished and the worktree went back to being ordinary stale + // work. `list` still names the session; cleanup must not be held by it. + const finishedAgentWorktree = { + path: "/path/to/project.worktrees/feature/agent-done", + branchName: "feature/agent-done", + remote: "origin/feature/agent-done", + remoteExists: false, + pathExists: true, + uncommittedChanges: 0, + agent: { + name: "feature-done-3b", + pid: 9189, + live: false, + interactive: false, + waiting: false, + }, + safeToRemove: true, + }; + beforeEach(() => { vi.clearAllMocks(); const mockConfig = { @@ -214,4 +284,344 @@ describe("cleanup command", () => { expect(spinnerMocks.stop).toHaveBeenCalledTimes(1); expect(mockRemove).toHaveBeenCalledWith([safeWorktree]); }); + + it("reports worktrees skipped for uncommitted changes and removes the rest", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + staleWorktreeWithChanges, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(true); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch that has uncommitted changes:", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/stale-dirty (Remote removed, 3 uncommitted changes)", + ); + expect(mockRemove).toHaveBeenCalledWith([safeWorktree]); + }); + + it("reports skipped worktrees when --force is set", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + staleWorktreeWithChanges, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: true }, + }); + + await cleanup.run(); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(spinnerMocks.stop).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch that has uncommitted changes:", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/stale-dirty (Remote removed, 3 uncommitted changes)", + ); + expect(mockRemove).toHaveBeenCalledWith([safeWorktree]); + }); + + it("distinguishes everything-skipped from nothing-found", async () => { + const secondStaleWorktree = { + ...staleWorktreeWithChanges, + path: "/path/to/project.worktrees/feature/stale-dirty-two", + branchName: "feature/stale-dirty-two", + uncommittedChanges: 1, + }; + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + staleWorktreeWithChanges, + secondStaleWorktree, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(spinnerMocks.succeed).not.toHaveBeenCalled(); + expect(spinnerMocks.info).toHaveBeenCalledWith( + "No stale worktree branches can be removed safely.", + ); + expect(logSpy).toHaveBeenCalledWith( + "Skipped 2 worktree branches that have uncommitted changes:", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/stale-dirty (Remote removed, 3 uncommitted changes)", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/stale-dirty-two (Remote removed, 1 uncommitted change)", + ); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + // The join is what puts an agent on the entry in the first place, so a cleanup + // that never asked for it cannot see the session it is meant to respect. + it("asks for the session join by default", async () => { + const listSpy = vi + .spyOn(git, "gitGetWorktreeList") + .mockResolvedValue([safeWorktree]); + vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(false); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(listSpy).toHaveBeenCalledWith({ includeAgents: true }); + }); + + it("excludes a worktree with a live agent session and names the session", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + agentWorktree, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(true); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch with a live agent session:", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/agent-live (Remote removed, Agent: feature-agent-1f)", + ); + expect(mockRemove).toHaveBeenCalledWith([safeWorktree]); + }); + + it("removes a worktree whose agent session has finished", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + finishedAgentWorktree, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(true); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect( + logSpy.mock.calls.some((call) => (call[0] ?? "").startsWith("Skipped ")), + ).toBe(false); + expect(mockRemove).toHaveBeenCalledWith([finishedAgentWorktree]); + }); + + // The override is "do not look": with no join asked for, the builder returns + // entries carrying no agent at all, and the verdict is what it was before the + // flag existed. Asserting the argument is the real check — the list here is + // what the builder would then hand back. + it("asks for no session join with --ignore-agents and removes anyway", async () => { + const listSpy = vi + .spyOn(git, "gitGetWorktreeList") + .mockResolvedValue([ + { ...agentWorktree, agent: undefined, safeToRemove: true }, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: true, "ignore-agents": true }, + }); + + await cleanup.run(); + + expect(listSpy).toHaveBeenCalledWith({ includeAgents: false }); + expect( + logSpy.mock.calls.some((call) => (call[0] ?? "").startsWith("Skipped ")), + ).toBe(false); + expect(mockRemove).toHaveBeenCalledWith([ + { ...agentWorktree, agent: undefined, safeToRemove: true }, + ]); + }); + + // --force answers the confirmation prompt, not the safety verdict, so a live + // session survives it. That distinction is the whole reason --ignore-agents + // exists as a separate flag. See AGENT-MODE-PLAN §4. + it("does not let --force remove a worktree with a live agent session", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + agentWorktree, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: true }, + }); + + await cleanup.run(); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch with a live agent session:", + ); + expect(mockRemove).toHaveBeenCalledWith([safeWorktree]); + }); + + // A worktree an agent is working in almost always holds uncommitted work too. + // Reporting it under both headings would double-count it and read as two + // worktrees; the agent heading is the one that claims it, and the change count + // still appears in its details. + it("reports a dirty worktree with a live session under the agent heading only", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + agentWorktreeWithChanges, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(true); + vi.spyOn(git, "gitRemoveWorktreesWithProgress").mockResolvedValue( + undefined, + ); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch with a live agent session:", + ); + expect(logSpy).toHaveBeenCalledWith( + "- feature/agent-dirty (Remote removed, 2 uncommitted changes, Agent: feature-dirty-2a)", + ); + expect( + logSpy.mock.calls.filter((call) => + (call[0] ?? "").includes("feature/agent-dirty"), + ), + ).toHaveLength(1); + expect( + logSpy.mock.calls.some((call) => + (call[0] ?? "").includes("uncommitted changes:"), + ), + ).toBe(false); + }); + + it("reports agent hold-backs when nothing at all can be removed", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + agentWorktree, + staleWorktreeWithChanges, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect(spinnerMocks.succeed).not.toHaveBeenCalled(); + expect(spinnerMocks.info).toHaveBeenCalledWith( + "No stale worktree branches can be removed safely.", + ); + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch that has uncommitted changes:", + ); + expect(logSpy).toHaveBeenCalledWith( + "Skipped 1 worktree branch with a live agent session:", + ); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + // A worktree whose directory is already gone is safe to remove whatever its + // change count says, so it must not also be announced as held back. Both + // reports are gated on the verdict for exactly this reason. See findings.md + // F-003. + it("claims a worktree whose directory is gone for at most one report", async () => { + const ghostWorktree = { + path: "/path/to/project.worktrees/feature/ghost", + branchName: "feature/ghost", + remote: "origin/feature/ghost", + remoteExists: false, + pathExists: false, + uncommittedChanges: 3, + safeToRemove: true, + }; + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ghostWorktree]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: true }, + }); + + await cleanup.run(); + + expect( + logSpy.mock.calls.some((call) => (call[0] ?? "").startsWith("Skipped ")), + ).toBe(false); + expect(mockRemove).toHaveBeenCalledWith([ghostWorktree]); + }); + + // Only worktrees cleanup would otherwise have swept are reported. An active + // branch that happens to be dirty was never a candidate, so naming it here + // would be noise. See CLEANUP-DATA-LOSS-PLAN §4.2. + it("does not report an active worktree that merely holds uncommitted changes", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + unsafeWorktree, + ]); + const logSpy = vi.spyOn(cleanup, "log").mockImplementation(() => {}); + mockConfirm.mockResolvedValue(true); + vi.spyOn(git, "gitRemoveWorktreesWithProgress").mockResolvedValue( + undefined, + ); + + (cleanup as any).parse = vi.fn().mockResolvedValue({ + flags: { force: false }, + }); + + await cleanup.run(); + + expect( + logSpy.mock.calls.some((call) => (call[0] ?? "").startsWith("Skipped ")), + ).toBe(false); + }); }); diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts index feb2fa6..8f6ddcc 100644 --- a/src/commands/cleanup.ts +++ b/src/commands/cleanup.ts @@ -6,9 +6,43 @@ import { BaseCommand } from "../lib/base-command.js"; import { gitGetWorktreeList, gitRemoveWorktreesWithProgress, + hasLiveAgent, + isSafeToRemove, } from "../lib/git.js"; +import type { WorktreeListEntry } from "../lib/types.js"; import { worktreeListEntryToListName } from "../lib/utils.js"; +// A worktree cleanup would have swept but for the work sitting in it. Asking the +// predicate about a zeroed copy keeps the safety rule in one place rather than +// restating it here. See CLEANUP-DATA-LOSS-PLAN §3 D1 and D7. +// +// The `safeToRemove` guard in front is what keeps an entry out of both halves of +// the split: without it a worktree whose directory is already gone answers `true` +// to the zeroed probe — the first branch of the predicate never reaches the +// count — and would be reported as skipped and then removed anyway. See +// findings.md F-003. +function isSkippedForUncommittedChanges(wt: WorktreeListEntry): boolean { + return ( + wt.safeToRemove !== true && + !!wt.uncommittedChanges && + isSafeToRemove({ ...wt, uncommittedChanges: 0 }) + ); +} + +// The same shape for a live session. The probe drops the uncommitted count as +// well as the agent, because a worktree an agent is working in usually holds +// both and each entry belongs under exactly one heading: this one claims it, and +// the uncommitted probe above declines it — that probe keeps the agent, so a +// live session makes it answer `false`. The agent wins because it is the reason +// a human cannot simply commit and re-run. +function isSkippedForLiveAgent(wt: WorktreeListEntry): boolean { + return ( + wt.safeToRemove !== true && + hasLiveAgent(wt) && + isSafeToRemove({ ...wt, agent: undefined, uncommittedChanges: 0 }) + ); +} + export default class Cleanup extends BaseCommand { static override description = "Cleanup worktree branches by removing stale ones"; @@ -18,33 +52,107 @@ export default class Cleanup extends BaseCommand { char: "f", description: "Force cleanup without confirmation", }), + // No short alias, deliberately: this one overrules a safety verdict rather + // than a prompt, and having to spell it out is the point. `--force` is not + // it — that means "do not ask me", which is a different question. + "ignore-agents": Flags.boolean({ + description: + "Remove worktrees even when an agent session is living in them", + }), }; + private logSkipped(skipped: WorktreeListEntry[]) { + const count = skipped.length; + this.log( + `Skipped ${chalk.bold(count)} worktree ${count === 1 ? "branch that has" : "branches that have"} uncommitted changes:`, + ); + skipped.forEach((wt) => { + this.log(`- ${worktreeListEntryToListName(wt, "yellow")}`); + }); + } + + // Rendered with the agent detail on, unlike the report above: the session's + // name is the whole point here, because it is what tells a human which agent + // to go and look at before insisting. + private logSkippedForAgents(skipped: WorktreeListEntry[]) { + const count = skipped.length; + this.log( + `Skipped ${chalk.bold(count)} worktree ${count === 1 ? "branch" : "branches"} with a live agent session:`, + ); + skipped.forEach((wt) => { + this.log( + `- ${worktreeListEntryToListName(wt, "yellow", { agents: true })}`, + ); + }); + } + + // Both halves are guarded here rather than at the call sites, which each have + // one kind of hold-back or the other far more often than both. An unguarded + // heading would read "Skipped 0 worktree branches …". + private logHeldBack( + skipped: WorktreeListEntry[], + agentSkipped: WorktreeListEntry[], + ) { + if (skipped.length > 0) { + this.logSkipped(skipped); + } + if (agentSkipped.length > 0) { + this.logSkippedForAgents(agentSkipped); + } + } + public async run(): Promise { const { flags } = await this.parse(Cleanup); const spinner = ora("Gathering worktree branches").start(); - const allWorktrees = await gitGetWorktreeList(); + // The override is "do not look", not "look and then ignore": with no + // sessions gathered there is no agent for isSafeToRemove to weigh, and the + // run costs exactly what it did before this flag existed. See + // AGENT-MODE-PLAN §4. + const allWorktrees = await gitGetWorktreeList({ + includeAgents: !flags["ignore-agents"], + }); const worktrees = allWorktrees.filter((wt) => wt.safeToRemove === true); + const skipped = allWorktrees.filter(isSkippedForUncommittedChanges); + const agentSkipped = allWorktrees.filter(isSkippedForLiveAgent); - if (worktrees.length === 0) { + if ( + worktrees.length === 0 && + skipped.length === 0 && + agentSkipped.length === 0 + ) { spinner.succeed("No stale worktree branches found."); return; } - if (!flags.force) { - const count = worktrees.length; + // Nothing is removable, but something was held back. Reporting "none found" + // here would hide exactly the worktrees this check exists to protect. + if (worktrees.length === 0) { + spinner.info("No stale worktree branches can be removed safely."); + this.logHeldBack(skipped, agentSkipped); + return; + } + + const count = worktrees.length; + if (flags.force) { + spinner.stop(); + } else { spinner.info( `Found ${chalk.bold(count)} worktree ${count === 1 ? "branch that is" : "branches that are"} marked safe to remove.`, ); worktrees.forEach((wt) => { this.log(`- ${worktreeListEntryToListName(wt, "gray")}`); }); + } + + // Reported in both paths: --force means "do not ask me", not "do not tell + // me". See CLEANUP-DATA-LOSS-PLAN §4.2. + this.logHeldBack(skipped, agentSkipped); + + if (!flags.force) { const message = `Are you sure you want to delete ${count === 1 ? "it" : "them"}?`; if (!(await confirm({ message, default: false }))) { return; } - } else { - spinner.stop(); } await gitRemoveWorktreesWithProgress(worktrees); diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts index 750cb61..2528957 100644 --- a/src/commands/config.test.ts +++ b/src/commands/config.test.ts @@ -1,5 +1,6 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: Allow any in tests */ import { confirm, input } from "@inquirer/prompts"; +import * as cli from "../lib/cli.js"; import * as git from "../lib/git.js"; import * as validators from "../lib/validators.js"; import Config from "./config.js"; @@ -363,4 +364,144 @@ describe("config command", () => { ); }); }); + describe("agent.command prompt", () => { + const agentFlags = { + list: false, + missing: false, + yes: false, + names: "agent.command", + }; + + it("should prompt for the agent command and store it when the user confirms", async () => { + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValue("claude --bg"); + const mockSetConfigValue = vi + .spyOn(git, "gitSetConfigValue") + .mockResolvedValue(); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: agentFlags, + }); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Do you want to hand new worktrees to a coding agent?", + }); + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Command to start the coding agent?", + }), + ); + expect(mockSetConfigValue).toHaveBeenCalledWith( + "agent.command", + "claude --bg", + ); + }); + + it("should skip the agent prompt when the user declines", async () => { + mockConfirm.mockResolvedValue(false); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: agentFlags, + }); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Do you want to hand new worktrees to a coding agent?", + }); + expect(mockInput).not.toHaveBeenCalled(); + }); + + it("should skip the confirmation prompt when --yes is set", async () => { + mockInput.mockResolvedValue("claude --bg"); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { ...agentFlags, yes: true }, + }); + + await config.run(); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockInput).toHaveBeenCalledTimes(1); + }); + + // No agent runtime is named as a fallback, unlike codeEditor's "code". + // Suggesting one would make this tool depend on a particular CLI, which + // AGENT-MODE-PLAN §2 rules out. + it("should offer no default agent runtime when none is configured", async () => { + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValue("claude"); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: agentFlags, + }); + + await config.run(); + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Command to start the coding agent?", + default: "", + prefill: "tab", + }), + ); + }); + + it("should pre-fill with the existing value when one is configured", async () => { + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValue("codex -q"); + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { + if (key === "has-called-config") return Promise.resolve("true"); + if (key === "agent.command") return Promise.resolve("codex -q"); + return Promise.resolve(""); + }); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: agentFlags, + }); + + await config.run(); + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Command to start the coding agent?", + default: "codex -q", + prefill: "editable", + }), + ); + }); + + // The prompt validates on the argv head, so a value carrying flags is + // accepted while the program alone is what gets looked up. Exercising the + // captured validate pins the behaviour rather than the function identity. + it("should validate the entered command on its argv head", async () => { + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValue("claude --bg"); + const mockCommandExists = vi + .spyOn(cli, "commandExists") + .mockResolvedValue(true); + + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: agentFlags, + }); + + await config.run(); + + const { validate } = mockInput.mock.calls[0][0] as { + validate: (value: string) => Promise; + }; + + expect(await validate("claude --bg")).toBe(true); + expect(mockCommandExists).toHaveBeenCalledWith("claude"); + expect(await validate(" ")).toBe("Command cannot be empty"); + }); + }); }); diff --git a/src/commands/config.ts b/src/commands/config.ts index f56be5d..f7e79ad 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -10,6 +10,7 @@ import { conjoin } from "../lib/utils.js"; import { isValidBranch, isValidCommand, + isValidCommandLine, isValidEmail, validateConfigValue, } from "../lib/validators.js"; @@ -223,6 +224,24 @@ export default class Config extends BaseCommand { await gitSetConfigValue("codeEditor", codeEditor); } + if ( + shouldPrompt("agent.command") && + (await this.maybePrompt( + "Do you want to hand new worktrees to a coding agent?", + flags.yes, + )) + ) { + // No fallback default: the agent runtime is deliberately not named here. + // Suggesting one would make this tool depend on a particular CLI, which + // AGENT-MODE-PLAN §2 rules out. + const agentCommand = await input({ + message: "Command to start the coding agent?", + ...(await this.getInputConfig("agent.command")), + validate: isValidCommandLine, + }); + await gitSetConfigValue("agent.command", agentCommand); + } + this.log(`${chalk.green("✔")} Configuration complete!${EOL}`); } diff --git a/src/commands/list.test.ts b/src/commands/list.test.ts index d59333b..8ac5f22 100644 --- a/src/commands/list.test.ts +++ b/src/commands/list.test.ts @@ -33,10 +33,14 @@ describe("list command", () => { (list as any).parse = vi.fn().mockResolvedValue({ args: {}, - flags: {}, + flags: { agents: false }, }); }); + function withFlags(flags: Record) { + (list as any).parse = vi.fn().mockResolvedValue({ args: {}, flags }); + } + it("logs each worktree list item", async () => { const worktrees: WorktreeListEntry[] = [ { @@ -63,7 +67,10 @@ describe("list command", () => { await list.run(); expect((list as any).parse).toHaveBeenCalledWith(List); - expect(mockGetWorktreeList).toHaveBeenCalledWith({ includeCurrent: true }); + expect(mockGetWorktreeList).toHaveBeenCalledWith({ + includeCurrent: true, + includeAgents: false, + }); expect(ora).toHaveBeenCalledWith("Gathering worktree list"); expect(spinnerMocks.start).toHaveBeenCalledTimes(1); expect(spinnerMocks.stop).toHaveBeenCalledTimes(1); @@ -84,4 +91,69 @@ describe("list command", () => { expect(mockListName).not.toHaveBeenCalled(); expect(logSpy).not.toHaveBeenCalled(); }); + + // Without the flag the session lookup must not happen at all: `list` pays for + // no agent runtime today and must not start. See AGENT-MODE-PLAN §5 R4. + it("asks for no session lookup and renders no agent details by default", async () => { + const worktrees: WorktreeListEntry[] = [ + { + path: "/tmp/project.worktrees/feature/one", + branchName: "feature/one", + remote: "origin/feature/one", + agent: { name: "feature-one-1f", pid: 9187 }, + }, + ]; + + const mockGetWorktreeList = vi + .spyOn(git, "gitGetWorktreeList") + .mockResolvedValue(worktrees); + const mockListName = vi + .spyOn(utils, "worktreeListEntryToListName") + .mockReturnValue("feature/one"); + vi.spyOn(list, "log").mockImplementation(() => {}); + + await list.run(); + + expect(mockGetWorktreeList).toHaveBeenCalledWith({ + includeCurrent: true, + includeAgents: false, + }); + expect(mockListName).toHaveBeenCalledWith(worktrees[0], "gray", { + agents: false, + }); + }); + + it("performs the session lookup and renders agent details with --agents", async () => { + withFlags({ agents: true }); + + const worktrees: WorktreeListEntry[] = [ + { + path: "/tmp/project.worktrees/feature/one", + branchName: "feature/one", + remote: "origin/feature/one", + agent: { name: "feature-one-1f", pid: 9187 }, + }, + ]; + + const mockGetWorktreeList = vi + .spyOn(git, "gitGetWorktreeList") + .mockResolvedValue(worktrees); + const mockListName = vi + .spyOn(utils, "worktreeListEntryToListName") + .mockReturnValue("feature/one (Agent: feature-one-1f)"); + const logSpy = vi.spyOn(list, "log").mockImplementation(() => {}); + + await list.run(); + + expect(mockGetWorktreeList).toHaveBeenCalledWith({ + includeCurrent: true, + includeAgents: true, + }); + expect(mockListName).toHaveBeenCalledWith(worktrees[0], "gray", { + agents: true, + }); + expect(logSpy).toHaveBeenCalledWith( + "- feature/one (Agent: feature-one-1f)", + ); + }); }); diff --git a/src/commands/list.ts b/src/commands/list.ts index 386ec99..e96ca64 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,3 +1,4 @@ +import { Flags } from "@oclif/core"; import ora from "ora"; import { BaseCommand } from "../lib/base-command.js"; import { gitGetWorktreeList } from "../lib/git.js"; @@ -5,16 +6,34 @@ import { worktreeListEntryToListName } from "../lib/utils.js"; export default class List extends BaseCommand { static override description = "List worktree branches"; - static override examples = ["<%= config.bin %> <%= command.id %>"]; + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --agents", + ]; + + static override flags = { + agents: Flags.boolean({ + char: "a", + description: "Show the agent session living in each worktree", + }), + }; public async run(): Promise { - await this.parse(List); + const { flags } = await this.parse(List); const spinner = ora("Gathering worktree list").start(); - const worktrees = await gitGetWorktreeList({ includeCurrent: true }); + // The flag reaches both halves: it decides whether the session lookup + // happens at all, and whether the result is rendered. Without it this + // command costs exactly what it did before. See AGENT-MODE-PLAN §3 D8. + const worktrees = await gitGetWorktreeList({ + includeCurrent: true, + includeAgents: flags.agents, + }); spinner.stop(); worktrees.forEach((wt) => { - this.log(`- ${worktreeListEntryToListName(wt)}`); + this.log( + `- ${worktreeListEntryToListName(wt, "gray", { agents: flags.agents })}`, + ); }); } } diff --git a/src/commands/remove.test.ts b/src/commands/remove.test.ts index aaa7def..1433b97 100644 --- a/src/commands/remove.test.ts +++ b/src/commands/remove.test.ts @@ -309,4 +309,90 @@ describe("remove command", () => { expect(choices).toHaveLength(1); }); }); + + // Regression coverage for CLEANUP-DATA-LOSS-PLAN §4.3, which claims this + // command needed no change of its own once isSafeToRemove stopped calling a + // deleted-remote worktree safe while work sits in it. These cases pin that + // claim so a later edit to either side cannot quietly undo it. + describe("a worktree whose remote was deleted while work is uncommitted", () => { + const mergedWithWorkEntry = { + path: "/path/to/project.worktrees/feature/merged-with-work", + branchName: "feature/merged-with-work", + remote: "origin/feature/merged-with-work", + remoteExists: false, + pathExists: true, + uncommittedChanges: 3, + }; + // Taken from the real predicate rather than hand-set. Every other fixture + // in this file supplies its own verdict, so it would keep passing even if + // the classification regressed. + const mergedWithWork = { + ...mergedWithWorkEntry, + safeToRemove: git.isSafeToRemove(mergedWithWorkEntry), + }; + + beforeEach(() => { + (remove as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { force: false }, + }); + }); + + it("should be classified as not safe to remove", () => { + expect(mergedWithWork.safeToRemove).toBe(false); + }); + + it("should be grouped under Active branches, not Safe to delete", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([ + safeWorktree, + mergedWithWork, + ]); + mockCheckbox.mockResolvedValue([]); + + await remove.run(); + + const choices = mockCheckbox.mock.calls[0][0].choices as any[]; + const activeGroupStart = choices.findIndex((choice) => + String(choice.separator ?? "").includes("Active branches"), + ); + const index = choices.findIndex( + (choice) => choice.value?.branchName === "feature/merged-with-work", + ); + + expect(activeGroupStart).toBeGreaterThan(-1); + expect(index).toBeGreaterThan(activeGroupStart); + }); + + it("should prompt for confirmation when it is selected", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([mergedWithWork]); + mockCheckbox.mockResolvedValue([mergedWithWork]); + mockConfirm.mockResolvedValue(true); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + await remove.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: + "Some selected branches are not safe to delete. Are you sure you want to continue?", + default: false, + }); + expect(mockRemove).toHaveBeenCalledWith([mergedWithWork]); + }); + + it("should not remove it when the confirmation is declined", async () => { + vi.spyOn(git, "gitGetWorktreeList").mockResolvedValue([mergedWithWork]); + mockCheckbox.mockResolvedValue([mergedWithWork]); + mockConfirm.mockResolvedValue(false); + const mockRemove = vi + .spyOn(git, "gitRemoveWorktreesWithProgress") + .mockResolvedValue(undefined); + + await remove.run(); + + expect(mockConfirm).toHaveBeenCalled(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/integrations/github.test.ts b/src/integrations/github.test.ts index de8d3e8..8f7ce76 100644 --- a/src/integrations/github.test.ts +++ b/src/integrations/github.test.ts @@ -109,10 +109,10 @@ describe("GitHub integration", () => { }); it("fetches issue info from the current repository", async () => { - vi.spyOn(cli, "cmd").mockResolvedValueOnce( - "git@github.com:northguild/worktree.git", - ); - vi.spyOn(cli, "cmd").mockResolvedValueOnce(""); + const runSpy = vi + .spyOn(cli, "run") + .mockResolvedValueOnce("git@github.com:northguild/worktree.git") + .mockResolvedValueOnce(""); const fetchSpy = vi .fn() @@ -121,7 +121,7 @@ describe("GitHub integration", () => { const issue = await fetchGitHubIssue(42); - expect(cli.cmd).toHaveBeenCalledWith("git remote get-url origin"); + expect(runSpy).toHaveBeenCalledWith("git", ["remote", "get-url", "origin"]); expect(fetchSpy).toHaveBeenCalledWith( "https://api.github.com/repos/northguild/worktree/issues/42", { @@ -180,10 +180,9 @@ describe("GitHub integration", () => { it("throws when the GitHub API responds with an error", async () => { // Use a pre-configured token so the repo-check step is skipped, and the // error comes directly from the issue fetch. - vi.spyOn(cli, "cmd").mockResolvedValueOnce( - "git@github.com:northguild/worktree.git", - ); - vi.spyOn(cli, "cmd").mockResolvedValueOnce("ghp_test_token"); + vi.spyOn(cli, "run") + .mockResolvedValueOnce("git@github.com:northguild/worktree.git") + .mockResolvedValueOnce("ghp_test_token"); vi.stubGlobal( "fetch", @@ -201,11 +200,11 @@ describe("GitHub integration", () => { }); it("auto-resolves a token via gh CLI when the repo check fails without a token", async () => { - const cmdSpy = vi.spyOn(cli, "cmd"); - cmdSpy.mockResolvedValueOnce("git@github.com:northguild/worktree.git"); // git remote get-url origin - cmdSpy.mockResolvedValueOnce(""); // git config token (no token) - cmdSpy.mockResolvedValueOnce("ghp_auto_token"); // gh auth token - cmdSpy.mockResolvedValueOnce(""); // gitSetConfigValue (saving token) + const runSpy = vi.spyOn(cli, "run"); + runSpy.mockResolvedValueOnce("git@github.com:northguild/worktree.git"); // git remote get-url origin + runSpy.mockResolvedValueOnce(""); // git config token (no token) + runSpy.mockResolvedValueOnce("ghp_auto_token"); // gh auth token + runSpy.mockResolvedValueOnce(""); // gitSetConfigValue (saving token) const fetchSpy = vi.fn(); fetchSpy.mockResolvedValueOnce({ @@ -230,13 +229,13 @@ describe("GitHub integration", () => { expectCommands( "gh auth token", - 'git config northguild.worktree.github.token "ghp_auto_token"', + "git config northguild.worktree.github.token ghp_auto_token", ); const issue = await fetchGitHubIssue(13); expect(issue.number).toBe(13); - expect(cmdSpy).toHaveBeenCalledWith("gh auth token"); + expect(runSpy).toHaveBeenCalledWith("gh", ["auth", "token"]); expect(fetchSpy).toHaveBeenCalledWith( "https://api.github.com/repos/northguild/worktree/issues/13", { diff --git a/src/integrations/github.ts b/src/integrations/github.ts index ac9c854..01d6ad6 100644 --- a/src/integrations/github.ts +++ b/src/integrations/github.ts @@ -1,5 +1,5 @@ import { input } from "@inquirer/prompts"; -import { cmd, commandExists } from "../lib/cli.js"; +import { commandExists, run } from "../lib/cli.js"; import { gitGetConfigValue, gitSetConfigValue } from "../lib/git.js"; interface GitHubRepository { @@ -110,7 +110,7 @@ function parseGitHubRepositoryFromRemote(remoteUrl: string): GitHubRepository { async function getCurrentGitHubRepository(): Promise { try { - const remoteUrl = await cmd("git remote get-url origin"); + const remoteUrl = await run("git", ["remote", "get-url", "origin"]); return parseGitHubRepositoryFromRemote(remoteUrl); } catch (error) { if (error instanceof Error) { @@ -144,7 +144,7 @@ async function getGitHubTokenFromGhCli(): Promise { return ""; } try { - return await cmd("gh auth token"); + return await run("gh", ["auth", "token"]); } catch { return ""; } diff --git a/src/integrations/jira.test.ts b/src/integrations/jira.test.ts index efc7934..b32c2d1 100644 --- a/src/integrations/jira.test.ts +++ b/src/integrations/jira.test.ts @@ -40,8 +40,8 @@ describe("Jira integration", () => { "git config northguild.worktree.jira.apiToken", ); - const cmdSpy = vi.spyOn(cli, "cmd"); - cmdSpy + const runSpy = vi.spyOn(cli, "run"); + runSpy .mockResolvedValueOnce("https://example.atlassian.net/") .mockResolvedValueOnce("test@example.com") .mockResolvedValueOnce("api-token"); @@ -74,7 +74,7 @@ describe("Jira integration", () => { "git config northguild.worktree.jira.apiToken", ); - vi.spyOn(cli, "cmd") + vi.spyOn(cli, "run") .mockResolvedValueOnce("") .mockResolvedValueOnce("test@example.com") .mockResolvedValueOnce("api-token"); @@ -91,7 +91,7 @@ describe("Jira integration", () => { "git config northguild.worktree.jira.apiToken", ); - vi.spyOn(cli, "cmd") + vi.spyOn(cli, "run") .mockResolvedValueOnce("example.atlassian.net") .mockResolvedValueOnce("test@example.com") .mockResolvedValueOnce("api-token"); @@ -120,7 +120,7 @@ describe("Jira integration", () => { "git config northguild.worktree.branchPrefix.bugfix", ); - vi.spyOn(cli, "cmd") + vi.spyOn(cli, "run") .mockResolvedValueOnce("example.atlassian.net") .mockResolvedValueOnce("test@example.com") .mockResolvedValueOnce("api-token") @@ -157,7 +157,7 @@ describe("Jira integration", () => { "git config northguild.worktree.jira.apiToken", ); - vi.spyOn(cli, "cmd") + vi.spyOn(cli, "run") .mockResolvedValueOnce("example.atlassian.net") .mockResolvedValueOnce("test@example.com") .mockResolvedValueOnce("api-token"); diff --git a/src/lib/agent.test.ts b/src/lib/agent.test.ts new file mode 100644 index 0000000..ff9de05 --- /dev/null +++ b/src/lib/agent.test.ts @@ -0,0 +1,319 @@ +import { expectCommands, mockRun } from "../test-setup.js"; +import { + findSessionForPath, + getAgentSessions, + isSessionInteractive, + isSessionLive, + isSessionWaiting, +} from "./agent.js"; +import * as git from "./git.js"; +import type { AgentSession } from "./types.js"; + +// Nothing here launches an agent runtime: run() is the globally mocked +// subprocess helper and gitGetConfigValue is stubbed, so the suite passes on a +// machine with no agent CLI installed at all — see AGENT-MODE-PLAN §2. +const worktreePath = "/repo/project.worktrees/feature/test"; + +function session(overrides: Partial = {}): AgentSession { + return { + name: "feature-test-1f", + pid: 9187, + cwd: worktreePath, + ...overrides, + }; +} + +function mockSessionsJson(sessions: unknown) { + expectCommands("claude agents --json"); + mockRun.mockResolvedValueOnce(JSON.stringify(sessions)); +} + +describe("getAgentSessions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("claude"); + }); + + it("returns every well-formed entry, optional fields included", async () => { + mockSessionsJson([ + { + pid: 9187, + cwd: "/Users/dev/Documents/notes", + kind: "interactive", + startedAt: 1788600791123, + sessionId: "53fac4da", + name: "notes-1f", + }, + { + pid: 33471, + id: "23f50fae", + cwd: worktreePath, + kind: "background", + status: "idle", + state: "working", + name: "feature-test-1f", + }, + ]); + + const sessions = await getAgentSessions(); + + expect(sessions).toEqual([ + { + name: "notes-1f", + pid: 9187, + cwd: "/Users/dev/Documents/notes", + kind: "interactive", + status: undefined, + state: undefined, + }, + { + name: "feature-test-1f", + pid: 33471, + cwd: worktreePath, + kind: "background", + status: "idle", + state: "working", + }, + ]); + }); + + // The load-bearing assertion of this file. A regression to --all would pass + // every other test here and only surface as a worktree nobody can delete, + // because the default listing is what excludes completed sessions. D4/D6. + it("never asks for completed sessions with --all", async () => { + mockSessionsJson([]); + + await getAgentSessions(); + + expect(mockRun).toHaveBeenCalledWith("claude", ["agents", "--json"]); + expect(mockRun).toHaveBeenCalledTimes(1); + const [, args] = mockRun.mock.calls[0]; + expect(args).not.toContain("--all"); + }); + + it("invokes the head of agent.command, not its dispatch arguments", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("claude --bg"); + mockSessionsJson([]); + + await getAgentSessions(); + + expect(mockRun).toHaveBeenCalledWith("claude", ["agents", "--json"]); + }); + + it("returns nothing and runs nothing when agent.command is unset", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(""); + + await expect(getAgentSessions()).resolves.toEqual([]); + + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("treats a whitespace-only agent.command as unset", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(" "); + + await expect(getAgentSessions()).resolves.toEqual([]); + + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("returns nothing when the command exits non-zero", async () => { + expectCommands("claude agents --json"); + mockRun.mockRejectedValueOnce(new Error("Command failed: claude agents")); + + await expect(getAgentSessions()).resolves.toEqual([]); + }); + + it("returns nothing when the command is not installed", async () => { + expectCommands("claude agents --json"); + mockRun.mockRejectedValueOnce(new Error("spawn claude ENOENT")); + + await expect(getAgentSessions()).resolves.toEqual([]); + }); + + it("returns nothing when stdout is not JSON", async () => { + expectCommands("claude agents --json"); + mockRun.mockResolvedValueOnce("Usage: claude agents [options]"); + + await expect(getAgentSessions()).resolves.toEqual([]); + }); + + it("returns nothing when stdout is empty", async () => { + expectCommands("claude agents --json"); + mockRun.mockResolvedValueOnce(""); + + await expect(getAgentSessions()).resolves.toEqual([]); + }); + + it("returns nothing when the JSON is not an array", async () => { + mockSessionsJson({ sessions: [] }); + + await expect(getAgentSessions()).resolves.toEqual([]); + }); + + it("keeps a session carrying no state, status or kind", async () => { + mockSessionsJson([ + { name: "feature-test-1f", pid: 9187, cwd: worktreePath }, + ]); + + await expect(getAgentSessions()).resolves.toEqual([ + { + name: "feature-test-1f", + pid: 9187, + cwd: worktreePath, + kind: undefined, + status: undefined, + state: undefined, + }, + ]); + }); + + it("reads a null status as absent rather than as a value", async () => { + mockSessionsJson([ + { name: "feature-test-1f", pid: 9187, cwd: worktreePath, status: null }, + ]); + + const [parsed] = await getAgentSessions(); + + expect(parsed.status).toBeUndefined(); + }); + + it("drops malformed entries and keeps the rest", async () => { + mockSessionsJson([ + null, + "not-a-session", + { name: "no-pid", cwd: worktreePath }, + { pid: 1, cwd: worktreePath }, + { name: "no-cwd", pid: 2 }, + { name: "empty-cwd", pid: 3, cwd: "" }, + { name: "feature-test-1f", pid: 9187, cwd: worktreePath }, + ]); + + const sessions = await getAgentSessions(); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.name).toBe("feature-test-1f"); + }); +}); + +describe("findSessionForPath", () => { + it("matches a session whose cwd is the worktree itself", () => { + const target = session(); + + expect(findSessionForPath([target], worktreePath)).toBe(target); + }); + + it("matches a session sitting in a subdirectory of the worktree", () => { + const target = session({ cwd: `${worktreePath}/src/lib` }); + + expect(findSessionForPath([target], worktreePath)).toBe(target); + }); + + it("does not match a sibling worktree whose name shares a prefix", () => { + const target = session({ cwd: `${worktreePath}-two` }); + + expect(findSessionForPath([target], worktreePath)).toBeUndefined(); + }); + + it("does not match the directory containing the worktree", () => { + const target = session({ cwd: "/repo/project.worktrees" }); + + expect(findSessionForPath([target], worktreePath)).toBeUndefined(); + }); + + // The prefix trap this tool's own layout sets: every worktree path starts with + // the repository path, so a session in the main checkout must not be reported + // as living in each of that repository's worktrees. + it("does not match a session sitting in the main checkout", () => { + const target = session({ cwd: "/repo/project" }); + + expect(findSessionForPath([target], worktreePath)).toBeUndefined(); + }); + + // Two sessions in one worktree is the ordinary --agent case: an agent this CLI + // dispatched, plus the editor or terminal the human opened. If the finished one + // is listed first, returning it would report the worktree as free. + it("prefers a live session over a finished one in the same worktree", () => { + const finished = session({ name: "finished", state: "done" }); + const live = session({ name: "live", state: "working" }); + + expect(findSessionForPath([finished, live], worktreePath)).toBe(live); + }); + + it("still returns a finished session when it is the only one there", () => { + const finished = session({ state: "done" }); + + expect(findSessionForPath([finished], worktreePath)).toBe(finished); + }); + + it("returns undefined when nothing matches", () => { + expect(findSessionForPath([], worktreePath)).toBeUndefined(); + expect( + findSessionForPath([session({ cwd: "/elsewhere" })], worktreePath), + ).toBeUndefined(); + }); +}); + +describe("isSessionLive", () => { + it("counts a working session as live", () => { + expect(isSessionLive(session({ state: "working" }))).toBe(true); + }); + + it("counts a finished session as not live", () => { + expect(isSessionLive(session({ state: "done" }))).toBe(false); + }); + + it("counts a session with no state as live", () => { + expect(isSessionLive(session())).toBe(true); + }); + + it("counts an unrecognised state as live", () => { + expect(isSessionLive(session({ state: "hibernating" }))).toBe(true); + }); +}); + +describe("isSessionWaiting", () => { + it("marks a blocked session as waiting", () => { + expect(isSessionWaiting(session({ state: "blocked" }))).toBe(true); + }); + + it("marks an idle session as waiting", () => { + expect( + isSessionWaiting(session({ state: "working", status: "idle" })), + ).toBe(true); + }); + + it("does not mark a working session as waiting", () => { + expect( + isSessionWaiting(session({ state: "working", status: "running" })), + ).toBe(false); + }); + + it("does not mark a finished session as waiting", () => { + expect(isSessionWaiting(session({ state: "done", status: "idle" }))).toBe( + false, + ); + }); + + // Passing a status an interactive entry does not carry today, so this pins the + // kind test rather than the mere absence of the field. §4.1's invariant is that + // waiting is only ever true for a background session. + it("does not mark an interactive session as waiting", () => { + expect( + isSessionWaiting(session({ kind: "interactive", status: "idle" })), + ).toBe(false); + }); +}); + +describe("isSessionInteractive", () => { + it("recognises an interactive session", () => { + expect(isSessionInteractive(session({ kind: "interactive" }))).toBe(true); + }); + + it("does not mark a background session as interactive", () => { + expect(isSessionInteractive(session({ kind: "background" }))).toBe(false); + }); + + it("does not mark a session with no kind as interactive", () => { + expect(isSessionInteractive(session())).toBe(false); + }); +}); diff --git a/src/lib/agent.ts b/src/lib/agent.ts new file mode 100644 index 0000000..4e748ec --- /dev/null +++ b/src/lib/agent.ts @@ -0,0 +1,152 @@ +import path from "node:path"; +import { run } from "./cli.js"; +import { gitGetConfigValue } from "./git.js"; +import type { AgentSession } from "./types.js"; + +// The only module that knows an agent runtime's session JSON exists. Nothing +// outside this file reads `kind`, `state` or `status` — the three fields that +// appear in the output but not in the runtime's own --help — so swapping +// runtimes touches one module. See AGENT-MODE-PLAN §4. + +// `--all` is deliberately absent, and its absence is the primary guard against +// wedging a worktree behind a session that has already finished: the default +// listing excludes completed sessions, and --all is exactly what adds them back. +// See AGENT-MODE-PLAN §3 D4/D6. +const SESSION_ARGS = ["agents", "--json"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +// Read one string field, treating a wrong type the same as an absent one. The +// runtime writes `"status": null` rather than omitting it on a finished session, +// so null has to fall through to undefined here — every caller then compares +// against a known value instead of testing truthiness. See §4.1. +function readOptionalString( + source: Record, + key: string, +): string | undefined { + const value = source[key]; + return typeof value === "string" ? value : undefined; +} + +// An entry missing its identifying fields is dropped rather than patched: name +// and pid are what `list` renders and cwd is the join key, so an entry without +// them cannot match a worktree in the first place. The three optional fields are +// the ones R3 expects to be renamed one day; losing one degrades a marker, not +// the session. +function toAgentSession(entry: unknown): AgentSession | undefined { + if (!isRecord(entry)) { + return undefined; + } + + const { name, pid, cwd } = entry; + if (typeof name !== "string" || typeof pid !== "number") { + return undefined; + } + if (typeof cwd !== "string" || !cwd) { + return undefined; + } + + return { + name, + pid, + cwd, + kind: readOptionalString(entry, "kind"), + status: readOptionalString(entry, "status"), + state: readOptionalString(entry, "state"), + }; +} + +// One invocation per command run, joined in-process afterwards (D4). Every +// failure — no agent configured, a missing binary, a non-zero exit, output that +// is not JSON, or JSON that is not an array — yields no sessions and no error: +// this must never become a hard dependency on any particular agent runtime. +export async function getAgentSessions(): Promise { + const agentCommand = await gitGetConfigValue("agent.command"); + // agent.command is a dispatch command line, e.g. `claude --bg`. Only its head + // is reused here, because listing sessions is a different subcommand of the + // same program: appending the dispatch arguments would ask for + // `claude --bg agents --json`. + const [agent] = agentCommand.trim().split(/\s+/); + + if (!agent) { + return []; + } + + try { + const parsed: unknown = JSON.parse(await run(agent, SESSION_ARGS)); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map(toAgentSession) + .filter((session): session is AgentSession => session !== undefined); + } catch { + return []; + } +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + if (relative === "") { + return true; + } + if (path.isAbsolute(relative)) { + return false; + } + // Compared segment-wise rather than by string prefix, so a sibling worktree + // whose name merely starts with this one's does not match. + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +// A session counts as living in a worktree when its cwd is the worktree itself +// or anything under it. Nesting matters for the case D5 exists to protect: this +// CLI dispatches an agent with cwd set to the worktree exactly, but a human's +// own terminal is just as likely to sit in a subdirectory of one, and deleting +// a directory somebody is working in is the failure mode to avoid. +// +// A live match wins over a finished one. Two sessions in one worktree is the +// ordinary case for `--agent`, which dispatches an agent and then opens the +// editor, and the runtime's listing order is not ours to rely on: handing back a +// finished session first would tell the caller the worktree is free while +// somebody is still working in it. D5 blocks on any session, not on the first. +export function findSessionForPath( + sessions: AgentSession[], + worktreePath: string, +): AgentSession | undefined { + const isHere = (session: AgentSession) => + isPathInside(worktreePath, session.cwd); + + return ( + sessions.find((session) => isHere(session) && isSessionLive(session)) ?? + sessions.find(isHere) + ); +} + +// Fails safe: an absent or unrecognised state counts as live, so a session whose +// shape we no longer recognise blocks removal rather than being ignored. That is +// also what makes an interactive session live, since those carry no state at +// all. See D6 and §4.1. +export function isSessionLive(session: AgentSession): boolean { + return session.state !== "done"; +} + +// Live but not progressing. Only ever true for a background session: a human's +// own terminal is marked interactive, not waiting. Today an interactive entry +// carries neither field, so the kind test is redundant — it is here so the +// invariant holds by construction rather than by coincidence, should a runtime +// start attaching a status to interactive sessions. An unrecognised vocabulary +// degrades this to "no marker", which is cosmetic. See §4.1. +export function isSessionWaiting(session: AgentSession): boolean { + if (!isSessionLive(session) || isSessionInteractive(session)) { + return false; + } + return session.state === "blocked" || session.status === "idle"; +} + +// Keeps `kind` inside this module while still letting `list` tell a human's own +// terminal apart from an agent this CLI dispatched (D5, Q2). +export function isSessionInteractive(session: AgentSession): boolean { + return session.kind === "interactive"; +} diff --git a/src/lib/base-command.test.ts b/src/lib/base-command.test.ts new file mode 100644 index 0000000..35ddd6e --- /dev/null +++ b/src/lib/base-command.test.ts @@ -0,0 +1,216 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Allow any in tests */ +import { expectCommands, mockRun, mockSpawnDetached } from "../test-setup.js"; +import { BaseCommand } from "./base-command.js"; +import * as git from "./git.js"; + +// openWorktreePath is the only method covered here that draws a spinner. Mock it +// so the suite neither writes to the terminal nor depends on a TTY. +const spinnerMocks = vi.hoisted(() => { + const succeed = vi.fn(); + const fail = vi.fn(); + const start = vi.fn().mockReturnValue({ succeed, fail }); + const oraFactory = vi.fn().mockReturnValue({ start }); + + return { succeed, fail, start, oraFactory }; +}); + +vi.mock("ora", () => ({ + default: spinnerMocks.oraFactory, +})); + +// BaseCommand is abstract and openWorktreePath is protected, so reaching it needs +// a concrete subclass. oclif requires run(); nothing in this file calls it. +class TestCommand extends BaseCommand { + async run() {} + + open(path: string) { + return this.openWorktreePath(path); + } + + dispatch(path: string, prompt: string) { + return this.dispatchAgent(path, prompt); + } +} + +describe("openWorktreePath", () => { + const worktreePath = "/repo/project.worktrees/feature/test"; + let command: TestCommand; + + beforeEach(() => { + vi.clearAllMocks(); + command = new TestCommand([], { runCommand: vi.fn() } as any); + // git.ts reads the value through the mocked run(); stub it so the only + // subprocess call each test sees is the editor launch itself. + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("code"); + }); + + it("passes the worktree path as one argument, spaces and all", async () => { + const spacedPath = "/repo/space demo.worktrees/feature/test"; + expectCommands(`code "${spacedPath}"`); + mockRun.mockResolvedValueOnce(""); + + await command.open(spacedPath); + + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith("code", [spacedPath]); + expect(spinnerMocks.oraFactory).toHaveBeenCalledWith("Opening in code"); + }); + + it("splits a configured command line into program and leading arguments", async () => { + expectCommands(`code -n ${worktreePath}`); + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("code -n"); + mockRun.mockResolvedValueOnce(""); + + await command.open(worktreePath); + + expect(mockRun).toHaveBeenCalledWith("code", ["-n", worktreePath]); + }); + + it("collapses repeated whitespace instead of passing an empty argument", async () => { + expectCommands(`code -n ${worktreePath}`); + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(" code -n "); + mockRun.mockResolvedValueOnce(""); + + await command.open(worktreePath); + + expect(mockRun).toHaveBeenCalledWith("code", ["-n", worktreePath]); + }); + + it("succeeds the spinner once the editor has been launched", async () => { + expectCommands(`code ${worktreePath}`); + mockRun.mockResolvedValueOnce(""); + + await command.open(worktreePath); + + // The launch is not awaited by openWorktreePath, so the spinner settles a + // microtask after it returns. + await vi.waitFor(() => expect(spinnerMocks.succeed).toHaveBeenCalled()); + expect(spinnerMocks.fail).not.toHaveBeenCalled(); + }); + + it("fails the spinner with the error message when the launch is rejected", async () => { + expectCommands(`code ${worktreePath}`); + mockRun.mockRejectedValueOnce(new Error("spawn code ENOENT")); + + await command.open(worktreePath); + + await vi.waitFor(() => + expect(spinnerMocks.fail).toHaveBeenCalledWith("spawn code ENOENT"), + ); + expect(spinnerMocks.succeed).not.toHaveBeenCalled(); + }); + + it("logs the path and launches nothing when no editor is configured", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(""); + const logSpy = vi.spyOn(command, "log").mockImplementation(() => {}); + + await command.open(worktreePath); + + expect(mockRun).not.toHaveBeenCalled(); + expect(spinnerMocks.oraFactory).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + `✔ Worktree created in ${worktreePath}`, + ); + }); +}); + +describe("dispatchAgent", () => { + const worktreePath = "/repo/project.worktrees/feature/test"; + const prompt = "implement the issue"; + let command: TestCommand; + let logSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + command = new TestCommand([], { runCommand: vi.fn() } as any); + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("claude --bg"); + logSpy = vi.spyOn(command, "log").mockImplementation(() => {}); + }); + + it("splits the configured command line and runs it in the worktree", async () => { + await command.dispatch(worktreePath, prompt); + + expect(mockSpawnDetached).toHaveBeenCalledTimes(1); + expect(mockSpawnDetached).toHaveBeenCalledWith( + "claude", + ["--bg", prompt], + expect.objectContaining({ cwd: worktreePath }), + ); + expect(logSpy).toHaveBeenCalledWith(`✔ Agent started in ${worktreePath}`); + }); + + it("passes a prompt full of quotes as one argument", async () => { + // No shell parses this value, so a prompt that would need escaping in a + // command string arrives at the agent byte for byte. + const quotedPrompt = `fix the 'login' bug in "auth.ts"; don't stop`; + + await command.dispatch(worktreePath, quotedPrompt); + + expect(mockSpawnDetached).toHaveBeenCalledWith( + "claude", + ["--bg", quotedPrompt], + expect.objectContaining({ cwd: worktreePath }), + ); + }); + + it("collapses repeated whitespace instead of passing an empty argument", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(" claude --bg "); + + await command.dispatch(worktreePath, prompt); + + expect(mockSpawnDetached).toHaveBeenCalledWith( + "claude", + ["--bg", prompt], + expect.objectContaining({ cwd: worktreePath }), + ); + }); + + it("runs a bare command with the prompt as its only argument", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("claude"); + + await command.dispatch(worktreePath, prompt); + + expect(mockSpawnDetached).toHaveBeenCalledWith( + "claude", + [prompt], + expect.objectContaining({ cwd: worktreePath }), + ); + }); + + it("points at the config command and starts nothing when no agent is configured", async () => { + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(""); + + await command.dispatch(worktreePath, prompt); + + expect(mockSpawnDetached).not.toHaveBeenCalled(); + // The form named here has to be one that does something: `worktree config + // ` with no value reads the key and discards it (config.ts:273-274). + expect(logSpy).toHaveBeenCalledWith( + 'No agent configured. Run worktree config agent.command "" to set one.', + ); + }); + + it("treats a whitespace-only command as no agent rather than spawning nothing", async () => { + // The head is what spawn receives, and spawn("") throws synchronously — + // which would take the editor launch down with it. + vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(" "); + + await command.dispatch(worktreePath, prompt); + + expect(mockSpawnDetached).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + 'No agent configured. Run worktree config agent.command "" to set one.', + ); + }); + + it("reports a failed launch through the error handler it registers", async () => { + await command.dispatch(worktreePath, prompt); + + // The launch is fire-and-forget, so a missing binary can only surface + // through the handler passed to spawnDetached. + const { onError } = mockSpawnDetached.mock.calls[0][2]; + onError(new Error("spawn claude ENOENT")); + + expect(logSpy).toHaveBeenCalledWith("Error: spawn claude ENOENT"); + }); +}); diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 679d7da..050d4b2 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -1,9 +1,9 @@ -import { exec } from "node:child_process"; import { confirm } from "@inquirer/prompts"; import { Command } from "@oclif/core"; import type { CommandError } from "@oclif/core/interfaces"; import chalk from "chalk"; import ora from "ora"; +import { run, spawnDetached } from "./cli.js"; import { gitGetConfigValue } from "./git.js"; import type { ConfigName } from "./types.js"; @@ -53,18 +53,54 @@ export abstract class BaseCommand extends Command { if (codeEditor) { const spinner = ora(`Opening in ${codeEditor}`).start(); - exec(`${codeEditor} ${path}`, (error) => { - if (error) { - spinner.fail(error.message); - } else { - spinner.succeed(); - } - }); + // codeEditor is a command line, not a bare program name: the head is the + // file to launch and the tail is leading arguments, with the worktree path + // passed last as one argument however many spaces it contains. This is the + // contract commandExists already validates by — it looks up the head alone + // (cli.ts) — so validation and execution now agree. No shell parses this + // value any more, which is why quoted arguments are not supported. + const [editor, ...editorArgs] = codeEditor.trim().split(/\s+/); + // Deliberately not awaited, exactly as the exec callback was not: the + // editor outlives this command, and the spinner settles when it exits. + run(editor, [...editorArgs, path]).then( + () => spinner.succeed(), + (error: Error) => spinner.fail(error.message), + ); } else { this.log(`${chalk.green("✔")} Worktree created in ${path}`); } } + protected async dispatchAgent(path: string, prompt: string) { + const agentCommand = await gitGetConfigValue("agent.command"); + // Split exactly as openWorktreePath splits codeEditor: the head is the + // program to launch and the tail is leading arguments. The prompt is + // appended as one argument however many quotes or spaces it contains — no + // shell parses any of this, which is what keeps arbitrary prompt text out of + // command position. See AGENT-MODE-PLAN §3 D2. + const [agent, ...agentArgs] = agentCommand.trim().split(/\s+/); + + // Unset and whitespace-only are the same fact: no agent to run. The head is + // what spawn would receive, so it is what decides — an empty one makes spawn + // throw synchronously, which would take the editor launch down with it. The + // command named here has to be one that works: `worktree config ` with + // no value reads the key and discards the result (config.ts:273-274). + if (!agent) { + this.log( + `No agent configured. Run ${chalk.cyan('worktree config agent.command ""')} to set one.`, + ); + return; + } + + // Fire-and-forget: the agent outlives this command, so there is no exit + // status to report and no spinner that could ever settle. + spawnDetached(agent, [...agentArgs, prompt], { + cwd: path, + onError: (error: Error) => this.log(chalk.red(`Error: ${error.message}`)), + }); + this.log(`${chalk.green("✔")} Agent started in ${path}`); + } + protected async catch(error: CommandError) { if (error instanceof Error) { if (error.name === "ExitPromptError") { diff --git a/src/lib/cli.test.ts b/src/lib/cli.test.ts new file mode 100644 index 0000000..eb68e59 --- /dev/null +++ b/src/lib/cli.test.ts @@ -0,0 +1,216 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, delimiter, dirname, join } from "node:path"; +import { commandExists, run, spawnDetached } from "./cli.js"; + +// src/test-setup.ts mocks ./lib/cli.js for every suite so command tests never +// execute anything. This file covers the real helper, so it opts back out. +vi.unmock("./cli.js"); + +// The node binary running this suite: always present, on every platform CI and +// contributors use, and reachable without a shell — which is the point here. +const node = process.execPath; + +// Print argv[1], which under `node -e