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

Base commit: d0044d1 (branch feat/stage-detection)

Task 1 (B1): written by Codex terra (foreground; four earlier background runs were killed by the harness's background wall-clock limit). Review — spec MET, all 9 scenarios covered by 5 tests; totality verified by a reviewer that actually threw null/numbers/arrays/circular refs/symbols/malformed rules/a throwing property getter at it (zero throws), and regex compilation confirmed once-per-pipeline (1 RegExp construction across 1000 events, WeakMap keyed on pipeline identity). 2 Important, 1 Minor.
Task 1: fix round 1/5 (3 addressed — the signal was unbounded at 5002 chars for a real Bash command and is now whitespace-collapsed and capped at 120 with an ellipsis; `flattenInput` had concatenated EVERY top-level string, so a real Edit event's `old_string`/`new_string` (whole code blocks) would have been matched against and then shown as the reason for the inference — now restricted to an allowlist of identifying fields; compileRules gained direct totality tests. Totality re-verified by the fixer with its own script.) A Codex attempt at this fix hit the 540 s wall and produced nothing, so it was done by a Claude subagent.
Task 1: complete (869 server tests) — commits 6bb445e + the fix commit
Tooling note: Codex completed 3 of 10 attempted runs in this environment; when it finishes, its work is good (it was the only implementer that ran clause-deletion experiments unprompted), but each failure costs ~9 minutes, so implementation moved to Claude subagents. The useful half of the Codex protocol was kept for every implementer: the agent runs only its own focused test file and does NOT commit; the controller runs the full suite and commits, so the 861-test pre-commit hook runs once per task instead of twice.

Task 2 (B2): rules on plan/implement/tests/review/ship in the default template (intake/gate/done deliberately bare — a gate is a judgement, `done` is a claim); three per-column probes for detected_stage/detected_signal/detected_at; `recordDetection` with the forward-only + declared-wins guard; `detected` decorating nodeStates' output without touching how any state is computed.
Task 2: the implementer found an integration bug in the PLAN, not in its own work, and flagged it instead of fixing outside its file list: `loadAll()` in pipelines.js normalises each node to {id,label,icon,gate,aliases} and DROPPED the new `detect` array, so every rule shipped in the template was inert. Controller reproduced it directly — raw template matched `tests`, `getPipeline('default')` matched null. Neither B1's review nor B2's own tests could have caught it: B1 tested the matcher with raw fixtures that bypass the loader, and B2 tested the rules as JSON. Fixed by preserving `detect` defensively in the loader, plus an END-TO-END test that calls detect(getPipeline('default'), event) through the real loader — the pin that was missing.
Task 2: controller verified through the real path afterwards: rules live, an Edit's signal is its file_path (not the code from old_string/new_string), `gate` still uninferrable, and getPipeline() still returns a stable object so stage-detect's WeakMap regex cache keeps compiling once per pipeline.
Task 2: complete (878 server tests)

Task 3 (B3): detect + recordDetection + broadcast placed inside the existing fail-safe try/catch in touchLaneFromHook, right after the lane is resolved; broadcast only when recordDetection reports written:true, with a comment naming the 29,470-Bash-event volume behind that rule. 5 new API tests including the premise guard at the API level.
Task 3: controller smoke-tested on a real server. First attempt looked like a total failure (detected_stage null) — cause was NOT the code: a server from an earlier branch still held port 4820, so the new instance refused to start and the probes hit stale code. After stopping it: detected_stage=tests with the right signal; 15 identical events plus one backward Edit left updated_at completely unchanged (write-on-change holds under real traffic); the backward Edit did not drag the lane back; every node still `pending` with a `detected` flag and no node `done`.
Task 3: complete (883 server tests)

Task 4 (B4): detected nodes render amber-dashed via a class that REPLACES the state-driven one, so even a hypothetical {detected:true, state:"done"} payload renders amber and never green — the safe direction. `auto:` chip only when the detection leads the declaration. tsc (not vitest) caught a missing field in an unrelated test fixture, which is why `npm run build` is in every client task's verification list.
Task 4: complete (308 client tests)
Task 5 (B5): `ccam lanes` gains a detected suffix using the same lead-comparison as the card, so terminal and browser never disagree; docs/LANES.md documents the signals, the field allowlist, the 120-char cap, the rules node by node, the deliberate blanks on intake/gate/done, forward-only + write-on-change, and the evidence boundary with its reason. The implementer flagged rather than papered over the fact that GET /api/lanes has NO OpenAPI path docs at all and no Lane schema — it added a standalone schema for the three fields instead of inventing either.
Task 5: complete (885 server tests)

FINAL whole-branch review (Opus): READY WITH FIXES — 1 Critical, 5 Important, 7 Minor. The evidence boundary itself held: no lane state could be constructed in which a detection renders green or writes lanes.stage, proved by mutation and live probing rather than by reading, with per-event cost measured (1 µs for an ordinary Bash event, zero queries when nothing changes).
FINAL Critical — clearLane() did not reset the detection columns. A lane reset back to base kept claiming `auto: ship` with 7 of 8 nodes amber for an empty tree, and then refused every subsequent detection with `behind-detected` — and since no node past `ship` carries a rule, detection was dead for that lane permanently.
FINAL corrected MY OWN RULING: I had called the ENOLANE race in the hook path a non-issue "because the outer try/catch swallows it". Right conclusion, wrong reasoning — entering the catch skips the rest of the function, so `needs_action` was left lit and a rebound `session_id` unwritten. The fix is ordering/isolation, not error handling.
FINAL found a second inert rule, the same defect class as the loader bug: `plan`'s `Write docs/.*plan.*\.md` was shadowed by `implement`'s unconditional `Write` because detect() takes the LAST match, so writing a plan document reported `implement`. Documented as live, could never fire.
FINAL proved the boundary was unpinned: removing `&& !stages[n.id]` from withDetected left 56/56 tests passing, because both tests calling themselves PREMISE GUARD sat on lanes with no declarations, making "no node is done" vacuously true.
Fix wave (one commit, 9da58ea, Opus): all 9 items fixed with seven verify-by-deletion experiments and their exact failure messages. It DECLINED my instruction to move the detection block below the bookkeeping, correctly: that block ends in `if (!Object.keys(patch).length) return;`, so detection would have become unreachable on the common hook. It used an inner try/catch instead and corrected the docs sentence. `implement`'s Write rule is now constrained with a negative lookahead, verified at the boundaries (mydocs/, docs2/ still implement).
Fix-wave re-review: READY. Re-ran the G-1 mutation itself in a throwaway copy (32 pass / 1 fail, same tally), rebuilt the I-2 resolution table independently, and traced the alias mechanism to confirm the I-3 test would fail against the old code. All three of the fixer's open items judged non-blocking.
B complete: 10 commits c78e7f7..9da58ea, 892 server tests, 308 client tests.
