Commit Graph

12907 Commits

Author SHA1 Message Date
gsxdsm
a319e35a67 fix(dashboard): the card's completion timestamp reads the resolved complete lane (census 13 → 12) (#3146)
`TaskCard.tsx` 1 → 0. **Census 13 → 12**, baseline re-recorded
in-commit.

## The defect

`getInReviewCompletionMs` gated on `task.column === "done"`, so on a
board whose completion lane is renamed, a finished card rendered its
execution time **without the completion half** — the `Completed <when>`
part of the indicator's `title` / `aria-label` never appeared.

Nobody reported it because the card does not look broken. It looks like
a card whose completion time was never recorded.

## The recorded blocker had expired, and I trusted it twice

The note on that helper read:

> Module-scope, takes only a `Task`, and has no flags to consult.
Converting it means either threading resolved flags through a pure
duration helper or resolving a workflow inside it.

True when written (2026-07-30). False within a day, and the evidence is
in the same file:

- `taskColumnFlags` is a **prop of this component**, destructured and
already consumed by `isWipColumnRole` / `isReviewColumnRole`.
- The **sibling duration helpers were threaded for exactly this
purpose** — `getTotalAgentActiveMs` carries the note *"THREADED SO THE
CONVERSION IS NOT INERT"*.
- This helper has **one caller**, inside the component, where the flags
are in scope.

The threading the note called prohibitive was already done; only this
helper was left behind. I read that note twice this week and took it at
face value both times — and what finally prompted the check was main
landing `taskRevert 2 → 0 — **the recorded blocker named the wrong
variable**` (#3129), someone else finding the same class of decay in a
note I had also accepted.

This program's own learnings say a deferral's stated blocker is a claim
that ages like any measurement. I had applied every other entry in that
document this week except that one.

## A dependency-array bug the conversion would have introduced

The memo now reads `taskColumnFlags`, so it joins the dependency array.
Flags arrive **asynchronously** — the board resolves workflows after
first paint — so a card rendered before they load and re-rendered after
would otherwise keep the pre-flag answer, since none of the memo's other
inputs changed. This repo has **no `react-hooks/exhaustive-deps` rule**,
so nothing would have flagged the omission.

## Two wrong probes before a correct one, both caught by controls and
mutation

Recording these because the fix was right from the start and my
instruments were not:

1. **`textContent` matched nothing.** The completion time lands in
`title`/`aria-label`, never in visible text. The **control failed too**
— the signature of a broken probe rather than a broken fix.
2. **`innerHTML` on the whole card matched always.** The lifecycle-dates
footer renders its own `Completed <date>` line, and *that* path already
resolves the complete lane correctly. The probe was reading a different,
already-converted feature. **Mutation exposed it: reverting the fix left
all six green.**

The final assertion queries `.card-time-indicator` and reads its
`title`, which is the only form that can tell the two apart.

## Verification

| | result |
|---|---|
| suite | **6 passed** |
| mutation (restore `=== "done"`) | **1 failed \| 5 passed** — the
renamed case only, control still green |
| dashboard `tsc -p tsconfig.app.json` | **0 errors** |
| census `--strict` | exit 0, baseline re-recorded in-commit |

Flags stay optional with the legacy id as fallback
(`isCompleteColumnRole`), so any caller without resolved flags behaves
exactly as before.

## Note on `check-fnxc-future-dates`

It fails on this branch, but **not because of it** — `scheduler.ts` and
one PG test carry future stamps on `main` itself. #3139 fixes that. None
of my files appear in the report.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:18:18 -07:00
gsxdsm
aef88a2976 test(engine): pin the PR-conflict sweep's worktree-owner index (20th resolver, after two discarded attempts) (#3202)
`prConflictWipColumns` builds the worktree-owner index behind
`ownedByOtherInProgressTask` — the guard that stops this sweep
**deleting a worktree another live task is executing in**. Keyed on the
id, that index is empty on a renamed board, so every worktree reads as
unowned.

## Two discarded attempts, and why they matter more than the fix

**1. Asserted `result.outcome !== "reclaimed"`.** It failed *with the
fix in place* — `reclaimed` is reachable through a second path this
guard does not gate. **An outcome assertion cannot isolate a guard in a
sweep with several routes to the same outcome.** That also explains my
earlier discard on `reclaimSelfOwnedBranchConflicts`, which has the same
shape.

**2. Asserted `removeWorktree` was not called — but overrode the task's
branch while leaving its id.** The reclaim path also requires
`branchOwnerTaskId === taskIdUpper`, so the branch was never reachable
and the case passed **blinded**: vacuous for a reason that had nothing
to do with lanes.

The shipped version asserts `removeWorktree`, which runs **only** on the
guarded branch and is the irreversible part, and keeps the default
id/branch pair so that branch is genuinely reachable.

## Measured

16 pass; blinding `prConflictWipColumns` fails exactly this case.

**20 of 26 pinned** across 19 merged PRs.

## Generalisation

For sweeps with multiple paths to one outcome, the discriminating
observable is a **path-specific side effect** — `removeWorktree`, a
`task:reconcile-*` audit type, a specific `reason` string — not the
return value. Every case I landed today that stuck used one; both
discards asserted a return value.

## Verification

`self-healing-pr-conflict` **16 passed** · `pnpm test:gate` 13 + 161 +
487 + 71 · lint — green.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved protection for active worktrees during pull request conflict
recovery, including tasks in renamed workflow lanes.

* **Tests**
* Added regression coverage to verify that worktrees owned by other
tasks are not removed incorrectly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 10:15:21 -07:00
gsxdsm
5f97fbcb06 docs(learnings): a blocker described four times, wrong twice — instrument before you file (#3200)
Records the method that moved a `triage.ts` site flagged unconvertible
for four cycles. The method transfers; the three conversions do not.

## Four mechanisms, split by derivation rather than care

| # | claimed mechanism | derived from | held? |
|---|---|---|---|
| 1 | merged intake/hold vocabularies | reading | no |
| 2 | orphan arm scoped to `source === "selection"` | reading + one test
run | partly |
| 3 | provenance verifies by `ir.id`, which builtins lack | reading a
**comment** | **no — filed as #3187, closed as wrong** |
| 4 | two test harnesses cannot answer a selection query | instrumented
isolation | **yes** |

(3) is the expensive one. The text I quoted was **historical prose
describing code that had been removed**, sitting directly above a
paragraph saying exactly that. I read a rationale as an implementation,
and it reached an issue other lanes could have acted on.

## The isolation took three runs

```
flag only, no conversion             8 passed   -> the orphan arm is not the cause
flag + conversion                    5 failed   -> the conversion is
same, with a realistic mock store    8 passed   -> the mock was the cause
```

Change one variable, let the suite answer. Available from cycle one.

## Why this is not just "test more"

Every wrong mechanism was plausible, specific, and consistent with the
code as read. **Plausibility is what made them dangerous** — each was
good enough to write down, publish and act on. The failure mode is not
sloppiness; it is that a careful reading of a large file *feels* like
evidence.

The tell is grammatical: **a claim that can be written without running
anything is a hypothesis, not a measurement.** "This cannot be converted
because X" versus "reverting X fails these 3 of 8 cases."

## The corollary, including its negative result

Once the harness was the suspect, a class fell out: a test that stubs a
reader **broken in production** proves the call site's logic while
unable to see that production resolves nothing. Eight files stubbed
`resolveTaskWorkflowIrSync` — one masking a live defect, four redundant
(#3198), one legitimate.

The doc also records that the obvious generalisation **fails**:
`getTaskWorkflowSelection` is equally degraded under PostgreSQL but
stubbing it masks nothing, because the resolver prefers the async twin
and both answer the same. The distinguishing property is that the reader
returns something *incorrect*, not merely *unused*. Written down so
nobody repeats the 120-file sweep.

Docs only; `check-fnxc-future-dates` exit 0.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
  * Added a case study for diagnosing an unconvertible workflow site.
* Documented controlled-run findings identifying the realistic mock
store as the cause.
* Clarified the difference between reading-based hypotheses and
instrumented evidence.
* Added guidance for distinguishing conversion, orphan-arm, and
mock-store issues.
* Recorded an audit of related test stubs, including redundant, masking,
legitimate, and unresolved cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:07:07 -07:00
gsxdsm
9234ca2402 fix(triage): the startup sweep resolved its columns from a SENTINEL task id — a characterization test already pinned it (#3201)
Fourth and last convertible site in `triage.ts`. This one needed a
different fix from the other three, and the codebase already said so.

## The defect

```ts
const sweepLanes = resolvePlannerLanes(this.store, "");
const sweepColumns = [...new Set(["triage", "todo", sweepLanes.intake, sweepLanes.hold])];
```

There is no task `""`. No selection can be read for it, no board
resolved — the lanes come back as the **default** board's and the union
collapses to the legacy pair `{triage, todo}`. On a renamed board the
sweep queries columns the card is not in, so its stale `planning` status
survives and it **holds a planning admission slot indefinitely**.

## A characterization test already pinned this, and called the fix
correctly

`workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts` documents it as a
third inert-conversion mechanism — *"inert by construction rather than
by environment"* — and its header says:

> making `resolvePlannerLanes` async would **NOT** repair this site,
because the defect is the argument, not the resolver

That is right, and it is why this fix differs from #3191 / #3193 /
#3195, which all used the async twin. Here the sweep has **no task to
resolve against** and wants every column playing these roles **anywhere
in the project** — so the correct resolver is
`resolveProjectColumnsForRoles(store, ["intake", "hold"])`, the same
helper `self-healing.ts` already uses for the same purpose.

The legacy pair stays in the union deliberately: the note at the site
explains that `triage` and `todo` must both be swept for pre-U11 and
Coding (Ideas) rows, and extra columns are free because the sweep only
**reads** and filters on `status === "planning"` first.

## The test is inverted, not deleted

It asserted `"planning"` survives — the bug. It now asserts the status
is cleared. Keeping the case with its original reasoning intact
preserves the file's record of what the defect *was*.

## Measured

| | result |
|---|---|
| broad suite (triage / planning / self-healing) | **76 files, 1302
tests passed** |
| differential | restoring the sentinel call → **1 failed \| 2 passed**
|
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**Inert count unchanged at 4 for `triage.ts`** — these lanes fed an
*array literal*, not a comparison, so the ratchet never counted them.
Third fix this session in that blind-spot class, stated so the number is
not read as the whole picture.

## What remains in this file

Two sites: the `task:moved` wake handler and the evacuation handler —
both **synchronous arrow callbacks** whose answers are consumed in-tick.
Genuinely blocked on the emitter-side work in #3082, with corroborating
evidence attached there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:04:15 -07:00
gsxdsm
09edce2366 test(engine): cover #3112's executor lane conversion — three renamed-board cases main lacks (#3118)
**I flagged these four as unconvertible in #3104. #3109 landed and
dissolved both of my reasons, so the flag comes off.** Leaving a
"blocked" note standing behind a blocker that no longer exists is the
exact decay this program keeps paying for — I have now found three other
people's deferrals in that state this session, and I am not adding a
fourth of my own.

## Both blockers, and why they are gone

| my stated blocker | why it is gone |
|---|---|
| **A.** `trackTaskDisposal` writes `pendingTaskDisposals` in *this*
tick, and the wip branch reads that map to serialise a fast bounce
(FN-5256). Deferring branch selection to a microtask reopens that race.
| Reading `lanes` off the payload needs **no await**. The prologue stays
synchronous and the race stays closed. |
| **B.** It is an if / else-if **chain**, so the guards are entangled
and convert together or not at all. | They convert together here. |

#3109 made the **emitter** carry the resolved lanes, which is the one
route that removes the dilemma instead of trading one horn for the
other.

`lanes` is optional and fail-soft to `undefined` — *"unknown, never
legacy"* — so each guard keeps its literal as the fallback, following
the `mergeParkedColumns` convention #3109 established in `scheduler.ts`.
An emit path that cannot resolve is no worse than before.

## What it fixes

On a renamed board: execution never started on a move into the board's
own wip lane, terminal session release never ran on a move into its
archive lane, and neither `from` guard fired — so in-flight work was not
aborted when a card left implementation. Nothing errored; the engine
simply stopped reacting.

## Census

| | before | after |
|---|---|---|
| `executor.ts` | 4 | **0** |
| repo backlog | 45 | **41** |

## Measured

- 3 new cases added to the FN-7717 suite; file **13/13 pass**.
- **MUTATION**: restoring the `archived` literal fails the renamed case.
- **The paired negative is the load-bearing one.** `done`/`in-review`
deliberately keep their merge leases across the transition (FN-6736 /
Phase C–D). The renamed **complete** lane must therefore *not* release —
a conversion that released on every terminal-ish lane would satisfy the
positive case and quietly break the guarantee that file already exists
to protect.
- A **fail-soft** case pins that an emit carrying no `lanes` behaves
exactly as before.
- `src/__tests__/executor*` — **84 files / 853 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Note on #3104

That PR (merged) added the flag and the sharpened reasoning. This one
removes it. The reasoning there was correct at the time and is what made
it possible to check quickly whether #3109 actually addressed it — a
flag that states its blocker precisely is cheap to retire, which is the
argument for writing them that way.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:56:10 -07:00
gsxdsm
339d4451af test(engine): drop four redundant sync-reader stubs — they fed the broken reader the right answer (#3198)
Completes the audit filed as #3197.

## Why a stub here is not neutral

`resolveTaskWorkflowIrSync` answers with the **default board for every
task** in production — its selection reader returns `undefined`
unconditionally under PostgreSQL. A test that stubs it with a working IR
proves its call site's *logic* while being structurally unable to notice
that the real path resolves nothing. The suite stays green even if the
site goes inert, which is the failure this whole phase has been chasing.

## The audit, complete

Deleted each stub and checked whether the suite still discriminates:

| file | without the stub | verdict |
|---|---|---|
| `planner-lane-resolution` | 7 passed | redundant → **removed** |
| `triage-undeclared-column-rescue` | 7 passed | redundant → **removed**
|
| `recover-approved-intake-post-u11` | 6 passed | redundant →
**removed** |
| `workflow-scheduler-parked-columns-live-e2e.pg` | 2 passed | redundant
→ **removed** |
| `planner-lanes-async-resolution` | 1 failed | **legitimate** — the
stub is its subject |
| `scheduler-renamed-hold-events` | **3 failed** | **masking** — see
#3082 |
| `triage.test.ts`, `triage-release-renamed-hold` | — | resolved in
#3191 / #3193 / #3195 |

**Only the redundant four are touched.**

`planner-lanes-async-resolution` stubs the reader *deliberately*, to
contrast the two resolvers given the same store and task — removing it
would delete the point of the file. That is the case that makes this a
hand audit rather than a ratchet: a hit is not presumptively a defect.

`scheduler-renamed-hold-events` is left alone because its three failures
**are the finding, not the fix**. They correspond to the 13 inert guards
`check-inert-sync-lanes` counts in `scheduler.ts` — two independent
instruments agreeing that those handlers are green in tests and dead in
production on renamed boards. They live in synchronous `task:*`
listeners, so they need the emitter-side work in #3082, not a stub edit.

## Verification

- 4 files / **22 tests pass** without the stubs
- engine `tsc` 0 errors
- `census --strict`, `check-inert-sync-lanes`,
`check-fnxc-future-dates`: exit 0

The PG e2e was the one file I had marked unaudited when filing #3197; it
ran here and is included rather than left as an open question.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:50:01 -07:00
gsxdsm
1f5f296c11 test(engine): pin the workspace land-lease owner check (19th resolver) (#3199)
Nineteenth resolver from the coverage map on #3115. The terminal-owner
reclaim directly above it proves the behaviour with `column: "done"` —
**the id** — so blinding `leaseOwnerCompleteColumns` left all 20 tests
green.

## What the literal costs

That set is what `isWorkspaceOwnerLive` consults. Keyed on the id, an
owner resting in a renamed completion lane reads as **live**, so its
land lease is never reclaimed. The workspace repo stays leased by a task
that has finished, and **every later land against that repo waits behind
a phantom**.

## A note on how this resolver came to exist

`isWorkspaceOwnerLive` is one of the sites I flagged earlier today as
**unconvertible** — synchronous, no store handle, converting it would
mean a signature change I had excluded from that PR's scope.

Someone threaded the resolved set through its callers instead. That is
the better answer than either converting in place or leaving it, and
this test pins it — so the threading cannot be undone silently.

## Measured

21 pass; blinding `leaseOwnerCompleteColumns` fails exactly this case.

**19 of 26 pinned** across 18 merged PRs.

## Verification

`self-healing-workspace` **21 passed** · `pnpm test:gate` full pass ·
lint — green.
2026-07-31 09:47:08 -07:00
gsxdsm
73bff5f88c test(engine): pin the orphan-only sweep's project query — the harness could not see a filter bug (#3196)
Eighteenth resolver from the coverage map on #3115, and **why** it was
uncovered is the interesting part.

## A fake that ignores its own filter cannot see a filter bug

Every case in this file stubs `listTasks` to return the same task
**whatever column is asked for**:

```ts
(store.listTasks as ...).mockResolvedValue([failedReviewTask()]);
```

So the project query is never exercised. Blinding `orphanReviewColumns`
changes which column is *requested*, the fake answers identically, and
nothing fails. Eight passing tests, and the selection logic among them
was untested.

That is the same blindness the production sweep had — querying a column
that does not exist and finding nothing — reproduced in the harness that
was supposed to catch it.

## The case

`listTasks` honours the column, so a card resting in a renamed review
lane is found **only if the query asked for that lane**. Keyed on the
id, the sweep asked for `in-review`, got nothing, and a failed
orphan-only card **stayed failed forever**.

## Measured

9 pass; blinding `orphanReviewColumns` fails exactly this case.

**18 of 26 pinned** across 17 merged PRs.

## Generalisation worth checking elsewhere

Any sweep whose test stubs `listTasks` with a flat `mockResolvedValue`
has this hole. The fix is a store fake that filters on `options.column`
— the shape `self-healing-query-filter-blindness.test.ts` already uses.
I would look there first for the remaining map entries.

## Verification

`self-healing-orphan-only-scope` **9 passed** · `pnpm test:gate` full
pass · lint — green.
2026-07-31 09:38:54 -07:00
gsxdsm
a6d67844b8 fix(triage): the "unconvertible" site was convertible — the blocker was two test harnesses (#3191)
#3141 measured this site as unconvertible, and I twice reported the
cause as a production constraint. It was not. This is the instrumented
answer to the probe I recommended there and then ran myself.

## The isolation

| configuration | result |
|---|---|
| flag only, no conversion | **8 passed** → the orphan arm is *not* the
cause |
| flag + conversion | **5 failed** → the conversion is |
| same, with a realistic mock store | **8 passed** → the mock was the
cause |

`triage-stuck-requeue-preserve-draft.test.ts` defined neither
`getTaskWorkflowSelection` nor its async twin — exactly like
`triage.test.ts` did before #3189. Both made
`resolveWorkflowIrForTaskWithProvenance` **throw** and take its catch
branch: the *"could not ask"* shape, which a production store never
presents.

So the 5 failures I deferred as a possible semantics change were the
same harness gap in a second file — confirmed, not argued.

## What changes

**`selectionAbsent`** marks the determinate case: the store *answered*
"no selection", so the workflow is the default and its IR is in hand.
Added as a **separate field, not a third `source` value** — `source ===
"default"` is compared in **31 places** in `self-healing.ts` meaning "be
conservative", and a new enum value would silently stop matching every
one of them while still compiling and still passing on a default board.

**`recoverApprovedTask`** now accepts a legacy `triage` row *explicitly*
(its workflow does not declare that column) instead of depending on
`resolvePlannerLanes` **failing** and falling back to legacy ids.
Correctness resting on a resolver's failure mode is what this removes.

## Measured

| | result |
|---|---|
| broad suite (triage / self-healing / recovery / planning) | **77
files, 1302 tests passed** |
| the three directly affected suites, post-rebase | **245 passed** |
| the flag is load-bearing | conversion **without** it: **18 failed \|
221 passed** |
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**The inert-sync-lane count is unchanged at 7 for `triage.ts`.** This
site was never among the counted guards, so this is **not** a ratchet
reduction — stating that rather than letting a conversion imply one. It
removes a real inert dependency the ratchet cannot see, which is the
blind-spot class this phase has been mapping.

## Why this took four attempts

I described this blocker at four levels: merged intake/hold, orphan-arm
scoping, identity verification (filed as **#3187**, closed as wrong),
and finally the harness. **The two I instrumented held; the two I
reasoned to did not.** The fix here is the probe I wrote down for
someone else — which is where it should have started.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:36:03 -07:00
gsxdsm
f703b499d3 census: --triage's pick-work list was 100% false positives (#3194)
## Every entry on the pick-work list was already decided

`--triage` prints a list headed *"unexamined, by file — this is the list
to pick work from"*. On `main` it held 2 sites. **Both carry a full
deferral note:**

| site | what its note says |
|---|---|
| `triage.ts:793` | *"the arm goes back to the literal, which is
**honest about being one**"* — restored by #3126 after #3114 converted
it inertly |
| `scheduler.ts:1323` | *"the second of the two **honest literals** …
converting it here **would be inert**"* |

Neither phrasing was in the marker set. So the pick-list was **100%
false positives**.

## Why this direction of error is the expensive one

Under-reporting a deferral sends a worker at a site whose owner already
wrote down why it must not move. That is not a hypothetical failure — it
is the sequence that cost three PRs: **#3108** flagged a site with both
blockers named and a test behind it, **#3114** converted it anyway hours
later, **#3126** reverted it. A pick-list that nominates decided sites
reproduces exactly that.

Over-reporting has the opposite failure — it hides real work — so the
added phrases are specific to *declining a conversion* (`honest
literal`, `would be inert`), not generic words that appear in ordinary
notes.

## Verified in both directions

Not asserted. I resolved **each of the 12** remaining guards to its
individual marker:

```
 1. scheduler.ts:1238            FLAGGED
 2. scheduler.ts:1323            honest literal
 3. audit-ops.ts:231             Not converted
 4. lifecycle-ops.ts:667         do not convert
 5. merge-queue-ops-2.ts:53      FLAGGED
 6. moves.ts:346                 STAYS INLINE
 7. task-id-integrity.ts:445     Left counted
 8. ResearchTaskActionModal:66   SIZED, NOT
 9. TaskCard.tsx:406             FLAGGED
10. notification-service:1245    FLAGGED
11. self-healing.ts:6054         FLAGGED
12. triage.ts:793                honest about being one
```

**Unexamined is 0.** That is a meaningful state, not just a small
number: the conversion backlog is fully *triaged*, every remaining
literal has a recorded reason, and the next person to touch one is
reading an argument rather than guessing.

## Census before / after

```
before:  COLUMN guards (the backlog):   12
after:   COLUMN guards (the backlog):   12
```

Unchanged, as required — `--triage` is opt-in and moves no count and no
exit code. `--json` and `--strict` verified unaffected.

## Verification

`test:gate` exit 0 · `--strict` exit 0 · `--json` exit 0 · plus
`fnxc-future-dates`, `lifecycle-columns`, `inert-sync-lanes`,
`quarantine-ledger`, `inert-flag-seams`, `lane-wiring`,
`sql-column-literals` — all exit 0. One script; no production file
touched.

*Process note: my first draft of this PR carried a future-dated FNXC
stamp — the third time I have done that. I have switched to taking the
stamp from `date -u` per #3174 rather than typing it, and my pre-push
run of the full `pr-checks` ratchet set (not `test:gate` alone) caught
it before it left the branch, which is what that habit is for.*
2026-07-31 09:35:51 -07:00
gsxdsm
c8268a6454 test(engine): pin the temp-merge sweep's terminal grace (17th resolver) (#3192)
Seventeenth resolver from the coverage map on #3115. The two cases
around this one use `done` and `archived` — **the ids** — so blinding
`mergeTempTerminalColumns` left all 21 tests green.

## What the literal costs

The terminal check selects the **shorter grace**: a finished task's temp
merge worktree is reaped after `DONE_TASK_TEMP_WORKTREE_GRACE_MS`
instead of the full stale window. Keyed on the ids, a card in a renamed
completion lane never qualified, so its worktree lingered for the long
window — **disk held by work that already finished**.

## The second cost, which is why this asserts on the audit reason

Without the resolver the sweep eventually acts, but records `reason:
"stale"` instead of `"done-task-stale"`. So its own trail
**misattributes why it acted**.

A sweep that does roughly the right thing under the wrong label is the
kind of defect nobody notices until they are reading audit events during
an incident — and then the record actively misleads. Asserting only on
the file being gone would have passed either way.

## Measured

22 pass; blinding `mergeTempTerminalColumns` fails exactly this case.

**17 of 26 pinned** across 16 merged PRs. Also re-measured this turn:
`wsDoneColumns` and `doneMetaColumns` have gone green independently, so
the map keeps drifting as the fleet adds coverage — re-run before
picking the next entry.

## Verification

`self-healing-tempdir-sweep` **22 passed** · `pnpm test:gate` full pass
· lint — green.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed stale-task handling for tasks in terminal columns of custom
workflows.
* These tasks now correctly follow the done-task grace period and record
the appropriate audit reason.

* **Tests**
  * Added regression coverage to verify the corrected behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 09:24:35 -07:00
gsxdsm
f755f44734 test(engine): pin the completion fan-out's review dependent bucket (16th resolver) (#3190)
Sixteenth resolver from the coverage map on #3115.

`completedReviewColumns` reads the **dependents** resting in review when
a blocker completes. No case in this file put a dependent in a renamed
review lane, so blinding it left all 13 tests green.

## What the literal costs

A dependent sitting in review is never read, so its `blockedBy` is never
cleared when the blocker finishes. **It stays blocked by work that is
already done** — the most visible form of this class, because the board
simply stops moving.

## Measured

14 pass; blinding `completedReviewColumns` fails exactly this case.

## Note for anyone continuing the map

`completedHoldColumns` in this same sweep measured as **already
covered**, so only the review bucket was owed. Three buckets, three
resolvers, covered independently — the same per-resolver granularity
that found the missing halves in #3138 and #3186, where my own earlier
tests pinned one resolver of a pair and I had recorded the sweep as
done.

**16 of 26 pinned** across 15 merged PRs.

## Verification

`self-healing-completion-fanout` **14 passed** · `pnpm test:gate` full
pass · lint — green.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed task completion reconciliation for workflows with renamed lanes.
* Dependent tasks in review lanes are now correctly unblocked when their
blocker moves to a custom completion lane.

* **Tests**
  * Added regression coverage for custom workflow lane configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 09:13:21 -07:00
gsxdsm
da10131d3f test(triage): the mock store could not be ASKED for a selection — 231 cases exercised a shape production cannot produce (#3189)
`createMockStore` in `triage.test.ts` defined **neither**
`getTaskWorkflowSelection` nor its async twin. So
`resolveWorkflowIrForTaskWithProvenance` **threw** calling them and took
its catch branch, reporting `source: "default"` in the sense of *"the
lookup failed"*. Production stores always expose both readers — every
case in this file was exercising a store shape that cannot exist.

Returning `undefined` models the real answer: the store **can** be asked
and says there is no selection row, which is what a pre-U11 card
actually presents.

## Why it mattered

`triage.ts`'s post-U11 intake recovery gates on that provenance. A
*failed* lookup correctly refuses to claim a workflow lacks `triage`, so
the orphan arm stayed off and the recovery depended on
`resolvePlannerLanes` **failing** and falling back to legacy ids —
correctness resting on a resolver's failure mode.

In #3141 I measured the async conversion of that site as failing 13
cases and **twice reported it as a production constraint**. It was this
harness. That is the concrete cost of a mock that cannot answer a
question production always can.

## Behaviour-preserving on its own

**380 passed across 26 triage/recovery suites.**

## What this deliberately does NOT do

It does not convert the site. I prototyped the full unblock — a
`selectionAbsent` flag on the determinate `!workflowId` branch, its
single consumer, and the async conversion — and it works: the
previously-failing suite goes **237 passed**.

But with a realistic store the orphan arm starts firing for no-selection
rows, which changes recovery flow in **5
`triage-stuck-requeue-preserve-draft` cases** that currently assert the
refusing behaviour. Whether accepting a legacy `triage` row there is
correct is a lifecycle-semantics decision about migration, not a harness
fix. So it is reverted and reported rather than bundled.

Findings and the measured branch table are on #3141.

## One correction carried from this work

I filed #3187 claiming provenance verifies resolution via `ir.id ===
workflowId`, which cannot pass for builtins. **That was wrong** — the
live code uses a symbol marker, and the text I quoted was historical
prose describing what was removed. Closed with the measurement:

```
store with NO selection readers    -> source: default    (catch: could not ask)
store answering builtin selection  -> source: selection  ✓
```

That is the same class of error this PR fixes — reasoning from what
something says rather than what it does.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved workflow-resolution test coverage by supporting stores with
no selected workflow.
* Added synchronous and asynchronous test readers for workflow
selection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:07:45 -07:00
gsxdsm
893b6421be test(engine): pin the contamination sweep's WIP bucket (15th resolver) (#3188)
Fifteenth resolver from the coverage map on #3115. Every case in this
file seeds the candidate in `in-review`, so only the review bucket was
exercised — blinding `contaminationWipColumns` left the file green.

## Why the WIP bucket matters

A card sent back for a fix **re-enters execution while its branch still
carries the foreign commits**, so contamination is discovered there as
often as in review. Keyed on the id, that bucket read nothing on a
renamed board and the card kept a branch built on someone else's work —
which is what this sweep exists to re-anchor.

## Two facts the fixture had to learn, both from failing first

- **This is an ACTION site and deliberately skips a card whose own board
cannot be read**, rather than guessing from the project union. A fake
with only `listWorkflowDefinitions` resolves the default IR, the card is
reported unclassifiable, and the case fails for a reason unrelated to
the resolver under test. The per-task selection readers are required.
- **The WIP bucket's predicate is not the review bucket's.** It
additionally requires `paused === true` with `pausedReason` of
`branch-cross-contamination` or `branch-conflict-unrecoverable`. A card
merely resting in the wip lane is not a candidate — the FN-5704
manual-review contract this sweep mirrors.

Neither is guessable from the resolver. Both came from the test failing
twice, and I would have shipped something that exercised nothing had the
first version passed.

## Measured

3 pass; blinding `contaminationWipColumns` fails exactly this case.

**15 of 26 pinned** across 14 merged PRs.

## Verification

`self-healing-foreign-only-contamination` **3 passed** · `pnpm
test:gate` full pass · lint — green.
2026-07-31 08:59:26 -07:00
gsxdsm
24c565540e gate: a sync lane handed to a wrapper is still inert — 13 scheduler guards were invisible (#3181)
## The fourth shape: a sync lane handed to a wrapper

#3169 taught `unwrapForSyncCall` to walk await, parenthesized,
conditional and binary expressions. It still stops at the **call
boundary**, so a source call sitting in an *argument* position stays
invisible:

```ts
const parked = mergeParkedColumns(resolveTaskParkedColumnsSync(store, id), lanes);
```

That prefers the event payload and falls back to the sync answer
whenever `lanes` is absent. The callee is `mergeParkedColumns`, not a
source — so the walker never looked inside, and **the entire
`scheduler.ts` file read as clean**.

```
main today:   9   (triage 7, executor 2, scheduler 0)
this PR:     22   (scheduler 13, triage 7, executor 2)
```

Thirteen guards. And `check:inert-sync-lanes` has run in `test:gate`
since #3136, so CI is currently enforcing a ratchet that reports a file
it cannot see into as fully converted. The green is official, which
makes it worse than the version nobody ran.

## Is the fallback still reachable?

Yes, which is why these are not retired. #3135 attached lanes at every
*live* emitter, but absence remains reachable three ways: the two
`lifecycle-ops.ts` emitters on the SQLite-only polling path, any future
emitter added without lanes, and the three forwarders
(`project-manager.ts`, `remote-node-runtime.ts`,
`child-process-runtime.ts`) that reconstruct the event object
field-by-field rather than forwarding it.

A rarely-exercised fallback is still a fallback. Counting it as clean is
how the ledger stops meaning anything.

## The change

One line inside your walker, plus its note:

```js
if (ts.isCallExpression(n)) { for (const a of n.arguments) walk(a); }
```

Every shape #3169 added is preserved. Still a name match, not dataflow —
the limits section still applies.

## Mutation evidence — all three shapes, one tree

| Mutant | Result |
|---|---|
| baseline (22) | exit 0 |
| **argument position** (this PR) | **exit 1**, 13 → 14 |
| conditional (#3169's) | exit 1, 13 → 14 |
| inline (#3062's) | exit 1, 13 → 14 |

`scheduler.ts` restored clean after each run.

## Baseline 9 → 22

**Detection, not regression.** No production file changes in this PR. 22
is the exact union I measured before #3169 merged (13 + 7 + 2) and
posted on both PRs at the time — it landing unchanged is the
confirmation that the two fixes were additive rather than overlapping.

## Census before / after

```
before:  COLUMN guards (the backlog):   12
after:   COLUMN guards (the backlog):   12
```

Unchanged — this converts nothing. It restores 13 guards to a ledger
that had silently dropped them.

## Supersedes #3122

#3122 carried this fix as a standalone rewrite of `syncLaneLocals` and
conflicted with #3169 the moment it landed. This is the six-line version
I offered there; #3122 is closed.

## Verification

`test:gate` exit 0 · `check:inert-sync-lanes` exit 0 at the re-recorded
baseline · plus `fnxc-future-dates`, `lifecycle-columns`,
`quarantine-ledger`, `inert-flag-seams`, `lane-wiring`,
`sql-column-literals` — all exit 0. Gate script + baseline only.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved detection of synchronous operations nested within wrapper
arguments.
* Updated synchronization checks to report all currently identified
findings, including additional scheduler-related cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 08:56:32 -07:00
gsxdsm
6bc90ccbe2 fix(core): allow-list the legacy workflow IR — it found a fourth bug my grep missed (#3185)
## The name is the defect

`BUILTIN_CODING_WORKFLOW_IR` reads like the default and **is** the
legacy workflow (`builtin:legacy-coding`). Post-U11 they differ by
exactly one column — `triage` — the one a caller most often wants
absent.

**Four bugs have come from reaching for it by name:**

1. two move-path resolvers disagreed on the no-selection default →
*"workflow move policy preflight is stale"* on every flag-on move
(recorded in `resolveDefaultWorkflowIr`'s own header)
2. the TUI board rendered a `triage` lane the default board lacks —
#3178
3. `deleteWorkflow` re-homed occupants into `triage` — #3183
4. **`board-workflows.ts`** described a *custom* workflow whose
definition failed to load using legacy columns — the #3178 symptom
through the dashboard route. **Fixed here.**

It type-checks, it is the obvious identifier, and on the five shared
columns it behaves correctly. The mistake only shows on the column that
differs.

## I said the sweep was complete last round. It wasn't.

My grep excluded paths and truncated at `head -10`; it missed two sites.
**The allow-list found both on its first run.**

That is the lesson the sibling sync-resolver ratchet already records —
*"FOUND BY THIS RATCHET, not by the grep that seeded the list"* — and I
had just quoted that file while repeating the mistake.

## One site is allow-listed rather than fixed, and I tried the fix first

`workflow-graph-executor.run()`'s default `ir` is unreachable in
production (both callers pass it explicitly). But
`workflow-graph-executor-parity.test.ts`, in the **engine-core gate
suite**, drives the method *without* the argument to assert the
historical seam sequence.

Switching it to the catalog default rewrites what "parity" means:
**measured, 6 gate tests fail** with `expected 'failure' to be
'success'`. Reverted, and recorded at the call site *and* in the
allow-list entry so nobody repeats the experiment.

That is what an allow-list is for: a legitimate narrow use next to a
plausible-looking wrong one.

## Guard construction

Follows the repo's existing call-site allow-lists (sync resolver, engine
blocking-shellout, detached-spawn script guard).

- **Comments stripped before scanning** — `activity-analytics.ts` and
`TaskContextMenu.tsx` name this constant in notes *about past bugs*
while correctly avoiding it. Counting prose would train readers to
allow-list mentions.
- **Anti-vacuity**: the scan still sees the catalog's own uses, so a
renamed constant or broken walker cannot make the guard pass by finding
nothing.
- **Stale-entry**: the list cannot rot into files that no longer touch
it — the decay every ledger in this repo has hit.

## Measured

- Guard **3/3**; `tsc --noEmit` clean in core, engine, dashboard.
- census `--strict`, `check-fnxc-future-dates` clean.

## Census

**No movement — that is the point.** This class has no column literal to
count, which is why the census never saw any of the four bugs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:45:31 -07:00
gsxdsm
39a2e0481a test(engine): pin the merged-review sweep's HOLD bucket (14th resolver — the half my own test missed) (#3186)
Fourteenth resolver from the coverage map on #3115, and it is the other
half of a sweep **I converted and tested myself**.

The file already pinned `mergedReviewColumns`. Blinding
`mergedHoldColumns` back to `["todo"]` left all 71 tests green — no case
put a merge-confirmed card in a renamed hold lane.

## The lane is not hypothetical

A merge-confirmed card gets **rebounded to hold** by other recovery
paths — a failed post-merge step, a requeue. So *merged but sitting in
hold* is exactly the state this sweep's second bucket exists to
finalize. Keyed on the id, that bucket read nothing on a renamed board
and the card stayed unfinished **while its commit was already on the
base branch**.

## The lesson, repeated

This is #3138's finding again: a test that pins one resolver of a pair
reads as covering the sweep. I wrote the earlier case, recorded the
sweep as done, and it was half-done.

**Only blinding each resolver separately finds this.** A single passing
revert proves one guard — which is why the map is keyed by resolver, not
by sweep.

## Measured

72 pass; blinding `mergedHoldColumns` fails exactly this case.

**14 of 26 pinned** across 13 merged PRs.

## Verification

`self-healing-query-filter-blindness` **72 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 08:45:19 -07:00
gsxdsm
9c00699e61 test(engine): pin the stalled-card watchdog's terminal skip on a renamed board (13th resolver) (#3182)
Thirteenth resolver from the verified coverage map on #3115.

`sweepTerminalColumns` was uncovered: the case directly above it asserts
the terminal skip using `done` and `archived` — **the ids** — so
blinding the resolver left the file green.

## What the literal costs

The skip matched nothing on a renamed board, so **finished cards were
scanned as live**, and a card parked in a renamed completion lane could
be reported stalled.

A watchdog that cries about completed work is worse than a quiet one: it
trains operators to ignore the alert. That is the exact failure this
sweep's own dedup logic was built to avoid, reintroduced through the
lane vocabulary.

## The case

The renamed twin of the existing terminal-skip test — same assertion,
same shape, different vocabulary. That is the whole point: the original
passes either way, so it cannot see the conversion.

**Measured:** 10 pass; blinding `sweepTerminalColumns` fails exactly
this case.

## Map status

**13 of 26 pinned** across 12 merged PRs, plus `starvedWaitingColumns`
now covered by another worker independently. Re-measure before picking
the next one — the map drifts green as the fleet adds coverage, and I
have already caught it stale once today.

## Verification

`self-healing-stalled-card-watchdog` **10 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 08:34:16 -07:00
gsxdsm
757ce71731 fix(core): deleting a workflow re-homed its cards into triage, a lane the default board lacks (#3183)
A **third door** into the drift #3178 just fixed in the TUI. Found by
looking for siblings of that bug — **not** by the census, which
structurally cannot see this class: there is no column literal here to
count. The wrong answer comes from reading the wrong IR.

## The bug

`deleteWorkflow` clears each occupant's selection so they fall back to
the built-in default, then re-homes them to *"the default workflow's
entry column"* — its own comment's words.

It read that entry column from `BUILTIN_CODING_WORKFLOW_IR`, which is
`builtin:legacy-coding`, **not** the catalog default. Post-U11 the two
differ by exactly the column this reads:

```
default  todo, in-progress, in-review, done, archived
legacy   triage, todo, in-progress, in-review, done, archived
```

**Measured, not inferred:** `resolveEntryColumnId` answers `triage` for
the legacy IR and `todo` for the default.

## Why it got past the guard built for exactly this

`moveTask` rejects a target the workflow does not declare — **except**
under `recoveryRehome` with a **legacy id**, the #1411 escape hatch that
keeps a custom-workflow card rescuable.

`triage` *is* a legacy id. So the rehome slipped through the check that
exists to stop this, and left the card in a lane its new workflow has no
node for — the undeclared-column state other reconcilers exist to
repair.

## Measured

- 3 new cases; **MUTATION**: restoring the legacy constant fails the
anti-vacuity case.
- The first two cases pin the two IRs' entry columns as **facts in the
suite** rather than claims in a comment — that difference is the entire
reason the bug existed. They go quiet, correctly, if the IRs ever
converge again.
- The third pins the **call site**, because the first two would keep
passing against the unfixed code: they describe the IRs, not the caller.
That gap is how an anti-vacuity case earns its place.
- `src/__tests__/workflow*` — **30 files / 402 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-fnxc-future-dates` clean.

## Census

**Unchanged — and that is the finding.** This defect has no literal to
count. `builtin-workflows.ts` already records the move-path resolvers as
fixed for the same drift, #3178 fixed the TUI, and this is the third
instance. The census measures *comparisons*; a surface that resolves the
**wrong workflow** produces identical-looking code and a wrong answer.

If there is appetite for a next sweep, that is where I would point it:
sites that resolve a workflow at all, rather than guards that compare a
column.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:34:04 -07:00
gsxdsm
fa62c951cf fix(gate): the inert ratchet went quiet exactly when the code improved (conditional initializer) (#3169)
Found by dogfooding my own change: I wrote `executor.ts` in the
payload-first/sync-fallback shape while adopting #3140's better
fallback, **predicted in a comment that the guards would stay counted**,
and the gate reported **zero**. The prediction was wrong in the
direction that matters — the gate under-reports.

## The gap

`syncLaneLocals` registered a local only when its initializer **was** a
call expression:

```ts
const sync = payload ? undefined : localSync(store, id);
return column === sync?.hold;          // inert, and counted as nothing
```

Conditionals and `??`/`||` chains are now unwrapped, so a sync call in
any branch registers the local. Still a **name** match, not dataflow —
the file's LIMITS section still applies.

## Why this shape matters more than the inline one already guarded

**The missed shape is the one authors are steered toward.** Falling back
to the sync resolver is *better* than falling back to legacy literals —
it is best-effort under legacy SQLite, whereas a literal can never be
right on a renamed board. So writing the guard well is what made it
invisible.

A ratchet that goes quiet exactly when the code improves is worse than
none: it rewards the worse degraded path with a tidier number.

## Known remaining gap, stated in the test rather than implied

Only **one hop** is followed. The two-hop form is still uncounted:

```ts
const sync  = payload ? undefined : localSync(store, id);
const lanes = { hold: payload?.hold ?? sync?.hold ?? "todo" };
if (from !== lanes.hold) …            // still invisible
```

`executor.ts` is written that way today, which is why it reads 0 while
the sync call is still present. Closing it needs propagation through
object-literal construction — a larger change than this one, and I would
rather ship the one-hop fix with the gap documented than imply full
coverage.

## Verification

| | result |
|---|---|
| gate on `main` | **exit 0**, output unchanged (11 = triage 7 +
executor 4) |
| test suite | **5 pass** |
| new case against the **unfixed** gate | **fails** — `the
conditional-initializer shape must be counted` |

The regression case drives a real file through the scanned tree rather
than calling a helper, because the bug was in which nodes the scan
**visits**. A helper-level assertion would have been written against the
same wrong mental model that produced the gap — which is how the
inline-spelling hole in this same file survived its first draft.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved detection of sync-lane conversions in conditional
expressions, fallback logic, awaited and parenthesized values, and
object-literal relays.
  * Corrected matching for identifiers containing special characters.
* Updated validation results to include two additional findings that
were previously missed.

* **Tests**
* Added integration coverage for conditional initializers, chained
object-literal conversions, and special-character identifiers.
  * Ensured temporary test files are cleaned up automatically.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:20:06 -07:00
gsxdsm
5eec7dc73b test(engine): pin the completed-blocked park release on a renamed board (12th resolver) (#3180)
Twelfth resolver from the verified coverage map on #3115.

`completedBlockedHoldColumns` was uncovered: every case in this file
seeds the park in `todo`, where the literal is correct, so blinding the
resolver left all 21 tests green.

## What the literal costs

A completed-blocked park rests in the board's **hold** lane, which is
only called `todo` on the built-in workflow. Keyed on the id, the sweep
selects nothing on a renamed board — so **finished work stays parked
behind a blocker that has already cleared**, stranded exactly as FN-7926
describes. Silently: a sweep that selects no rows reports success.

## Two fixture facts, found by the test failing first

- **The completion-blocker gate resolves the *blocker's* own workflow**,
so the per-task selection readers are required too.
`listWorkflowDefinitions` alone leaves the renamed complete lane
unrecognised and the park is rejected for the wrong reason — a
green-for-the-wrong-reason test, which is the exact thing this effort
removes.
- **The blocker must rest in the renamed complete lane**, not the legacy
one, or the case proves nothing about the board it claims to test.

I only learned both because the first version failed. Had it passed, I
would have shipped a test that exercised none of this.

## Measured

21 pass; blinding `completedBlockedHoldColumns` fails exactly this case.

## Map status

**12 of 26 pinned.** Also re-measured six entries this turn:
`starvedWaitingColumns` is **now covered by another worker's test**
(#3128-era, peer-progress vocabulary), so the map is drifting green
underneath me as the fleet adds coverage too — worth re-running before
anyone picks the next entry.

## Verification

`execute-requeue-loop-guard` **21 passed** · `pnpm test:gate` full pass
· lint — green.
2026-07-31 08:19:52 -07:00
gsxdsm
bc37185026 gate: enforce the quarantine deletion ratchet — nothing ran it, and it could not fail (#3167)
## A policy with nothing enforcing it

AGENTS.md states the deletion ratchet plainly:

> A quarantined test is **DELETED after 14 days** (`quarantinedAt` + 2
weeks) unless rescued.

Nothing enforced it, and it failed in two independent ways:

1. **`check:quarantine-ledger` omitted `--strict`.** The script only
exits non-zero with that flag (`check-quarantine-ledger.mjs:197`:
`return args.strict && (summary.expired > 0 || summary.near > 0) ? 1 :
0`). Without it, it is a report that always exits 0.
2. **No workflow ran it.** I audited all 12 `check:*` scripts against
`pr-checks.yml` and `full-suite.yml`: this is the only one appearing in
neither.

Either alone would have made it toothless. Together, a quarantined test
could sit past its deletion date indefinitely with every gate green.

## This exact shape is already documented in the file I edited

The comment above the lifecycle-column ratchet in `pr-checks.yml` says:

> `pnpm census:lifecycle-columns` (no `--strict`) and nothing ran it, so
three PRs lowered counts without re-recording and left allowances the
deleted guards could return through while this gate stayed green.

Script supports enforcement → package script omits the flag → no
workflow runs it. Same three steps, different ratchet. That precedent is
why I went looking.

## What changed

- `check:quarantine-ledger` now passes `--strict`
- wired into `pr-checks.yml` beside its siblings

It fires **5 days before** the deadline, not after, so the response is
still delete-or-rescue rather than an overdue entry. That window is the
script author's design; I did not invent it.

## Mutation evidence

Against a temp ledger, real one restored after:

| Ledger state | Result |
|---|---|
| today (1 entry, 13 days remaining) | exit 0 |
| entry inside the 5-day near window | **exit 1** |
| entry 6 days past deadline | **exit 1**, reports `EXPIRED (6 days
overdue)` |
| real ledger restored | exit 0 |

Without `--strict` all four exit 0 — which is the state on `main`.

## Honest note on what this will do

This is a **deadline ratchet**: it fires on a timer by design. The
current entry (`useTasks-hydration-freshness.test.ts`, deadline
2026-08-13) will trip it on **2026-08-08** unless someone deletes or
rescues it first. That is the intended behaviour and the whole point —
AGENTS.md is explicit that rescue "requires evidence the test catches
real regressions plus a root-cause fix — not stabilization passes." A
gate that never fires enforces nothing.

## Census before / after

```
before:  COLUMN guards (the backlog):   13
after:   COLUMN guards (the backlog):   13
```

Unchanged — this touches no lifecycle guard. It is gate wiring.

## Verification

`test:gate` exit 0 · `check:quarantine-ledger --strict` exit 0 on the
real ledger · both failure arms mutation-verified · ledger file restored
byte-for-byte.
2026-07-31 08:17:00 -07:00
gsxdsm
7d9d097acf fix(cli): the TUI board fell back to the LEGACY workflow — it rendered a triage lane the default no longer has (#3178)
Found by following an unexplained number rather than by a sweep: while
re-verifying #3141 the resolver reported `intake: "todo"` where
`BUILTIN_CODING_WORKFLOW_IR` resolves `intake: "triage"`. That
divergence is correct and intentional inside core — and wrong here.

## The defect

`dashboard.ts` resolved a task's columns as `def?.ir ??
BUILTIN_CODING_WORKFLOW_IR`, and its card-chip fields the same way. That
constant is the **legacy** monolithic IR (`builtin:legacy-coding`); the
catalog's actual default is `resolveDefaultWorkflowIr()`. Post-U11 they
differ **by a whole column**:

```
default  todo, in-progress, in-review, done, archived          (planning merged into todo)
legacy   triage, todo, in-progress, in-review, done, archived
```

So a task with **no workflow selection row** was rendered against a
six-column board including `triage` — a lane the real default no longer
declares.

## The same drift is already documented as fixed elsewhere

`builtin-workflows.ts` records it:

> `prepareWorkflowMovePolicyPreflightImpl` resolved the default through
the catalog while `resolveTaskWorkflowIrForMove` used the raw constant,
so a task with NO selection row produced two different workflow
signatures and every flag-ON move threw *"workflow move policy preflight
is stale"*. Both sides (and the sync resolver) now call this helper so
the default cannot drift again.

This surface was missed, and it is the **last non-test consumer of the
legacy constant outside core**.

## Test scope, stated because it is narrow

Driving the TUI end-to-end needs a rendered terminal and a live store.
That harness does not exist here, and building one to assert a fallback
would be testing the harness. So the test pins the two facts that make
the bug possible and the fix meaningful:

1. **the two IRs genuinely disagree, about `triage` specifically** — if
a future change re-merges them, this reports it rather than leaving the
fix silently pointless;
2. **the source no longer reaches for the legacy constant.**

(2) is a source assertion, weaker than driving the code. It is used for
the same reason as the `FloatingWindow` aria-label scan: the defect is a
**value at a call site**, there is no single render that reaches both
sites, and a per-site render test would pin the one someone bothered to
write. Both assertions are anti-vacuity guarded — the IR comparison
fails if either side stops resolving to a v2 column set.

## Verification

| | result |
|---|---|
| cli `tsc` | **0 errors** |
| new test | **2 passed** |
| mutation — restore `?? BUILTIN_CODING_WORKFLOW_IR` | **1 failed / 2**
|
| census `--strict`, `check-fnxc-future-dates` | exit 0 (this class is
invisible to the census — an argument, not a comparison) |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:57 -07:00
gsxdsm
2cb5cab595 chore(core): mark the mission-store dead-sync-path literal DELIBERATE (census 13→12) (#3179)
Comment-only. `tsc` 0 errors, `census --strict` and
`check-fnxc-future-dates` exit 0.

## Claimed with the new tool

First use of `scripts/check-file-claimed.mjs` (#3175) to pick work
instead of guessing:

```
CLAIMED    packages/engine/src/scheduler.ts        #3177, #3142
CLAIMED    packages/core/src/task-store/audit-ops.ts   #3165
UNCLAIMED  packages/core/src/mission-store.ts
UNCLAIMED  packages/core/src/task-store/task-id-integrity.ts
```

Two of the four files I would have reached for were already taken — by
PRs whose branch names give no hint they touch those paths. That is the
collision this phase paid for five times, answered in one command. I
took `mission-store.ts`; `task-id-integrity.ts` is still free.

## Census 13 → 12

Reclassification, not conversion — the line is unchanged.

## Verified the blocker rather than deferring to it

The site carries an audited note: the sync `MissionStore` reaches
`this.db.prepare`, and `getMissionStoreImpl` returns the
`AsyncDataLayer`-backed `AsyncMissionStore` under PostgreSQL, so the
class is unreachable in the shipped backend.

I checked that independently instead of accepting it —
`async-mission-store.ts:168` states the same routing from the other
side. **That check exists because of #3129**, where a note I had
accepted as settled ("blocked on a per-neighbour flag map that does not
exist") turned out to name the wrong variable, and the file was
convertible all along. I had publicly argued it should stay counted.

So the rule I am applying: a documented blocker gets marked only after
its named obstacle is confirmed from a second source. Here it held; on
`taskRevert.ts` it did not.

## Related, and still open

`merge-queue-ops-2.ts` carries a note of the same shape that does
**not** survive this check — it names two ways to convert (make the path
async, push the trait read down) and misses the one that worked twice
this phase: thread the resolved lanes in from a caller that already
awaited them, as #3112 and #3118 did for `executor.ts`. Its sibling
`taskStillInReview(projectId, reviewColumns)` already takes lanes from
its caller. Worth a real look rather than a marker.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:45 -07:00
gsxdsm
79a292b57c docs(agents): take FNXC timestamps from date -u, not the local clock (#3174)
I have patched this same breakage **three times today**, and it is not a
per-file defect — the convention is under-specified.

`check-fnxc-future-dates` validates against **UTC**. A stamp written
from a clock **behind** UTC is a future stamp the moment UTC rolls over,
and `pnpm lint` passes locally because the local date agrees with what
was written. Nothing in the authoring loop can catch it. It surfaces
only as a **red main for everybody else**.

## The evidence

Four separate breakages in one day, four files, at least two authors:

| file | stamps |
|---|---|
| `packages/engine/src/scheduler.ts` | 7 dated 2026-08-01 → 08-06 |
| the scheduler PG test | 1 |
| `packages/core/src/task-store/task-update.ts` | 2, fixed by two
different people |

Every one was a **real time on the wrong day** — nobody was careless,
they read their own clock.

## What changed

`AGENTS.md` already specifies the *format* (`yyyy-MM-dd-hh:mm`) and says
nothing about the *clock*, so every worker reasonably used their own.
This adds the one missing sentence, plus the impossible-hour rule the
gate also enforces — which produced its own main-red earlier today
(#3006 normalized four hour-26 stamps).

Docs only; no changeset, per the AGENTS.md rule for internal docs.

## Note

This PR will show red on Gate until **#3173** merges — main's
inert-sync-lane allowance is stale (11 → 7, never re-recorded),
unrelated to this change and inherited by every open PR.
2026-07-31 08:10:41 -07:00
gsxdsm
b8bfcd031b tooling: answer "is this file claimed?" in one command (#3175)
Addresses the root cause of a pattern I have now measured four times.

## The finding

**Every fleet worker pushes as the same GitHub account.** `gh pr list
--author "@me"` returns **all 17 open PRs** — mine and teammates' are
indistinguishable. So "is this file already being converted?" can only
be answered by fetching every open PR's file list by hand: 25+ API calls
that no worker makes before starting. I didn't either.

## The measured cost

| PR | Outcome | Landed instead as |
|---|---|---|
| #3096 | shrank to a test | teammate's serialisation + union |
| #3116 | shrank to a test | `preExecLiveColumns`,
`starvedWaitingColumns`, … |
| #3140 | shrank to a test | #3137 |
| #3125 | **shrank to nothing — closed** | #3135 |

Plus #3118, a teammate independently writing the same coverage I wrote
for #3112.

**In every case both implementations were correct and independently
reached the same design** — #3137 chose payload-first-with-sync-fallback
for the same reason I did. This is not carelessness; the fleet is doing
correct work twice and discovering coverage gaps by accident, when
rebases collide.

## What this adds

```
$ node scripts/check-file-claimed.mjs packages/engine/src/self-healing.ts
CLAIMED    packages/engine/src/self-healing.ts
             #3152  fix(self-healing): 18 recovery rebounds hardcoded `todo` …
```

**On its first run it reported `self-healing.ts` claimed by #3152 —
which I had no way to know a moment earlier.** Exits non-zero when
claimed, so it can gate work: `node scripts/check-file-claimed.mjs
<path> && start-work`.

## Deliberate limits

- **It cannot see unpushed work**, so it narrows the collision window
rather than closing it. Two workers starting the same file minutes apart
still collide. Closing that needs **distinguishable authorship** — a
per-worker `Co-Authored-By` or a title prefix — which is a coordination
decision, not a script.
- **A `gh` failure exits 2 and says UNKNOWN, not unclaimed.** A claim
check that fails open is worse than none, which is the same false-green
shape this program keeps finding elsewhere.

Also adds a short AGENTS.md pointer next to the other standing rules.

## Verification

Run against a claimed and an unclaimed path; both answers correct, exit
codes as documented.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a command-line check for open pull requests that may already be
modifying specified files.
* Reports matching pull request details and clearly indicates whether
each file is claimed.
* Returns distinct statuses for claimed files, unclaimed files, and
unavailable GitHub checks.

* **Documentation**
* Added guidance for checking file ownership before beginning conversion
work.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:10:29 -07:00
gsxdsm
f8fb9b1473 test(engine): pin the unmet-dependency rebound on a renamed board (11th resolver from the coverage map) (#3176)
Eleventh resolver from the verified coverage map on #3115.

`unmetDepReviewColumns` was uncovered: the existing FN-6778/FN-6779 case
uses `in-review`, where the literal is correct, so blinding the resolver
left the file green.

## What the literal costs

The sweep selects **no card**. A review card whose dependency is still
unmet is never rebounded — it sits in review, **eligible for merge,
ahead of the work it depends on**. That is precisely the ordering
violation this sweep exists to prevent, and it fails silently: no error,
no audit event, nothing to notice.

## Measured

3 pass; blinding `unmetDepReviewColumns` fails exactly the new case.

## Map status

**11 of 26 resolvers pinned** across 10 merged PRs. The remaining 15
need real harness work — I threw away two probes earlier today that
passed while proving nothing (`reconcileInReviewBranchRebind` never
entered its loop; `recoverAgentsRunningOnInactiveTasks` stayed green
under both blindings), and recorded them on #3164 rather than shipping
green decoration.

## Verification

`in-review-unmet-dependency-reconcile` **3 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 07:59:02 -07:00
gsxdsm
2a57820dd2 chore(gate): normalize the last future-dated stamp in task-update.ts (tightens the allowance 1 → 0) (#3168)
**Main is red on the FNXC gate again** — third occurrence of this class
today, third different file.

```
packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1
```

Stamps dated **2026-08-01** while UTC is **2026-07-31-14:24**. Every
open PR inherits the failure; #3164 merged carrying it.

## The fix

Date only, to today. Clock times preserved exactly — they were real
times on the wrong day — and no comment text touched, so the record
reads identically, just in order:

```
-FNXC:StateMachine 2026-08-01-10:20 (PR #2793's finding — the INNER half, merged with #2821):
+FNXC:StateMachine 2026-07-31-10:20 (PR #2793's finding — the INNER half, merged with #2821):
```

Baseline **tightened** as a side effect (`1 → 0`): one future stamp was
grandfathered, normalizing the file cleared it too, and the gate refuses
a stale allowance on the way down. Re-recorded in the same commit.

## The recurrence is the point, not this fix

Three separate files have tripped this in one day — `scheduler.ts`, the
scheduler PG test, and now `task-update.ts` — plus the midnight-rollover
variant this morning that reddened everyone's baseline.

**Stamps are written from a local clock and validated against UTC.** A
worker behind UTC writes what is genuinely "today" for them and produces
a future stamp the moment UTC has already rolled. Nothing in the local
loop catches it: `pnpm lint` passes locally because the local date
agrees.

The durable fix is to generate the stamp from `date -u` rather than a
wall clock — one line in whatever produces these, and the class
disappears. I have patched the symptom three times today; someone should
take the cause. I have not done it myself because the stamps are
authored by hand across every worker's flow, so the change belongs
wherever that convention is documented, not in a file I happen to be
touching.

## Verification

`check-fnxc-future-dates` green (TZ=UTC CI=true) · `pnpm test:gate` 13 +
161 + 487 + 71 · lint · core typecheck clean · diff is date
substitutions only.
2026-07-31 07:56:09 -07:00
gsxdsm
d7324c1a20 fix(gate): main is RED on check-inert-sync-lanes — my #3137 dropped the count without re-recording (#3172)
`check-inert-sync-lane-conversions` is inside `test:gate`, so **the
merge gate itself is red on `main` right now**.

```
inert-sync-lane: total fell 11 -> 7.
  Good news — but re-record the baseline in the SAME commit, or the allowance stays
  high and the gate silently accepts that many NEW inert conversions
exit 1
```

## Cause: mine

#3137 converted `executor.ts`'s planner-evacuation guards (4 → 0 under
the current scan) and **did not re-record the baseline in the same
commit**. The gate fails an unrecorded drop by design — a stale-high
allowance is four free slots for new inert conversions — and that
requirement is stated both in its failure text and in its own test suite
(*"an unrecorded DROP fails, so the allowance cannot stay stale-high"*).
I knew the rule and still shipped without it; the drop only became
visible once the PR merged.

This PR is the baseline only: **11 → 7**, `executor.ts` 4 → 0,
`triage.ts` unchanged at 7.

## Ordering note for my two open gate PRs

#3169 and #3170 make the scan follow a sync lane through a **conditional
initializer** and through an **object literal**. `executor.ts` is
written in exactly that shape after #3137, so with those fixes the count
**rises 7 → 9** — a legitimate rise from better detection, not a
regression. That re-record belongs in #3170's own commit, which is where
the gate asks for it, and I will put it there rather than pre-baking it
here.

So the expected sequence is: **7 now**, **9 when #3170 lands**.

## Process notes

- I checked for an existing fix PR before writing this one.
- I also caught this only because I re-measured the exit code **without
a pipe**. `node gate.mjs | head` then `echo $?` reads `head`'s status
and reported 0 — the exact harness trap recorded in this program's
learnings doc, which I walked into while checking whether main was
healthy.

## Verification

- gate **exit 1 on `origin/main`**, **exit 0** here
- diff is the baseline file only

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:44:42 -07:00
gsxdsm
10a0c5848f fix(executor): planner-evacuation lanes come from the emitter — executor leaves the inert list (16 → 12) (#3137)
`executor.ts` was the last file besides `triage.ts` and `scheduler.ts`
on `check-inert-sync-lanes`, holding **4 guards that read as converted
and behave as literals**. Neither cause turned out to be "needs an async
resolver".

## 1. Two of the four were in code with no caller

`isPlannerColumnFor` is a **private method with zero production
callers**. `tsc` reports it unused; the only things reaching it were two
tests casting through `executor as unknown as { … }`, which is exactly
what let it look alive. Its doc comment described the
planning-evacuation branch — but that branch calls
`isBackwardMoveOutOfPlanning` and never called this.

Deleted, along with the two tests whose subject it was. Converting
guards in unreachable code would have "fixed" behaviour that cannot run
and left two more sites to maintain; a test whose subject has no caller
pins nothing.

## 2. The other two no longer need to resolve anything

`isBackwardMoveOutOfPlanning` resolved its own lanes via
`resolvePlannerLanes`, whose selection reader returns `undefined`
unconditionally under PostgreSQL — so it answered with the **default
board for every task**, and both its guards were inert.

Its comment justified the sync resolver by the synchronous `task:moved`
emitter. That was true and **is no longer binding**: the emitter now
resolves lanes once, asynchronously (`moves.ts` →
`resolveWorkflowIrForTask`), and hands them on the payload — which #3112
already reads in this same listener. Reading a parameter is as
synchronous as reading `from`, so nothing reorders and no listener
resolves.

`lanes` is **required, not optional**. An optional parameter that the
one production caller happens to pass is the seam-with-no-supplier shape
this program keeps finding; required means a future caller fails
typecheck instead of silently getting a default board. When the emitter
itself could not resolve, the legacy ids answer — exactly what
`resolvePlannerLanes` degraded to anyway.

## Measured

| | before | after |
|---|---|---|
| `check-inert-sync-lanes` | **16** guards, 3 files | **12** guards, 2
files |
| `executor.ts` on that list | 4 | **0 — off the list** |
| census | 18 | 18 (`--strict`: every file matches baseline exactly) |

**The census is deliberately unchanged.** This targets the inert
population, which the census cannot see by construction: those guards
already read as converted. That gap is the argument in #3082 — 12 guards
still behave as literals while the census shows them as done.

## The producer half, which I nearly shipped without

The predicate's own suite covers it thoroughly — and every case calls it
**directly**. Mutation testing exposed that this proves nothing about
the listener: replacing the listener's `lanes` argument with `undefined`
left `planning-evacuation` at **20/20 green**. That is the fifth failure
shape in this program's learnings verbatim — a converted consumer with
an unconverted producer passing every instrument.

So there is now a case driving the **real listener** on a board whose
planner lanes share no id with the legacy pair (`queued` holds,
`drafting` intakes), withdrawing a card to a non-lifecycle column — the
reported symptom (`todo -> Ideas`) in that board's vocabulary.

## Verification

- engine `tsc` — **0 errors**
- `executor-planner-lanes-resolved` — **12 passed**
- `executor-archive-releases-active-session` — **14 passed**; listener
passing `undefined` → **1 failed | 13 passed**
- `planning-evacuation` + `triage-planning-wake` + archive suite — **47
passed**
- `check-inert-flag-seams`, `check-fnxc-future-dates`, census `--strict`
— exit 0
- `eslint` on changed files — 0 errors

The predicate tests are also **stronger than before**, not merely
adapted: they now build lanes with `toTaskMoveLanes`, the same function
`moves.ts` uses for the payload. Previously they reached the predicate
through the store-backed sync reader, so renamed-lane assertions passed
in the harness while the real path could never see a renamed lane.

## Not done here

The inert baseline still reads 29 against a tree of 12 and the gate
advises re-recording. I left it: a stale allowance is a real hazard, but
re-recording is a one-line change that conflicts with every lane, and it
should land once rather than in each of our branches.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:30:43 -07:00
gsxdsm
b3d009edde fix: main is red on check-fnxc-future-dates — one stamp dated tomorrow (blocks every open PR) (#3166)
`check-fnxc-future-dates` runs in `pr-checks.yml`, so while `main` is
red **every open PR fails this check** regardless of what it touches.
Measured on a clean detached `origin/main`:

```
[check-fnxc-future-dates] FNXC stamp population changed:
  packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1
    FNXC:StateMachine   2026-08-01  (dated after today)
    FNXC:WorkflowEvents 2026-08-01  (dated after today)
```

## One line, scoped by blame

Two stamps in the file are future-dated; only one is **new**:

| line | stamp | commit | action |
|---|---|---|---|
| 86 | `FNXC:StateMachine 2026-08-01-10:20` | `e5c9ea38709` (07-30) |
**baselined — left alone** |
| 964 | `FNXC:WorkflowEvents 2026-08-01-05:10` | `71f459c2a5d` (07-31) |
corrected → `2026-07-31-23:10` |

The baselined one is not what turned main red, and rewriting it would
register as a **drop** — which is exactly how I did collateral damage in
the #3124 cycle by rewriting two stamps I had not authored. Exact-match
replacement on the distinct new string; the older stamp is verified
still present afterwards.

## What is not changed

**The baseline file is untouched.** The fix is the stamp, not the
allowance — re-recording would clear the red while leaving tomorrow's
date in the tree, which is the false green this gate exists to prevent.

## Process note

I checked for an existing fix PR **before** writing this one. My #3143
was a duplicate of #3139 because I skipped that step on the last red,
and a red `main` is the single most likely thing for two lanes to notice
simultaneously.

## Verification

- `check-fnxc-future-dates` — **exit 1 on `origin/main`, exit 0 here**
- diff is one line; baseline file confirmed unmodified

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:30:21 -07:00
gsxdsm
27741d0e2f fix(core): an archived child kept blocking its parent's delete on a renamed board (#3162)
Second **LANE** site from the archived triage (#3154), same additive
shape as #3160.

## The bug

`liveLineageChildFilter` is the lineage-integrity gate (VAL-DATA-010)
behind `deleteTask` and `archiveTask`: a parent with **live** children
is refused with `TaskHasLineageChildrenError`.

It excluded children in the `archived` column **by id**. So on a board
that renames that lane, an archived child still counted as live and the
parent could not be deleted — with an error naming a child the operator
had **already filed away**.

## The fix is permissive, and that is the correct direction

The gate exists to protect **live** children; an archived child is not
one. Resolving makes fewer rows block, which is what the gate always
meant.

I am flagging this explicitly because *"a conversion makes a delete gate
stop firing"* deserves a second look. The second look is that it was
firing on rows it was never meant to protect.

## LANE, not STATE

The two STATE sites in this inventory are marked at their own sites
(#3157) and must never be resolved — one of them deletes directories.
This one asks about the board.

## Wired at every caller, not left as an optional seam

`findLiveLineageChildrenImpl` (has `store`) and both
`archive-lifecycle-2.ts` gates resolve and pass it.
`hasLiveLineageChildren` takes the same parameter so the two readers
**cannot disagree** about which children are live — the half-conversion
shape this program keeps finding, and the reason #3129's earlier attempt
was reverted for leaving a seam unsupplied.

## Parity gate satisfied

Same reason as #3160: the conversion is **additive** — it keeps the
literal as the fallback, so no encoding's literal count moves and a
caller supplying no set gets byte-identical SQL.

## Measured

- 4 new cases; parity test still **2/2**, inventories unmoved.
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control**, the **fail-soft** case, and the
parent/project-scope **negative** green.
- lineage / archive / soft-delete / archived suites — **6 files / 19
tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals`, `check-fnxc-future-dates` clean.

## Census

**Unchanged** — the literal remains the fallback arm, by design.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:18:54 -07:00
gsxdsm
4365a3b10b test(engine): pin the paused-scope-decay lane filter (plus two probes I threw away) (#3164)
Next entry from the verified coverage map on #3115.
`scopeDecayWipColumns` was uncovered — the existing case uses
`in-progress`, where the literal is correct, so blinding the resolver
left all 420 tests green.

## What the literal costs

A paused holder resting in a renamed wip lane is **never selected**. The
loop does not run, nothing is recorded, and its file scope decays with
nothing to rebound it — so its followers stay blocked behind a card that
is not coming back.

## The observable

The audit event. Reaching a no-action record proves the holder was
**selected by the lane filter**, which is the only thing this resolver
controls. Asserting on the rebound itself would have needed triple-proof
to succeed, dragging in state the resolver has nothing to do with.

**Measured:** 420 pass; blinding `scopeDecayWipColumns` fails exactly
this case.

## Two attempts thrown away first

Worth recording, because the remaining map entries are not uniform with
the ones already closed:

- **`reconcileInReviewBranchRebind`** — a git-free probe (workspace
task, rejected before any git runs) returned `{outcomes: [], repaired:
0}`. The loop never ran. I confirmed the `merge` trait does map to
`mergeOrchestration`, so the filter should have matched; something else
short-circuits and I could not establish what.
- **`recoverAgentsRunningOnInactiveTasks`** — the test passed, then
**both** resolvers stayed green when blinded. `agentLinkTerminalColumns`
never fires because the live card is caught by the wip∪review set first;
`agentParkedColumns` only feeds `evaluateParkedAgentTaskLink`, whose
result my fixture already forced true via a fresh run.

Both would have been green, plausible, and worthless. They were reverted
rather than adjusted until they passed — which is the failure this whole
effort exists to remove, and the one I committed myself in #3078.

## Map status

Closed: 9 resolvers across 7 PRs. **~18 remain.** The easy ones are
done; what is left needs real harness work — an `execAsync` git fixture,
and understanding how `evaluateParkedAgentTaskLink` weighs run-freshness
against lane. Budget for that rather than expecting the pattern that
closed the first nine.

## Verification

`self-healing.test.ts` **420 passed** · `pnpm test:gate` 161 + 13 + 487
+ 71 · lint — green.
2026-07-31 07:15:56 -07:00
gsxdsm
5d5a3ddd60 fix(core): live search excluded the archived id, not the board's archive lane (#3160)
The first **LANE** site from the archived triage (#3154), converted —
and it establishes that this family does **not** need the single 52-site
commit the parity gate's message implies.

## The bug

`liveSearchPredicate` builds the "not archived" half of every task
search. Keyed on the literal, a card filed away on a renamed board
stayed in **every live search result** — including the CREATE-time
near-duplicate check, which calls `searchTasks()`.

So creating a task could be rejected as a duplicate of one the operator
had **already archived**, with nothing on screen explaining why.

## Why this site and not its neighbours

The triage classifies all eight Drizzle `archived` sites. Two are
**STATE** markers that must never be resolved —
`cleanupArchivedTasksImpl` deletes directories,
`listSoftDeletedColumnDriftCandidates` would "repair" already-correct
rows. Both are marked at their sites in #3157.

This one asks about the board, so it must resolve. That distinction is
the entire product of the triage, and it is why this is a one-site PR
rather than a sweep.

## The parity gate is satisfied — and the reason generalises

That gate exists because converting one encoding while the others
compare the raw string makes them **disagree**.

This conversion is **additive**: it adds a resolved path and keeps the
literal as the documented fallback. The SQL encoding's literal count
does not move, and no encoding shifts relative to another. A caller
supplying no set gets **byte-identical SQL**.

So the family can be converted **incrementally** — one site at a time,
each keeping its fallback — rather than in one coordinated 52-site
commit. The gate's rule is about not letting the encodings *diverge*,
not about batching.

That was the last thing making this cluster look unapproachable, and it
turns out not to be true.

## Threading was two layers, and the root already had the answer

`reads.ts` resolves archived lanes for its cold-storage decision a few
hundred lines above; the same call now serves both search paths. My
earlier scoping note (#3147) guessed "one parameter each" — it is the
builder plus its two entry points, with the resolution already present
at the root.

## Measured

- 4 new cases; parity test still **2/2** (inventories unmoved — that is
the point).
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control** and both negatives green.
- The `includeArchived: true` negative earns its place: resolving lanes
must not start excluding them from a search that explicitly asked for
archived rows.
- The predicate walker needed **cycle detection** — Drizzle's SQL graph
is circular (column → table → columns) and my first version blew the
stack on the first assertion.
- core search / archive / cold-storage / reads suites — **5 files / 15
tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals`, `check-fnxc-future-dates` clean.

## Census

**Unchanged** — the literal remains as the fallback arm, by design. A
census drop here would mean the fallback had been removed, which is what
the parity gate is protecting against.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:13:01 -07:00
gsxdsm
71f459c2a5 fix(events): the last two live task:moved emitters carry the resolved lanes (#3135)
## The last two live emitters

#3109 attached lanes at `moves.ts`. #3120 attached them at the archive
and completion emits. Two live emitters were still sending `lanes:
undefined`:

```
task-update.ts:962        todo -> triage
update-task-deps.ts:502   emits to: task.column — the row's ACTUAL lane
```

A listener reads absence as "unknown" and falls back to
`resolveTaskParkedColumnsSync`, which returns the **default board** for
every task under PostgreSQL. So these two paths kept the pre-#3109
behaviour while the listener code reads as resolved at every site.

`update-task-deps.ts` is the sharper of the two: it emits `to:
task.column`, the row's real lane, so on a renamed board it sends e.g.
`"shipped"` to a listener comparing against `"done"`. The emitter had
already resolved the board and threw the answer away. Nothing errors;
the branch stops firing.

## Emitter coverage after this

```
LANES    moves.ts:1450               (#3109)
LANES    archive-lifecycle-2.ts:385  (#3120)
LANES    task-artifacts-ops.ts:578   (#3120)
LANES    task-update.ts:970          (this PR)
LANES    update-task-deps.ts:504     (this PR)
MISSING  lifecycle-ops.ts:668        deliberate — see below
MISSING  lifecycle-ops.ts:715        deliberate — see below
```

**Every emitter that can execute under the shipped backend now carries
lanes.**

## Flagged — the two I did NOT convert

`lifecycle-ops.ts:668` and `:715` stay lane-less on purpose. Both sit on
the polling-replica path that file already documents as
legacy-SQLite-only — it reaches `store.db`, which throws under
PostgreSQL — and the same note argues against spending a signature
change on dead code. I agreed rather than overrode it. If that path is
ever revived they must be attached, because absence resolves to the
default board rather than to nothing.

## Supersedes my own earlier PR

This replaces **#3119**, which carried four emitters. #3120 landed two
of them first, so I rebuilt against current `main` with only the
remainder rather than resolving a conflict into a half-redundant diff.
#3119 is closed with nothing lost.

## Census before / after

```
before:  COLUMN guards (the backlog):   17
after:   COLUMN guards (the backlog):   17
```

Unchanged — this converts no guards. It makes the resolved answer
*reach* guards that were already converted, which is the half that was
missing.

## Verification

Full `@fusion/core` suite **462 files / 4906 passed, 0 failed** ·
`test:gate` exit 0 · typecheck exit 0 · lifecycle-column census exit 0 ·
`pnpm lint` clean.

## Still open

`main` is **red on `check:inert-sync-lanes`** and nothing in CI runs it
— **#3127** fixes both halves. **#3122** restores 13 guards laundered
through `mergeParkedColumns`. **#3131** corrects a 44% under-report in
`--triage`.
2026-07-31 07:10:09 -07:00
gsxdsm
f818bdce2c gate: run the inert-sync-lane ratchet in test:gate — it was wired to nothing (#3136)
## A gate that nothing runs

`check:inert-sync-lanes` has existed since #3062 and been hardened three
times (#3068, #3079, and #3122 pending). It is invoked by **nothing** —
not `test:gate`, not any workflow. It is a `package.json` script that
only runs if a human types it.

Here is the round trip that cost, on `main`:

| | |
|---|---|
| **#3108** | flagged `triage.ts:765`: *"the obvious next move is to
convert the third with the same helper. **That would be wrong twice
over.**"* Both blockers named, backed by
`sync-workflow-ir-second-blocker.test.ts`. |
| **#3114** | converted exactly that arm, replaced the warning with its
own note, reported census 45 → 44. |
| **#3126** | reverted it — after someone ran the ratchet by hand and
found `main` red. |

Hours apart. All three green in CI. **No behaviour changed on any board
at any point.** The ratchet flagged it correctly the entire time;
nothing ran it, so a caught defect cost three PRs instead of one failed
check.

This is not a criticism of #3114's author. A written warning at the
exact line, with a test behind it, was overwritten within hours by a
well-intentioned change — that is simply what unenforced prose does
under fleet pressure. It is the fourth time this class has landed
(#3051, the `resolveMoveFanoutColumnsSync` family, #3114, and my own
`--triage` draft that failed to zero).

## What this does

One line: adds `node scripts/check-inert-sync-lane-conversions.mjs` to
`test:gate`, beside the cheap AST guards it belongs with
(`check-no-nohup`, `check-capacity-pool-id`, `check-mock-completeness`).
Single-pass parse; no measurable cost.

## Scope reduced from the earlier version

This branch previously also restored the triage literal. **#3126 landed
that first**, so I rebuilt it as the wiring alone rather than carry a
half-redundant diff. #3127 is closed with nothing lost.

## Census before / after

```
before:  COLUMN guards (the backlog):   18
after:   COLUMN guards (the backlog):   18
```

Unchanged — this converts nothing. It makes an existing check actually
run.

## Verification

`test:gate` exit 0 **with the ratchet inside it**, against current
`main` (now green on the ratchet since #3126). Diff is one line of
`package.json`.

## Related

**#3122** restores 13 guards laundered through `mergeParkedColumns` —
worth landing after this so the ratchet enters CI at full sensitivity.
**#3135** attaches lanes at the last two live emitters. **#3131**
corrects a 44% under-report in `--triage`.
2026-07-31 07:09:57 -07:00
gsxdsm
b9b7d14804 fix(core): a type that taught the wrong invariant — staleness signal column narrowed to legacy ids (#3159)
**Type-only. No runtime behaviour changes**, and I would rather say that
than let a green suite imply otherwise.

## The type described a guard that no longer exists

`TaskAgeStalenessSignal.column` was typed `"in-progress" | "in-review"`
and filled through a cast carrying this justification:

```ts
// The guard above proves `column` is one of these two legacy ids ... (#1403)
const activeColumn = task.column as "in-progress" | "in-review";
```

True when written. The guard now reads:

```ts
const wipColumn    = context.lifecycle?.wip    ?? "in-progress";
const reviewColumn = context.lifecycle?.review ?? "in-review";
if (task.column !== wipColumn && task.column !== reviewColumn) return undefined;
```

So on a renamed board it proves the column is `building` or `checking` —
and the cast asserted the **opposite** of what the guard established.
The runtime was always fine; the real id passed straight through.

## The damage is in what the type taught

A consumer writing `signal.column === "building"` got a **compile
error** saying the comparison was impossible. The type actively
instructed callers that `=== "in-progress"` is exhaustive — the exact
guard shape this program spends its time removing.

This is the second instance of the shape today. The first was
`dashboard/src/server.ts`:

```ts
moveTask(taskId: string, column: "todo", options?: …): Promise<unknown>;
```

which made the type system **reject** a resolved target (#3158). Neither
was a constraint anyone chose — both were inferred from a single legacy
call site and then hardened into an assertion about live data.

**A type narrowed to legacy ids is a lint against fixing the code**, and
it is invisible to every gate this program has: the census counts
comparisons, the move-target ratchet counts arguments, and neither looks
at type positions.

## The test is a characterization, and says so

No runtime test can differentiate a type-level fix — **`tsc` is what
differentiates it**. The added case pins a value that was already
correct, so a future narrowing has something to break against besides a
compile error nobody sees until they hit it. I have labelled it in the
file rather than presenting it as a regression test.

## Verification

| | result |
|---|---|
| `tsc` — core, engine, dashboard (app + src) | **0 errors** each |
| `task-age-staleness` | **17 passed** |
| all three staleness suites | **28 passed** |
| census `--strict` | exit 0 |

All three consumers of `.column` only display or compare it
(`taskAgeStalenessCopy.ts`, `TaskDetailModal`, a `TaskCard` memo
comparison), so nothing downstream narrows on the widened type.

## Scope note

I scanned for this class and the raw pattern is noisy — 192 candidates,
almost all object-literal **values** (`status: "archived"`), agent
roles, and unrelated `type: "done"` stream events. This one and
`server.ts` are the two I could confirm as genuine type-position
narrowings on a *task column*. I have not filed an issue for the class
because I cannot yet separate it from the noise reliably; if a cheap
discriminator turns up, it is worth a ratchet like the move-target one.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:06:48 -07:00
gsxdsm
44a67df65d test(engine): pin both dependency-lease resolvers on a renamed board (one case covered only half the conversion) (#3138)
Next two entries from the verified coverage map on #3115.
`reconcileDependencyBlockingLeases` had **both** of its resolvers
uncovered — blinding either `leaseWipColumns` or `leaseHoldColumns` back
to its legacy id left all 825 self-healing tests green, because every
fixture in that block uses `in-progress` / `todo`, where the literals
happen to be correct.

## What the literals cost

The holder scan matches no card **and** the dependency scan matches no
card. A stale file-scope lease blocking a real dependency is never
rebounded, so the dependent stays `overlapBlockedBy` behind a holder
that is not coming back. That is the deadlock this sweep exists to break
— silently not broken, no error, no log.

## Two cases, because one did not cover both — measured, not assumed

My first case (holder in a renamed wip lane, dependency marked
`overlapBlockedBy`) pinned `leaseWipColumns`. I then blinded
`leaseHoldColumns` against it and **it stayed green**.

The reason is in the control flow: the `overlapBlockedBy === holder.id`
branch short-circuits and `break`s **before** the hold membership is
consulted. So that fixture can never reach the guard `leaseHoldColumns`
feeds.

The second case drops the marker, leaving an unmarked dependency resting
in a renamed hold lane, which falls through to the
overlapping-hold-dependency branch.

| blinded | result |
|---|---|
| `leaseWipColumns` | **1 failed** |
| `leaseHoldColumns` | **1 failed** |

Before the second case, that table read `1 failed` / `still green`.
Checking each resolver separately is the only reason I noticed — a
single "the suite fails when reverted" would have looked like proof and
covered half the conversion.

## Remaining

23 uncovered resolvers on the map. Next by risk:
`reclaimStaleActiveBranches` (deletes branches) and
`reconcileInReviewBranchRebind` (rebinds branches of live cards), both
needing a git-shelling harness.

## Verification

`self-healing.test.ts` **415 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved recovery of stalled workflow tasks when dependency-blocking
leases become stale.
* Added support for workflows using customized task status lanes,
including marked and unmarked overlap blockers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 06:58:27 -07:00
gsxdsm
7cdd3f3e87 test(engine): pin the last two worktree-metadata resolvers — completes the sweep with 3 of 3 uncovered (#3148)
Completes `reconcileTaskWorktreeMetadata` — the sweep with the most
uncovered resolvers on the #3115 map (3 of 3). #3132 pinned the wip
half; these are **terminal** and **review**.

Both were uncovered: blinding either back to its legacy ids left all 825
self-healing tests green, because no fixture in that block used a
renamed lane.

| resolver | what the literal cost |
|---|---|
| terminal | a finished card is not this sweep's business. Keyed on the
ids the skip never fired, so finished cards were reconciled on every
pass |
| review | the other half of the **FN-5256** liveness guard. Keyed on
the id it went silent — and this sweep nulls
`worktree`/`branch`/`sessionFile` on a live row |

## Blinded separately, not as a pair

Each resolver was blinded on its own and measured on its own. **#3138 is
exactly why**: there, one case pinned `leaseWipColumns` and left
`leaseHoldColumns` green, because the control flow short-circuited
before the second guard was ever reached.

A single revert that fails proves *one* resolver, not the conversion.
That is the finer-grained version of the lesson from #3078, where a
whole suite passing proved nothing at all.

| blinded | result |
|---|---|
| `worktreeReconcileTerminalColumns` | **1 failed** |
| `worktreeReconcileReviewColumns` | **1 failed** |

## Map progress

Closed: `archiveStaleDoneTasks` ×2,
`reconcileOrphanedPendingStepResults`, `recoverDriftedAgentTaskLinks`,
`reconcileDependencyBlockingLeases` ×2, `reclaimStaleActiveBranches`,
`reconcileTaskWorktreeMetadata` ×3. **20 uncovered resolvers remain** of
the original 26.

Next: `reconcileInReviewBranchRebind` (rebinds branches of live cards),
then `recoverAgentsRunningOnInactiveTasks` ×2.

## CI note

This will show red on Lint until **#3145** merges — main carries FNXC
stamps dated 2026-08-01 through 08-06 while UTC now is 07-31, so every
open PR inherits it. #3145 fixes it; this PR touches none of those
files.

## Verification

`self-healing.test.ts` **416 passed** · `pnpm test:gate` 161 + 487 + 13
+ 71 · lint — green locally.
2026-07-31 06:58:11 -07:00
gsxdsm
1136474a63 test(engine): pin the archive skip in branch reclaim — the first uncovered resolver whose failure deletes a branch (#3144)
Next entry from the verified coverage map on #3115 — and the first one
whose failure mode is **irreversible**.

## The gap

`reclaimArchivedColumns` was uncovered: blinding it back to the id
`archived` leaves all 825 self-healing tests green, because no fixture
in this suite puts a card in a renamed archive lane.

## Why it matters more than the other 22

That guard **skips** archived cards — their branches belong to archive
cleanup, not to branch reclaim. Keyed on the id, a card filed in a
renamed archive lane fails the skip, and this sweep reaches:

```
git branch -D "fusion/<id>"
```

Every other uncovered resolver I have pinned so far causes a wrong
lifecycle decision — a card not requeued, a lease not released, a
diagnostic not surfaced. All of those are recoverable from the task row.
**A deleted branch is not.**

## Measured

415 pass. Blinding `reclaimArchivedColumns` fails exactly this case, and
the assertion that fails is the one checking `git branch -D` was never
called — so the failure *is* the branch being deleted, not a proxy for
it.

## Progress on the map

Closed so far: `archiveStaleDoneTasks` ×2 (#3115),
`reconcileOrphanedPendingStepResults` (#3090),
`recoverDriftedAgentTaskLinks` (#3102), `reconcileTaskWorktreeMetadata`
wip (#3132), `reconcileDependencyBlockingLeases` ×2 (#3138), and this
one. **22 uncovered resolvers remain.**

Every sweep probed so far has been uncovered, and one
(`reconcileDependencyBlockingLeases`) was only half-covered by its own
first test — the branch short-circuited before the second resolver was
ever consulted. That is why I now blind each resolver separately rather
than trusting a single revert.

Next: `reconcileInReviewBranchRebind` (rebinds branches of live cards)
and the two remaining `reconcileTaskWorktreeMetadata` resolvers
(terminal, review).

## Verification

`self-healing.test.ts` **415 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.
2026-07-31 06:55:15 -07:00
gsxdsm
db71b4fffb docs(core): scope the archived three-encoding decision — it is not 52-or-nothing (#3147)
The `archived` family is the largest unclaimed cluster (52 sites) and
its blocker is that **nobody has scoped it**. This scopes it. It
converts nothing.

## The two options both read as enormous because 52 sites are counted as
one lump

They are not one lump. The sites answer **two different questions**:

| question | renameable? |
|---|---|
| **LANE** — "is this row resting in the board's archive lane?" | yes —
must resolve |
| **STATE** — "did Fusion archive this row?" (the marker `archiveTask`
writes) | **no** |

`async-maintenance.ts` already draws that line and marks its own site
DELIBERATE-LITERAL:

> `'archived'` is the STATE marker here, not a lane. This sweep collects
rows Fusion itself archived or soft-deleted; a card merely sitting in a
workflow's archived-TRAIT lane is live work and must not be collected.
Widening to the resolved archived set would pull real cards into a
cleanup pass.

**Converting that site would be a bug, not progress.**
`async-archive-lineage.ts`'s soft-delete path is the same shape —
`column = 'archived', deleted_at IS NOT NULL` is the storage state it
has just written.

So the first question is a **triage**, not a conversion: which of the 52
are lane questions? Nobody has answered it, which is exactly why the
cost reads as unbounded.

## Measured: the SQL half, which the existing note calls the hard part

8 Drizzle sites across 7 files.

**Four already have `store` in scope** — they could take a resolved set
today with no signature change:

- `branch-group-ops.ts` — `clearNearDuplicateReferencesToImpl(store,
...)`
- `branch-and-pr-entities.ts` —
`findRecentTasksByContentFingerprintImpl(store, ...)` (2 sites)
- `task-mutation-ops.ts` — `cleanupArchivedTasksImpl(store)`

**Four need one parameter each**, the same optional-lane-set shape used
throughout this program:

- `async-lifecycle.ts` — `liveLineageChildFilter(parentId, projectId?)`
- `async-search.ts` — `liveSearchPredicate(includeArchived, projectId?)`
- `async-self-healing.ts` — `listSoftDeletedColumnDriftCandidates(db,
...)`
- `store.ts` — the revert-lookup conditions (already holds
`this.asyncLayer`)

That is not *"threading a resolver into the persistence layer"*. It is
four call sites that already have what they need, plus four
one-parameter widenings — **before** any triage removes the STATE sites
from the count entirely.

## What I did not do, and why

The triage itself: a per-site judgement about what each guard *means*.
That belongs to whoever owns this gate, not to a passing fleet lane —
and getting it wrong in the STATE direction pulls live cards into a
cleanup sweep, which is the one failure mode here that destroys work
rather than hiding an affordance.

What was cheap and missing was the **shape** of the problem.

## Measured

- Comment-only; parity test **2/2**.
- `tsc --noEmit -p packages/core` clean; census `--strict` clean.
- `check-fnxc-future-dates` is red from `main`'s own #3128 stamps —
**#3139** fixes that; this branch inherits and does not add to it.

## Census

No movement.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:55:03 -07:00
Phil Larson
920d68e10f fix(dashboard): expose column roles to browser bundle (#3151)
## Summary
- export the browser-safe `@fusion/core/column-roles` subpath
- keep Vite/Vitest aliases ahead of broad `@fusion/core` aliases
- restore production dashboard builds after task undo classification
adopted shared column-role helpers

## Test plan
- `node scripts/check-no-node-only-core-imports-in-dashboard.mjs`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run app/utils/__tests__/taskRevert.test.ts --pool=threads
--maxWorkers=1`
- `pnpm --filter @fusion/core typecheck`
- `pnpm --filter @fusion/dashboard typecheck`
- `CI=true pnpm check:changesets`
- `pnpm build`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed dashboard build compatibility for browser-based environments.
* Improved reliability when importing column role functionality across
supported application components.

* **Refactor**
* Made column role utilities available through a dedicated browser-safe
entry point.

* **Chores**
* Updated development and test configurations to consistently resolve
the new entry point.
* Documented the browser-safe module classification and recorded the
release patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 06:46:19 -07:00
gsxdsm
110d6fd150 docs(core): the archived LANE-vs-STATE triage, done — 8 SQL sites classified with evidence (#3154)
#3147 scoped this cluster and said the first question is *"which of
these are LANE questions and which are STATE markers?"* — and that
nobody had answered it. **This answers it** for the Drizzle half, per
site, by reading what each query is for.

I claimed it because it has sat unclaimed for many rounds and `--claims`
reports `AVAILABLE: 0 files / 0 guards` — this is the only real work
left in the area. Nothing is converted here.

## LANE (6) — must resolve a renamed archive lane

| site | evidence |
|---|---|
| `store.ts` revert lookup | `ne(archived)` + `ne(done)` picking
**live** revert candidates |
| `branch-group-ops.ts:82` | near-duplicate marker cleanup over **live**
rows |
| `branch-and-pr-entities.ts:438` | content-fingerprint duplicate guard,
gated on `!includeArchived` |
| `branch-and-pr-entities.ts:470` | recent **sibling** lookup |
| `async-lifecycle.ts:68` | `liveLineageChildFilter` — the name is the
classification |
| `async-search.ts:82` | `liveSearchPredicate(includeArchived)` — same |

Four already hold `store` / `this.asyncLayer`. The two predicate
builders need one optional parameter each — the shape used throughout
this program.

## STATE (2) — converting these would be a **bug**

**`task-mutation-ops.ts:1072`** — `cleanupArchivedTasksImpl` selects
`eq(column, "archived")` and then `rm`s each row's files. Widening it to
the resolved archived set would feed cards **merely resting in a board's
archive lane** into a filesystem delete.

This is the most destructive site in the family, and it **looks
identical to the LANE sites at a glance** — same column, same operator,
same file neighbourhood. That is the whole argument for triaging before
converting.

**`async-self-healing.ts:61`** — soft-deleted rows whose column
*drifted* from the archive marker (`isNotNull(deletedAt) && ne(column,
"archived")`). Resolving it would classify a soft-deleted row sitting in
a renamed archive lane as drift and "repair" it.

## The raw-SQL half is already partly triaged in place

`async-maintenance.ts` is marked DELIBERATE-LITERAL as a STATE marker,
and `async-archive-lineage.ts`'s soft-delete path writes `column =
'archived', deleted_at IS NOT NULL` as the storage state it has just set
— STATE by construction.

## What this changes about the decision

Roughly **three quarters LANE, one quarter STATE** — and the STATE sites
are the ones that destroy data if converted.

That is why "convert all three encodings" cannot be a sweep, and why the
raw count of 52 made it look larger than it is: there are fewer sites to
convert than the headline, and the ones that must **not** be touched are
the part worth being careful about.

## Not converted here, deliberately

The gate requires all three encodings to move together, so the
conversion is one coordinated change with its inventories updated in the
same commit. This supplies the classification that change needs without
pre-empting it — and without me making a 52-site coordinated change at
the tail of a long session, which is exactly when I have made my worst
calls today.

## Measured

- Comment-only; parity test **2/2**.
- `tsc --noEmit -p packages/core` clean; census `--strict` clean. **No
census movement.**

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:43:18 -07:00
gsxdsm
a8dae03fdb fleet(dashboard): taskRevert 2 → 0 — the recorded blocker named the wrong variable (#3129)
The largest remaining census cluster. Deferred twice, with a blocker
that turns out to be false **in the same component where its
counter-example already lives**.

## What the earlier notes got right

`detailColumnFlags` describes the **modal's own task**, and the column
classified here belongs to a **neighbour**. Supplying it would answer
*"is this neighbour finished?"* with a different row's traits — wrong on
data, not merely stale on vocabulary. That reasoning stands and I kept
it.

An earlier pass also converted this, left the parameter unsupplied, and
**reverted it** — correctly. An unsupplied optional parameter is
strictly worse than the literal: the guard is gone, the census counts a
conversion, and the behaviour is the legacy fallback forever. That rule
is why the wiring ships in this same commit.

## What the conclusion got wrong

> "A correct conversion needs per-**neighbour** flags — which the modal
does not have and should not fetch mid-render."

`columnFlagsByTaskId` is a per-task map. It is **already a prop** of
`TaskDetailModal` (declared :367, destructured :727), and the call site
at :992 sits **below** that destructure.

And `TaskDetailModal` already uses it exactly this way, for the
near-duplicate canonical:

```ts
columnFlagsByTaskId?.get(nearDuplicateCanonical.id)
```

…under a note observing that *its* blocker had been *"asserted from the
shape of the problem rather than tested against what was in scope."*
Same assertion, one function over. So the supplier the earlier note went
looking for exists, is per-neighbour, and needs no fetch.

## What it fixes

This lookup skips **finished** candidates so a done/archived prior undo
attempt never renders as an active "Undo task" link. On a board that
renames those lanes it matched neither — a finished undo task kept
rendering as open, which is precisely the stale affordance the
function's own header says it exists to prevent.

## Census

| | before | after |
|---|---|---|
| `taskRevert.ts` | 2 | **0** |
| repo backlog | 17 | **15** |

## Measured

- 4 new cases; `taskRevert.test.ts` **11/11 pass**.
- **MUTATION**: restoring the literal pair fails the renamed case.
- **The negative is load-bearing.** The map is fail-soft, so a candidate
it does not cover must still be treated as **open**, not skipped. A
conversion that skipped unknown candidates would *hide live undo links*
— failing in the direction nobody reports.
- A **control** pins that an unwired caller (no flags at all) still
skips the legacy ids, so the optional parameter cannot regress default
boards.
- `TaskDetailModal` suites — **31 files / 664 tests pass**.
- `tsc --noEmit -p tsconfig.app.json` clean; census `--strict`,
`check-lane-wiring`, `check-fnxc-future-dates` clean.

## Pattern worth noting

This is the fourth deferral this session whose stated blocker had
dissolved or misidentified itself, and the second where the
counter-example was already in the same file. The common shape: a note
records *why* something is blocked, is accurate when written, and is
never re-checked — so the block outlives its cause. Re-reading them cost
minutes each and returned two real conversions.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:58:44 -07:00
gsxdsm
cc7d619a1a fix(test): main is red on the census baseline fixture — it pinned a count the fleet has since converted (#3133)
## `main` is red right now

Measured on a clean detached `origin/main` checkout, nothing from any
branch applied:

```
 FAIL  src/__tests__/lifecycle-column-census.test.ts > the baseline can always be re-recorded
       > driven end to end > exits 0 and REWRITES the baseline under --update-baseline, even when the count rose

AssertionError: expected 1 to be greater than 1

 Tests  1 failed | 39 passed (40)
```

## The subject of the test never changed — the fixture expired

Both re-record cases pinned a **file** and a **number**: stale baseline
says `self-healing.ts: 1`, tree has more, therefore a rise. Conversions
have since taken that file to exactly **1**, so `toBeGreaterThan(1)`
fails while the behaviour under test — *does `--update-baseline` write
when the count rose?* — is completely unaffected.

This is fixture rot with a guaranteed expiry date. **The backlog
shrinking is the point of this program**, so any fixture keyed to a
specific file's guard count will expire; the only open question was
which cycle. It expired this one.

## The fix derives what it used to hardcode

Ask the census which file currently holds guards, then construct a
baseline **one below that file's real count**. The rise is manufactured
rather than assumed, and the assertion becomes exact:

```ts
const { file, count } = fileWithGuards();
const stale = { …, byFile: { [file]: count - 1 }, … };
…
expect(written.byFile[file]).toBe(count);   // was: toBeGreaterThan(1)
```

`toBe(count)` is also a stronger claim than the inequality it replaces,
which was only ever a proxy for "the rewritten pin carries the tree's
real number".

This is the same discipline as the self-syncing fixture directly below
it in the file (#3106), which syncs its temp baseline to the tree before
inflating it — for exactly this reason. That one survived; these two
were the half that still hardcoded.

## Verification

- **43 passed** on this branch
- **1 failed | 39 passed** on unmodified `origin/main` — the red this
fixes
- Differential, so the de-rotted test still catches the bug it was
written for: disabling `--update-baseline` in the CLI gives **1 failed |
42 passed**. The original ordering defect (a rise exiting before the
write, so the one command whose whole job is re-recording could not
re-record) is still caught.

## Scope

One test file. No production code, no gate, no baseline change, no
changeset (internal test). Split out of #3124 deliberately so it can
merge on its own while main is red, rather than waiting behind a larger
census PR.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:53:05 -07:00
gsxdsm
ada62a7c4a census: --claims shows which remaining files an open PR already holds (two duplicate claims today) (#3124)
The census says **where** the work is but not **who has it**, and
duplicate claims are now the dominant coordination cost of this phase.
This adds an opt-in `--claims` report mapping each remaining file to the
open PRs already touching it.

## The problem is measured, not suspected

- **`self-healing.ts` took three overlapping conversions** from
different lanes while one branch was open (#3049, #3075, #3078). Each
forced a full rebuild of #3094, and every conflict was the same shape:
*same guard, two spellings, different variable names*. That PR's body
asks, in as many words, for one lane to own the file.
- **`executor.ts` took two independent conversions today** — #3112 and
#3118 — same four literals, same payload-lanes fix, two branches. Two
workers each read the census, saw the top cluster, and started. Neither
could see the other; I only caught it because both appeared in one `gh
pr list`.

The census is what sends everyone to the same file, so the claim signal
belongs here rather than in a side channel nobody reads. `--triage`
(#3097) already measured the underlying fact — 53 of 88 guards sat
inside an open PR — one step short of being actionable.

## Measured on current main (29 guards)

```
  CLAIMED by an open PR: 6 files holding 15 guards
       6  packages/engine/src/self-healing.ts  ← #3121 #3116
       4  packages/engine/src/executor.ts  ← #3118 #3112
       2  packages/engine/src/auto-merge-finalization.ts  ← #3107
       1  packages/core/src/task-store/task-artifacts-ops.ts  ← #3120 #3119 #3091
       …
  UNCLAIMED: 12 files holding 14 guards — start here
       2  packages/dashboard/app/utils/taskRevert.ts
       2  packages/engine/src/scheduler.ts
       …
```

It independently reproduces **both** collisions I found by hand today,
which is the strongest evidence I can offer that it works: `executor.ts
← #3118 #3112` and `self-healing.ts ← #3121 #3116`.

It also answers the standing fleet instruction empirically. "Claim the
largest unclaimed cluster" currently resolves to **12 files holding 14
guards, none larger than 2** — and one of those two (`scheduler.ts`) is
in the SYNC-RESOLVED list, where conversion is inert. That is a
materially different picture from the headline `29`.

## Design decisions

**Report-only and fail-soft**, on the same terms as `--triage`: opt-in,
printed beside the totals, changes no count and no exit code. It shells
to `gh`, so it is unavailable offline, in CI without a token, and in
sandboxes — all of which print a notice and continue. A gate must not
depend on network state; this is a work-selection aid, not a gate.

**The fail-soft path is loud on purpose**, and it is the case I care
most about. A claim report that silently degrades to "nothing is
claimed" is *worse than no report*, because it actively sends the reader
into work another lane holds — the exact failure the flag exists to
prevent. So when `gh` cannot answer it prints `POSSIBLY CLAIMED` and
suppresses the start-here list entirely rather than rendering it empty.

**Heuristic, and says so.** A PR touching a file is not proof it
converts *that file's* guards — it may edit an unrelated function. It
over-reports rather than misses, which is the safe direction: a false
claim costs one comment asking, a missed one costs a rebuilt branch.

**One bulk `gh pr list` call**, not a request per PR — the per-PR shape
was too slow to become habitual, and a report nobody runs is not a fix.

## Verification

- `lifecycle-column-census.test.ts` — **42 passed** (was 40)
- Differential: disabling the flag gives **2 failed | 40 passed**. Both
new tests fail on the defect they were written for.
- `--strict` and `check-fnxc-future-dates` — exit 0
- Tests stub `gh` on PATH, so no network call and no dependency on the
live PR list. The fixture reads the census's **own current top file**
rather than a hardcoded path, so it cannot rot as the backlog shrinks
(same self-maintaining discipline as #3106).

## What this does not do

It does not reserve anything — there is no lock, and two workers who
both run it can still collide if they start simultaneously. It reports
what is already visible in the PR list, which is enough to catch the
every-case-so-far pattern of *starting work on a file someone has held
for hours*. A real reservation would need shared mutable state, and I
would not add that without an owner asking for it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:50:13 -07:00
gsxdsm
d09a856941 test(engine): pin the FN-5256 liveness guard on a renamed board (the sweep that clears a live task's worktree) (#3132)
Top item from the verified coverage map on #3115.
`reconcileTaskWorktreeMetadata` had **three** uncovered resolvers — the
most of any sweep in the file — and it is the one that nulls
`worktree`/`branch`/`sessionFile` on a live row.

## Why this sweep first

Its own header names FN-5256: the incident where clearing worktree
metadata yanked a checkout out from under a running shell. The guard
that prevents it is `scopeOverrideMergeActiveSafe`, and that guard is
exactly what the wip/review resolvers feed.

The existing guard test uses `column: "in-progress"` — **the literal**.
So blinding `worktreeReconcileWipColumns` back to `["in-progress"]`
leaves all 825 self-healing tests green. The guard is converted; nothing
in the suite could tell.

On a renamed board the pre-conversion form matched nothing,
`scopeOverrideMergeActiveSafe` became true for a card an executor was
actively running, and the sweep cleared its metadata.

## The case

The renamed twin of the existing FN-5256 test: a `scopeOverride` task
live in a **renamed wip lane** keeps its metadata. Same shape, same
assertions, different vocabulary — which is the whole point, since the
original passes either way.

**Measured:** 414 pass; blinding `worktreeReconcileWipColumns` to the
legacy id fails **exactly this test**.

## Remaining from the map

25 uncovered resolvers left. Next by risk: `reclaimStaleActiveBranches`
(deletes branches) and `reconcileInReviewBranchRebind` (rebinds branches
of live cards) — both need a git-shelling harness, so they are slower to
pin than this one was. Then the two `reconcileDependencyBlockingLeases`
resolvers.

I will keep working down that list. The map is on #3115 with verified
names; anyone can pick an entry and check it the same way — blind one
resolver, run `vitest run src/__tests__/self-healing`, and if it stays
green that conversion has nothing behind it.

## Verification

`self-healing.test.ts` **414 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.
2026-07-31 05:49:59 -07:00
gsxdsm
4db4052451 fix(gate): the inert-sync-lane ratchet had two free slots — my own drop went unrecorded (#3117)
Found by merging all five of my open branches together and running the
gates — a check none of them gets individually. The finding turned out
not to be about those branches at all: **`main` itself carries a
baseline of 20 against a real count of 18.**

## It is mine

#3065 replaced three `to === parked.complete || to === parked.archived`
guards with `parked.terminal.has(to)` and took the count **20 → 18**.
The gate *warned* and exited **0**, so nothing failed, I did not
re-record, and the allowance stayed high.

Bisected to be sure rather than inferred:

| commit | count |
|---|---|
| #3051 (`scheduler.ts 12 → 2`) | 20 |
| **#3065 (mine)** | **18** |
| #3100 (mine, comment-only) | 18 |

## The consequence is concrete

The gate whose entire purpose is to stop the inert-conversion class
growing **would have accepted two new inert conversions.**

Verified, not reasoned: with the baseline at 20, adding one new
`parked.wip` comparison to `scheduler.ts` still passed. With the
baseline corrected to 18, the same edit fails.

This is the exact failure mode I called out earlier in this program — a
fix landing without its ledger update — committed by me. The lenient
exit code is why it stayed invisible for a day.

## Two changes

1. **Re-record the baseline 20 → 18.** Restores the ratchet today.
2. **An unrecorded drop now exits 1**, matching the sibling
`check-lane-wiring.mjs`. Restores it tomorrow.

A ratchet that only tightens on request does not ratchet. Two gates
guarding the same program should not disagree about how seriously they
take their own ledger — `check-lane-wiring` already fails here and
explains why; this is the same rule for the same reason.

## Tests, because the exit code *is* the contract

Nothing covered this script's exit codes. The new cases drive it by
**running the script** against a temporarily swapped baseline rather
than importing a helper — a version that printed exactly the right
warning and still exited 0 would satisfy any assertion about its output.

- rise → exits 1, names the file
- unrecorded drop → exits 1, **and the message names
`--update-baseline`**, because a failure that does not name its fix is
noise to whoever hits it
- committed baseline matches the tree → exits 0
- **anti-vacuity**: the scan still finds real guards, so the two
mutated-baseline cases cannot pass against a gate that stopped reading
source and merely compares a number to itself

The baseline file is restored in a `finally`, so a failing test cannot
leave the repo's real ledger modified.

## Measured

- 4 new cases pass (`node --test`).
- Gate exits **1** before the re-record, **0** after, and **1** again
when a synthetic new inert conversion is added.
- `check-lane-wiring`, census `--strict`, `check-fnxc-future-dates` all
clean.

## Census

No movement — this is a gate fix, not a conversion.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:47:09 -07:00
gsxdsm
ce84aa48d0 test(self-healing): cover the renamed-board starved-refinement wake that main's conversion lacked (#3116)
**Rebased onto current `main`, and it shrank to one test.** Was
"self-healing consolidated (45 → 39)".

## What happened

**Every code change in this PR landed independently from other workers**
while it was open, and in each case theirs is equal or better. I took
theirs and dropped mine:

| My change | Landed on `main` as |
|---|---|
| pre-execution worktree seizure | `preExecLiveColumns` — same
"dangerous direction" reasoning |
| FN-5256 liveness cluster | `worktreeReconcileWipColumns` /
`worktreeReconcileReviewColumns` |
| agent-link membership | `agentLinkLiveColumns` /
`agentLinkTerminalColumns` |
| starved-refinement peer progress | `starvedWaitingColumns` — a project
union covering both duplicated sites |

Resolving the rebase by taking `main` left two orphaned declarations
(`activeOrQueuedColumns`, `holdPeerIds`) that nothing referenced. `tsc`
doesn't flag unused locals here, so I checked references by hand and
removed them rather than ship dead code that reads as converted.

## What's worth landing

**Their starved-refinement conversion has no renamed-board test — the
suite had zero.** This adds one.

A candidate resting in a renamed **intake** lane, with its peers in a
renamed **hold** lane, must still escalate. The two are deliberately
distinct columns so a wrong role set resolves no peers and escalates
nothing; a fixture where they coincide would pass either way.

The fake needed `listWorkflowDefinitions` — `starvedWaitingColumns` is a
**project union**, so per-task selection readers alone leave it
resolving nothing and the test would pass for the wrong reason. That
mismatch is how I found the gap: my original test failed against their
implementation.

## Verification

- Green against **their** code
- **Revert-proof against theirs:** restoring the literal fails it — 0
escalations against 1 expected
- 8 tests in the suite green

## Note for the fleet

This is the second PR of mine to shrink to a test on rebase (#3096 was
the first). Both times the duplicated work was real and mine was the
later arrival. The pattern is worth acting on at the coordination level,
not by me working faster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:41:32 -07:00
gsxdsm
6483f9ce2b fix(scheduler): resolve task:updated / task:deleted lanes asynchronously (scheduler inert 5 → 0) (#3128)
The last inert guards in `scheduler.ts`. Independent of my other
branches.

## Inert-guard ratchet

| Scope | Before | After |
|---|---:|---:|
| `scheduler.ts` | 5 | **0** |
| total | 12 | **7** (triage.ts 8 → other worker; executor.ts 4 → #3112)
|

## The live bug

These read `resolveTaskParkedColumnsSync`, which answers with the
**default** workflow in production. On a renamed board the scheduler
**never woke** on unpause or planning-finish, and a **deleted blocker
never unblocked its dependents** — the card sat behind a task that no
longer existed.

## The criterion, restated because I got it wrong before

**What blocks a guard is whether its answer is consumed synchronously —
not whether the enclosing listener is declared sync.** I assumed the
latter earlier in this program and reverted for it.

All three fail that test: two only gate `schedule()`, which is itself
`async`, fire-and-forget and re-entrance-guarded; the third already sits
below an `await getSettings()`. The edge-trigger bookkeeping
(`planningTaskIds.delete`) **stays synchronous** on purpose — deferring
*that* would let a second update re-enter the branch.

## The union is load-bearing, not defensive

Post-U11 the default lineage has no `triage` column, so a **resolved**
answer returns `intake: "todo"` where the inert path fell back to
`"triage"`. Converting without unioning the legacy ids silently
**narrowed** the wake set and stopped waking cards in a legacy-named
lane — caught by *"schedules when planning clears in triage"*.

**A resolved conversion must be a superset of what it replaces, or it is
a behaviour change wearing a vocabulary change's clothes.** That's the
reusable lesson here.

## Tests

- Drained with the repo's existing **`flushAsyncHandlers`** helper —
written for exactly this fire-and-forget shape — rather than loosening
any assertion.
- **The characterization test flipped, as designed.**
`workflow-scheduler-parked-columns-live-e2e.pg.test.ts` asserted *"a
dependent in a RENAMED hold column is NEVER unblocked"*, with its author
noting: *"expected to flip to null the moment the resolver is fixed —
and that flip is the whole point of writing it down."* It flipped.
Inverted to a REGRESSION case so the assertion holds the fix rather than
the defect; it now matches its own CONTROL arm, which still guards
against a vacuous pass.

## Verification

- 21 scheduler suites — **361 green**, including the live PostgreSQL e2e
- **`pnpm test:gate` green**; eslint and `tsc` clean
- Changeset added; `check:changesets` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:41:16 -07:00