Taken ahead of my capacity slice, per the drift review.
U11 merges the two pre-implementation columns into one that **keeps the
id `todo`** and **deletes `triage`**. Every `column === "triage"` here
is live breakage the moment that IR lands — and it does not throw, it
simply **stops matching**, so the sweep never fires again and the suite
stays green. That is the Problem Frame’s measured failure mode, landing
on self-healing, where a silently-dead recovery is least likely to be
noticed.
## Count for tracking convergence
`self-healing.ts`, code only, `column === / !== "todo" | "triage"`:
| | before | after |
|---|---:|---:|
| `"triage"` comparisons | **10** | **0** |
| `todo` + `triage` combined | 24 | 15 |
The 15 remaining are all `"todo"`, whose id **survives** U11 — not
breakage, and deliberately left for the hold-column conversion rather
than mixed in here.
## Ten sites, converted by role (intake / hold)
advanced-triage recovery (3, one sweep) · dependency-deadlock blocked
dependents · parked-agent task link · orphaned-approved planning ·
orphaned planning · duplicate-decision candidates · refine-source sweep
· leaked-slot reaper
## Two literals the grep did not count — and they would have silently
killed their sweeps
`listTasks({ column: "triage" })` in both orphaned-planning sweeps.
Converting only the predicate would have left the **query** returning
nothing once the id is gone; the sweep would have looked converted and
done nothing. Both now query the board and filter by role.
All ten route through one seam (`resolvePreWipColumns` /
`filterByPreWipRole`) with a caller-owned per-sweep cache, so 400 cards
over three workflows read three IRs, not 400.
## Two judgement calls, stated
**Unresolvable workflows fall back to the legacy literals, not to
nothing.** These are *recovery* sweeps: a card whose IR cannot be read
must keep its current behaviour rather than silently drop out of every
sweep. That is the conservative direction *here*, and deliberately
differs from conversions whose failure mode is a destructive move.
Pinned by test.
**The leaked-slot reaper’s predicate is left as-is and flagged in
place.** It is arguably too *wide* under plan-in-place — a card being
specified sits in the hold column while a planner works in its worktree,
so “waiting to run must not pin a worktree” no longer holds. What stops
that being live is the FN-6756 liveness gate (already merged). Narrowing
it is a behaviour change and gets its own commit; this PR is vocabulary
only.
The dependency-deadlock site needed restructuring rather than
substitution: its filter is synchronous and role resolution reads the
IR, so membership is precomputed once per sweep.
## Verification
**Revert-proof, measured:** making the resolver return the literals
regardless of workflow turns **4 of the 6** new cases red — the
renamed-workflow ones, precisely the case a literal cannot serve.
`self-healing.ts` restored byte-identical.
`pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green (414 +
10 + 71) · self-healing suites **436 passed / 2 failed** — the same 2
pre-existing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Re-verified against current `origin/main` (2026-07-29)
Per the definitive-list instruction, re-measured rather than re-asserted
— comment-stripped, so FNXC prose quoting a removed literal does not
inflate the count.
- `origin/main` `self-healing.ts`: **10** code sites (lines 2964, 2984,
3019, 9218, 10703, 11282, 12173, 12218, 12321, 12494 — the same ten,
line numbers shifted only).
- This branch: **0**.
- Main touched this file after my branch point (#2600, the R7 dead-guard
fix). Checked: its diff adds **no** `"triage"` literal, and git reports
this PR `MERGEABLE`, so the merge result stays at 0 — the branch being
behind does not hide a new site.
So merging this moves the tracked number **45 → 35**.
Both stated constraints hold: every site resolves to the **intake/hold
ROLE** (not a renamed literal), and the count is zero across the whole
file rather than per-branch — there is no surviving guard in another
branch of the same function. The two `listTasks({ column: "triage" })`
**queries** are converted too; predicate-only conversion would have left
these sweeps looking converted while returning nothing.
## Re-measured RECEIVER-AGNOSTICALLY (2026-07-30, after the revised bar)
The revised count matches any receiver, not just `.column`. Re-measured
with that pattern, comment-stripped, this file is **11 → 0**, not 10 →
0.
| pattern | origin/main | this branch |
|---|---:|---:|
| `<anything> === / !== "triage"` (any receiver, both quote styles,
line-splits) | **11** | **0** |
The eleventh is the one your list attributes separately to
`engine/self-healing.ts`:
```
origin/main:3017 if (!resumeColumn || resumeColumn === "triage") continue;
```
`resumeColumn` is a bare local holding `live.workflowIrPinColumnId`, so
a `.column`-anchored pattern cannot see it. This branch already converts
it — line 3098 reads `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was converted
because the sweep was rewritten around roles rather than by
pattern-matching on receivers, which is why it did not slip.
**Merging this therefore moves the revised 56 by 11, to 45.**
### The 5 literal mentions that remain, and why each is not a guard
Nothing above is a comparison. For completeness, since "a file is not
done because the pattern is gone from it":
- **3 legacy fallbacks** (`?? "triage"`) at 2978, 2980, 10821 — the
unresolvable-workflow path in `resolvePreWipColumns`. These are
*recovery* sweeps: a card whose IR cannot be read must keep its current
behaviour rather than silently drop out of every sweep. Pinned by test.
- **2 union reads** at 6044 (`["triage", "todo"]`) and 9291
(`listTasks({ column: "triage" })` alongside a `todo` read) — unions
covering both vocabularies, with the role filter deciding membership.
Neither replaces nor disables anything.
Known residual gap, stated rather than hidden: those unions do not cover
a **renamed** intake (Coding (Ideas)'s `ideas`). That gap **pre-dates
U11** — the same unions missed `ideas` before the merge — so it is not a
regression here, and closing it needs a cross-workflow lane union rather
than a vocabulary edit.
## AST-VERIFIED, replacing the grep-derived figure (2026-07-30)
Since no grep-derived number is authoritative, I re-measured this file
by PARSING it — `ts.createSourceFile`, walking binary expressions,
classifying on the left-hand side. Not a pattern match.
| | origin/main | this branch |
|---|---:|---:|
| lifecycle-column comparisons (AST-classified) | **11** | **0** |
All eleven classify as `COLUMN`; none is an agent role, session purpose
or surface name, so all eleven are real guards and every one is
converted:
```
2964 task.column 9218 dep.column 12218 task.column
2984 live.column 10703 task.column 12321 task.column
3017 resumeColumn 11282 linkedTask.column 12494 t.column
3019 current.column 12173 t.column
```
`3017` is `resumeColumn` — a bare local holding `workflowIrPinColumnId`,
which is exactly the receiver class the `.column`-anchored greps missed.
It is converted here to `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was caught
because the sweeps were rewritten around roles rather than
pattern-matched on receivers.
The classifier is on #2623 as `scripts/lib/lifecycle-column-ast.mjs` and
is offered to #2630 to import. My earlier "11 → 0" was correct — but it
was a regex reading, and this is the same number arrived at by parsing.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>