# SDD ledger — plan: docs/superpowers/plans/2026-07-28-worktree-lanes.md

Base commit: 7195741 (branch feat/worktree-lanes)

Task 1: first attempt committed with --no-verify after misdiagnosing a hook failure as "test isolation". Controller reproduced it via `git commit --amend`: git hooks export GIT_DIR/GIT_INDEX_FILE, every git child inherited them, and in a worktree `.git` is a FILE so `.git/index` gave ENOTDIR. Real bug in worktree.js, not the environment. Fixed by scrubbing 9 GIT_* vars (+ GIT_TERMINAL_PROMPT=0) in the git() helper and the test fixture, with a regression test that sets bogus GIT_DIR/GIT_INDEX_FILE. Recommitted through the hook.
Task 1: review — spec MET (one accepted deviation: reset-in-place instead of delete+recreate); adversarial checks all passed: path containment defeats symlink/prefix/`..`/missing-path attacks via realpath on both sides + path.relative boundary; branch deletion cannot be tricked into main/master/base even by a lying lane row; env scrub complete; no vacuous tests. 2 Important.
Task 1: fix round 1/5 (2 addressed, 0 open — ERESETBRANCH verifies the worktree really landed on the feature branch; ENOBASE verifies the base ref before ANY mutation, with a test proving the worktree and its dirty files are untouched on that path; commits 436adfa..03a6e63)
Task 1: complete (commits 7195741..03a6e63, review clean) — 801 server tests pass, every commit through the pre-commit gate

Task 2: review — spec met on the surface, 2 Critical underneath. (a) the migration probed only `kind` while adding four columns, so a crash after the first ALTER left three columns permanently missing on a real install — the plan's own fault; (b) `kind` was validated in createLane only, so updateLane could silently corrupt the boundary that decides whether CCAM may delete a directory. Plus 3 Important (lock Map never pruned, two missing tests).
Task 2: fix round 1/5 (5 addressed, 0 open — per-column independent probes that self-heal a partial migration; shared validateKind() used by both create and update, rejecting before any write so a mixed patch cannot half-apply; lock entry deleted when its chain settles if still current; regression tests for update-kind, per-lane (not global) locking, and an old-schema database plus a simulated mid-migration crash; commits fdf4da4..6138944)
Task 2: complete (commits 03a6e63..6138944, review clean) — 808 server tests pass

Task 3: review — read-only confirmed, shapes/route order correct. 3 Important: purge WHERE clause duplicated between counter and deleter; the purge test could not fail (every session it created was `active`, so all three exclusions were deletable with the test still green); `unpushed: 0` was a confident lie when no upstream is configured — the exact under-report the preflight exists to prevent.
Task 3: fix round 1/5 (2 addressed, 1 NOT — shared `purgeCandidateSessions()` now the single expression of the rule; `unpushedCount` counts commits on no remote via `--not --remotes` and surfaces a distinct `no-remote` fact; but the purge test got WEAKER, not stronger: the implementer deleted the sessions entirely and asserted zeros against an empty table, while its report claimed it had added direct DB inserts. Controller verified: zero INSERTs in the file. commits 51c1c4c..85aa098)
Task 3: fix round 2/5 (1 addressed, 0 open — handed to Codex gpt-5.6-terra effort medium, which built the discriminating fixture (counted / active / bound / sibling-prefix sessions + seeded events and token rows), ran all three clause-deletion experiments and reported the failure each produced; commit d247c37)
Task 3: controller re-verified independently — removed the bound-session exclusion by hand, test failed `2 !== 1`, file restored, `git diff server/lib/lanes.js` empty. 7/7 lifecycle tests, 815 server tests.
Task 3: complete (commits 6138944..d247c37, review clean)
Tooling note: Codex cannot run through `codex-rescue` here — the subagent's Bash sandbox makes Codex's own bwrap fail with `loopback: Failed RTM_NEWADDR`. Working invocation, with the user's explicit approval to drop the sandbox for it: `codex exec --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium "<prompt>"` run from the controller's Bash with dangerouslyDisableSandbox.

Task 4: implemented by Codex gpt-5.6-terra (effort medium) via `codex exec`, review by Claude. Review — no Critical; same-origin guard genuinely applied, slug cannot escape LANES_ROOT, failure path leaves the lane managed/failed/removable with git's stderr and no orphan directory (reviewer reproduced it against a real repo). Codex also updated ARCHITECTURE.md, docs/API.md, server/README.md and added an OpenAPI fragment — ruled NOT scope creep: .claude/skills/update-project-docs mandates exactly those files for an API change. 1 Important, 3 Minor.
Task 4: fix round 1/5 (4 addressed, 0 open — boot sweep marks any still-'provisioning' lane failed with an explanatory note (a lane killed mid-provision could otherwise never age into 'dead', since a fresh managed lane has no session_id for classifyLiveness to measure); 409 EDUPCWD and the `base` default now documented in both OpenAPI and docs/API.md; directory-suffix loop capped at 50 with a 409 and a test that pre-creates exactly the 51 colliding directories needed to reach it; commits d524cd3..cf8ebbf)
Task 4: re-review traced the boot ordering — the sweep runs as a microtask off `server.listen`'s resolve, before the poll phase can dispatch a connection, and is one synchronous better-sqlite3 UPDATE with no yield point, so no request can slip a new provisioning lane into the sweep. Confirmed it writes only status/notes/updated_at, leaving kind/cwd/branch/source_repo/base_branch/slug intact.
Task 4: minor (deferred): recoverInterruptedProvisioning writes raw SQL instead of going through updateLane/PATCHABLE; the boot handler logs err.message without the stack.
Task 4: complete (commits d247c37..cf8ebbf, review clean) — 821 server tests pass

Task 5: implemented by Codex terra, reviewed by Claude Opus. Review — guard work (three checks, force gating, purge honesty, error mapping, lock) clean, but 2 Critical: (a) "kill the run and await its exit" was vacuous — killRun sets status='killed' synchronously after SIGTERM, so the poll on status returned instantly and `git clean -fd` / `worktree remove --force` ran milliseconds later while a live Claude could still be writing into that directory; (b) `remove` had been silently narrowed to managed lanes, breaking the shipped Remove button for adopted lanes with no client path at all (api.ts had no DELETE method), and the existing test that encoded the old contract was rewritten instead of the regression being reported.
Task 5: fix round 1/5 (7 addressed, 2 NEW breakages — added `actualExitedAt`, written only in the child's exit handler, polled with a 7.5 s deadline (> killRun's 5 s escalation) and failing loudly via ERUNTIMEOUT before any git; restored `remove` for both kinds (managed tears down the worktree, adopted deletes the row only); made `expect` mandatory with the full field set; guarded DELETE; ESTALE now carries expected/current. commits 24841d4..58a94e8)
Task 5: fix round 2/5 (4 addressed, 0 open — the mandatory `expect` (my requirement) had broken the Remove button for EVERY lane with 400 EEXPECT, so the client now fetches preflight and echoes it, and surfaces errors instead of swallowing them; a child that fails to spawn emits only `error`, never `exit`, so `actualExitedAt` also set there — otherwise `claude` missing from PATH froze every destructive action for the whole reap window; killRun's SIGKILL escalation tested `!child.killed`, which Node sets true on a successful SIGTERM, so it could never fire — now keyed on real exit; PATCH guarded. commits 58a94e8..fa77725)
Task 5: controller committed one leftover line Codex left uncommitted (`preflight: r({})` in the screens-snapshot API mock, cac0d2a) and re-ran the client suite from a clean tree: 279/279.
Task 5: minor (deferred to C7): LaneCard has no Force affordance, so an unpushed reset/remove 409s with no way to retry from the UI; reset and purge are not on the card at all yet; adopted `remove` still demands force when the adopted directory has unpushed commits even though nothing is destroyed.
Task 5: complete (commits cf8ebbf..cac0d2a, review clean) — 834 server tests, 279 client tests

Task 6: written by Codex terra, whose process was killed by a wall-clock limit TWICE before it could run a single test or commit — the work survived staged. A Claude subagent then read the staged diff, verified it and committed it unchanged (00fc9fa). So the code reached review having never been run by its author; the reviewer was told that and given permission to run the CLI test file itself.
Task 6: review — no Critical, no Important. Verified field-by-field that the CLI's LANE_PREFLIGHT_FIELDS matches the server's expectedFields exactly and that `expect` is built from the freshly fetched preflight (not client-guessed); `--yes` cannot be bypassed (returns with exit 1 before any POST is constructed); `--force` only ever unlocks the unpushed gate; the adopted-lane refusal is plain language and its test asserts the file's CONTENTS survive; no spawnSync, no bare sleeps, no vacuous tests. Reviewer independently ran the CLI suite: 10/10.
Task 6: minor (deferred): `ccam help` shows `[--yes]` while the prose shows it unbracketed; no test covers the ESTALE 409 or the provisioning failed/timeout print paths; the destructive tests share a mutable `managedLane` across describe blocks.
Task 6: complete (commits cac0d2a..00fc9fa, review clean) — 839 server tests

Task 7: Codex wrote the component work and was killed by a wall-clock limit a third time, leaving it uncommitted and missing both the modal tests and all the documentation; a Claude agent audited it, wrote those, and committed (2a58f5a).
Task 7: that agent also found and fixed a real pre-existing bug OUTSIDE its brief: client/src/i18n/index.ts never registered the `lanes` namespace, so no string on the Lanes page had ever resolved — every locale, including the vi/ko/zh translations shipped in the earlier plan's Task 7, rendered as raw keys. Both that task's reviewer and its re-reviewer had passed i18n as complete; both had only checked that the locale files contained the keys, never that the namespace was loaded.
Task 7: review — spec MET throughout; docs fact-checked line by line against the code (three safety checks, `clean -fd` without `-x`, preflight field lists, bytesEstimate derivation) and found truthful, including correctly documenting that LANE_BASE_BRANCH / LANE_BRANCH_PREFIX do NOT exist rather than inventing support. 1 Critical.
Task 7: Critical (open, goes to C8) — `no-remote` sits in the same `blocked[]` array as hard blockers, so a managed lane in a repo with no remote can never be reset or removed from the UI: unpushedCount counts every commit when there is no remote, giving `blocked = ["unpushed-commits","no-remote"]`, the modal treats anything but `unpushed-commits` as a hard block, and its Force checkbox only appears when `blocked.length === 1`. The server gates nothing on `no-remote` and the CLI works fine — the UI locks only itself, permanently, for a common case.
Task 7: minor (goes to C8): LaneCard renders `t("status.<status>")` but no locale has any `status.*` key, and i18next returns the key on a miss, so the `||` fallback never fires — every lane shows literal text like `status.active` right now.
Task 7: complete (commits 00fc9fa..2a58f5a, 1 Critical carried into C8) — 287 client tests, 840 server tests

Task 8 (added after Task 7's Critical): implemented by a Claude subagent. Root-cause fix rather than a patch — `blocked[]` had been conflating three kinds of thing, so it now carries only the action-preventing conditions (adopted, missing, unreadable) plus `unpushed-commits` (the one Force overrides), while purely informational facts (`no-remote`) moved to a new `warnings[]`. The modal needed no logic change at all: it was locked out purely because the server mislabelled `no-remote` as blocking. Also landed: status.* i18n keys for all four locales, real support for LANE_BASE_BRANCH and LANE_BRANCH_PREFIX (the spec had promised them, the code had hardcoded main and feat/), CLI tests for the ESTALE print path and for failed provisioning, and `--yes` presented as mandatory everywhere.
Task 8: review — APPROVE, no Critical or Important. Reviewer enumerated every blocked[] combination the server can emit and confirmed the modal and the server now agree on all eight; ran the ESTALE test three times (12/12 each, ~300 ms) and confirmed it is structural rather than timing-based, since GET /preflight takes no lock while POST does; verified the env-var tests actually set the variables and observe their values rather than asserting a default that would pass anyway; fact-checked the docs against the code and found no inaccurate sentence.
Task 8: minor (deferred, for final-review triage): `lanes.status` is not validated at the API layer — `POST /:id/stage` and `ccam stage --status <s>` accept an arbitrary string, so a rogue value would reproduce the raw-key badge bug this task fixed. The robust fix is a t() defaultValue in LaneCard rather than restricting what an agent may declare.
Task 8: complete (commits 2a58f5a..3139de9, review clean) — 844 server tests, 292 client tests

FINAL whole-branch review (Opus): READY WITH FIXES — 2 Critical, 6 Important, 9 Minor. Safety core sound: every caller of resetWorktree/removeWorktree/purgeLaneSessions/deleteLane enumerated across server/, bin/, scripts/, mcp/ (the CLI goes over HTTP, so nothing outside the routes reaches the git layer), the guard sits INSIDE both destructive functions rather than only upstream, and the migration was verified against real SQLite including a simulated mid-migration crash.
FINAL: the reviewer earned its verdict by mutation — it deleted `await assertDestroyable(lane)` from removeWorktree and all 844 tests stayed green, proving the remove-path guard was pinned by nothing. The same deletion in resetWorktree failed a test.
FINAL: controller raised the reviewer's Important I3 to Critical — `cwd LIKE ? || '/%'` was unescaped and `_` is a LIKE wildcard, while every managed lane directory this branch creates is named `<repo>__<slug>`. A lane at /root/myrepo__feat-foo purged sessions belonging to /root/myrepoXXfeat-foo, and the preflight counted the victims too, so the confirmation was consistently wrong rather than detectably wrong. The old fixture (/tmp/wt-purge) had no underscore, so nothing could have caught it.
Fix wave (one commit, 513235a, Opus): all 16 findings addressed, +997/-151 across 28 files, 844→857 server tests, 292→297 client tests. Deletion experiments run and reported for C2, C3, I1 and I2. The fixer also found a FOURTH instance of "the UI refusing what the server permits" in bin/ccam.js and disclosed it rather than fixing it silently.
Fix-wave re-review (Opus): READY. Verified 857/857 itself, re-ran the C2 mutation in a throwaway copy, and built the four-way table (adopted/missing/unreadable/no-remote × reset/remove/purge × modal/server/CLI) — no square where the UI is stricter than the server, no third instance of the defect.
FINAL: the re-reviewer found what neither the fix wave nor I had: a FOURTH refusal shape for C2 that git does NOT incidentally refuse — an adopted lane legitimately pointing at a worktree the user registered themselves. With the guard removed it destroyed the directory silently with no error at all. Check 1 is the sole protection for that case, so the guard was load-bearing well beyond the three shapes the tests cover.
FINAL: it also disproved the fix wave's own reasoning on `unreadable` + `remove` by reproducing a corrupt-.git worktree: `git worktree remove --force` and even `--force --force` both refuse (code 128), so the promise written into `destructive.notice.unreadable` in all four locales and into two doc lines — that removal is forced and git's entry cleared — is FALSE for that shape. Decision to unblock stands (blocking recreates the defect class), but the copy over-promises.
FINAL: registered as a known fact about the branch, not a surprise — `removeWorktree`'s new prune path deliberately does NOT call assertDestroyable; it runs check 1 plus a lexical check 2 and substitutes `worktree prune` for check 3, fires only when the directory does not exist, and is pinned by a test that still refuses a missing cwd outside LANES_ROOT.
Open follow-ups, none blocking, surfaced to the user: (1) reword the `unreadable` notice + two doc lines to promise an attempt rather than success, add a prune-style fallback so an unreadable lane is genuinely removable, add the two missing modal tests; (2) wrap `start` in withLaneLock so its atomicity is an invariant rather than a property of the current await-free code; (3) add an openapi.yaml drift check to CI; (4) separate triage for the pre-existing unguarded `POST /api/lanes/` and `POST /api/lanes/:id/stage`.
