Commit Graph

11303 Commits

Author SHA1 Message Date
gsxdsm
41cdcc741e fix(events): carry resolved lanes on task:moved so listener guards stop being inert (#3109)
Removes the **inert-guard class at its source** instead of one call site
at a time. Independent of my other branches.

## The problem

`task:moved` listeners run synchronously, so a listener needing a lane
answer had to resolve one synchronously — and
`resolveTaskWorkflowIrSync` returns the **default** workflow under
PostgreSQL, the shipped backend. Every such guard behaved exactly as the
literal it replaced, while the census scored it as converted.

**Resolving asynchronously inside the listener is not available**, and
that is measured rather than assumed. The scheduler's
`snapshotManager.invalidate` is asserted to run in the listener's
**synchronous prologue**; putting an await ahead of it produced **3
failures across 21 scheduler suites**.

## The fix

The emitter carries the answer, which removes the dilemma rather than
trading one horn for the other. `moves.ts` is already async and already
post-commit, so it resolves the moving task's lanes **once** and hands
them to every listener. The guard becomes correct **and** the prologue
stays synchronous.

This is the file's own recorded preferred fix — *"having the emitter
carry the resolved lanes on the event payload so no listener resolves at
all"* — now that the audit it was waiting on is done and came back as
**one** prologue-dependent consumer, not a class.

## Design choices

- **`lanes` is optional and fail-soft to `undefined`** — "unknown",
never "legacy". Some emit paths fire from sync contexts or a cached row
mid-teardown. Listeners keep their existing fallback, so those paths are
no better than before but **no worse**, and they become the exception
rather than the rule.
- **`mergeParkedColumns` overlays only fields the emitter actually
resolved**, so a partial payload cannot blank a lane back to a wrong
answer.
- **The sync resolver stays** as that fallback. Deleting it would strand
the emit paths that cannot resolve.

## Verification

- **Revert-proof and it pins the prologue:** the new case asserts
invalidation on a **renamed** hold lane with **no `waitFor`**. Ignoring
the payload gives **0 calls**.
- 21 scheduler suites — **361 green**
- self-healing + notification suites — **491 green**
- core moves + the `sync-workflow-ir-callsite-allowlist` ratchet — green
- **`pnpm test:gate` green** (71)
- Changeset added; `check:changesets` passes

## What it unblocks

`scheduler.ts`'s 10 allow-listed guards now resolve correctly for every
move that goes through `moves.ts` — the path real moves take. Those were
already absent from the backlog, so **the census number does not move**;
what changes is that they now do what the number claimed.
`executor.ts`'s 4 remaining sites can follow the same pattern in a
separate PR.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:47:09 -07:00
gsxdsm
4afb32ef98 test(core): cover the untested log-entry archive gate; correct a deferral that named the wrong blocker (#3110)
I converted `audit-ops.ts`'s archived gate, measured, and **backed it
out**. Both halves of that are the deliverable.

## The old deferral was stale on its own terms

It declined the conversion because *"the fix is the same one
`getLiveTaskColumn` needs"* and doing one of the pair would leave them
disagreeing.

But `getLiveTaskColumn` now **takes** a resolved `archivedColumns` set,
and both of its callers already pass `await resolveArchivedLanes(store)`
— including the sentinel path **twenty lines up in this same function**.
The pair it worried about was already half-converted, and this arm was
the half out of step. Converting it would have made them *agree*.

That is the third deferral I have found this session whose stated
blocker had dissolved. A deferral note records the blocker at the moment
it was written, and nothing re-checks it.

## The real blocker is one neither note named

`archived-column-gate-parity.test.ts` failed my conversion, and its
reasoning is correct and not obvious. This gate has **three encodings**:

1. TypeScript comparisons
2. Drizzle `eq`/`ne` predicates
3. raw SQL templates

Converting only the TypeScript arm makes them **diverge**: the gate
would call the row archived while the SQL side still returns it as live
— a log write rejected by its gate while its parent is listed as live.

Every builtin workflow names the column `archived`, so all three agree
*by accident* on every board we ship, and nothing except that parity
test can see the split.

Unblocking means converting all three together — the SQL sides need the
resolved id as a query-build value, including inside `for update`
transactions that receive no store today — or declaring `archived` a
non-renameable system column. That test lays out both options and owns
the inventory that has to move in the same commit. I am not doing it
here; it is a different change from a lane conversion.

## What ships

**The corrected note**, and **a test for a gate that had no coverage in
any form**.

The test asserts the legacy refusal and — the case that matters more —
that a **live lane is not refused**. A gate that refused everything
would satisfy a one-sided test and silently break every log write on the
board.

The renamed case is recorded as a **deliberate, explained omission**
rather than left as a silent hole, so the next reader knows it is a
decision.

## Measured

- 3 new cases pass; the parity gate passes.
- **MUTATION**, on the conversion before I reverted it: restoring the
literal failed the renamed case. The conversion *worked* — which is
exactly why the parity gate mattered. A working change can still be the
wrong change.
- The live-lane negative asserts **the gate did not fire**, not that the
call succeeded: past the gate the fast path performs a real Drizzle
write this fake layer cannot serve, so asserting success would drag a
database fixture into a test about a lane comparison, and asserting a
bare rejection would pass even if the gate *had* fired.
- `src/__tests__/{log-entry,archive,cold-storage,unarchive}*` — **7
files / 23 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-fnxc-future-dates` clean.

## Census

**No movement — nothing converted, deliberately.** The count stays where
it is because the gate is blocked, not because it is fine.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:46:58 -07:00
gsxdsm
15a664a8f5 docs(engine): flag executor's four task:moved literals — the obvious conversion is provably inert (#3104)
The largest unclaimed census cluster. **Nothing in this file said why
the sync-lane pass skipped it**, and that silence is the hazard: the
obvious next move is to convert these the way `scheduler.ts`'s ten were
converted, which would make them **inert rather than fixed**.

## The literals are genuinely wrong — this is not a "non-issue" flag

All four sit in one synchronous `task:moved` listener, and on a renamed
board:

- execution **never starts** on a move into the board's own wip lane;
- terminal session release **never runs** on a move into its archive
lane;
- both `from` guards never fire, so **in-flight work is not aborted**
when a card leaves implementation.

Nothing errors. The engine simply stops reacting.

## Why the obvious fix is inert — proved, not argued

`task:moved` is emitted synchronously, so an `await` here reorders this
handler against every other subscriber. That points at the sync IR path,
which cannot answer for a renamed board for **two independent reasons**
(`sync-workflow-ir-second-blocker.test.ts`, #3103):

1. `getTaskWorkflowSelectionImpl` returns `undefined` unconditionally
under PostgreSQL, so `resolveTaskWorkflowIrSync` always takes its
`!workflowId` branch.
2. Even **with** a selection, the custom-workflow branch loads its IR
through `store.db`, whose implementation is an **unconditional throw** —
so it falls into the catch and returns the default IR anyway.

**A renamed lane is a custom workflow, so (2) alone is decisive.** The
sync path can never serve this listener's case, whatever the selection
reader is fixed to do. That is the part the existing notes across this
repo miss, and it is why flagging beats attempting here.

`check-inert-sync-lane-conversions` already baselines **twenty** guards
in exactly that state in `scheduler.ts`. These four must not join them.

## Census

**Unchanged at 4, deliberately.**

Marking them DELIBERATE-LITERAL would buy a smaller number by asserting
the code is *fine*. It is not fine — it is *blocked*. Those are
different claims with different expiries, and the census should keep
pointing here until the block is lifted. An unconverted literal is
visible; an inert conversion leaves the backlog and takes the evidence
with it.

## Measured

- Comment-only change.
- `src/__tests__/executor*` — **84 files / 853 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean.

## Unblocking, for whoever takes it

Either an async listener contract — a behaviour change to handler
ordering, not a column conversion — or a sync reader that answers for
**custom** workflows *and* survives a writer on another node. All three
constraints are written up in `sync-workflow-ir-second-blocker.test.ts`
(#3103).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:40:42 -07:00
gsxdsm
f5926d3b54 docs(engine): flag triage's evacuation guard — it looks two-thirds converted and is fully literal (#3108)
Completes the sync-listener audit across the three files holding the
remaining blocked guards — `executor.ts` (#3104), `scheduler.ts`
(#3100), and this one. Triage is the most misleading of the three.

## The shape lies

The guard reads as **two resolved arms and one literal**:

> `task.column === disposeLanes.hold || task.column ===
disposeLanes.intake || task.column === "in-progress"`

So the obvious next move is to convert the third arm with the same
helper. That is wrong twice:

**1. The two "resolved" arms are not resolved.** `resolvePlannerLanes`
goes through `resolveTaskWorkflowIrSync`, which cannot answer for a
**custom** workflow — the sync selection reader returns `undefined`
unconditionally, *and* the custom-workflow IR read goes through
`store.db`, whose implementation is an unconditional throw (#3103). So
`disposeLanes.hold` / `.intake` are `todo` / `triage` on every board.
**All three arms are literal in effect.** Converting the third the same
way adds a third inert comparison and retires a census entry that is
currently telling the truth.

**2. The guard's answer is consumed synchronously** — the criterion I
had to correct in #3104. Below it, `pauseAborted.add`,
`session.dispose()` and `activeSessions.delete` mutate in-memory state
in this tick, and other paths read those maps. Contrast
`self-healing.ts`'s fan-out, where three of four guards only gated work
the listener already `void`s and so *were* convertible via the async
resolver (#3094).

## What it costs, and the obvious reading is backwards

An evacuation **into** a renamed destination still falls through and
disposes correctly — no bug there.

The failure is the other direction: on a board whose **hold or intake**
lane is renamed, arms 1 and 2 stop matching, so a card **sitting still
in its own planning lane** is treated as evacuated and its live triage
session is aborted mid-run.

I state it that way because "renamed board → guard misses → nothing
happens" is the pattern everywhere else in this program, and here it
inverts.

## Census

**Unchanged at 1**, deliberately. Blocked, and now documented as *fully
literal* rather than part-converted — which is the fact a future pass
needs in order not to make it worse.

## Measured

- Comment-only.
- `src/__tests__/triage*` — **25 files / 374 tests pass**.
- `tsc --noEmit -p packages/engine` clean; `check-fnxc-future-dates`
clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:40:21 -07:00
gsxdsm
0e4a559a0a test(census): make the tighten fixture self-maintaining instead of pinned to committed state (#3106)
## What broke, and why it will break again

The two cases in this block assert the CLI tightens an inflated
allowance **by exactly the inflation**. That arithmetic only held while
the *committed* baseline matched the tree — so it broke the moment a
fleet PR took `self-healing.ts` from 26 to 22 without re-recording. The
CLI correctly tightened to 22 while the fixture expected 26, and both
cases went red for a reason that had nothing to do with the code under
test.

#3101 fixed that instance by committing the number. **This fixes the
class.**

## Why it recurs

The census **exits 0 on a drop** — deliberately, so one worker's merge
can't redden the gate for everyone else. The cost is that the committed
baseline goes stale *silently*: every run rewrites the file, prints
`COMMIT IT`, and exits 0. This fixture is what eventually trips over it.

With a fleet actively converting the largest file (eight open PRs
against `self-healing.ts` as I write this), that's a recurring red, not
a one-off.

## The change

The fixture syncs its temp copy to the tree with `--strict
--update-baseline` **before** inflating. The assertion is then about the
CLI's behaviour rather than about what happens to be recorded on disk.

## Differential proof, both directions

Against an artificially staled baseline (22 → 26):

| | result |
|---|---|
| with this change | **40 passed** |
| without it | **2 failed / 38 passed** |

So the fixture now tolerates drift it previously broke on — and still
fails if the CLI stops tightening, which is the property it was written
to guard. That second half matters: a fixture made tolerant of
everything would be worse than the flake.

The CLI invocation is extracted to a `runCli` helper so the sync run and
the assertion run share one path. No behaviour rides on that extraction.

## Measured

| check | result |
|---|---|
| census suite, clean tree | 40/40 |
| census suite, staled baseline | 40/40 |
| `--strict` | exits 0, no residual drift |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:33:37 -07:00
gsxdsm
39e6891c93 chore(core): mark the dead sync-path lane literal DELIBERATE-LITERAL (census 104→102) (#3060)
Fleet phase. Claimed `packages/core/src/task-store/project-store-ops.ts`
— the largest census file with no branch, worktree, or open PR against
it. Claim published by pushing the branch **before** starting work.

## Census before / after

| | total | this file | deliberate |
|---|---|---|---|
| before | **104** | 2 | 130 |
| after | **102** | 0 | 130 |

`--strict` exits 0, baseline re-recorded in the same commit.
**Reclassification, not conversion** — the line is unchanged.

## The site was already audited today, in prose the tool cannot read

```
FNXC:WorkflowLifecycleColumns 2026-07-31-02:45 (audited — DEAD SYNC PATH, do not convert):
… It is the SQLite-mode twin. The live path is `dequeueMergeQueueOnColumnExitInTransaction`
… and it is ALREADY converted … This body reaches for `store.db.prepare`, which throws in
   PostgreSQL backend mode …
```

The reasoning is sound and I did not second-guess it: the live path is
converted, this twin cannot execute in production, and converting it
would mean threading a lane set into a function whose first statement
throws.

The problem is purely mechanical — **the note is prose, and the census
reads markers.** So the site stayed in `byFile` looking like unconverted
debt, and each fleet pass pays to re-derive the same conclusion. Adding
`DELIBERATE-LITERAL` moves it to `deliberateByFile`, where a
reviewed-and-kept literal belongs.

## This is the second one, which makes it a pattern

Same shape as #3056 (`async-mission-store-queries.ts`, fallback arms).
Across the files I have checked this phase — `agent-store`,
`github-tracking-state`, `planner-overseer`, `auto-merge-finalization`,
`async-mission-store-queries`, and this one — **every site was either a
fallback arm or an already-documented deliberate leave**, and
`agent-store.ts:236` carries its own "FLAGGED AND LEFT COUNTED" note
from today.

So the count is not a work queue, and the gap is not judgement —
previous passes reached the right answer. They recorded it where only a
human reader would find it. Two lines of marker per site closes that,
and the number then means "conversions owed", which is how every worker
reads it when picking a cluster.

## Verification

- `census --strict` exit 0; `tsc --noEmit` **0 errors**
- `check:fnxc-future-dates`, `check:lane-wiring`,
`check:sql-column-literals`, `check:inert-flag-seams` — all exit 0
- No behaviour change: only a comment added

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:30:00 -07:00
gsxdsm
0da19f7963 fix(core): a renamed archive lane was recorded as done in the eval corpus; flag the scheduler's two honest literals (#3100)
Two pieces, both about the same distinction: which literals are worth
**converting** and which are worth **naming**.

## Converted — the eval corpus was mislabelling renamed archive lanes

`collectDeterministicSignals` writes `column` as a two-value eval-record
field. Against the `archived` literal, a card resting in a renamed
archive lane was recorded as `"done"`.

No crash, no lifecycle decision — a **mislabelled row in the eval
corpus**, which is a dataset every later comparison reads. That is the
expensive kind of quiet: nothing fails, the numbers just drift.

The collector is sync and pure (no store, no workflow), so the lane
answer arrives as an optional parameter.
`HybridEvaluatorService.evaluateTask` is async and already holds an
optional store, which is where the resolution is paid; a store-less
evaluator degrades to the legacy literal rather than failing.

**Only the archived arm was ever wrong.** A renamed *complete* lane was,
and remains, recorded as `"done"` — which is correct. So only that
answer is resolved, and a third case pins that the widening did not turn
every renamed lane into `"archived"`.

## Flagged, not converted — the scheduler's two honest literals

These are the two `scheduler.ts` literals the sync-lane pass did not
take, and **nothing in the file said why**. That silence is the problem:
the obvious next move is to "finish the job" the way the other ten were
converted, and that would make them **inert, not fixed**.

`getTaskWorkflowSelectionImpl` returns `undefined` unconditionally under
PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the
default builtin IR — proved in
`postgres/sync-workflow-ir-is-always-default.pg.test.ts`, and
`check-inert-sync-lane-conversions` already baselines **twenty** guards
in that state in this same file.

They stay literal and **counted**, which is the honest state. An
unconverted literal is visible to the census; an inert conversion leaves
the backlog and takes the evidence with it. The note names the real
blocker — a sync-capable workflow-selection reader — so the next pass
does not spend a cycle discovering this the way I did.

## Measured

- 3 new cases in `eval-signal-collector.test.ts` — file **5/5 pass**.
- **MUTATION**: restoring the `archived` literal fails the renamed case
and leaves **both** the legacy control and the renamed-complete negative
green. The negative matters here: the fix must not turn every renamed
lane into `"archived"`.
- core eval suites — **4 files / 20 tests**; engine scheduler +
evaluator — **14 files / 143 tests**.
- `tsc --noEmit` clean in both packages; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Census

Both files keep their counts, deliberately:

- `eval-signal-collector.ts` — the remaining entry is the new
parameter's documented default, which is the fallback doing its job.
- `scheduler.ts` — the two literals this PR deliberately leaves visible.

A census that fell here would mean the flags had been marked exempt,
which would assert the code is fine. It is not fine; it is blocked, and
those are different claims with different expiries.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:29:21 -07:00
gsxdsm
f1e96f7a17 test(engine): pin the agent-link-drift terminal check on a renamed board (an agent stayed linked to finished work) (#3102)
Second of the two uncovered sweeps I flagged when #3078 merged. Not a
conversion — the conversion is already on main
(`driftedTerminalColumns`, landed by another fleet PR). This is the
coverage it shipped without.

## What was unprotected

Every existing case in this file uses `done` or `archived`, where the
literal is correct. So the terminal check had **no renamed-board case at
all**, and the same measurement that caught #3078 applies: a green file
proves nothing about a conversion whose fixtures can't express the
failure.

What the literal cost on a renamed board: **a durable agent stayed
linked to a finished task forever.** A linked agent is not free to pick
up new work, so the drift this sweep exists to clear is exactly the
drift it stopped clearing.

## Measured, both directions

| case | result |
|---|---|
| agent linked to a task in a RENAMED complete lane is cleared | **fails
on revert** — `taskId` still `"FN-9"`, agent pinned to finished work |
| agent linked to a task still in a RENAMED wip lane keeps its link |
passes either way — the sweep must narrow, not widen |

14 pass on current main; reverting the terminal check to the id pair
fails exactly one.

## Note on the fleet

This sweep was converted by someone else's PR while I was writing the
test for it — I found out because my revert probe hit
`driftedTerminalColumns`, a name I did not write. That is the collision
pattern working in a *useful* direction for once: their conversion, my
coverage, no duplicated code.

It also means the two of us independently chose the same sweep from a
7-PR pileup on this file. Assigning files from the census list would
still be cheaper than discovering the overlap in a test harness.

## Verification

`self-healing-agent-link-drift` **14 passed** · `pnpm test:gate` 13 +
161 + 487 + 71 · lint — green.
2026-07-31 04:25:59 -07:00
gsxdsm
827386dde6 test(core): the sync IR path is blocked TWICE, not once — every note in the repo undercounts it (#3103)
Every remaining census cluster I could not convert — `executor.ts` (4),
`scheduler.ts` (2), `triage.ts` (1), and the four-guard fan-out I
withdrew from my own PR — is waiting on the same thing. So I went to
unblock it, and found the record is wrong.

## The repo says one blocker. There are two, plus a constraint

The call-site allow-list header, the live-PG proof, and a dozen FNXC
notes across engine and core — **several of which I wrote** — all say:
`resolveTaskWorkflowIrSync` is inert because the sync selection reader
returns `undefined`, and the fix is "a sync-capable workflow-selection
reader".

That understates the work by half, and the undercount is load-bearing:
it makes the unblock read like a caching job, so the next person ships a
selection cache and finds the rest at integration time.

### Blocker 2 — the IR read is dead too

`resolveTaskWorkflowIrSyncImpl` loads a **custom** workflow's IR through
`store.db.prepare("SELECT ir FROM workflows WHERE id = ?")`.

`TaskStore.db` is not "SQLite-only". Its implementation (`dbImpl`,
`task-id-integrity.ts`) is an **unconditional throw with no mode branch
at all**. That read always throws into the surrounding `catch`, which
always returns the default IR.

The consequence is precisely the one this program cares about:

| workflow kind | after a perfect selection reader |
|---|---|
| built-in | resolves — that branch never touches `store.db` |
| **custom** | **still the default IR, always** |

**A renamed lane is by definition a custom workflow.** So the sync path
cannot serve the renamed-board case *at all* until this second read is
replaced. Fixing the selection reader alone would produce a change that
looks like it works — on default boards.

### Blocker 3 — a node-local cache is unsafe here

Not a bug; a constraint that bounds the fix's shape. Multiple Fusion
nodes run their own engines against **one shared PostgreSQL**
(`docs/multi-project.md` → "Shared Postgres multi-node runbook").

A node-local synchronous cache of `task_workflow_selection` therefore
goes stale whenever *another node* rewrites a selection — and answers
with full confidence. That is **worse than today's default**, which is
at least uniformly wrong rather than intermittently wrong. Any sync
reader needs an invalidation story that survives a writer on a different
host.

## Why a test rather than a comment

A comment saying "db always throws" decays the moment someone adds a
mode branch, and the whole argument silently inverts — which is the same
decay mode this conversion program keeps hitting with allow-list entries
and stale notes.

The assertions are deliberately about `dbImpl`'s **source** rather than
a call. Calling it proves one construction path throws; the fix depends
on the stronger claim that **no mode returns a database**. Reintroducing
an `if`/`return` there fails the test, which is the correct outcome: the
premise really has changed and the file must be re-read.

## Measured

- 4 new cases pass; the allow-list file's own 7 still pass with its
corrected header.
- **MUTATION**: adding a mode branch to `dbImpl` fails the first case.
- An **anti-vacuity** case pins that the resolver is still live and
still allow-listed, so these source assertions cannot keep passing after
the concern is deleted.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean.

## Census

**No movement — this converts nothing.** It corrects the record about
what the remaining conversions are waiting on, and it corrects notes I
authored. I would rather spend a PR making the next attempt cheap than
leave a half-true blocker in place that costs someone a full cycle to
rediscover.

## What I did not do

I did not build the sync reader. With blockers 2 and 3 in view it is a
store-substrate change — a second read to replace, and an invalidation
story that survives a writer on another host — not a fleet conversion,
and starting it mid-sweep on a shared file would repeat the collision
pattern that has already cost this branch three rebuilds. It remains
unclaimed, and now it is fully specified.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:25:48 -07:00
gsxdsm
d448ab6951 fix(engine): the merge-refusal reason was classified by a column id, and it lands in run-audit (#3098)
Claimed `auto-merge-finalization.ts` — and this one is a **reversal of
an earlier audit in the same file**, which is the interesting part.

## The earlier note said "diagnostic only". It was wrong about the
consequence

`validateWorkflowDoneMergeProof` picks between two refusal reasons with
`task.column === "done"`. Both arms return `{ ok: false }`, so this
never changed which branch ran — and on that basis a prior pass recorded
it as *"REAL but DIAGNOSTIC-ONLY"* and declined it, reasoning that
widening a signature to improve an error string is a poor trade.

**The reason is not an error string.** It is written to run-audit
metadata alongside `previousColumn` — `merger-merge-lifecycle.test.ts`
asserts exactly that — and that row is what an operator reads to find
out why a merge was refused.

So on a board whose complete lane is not called `done`, a card resting
in that lane was refused with the generic `missing-merge-confirmation`:
the classification for a card that is **not in the complete lane at
all**. The audit trail recorded the opposite of what happened. A wrong
record is worse than a vague one, because it gets acted on.

## The trade was also cheaper than the note claimed

The function is **already async** and **already takes an options bag**.
`resolveFinalizationColumns`, two functions up in the same file,
**already builds this exact predicate** for its own guard.

Nothing new is resolved. The answer that existed is handed down instead
of being re-asked with an id — the half-conversion shape this program
keeps finding, here inside a single file, one line apart: the caller
guards on the resolved `isCompleteColumn(latest.column)`, then calls a
validator that re-asked the same question with the literal.

`isCompleteColumn` is **optional with the legacy literal as its
default** — the same default-to-legacy contract the lane-parameter
vocabulary uses elsewhere — and `check-lane-wiring` watches the
parameter, so the two call sites cannot silently stop passing it.

## Measured

- New `merge-proof-reason-renamed-complete-lane.test.ts` — **2 pass**.
- **MUTATION**: dropping the parameter fails the renamed case and leaves
the legacy **control** green. The control earns its place: a failure now
means *"renamed board"*, not *"the refusal stopped working"*.
- **Driven through `finalizeProvenAutoMergeTask`**, not by calling the
validator with the new argument. The contract under test is the
**wiring** — a test that passed the argument directly would assert my
own parameter works and prove nothing about the seam that was broken.
- **The audit row is asserted, not just the return value.** The return
value alone is not the contract that failed here.
- merger / auto-merge suites — **5 files / 159 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Census

`auto-merge-finalization.ts` stays at **2**, deliberately. Both
remaining entries are now documented **degraded-fallback arms** — the
resolver's `catch` and this parameter's default — which is the right
kind of literal rather than a missed conversion. Converting a fallback
to a resolution would defeat its purpose.

## A note on the FNXC gate

My first stamps were dated `2026-08-01` while local today is
`2026-07-31`. `check-fnxc-future-dates` caught it and I re-stamped.
Worth mentioning because it is the second time this session that a
date-only local-calendar comparison has caught a stamp written near
midnight — the gate is doing real work, not ceremony.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:22:33 -07:00
gsxdsm
a7b2a757fa fix(engine): serialise wedge handling per task, then convert the lane guards it was blocking (5 → 1) (#3087)
The largest unclaimed census cluster, and the one two earlier fleet
passes explicitly declined.

## The standing blocker, taken on

Both passes converted these four ids and reverted, each time after the
same test went red:

```
task-wedge-notification.test.ts > sends one actionable push and mailbox message per active terminal episode
  expected 2 calls, got 1
```

Their diagnosis was right and I have kept it: this branch **resolves** a
wedge episode, `handleTaskUpdated` starts it fire-and-forget from a
synchronous `(task) => void` listener, and **any** await introduced
before the resolve lets a re-wedge arriving close behind reach `claim`
while the previous episode is still active — `claimed: false`, second
operator notification silently dropped. Column resolution needs an
await, so the conversion could not be made safe from inside the branch.

Both notes named the fix and left it for "whoever owns the wedge episode
contract": *serialise wedge handling per task*. This PR does that, then
takes the conversion.

## 1. Serialisation

`enqueueWedgeHandling` chains handling per task id, so
resolve-then-claim keeps its order however many awaits either branch
acquires. Details that matter:

- **Keyed by task, not global** — different tasks stay concurrent, so
this is not a throughput regression on a busy board.
- **The map entry is dropped when its chain drains**, and only if no
later link was appended while it ran, so it does not grow with the task
table.
- **Links never reject.** `maybeNotifyTaskWedge` already owns its error
handling; a rejected link would poison every later notification for that
task.

## 2. The conversion it was blocking

The four ids are an enumeration of *"every lane except review"* — the
lanes whose occupancy proves a wedged card's lifecycle has visibly
resumed. On a renamed board none of them matched, so a recovered card's
episode never resolved. Two consequences, and the second is worse than
the first:

1. the operator keeps an open "needs operator action" alert for work
that has moved on;
2. an active episode **suppresses re-claim**, so the *next* genuine
wedge on that task is never delivered.

Membership over the four roles, legacy-seeded, so an unconverted board
resolves exactly the four ids it used to compare.

## Measured

**The acceptance test the earlier notes named is the gate on both
halves.** With the conversion and *without* the serialisation, "sends
one actionable push and mailbox message per active terminal episode"
fails exactly as they reported. With the serialisation, green. I
reproduced their finding rather than taking it on trust — it is the
evidence that the serialisation is load-bearing and not incidental
refactoring.

| | result |
|---|---|
| `task-wedge-notification.test.ts` | **15/15** (2 new) |
| notification suites | **11 files / 234 tests pass** |
| `tsc --noEmit -p packages/engine` | clean |
| census `--strict`, `check-lane-wiring`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` | clean |

**MUTATION**: restoring the four literals fails the renamed-recovery
case and leaves its paired negative green.

**A vacuity I caught and fixed, worth stating plainly.** My first
version of the renamed case recovered the card with `status: "queued"`.
`hasProgressed` is an OR whose other arm is *"status is a non-failed
string"* — so that arm answered true and the column comparison never
ran. The mutation did not fail it. The case now clears `status` and
`error` together, which makes column membership the only thing that can
resolve the episode, and the paired negative uses the identical shape so
only the lane differs.

## Census

| | before | after |
|---|---|---|
| `notification-service.ts` | 5 | **1** |
| repo backlog | 71 | **67** |

## The remaining 1, flagged not guessed

`isManualMergeHold` (`task.column !== "in-review"`) is sync, and so is
its only caller `classifyWorkflowTransitionNotification`, reached from
the same `handleTaskUpdated` listener. Converting it means making that
whole chain async — a change to notification *classification ordering*
against every other `task:updated` handler, which is a different
contract from the episode one this PR owns. The serialisation added here
does not cover it: it wraps wedge handling, not transition
classification. Threading a pre-resolved `LifecycleColumns` in as a
parameter is the likely fix, and it wants the same gate-placement
judgement applied deliberately rather than swept in behind this.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:10:03 -07:00
gsxdsm
701677a2e5 test(engine): pin #3078's executor-owned skip — it merged without coverage (204 tests passed against the reverted fix) (#3090)
#3078 merged its conversion of the orphaned-pending-step-results sweep
**before this test landed**, so that sweep is on main with no coverage.
This closes the gap.

## The gap was measured, not assumed

With all three of #3078's conversions reverted, **all 204 self-healing
tests still passed**. I had cited that number as verification when I
opened it. It was meaningless for that change: every existing test in
the file uses `in-review` / `in-progress`, where the literal is correct,
so none of them could see the defect.

This is the same "a green suite is not coverage" failure I flagged in
other PRs today — in my own work, twice. The only reason I caught it is
that I finally ran the revert check on myself.

## Two cases

- **An executor-owned card in a renamed wip lane is SKIPPED.** Against
the pre-#3078 sweep this fails: the sweep reaches a card an executor is
actively running and rewrites its `pending` step results to `failed` —
the one thing that file's header says it must never do. The liveness
triple does not cover it; those legs prove an *in-process* session, and
an executor on another node or between session handles is exactly what
the column skip is for.
- **A genuine orphan on that same renamed board is still recovered** —
the skip must narrow, not disable. Passes either way, deliberately.

## What it pins, precisely

The **invariant**, not a line. Reverting either single guard still
passes, because the page-snapshot check and the fresh-row re-read
protect independently. What fails is reverting the sweep's column
handling as a whole — which is the condition worth pinning, and matches
the project's "fix the invariant, not the repro" rule.

## Still uncovered, said plainly

#3078's other two sweeps — worktree-metadata liveness and agent-link
drift — have no dedicated case. The orphaned-step-results sweep got the
test first because it is the one that can corrupt a live executor's
state. The other two remain honest debt rather than implied coverage.

## Verification

`self-healing-orphaned-pending-step-results` **10 passed** on current
main · full self-healing suites 204 · `pnpm test:gate` 161 + 13 + 487 +
71 · lint — green.
2026-07-31 04:09:48 -07:00
gsxdsm
f7a7347e1b test(core): cover #3057's cold-storage conversion; audit two archived literals as dead sync (#3089)
**Replaces #3085, which I am closing.** #3057 landed the same
cold-storage conversion while that PR was open. Rather than argue about
which spelling wins, this keeps only what `main` does not have: the
coverage, and two audits.

## #3057 converted this and shipped no test

`listTasksImpl`'s `columnFilterIsArchive` replaced `columnFilter ===
"archived"`. Correct change — and the kind that needs a test more than
most, because of how it fails.

Archived rows do not live in `tasks`; `archiveTask` copies them into the
archive store and removes them. This decision is whether that second
store is read **at all**. Against the literal, a caller naming a renamed
archive lane — `listTasks({ column: "filed", includeArchived: true })`,
which is what an archive view does — got an empty page from the only API
that can reach those rows.

Note the shape: the **unfiltered** read (`!columnFilter`) was always
correct. It fails only for the caller that names the lane, so it
survives any board-level smoke test and presents as *"the archive is
empty"* rather than as a bug. That is precisely the class that regresses
quietly once the conversion that fixed it has nothing holding it.

Three cases, and each earns its place:

| case | what it stops |
|---|---|
| renamed archive lane | the regression itself |
| legacy `archived` id (**control**) | a future conversion that resolves
the renamed lane and *drops* the legacy seed — the seeding hazard in its
other direction |
| non-archive lane (**negative**) | the widening turning every filtered
board read into a second-store round-trip |

**MUTATION**: restoring `columnFilter === "archived"` fails **only** the
renamed case.

**A trap worth recording.** My first version left the LIVE read real
against a fake `layer.db`. It threw, the `.catch` swallowed it, and all
three cases passed the negative — *including the legacy control*, which
is what exposed it. A test whose subject is never reached looks
identical to one whose subject answered no. `readLiveTaskRows` is mocked
now, and the control is what made it detectable.

## Audited, not converted — two dead sync paths

Both would be real defects if they ran. Neither runs.

| site | why it is dead |
|---|---|
| `mission-store.ts` (feature-delete link check) | `getMissionStoreImpl`
returns the AsyncDataLayer-backed `AsyncMissionStore` under PostgreSQL;
the sync `MissionStore` reached via `this.db.prepare` is legacy SQLite
only |
| `lifecycle-ops.ts` (polling-replica archive emit) |
`checkForChangesImpl` opens with `store.db.getLastModified()` /
`store.db.prepare`, which throw in backend mode |

The second is the sharper one: **both** its guard and the `to:
"archived"` it emits are literals, so a polling replica on a renamed
board would emit a move to a column the board does not declare.

Recorded in place — the treatment `project-store-ops.ts`'s dequeue twin
already has — so the census entries are not mistaken for unconverted
debt, and whoever deletes the sync SQLite residue takes these with it.

**They stay COUNTED.** Marking them DELIBERATE-LITERAL would buy a
smaller number by asserting the code is *correct*. It is not correct; it
is unreachable. Those are different claims with different expiries, and
the census should keep pointing here until the code is gone.

## Census

No movement — by design. This PR adds coverage and audits; it converts
nothing that was not already converted on `main`.

## Measured

- 3 new cases pass; `src/__tests__/{cold-storage,archive,unarchive}*` —
**6 files / 21 tests pass**
- `tsc --noEmit -p packages/core` clean; census `--strict` clean

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:06:40 -07:00
gsxdsm
6949f22ef8 fleet: resolve the same-column handoff review target (census 84 → 83) (#3076)
## Census

| | column guards |
|---|---|
| before | **84** |
| after | **83** |

`moves.ts`: 2 → 1. One of its two sites converts; the other **must
not**, and that difference is the useful part of this PR.

## Converted — the move target at the same-column handoff

```ts
if (internal.fromHandoff && toColumn === "in-review")
```

Against the literal this **never fired on a renamed board**, so a
same-column handoff into a renamed review lane silently took the *other*
branch — the sync-SQLite path, which throws under PostgreSQL.

It now asks `moveReviewColumns`: the broad membership set
(`mergeOrchestration ∪ mergeBlocker ∪ humanReview`) already resolved
**three lines above** for the merge-queue pair. Same value, so this
branch cannot disagree with the enqueue/dequeue calls that receive it.

## Not converted — the archived fallback arm

I named it, and `archived-column-gate-parity.test.ts` went red on
**`TypeScript encoding changed`**.

That guard's argument holds: the archived gate is enforced in three
encodings, the SQL halves still compare the raw string, and moving the
TypeScript half alone is the split brain it exists to prevent. Restored
inline **with a note recording the measurement**, so the next person
doesn't retry it and rediscover the same red.

This is the second time that guard has stopped me this session. It's
doing exactly what it was built for.

## On the pre-existing red

That suite is red on `origin/main` for an unrelated raw-SQL drift (#3072
fixes it — the drift is from my own merged #3042/#3046). I verified this
branch produces the **identical** failure and no other, so it doesn't
compound it.

## Measured

| check | result |
|---|---|
| moves / handoff / merge-queue suites | green |
| four gates + strict census | green |
| core `tsc` | clean |
| parity suite | same single raw-SQL failure as `origin/main`, nothing
added |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:57:51 -07:00
gsxdsm
9e242ea294 fix(engine): backlog pressure called every dependency unfinished on a renamed board (#3081)
## The third lane question

This reporter had **three** lane questions. Two were resolved when the
file's query-blindness was fixed — hold and wip, both through
`resolveProjectColumnsForRoles`. The third sat one method down and was
never touched:

```ts
if (dependency.column !== "done") return false;
```

One board, two lane answers.

## What it cost

On a renamed board every dependency reads unfinished, so
`isRunnableCandidate` rejects every card that has one. The
backlog-pressure alert then names **only dependency-free cards** as the
runnable ones.

The failure mode is the quiet kind: the report still renders, the counts
are right, and the candidate list looks plausible. The operator is told
the queue is blocked on nothing in particular. No default-board test can
see it — which is exactly why the earlier conversion of this same file,
which fixed its reads, left this behind.

## Fix

`finishedColumns` (complete ∪ archived) resolved once by the async
caller alongside hold and wip, then passed into the sync predicate.

- **Required parameter, not optional-with-a-literal-default.** An
optional parameter leaves `done` in the file as a silent fallback and
the next caller gets pre-conversion behaviour by writing nothing.
- **Archived is included** because a dependency that has been archived
is finished too — and this reporter already reads with `includeArchived:
true` precisely so archived blockers resolve.
- **Async resolution.** `resolveProjectColumnsForRoles`' only store read
is `listWorkflowDefinitions()`, a project-wide async read that works
under PostgreSQL. That is the line between a real conversion and the
inert sync-IR kind (#3058), and the new test supplies its board through
that same reader so it exercises the production path.

## Census

| | before | after |
|---|---|---|
| `backlog-pressure-reporter.ts` | 1 | **0** |

## Measured

- One new case; file **11/11 pass**.
- **MUTATION**: restoring `dependency.column !== "done"` fails it.
- The case asserts **both directions in one test** — a dependency
resting in the board's own complete lane makes its card runnable, *and*
a dependency still in the hold lane still blocks it. Asserting only the
first would pass against a predicate that had simply stopped checking
dependencies.
- The file already had a `RENAMED_IR` scoped to its second describe;
mine is a distinct `RENAMED_DEPENDENCY_IR` with different lane names. I
hit the shadowing first and the test failed as `under-threshold` — worth
noting because a same-named fixture that silently resolves to the
*other* board is precisely how a renamed-lane test goes vacuous.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Flagged, not guessed

Adjacent census entries I looked at and deliberately left:

- **`executor.ts` (4)** — all inside a sync `task:moved` listener.
Converting via `resolveTaskWorkflowIrSync` would be inert for #3058's
reason, and making the listener async reorders it against every other
subscriber. Correctly out of scope, as #3048 judged.
- **`triage.ts:724`** — half-converted in the same shape:
`disposeLanes.hold`/`.intake` come from a sync resolver, so the resolved
arms are themselves inert and "finishing" the guard would add a third
inert comparison.
- **`auto-merge-finalization.ts` (2)** — one is the resolver's
documented degraded fallback (the live arm calls `columnHasFlag`), the
other is already recorded as a deferred signature-widening whose cost
exceeds the error string it sharpens.
- **`in-review-stall.ts:196`** — an explicitly marked DELIBERATE-LITERAL
no-metadata fallback.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:51:43 -07:00
gsxdsm
3c531d984c fix(engine): self-healing lane cluster round 2 — 38 → 26 (two sweeps could disturb live work) (#3078)
The largest census cluster became **unclaimed again** when #3055 closed
conflicting. I had closed my own #3050 an hour earlier expecting #3055
to land, so this re-applies the conversions #3047 and #3049 did not
cover.

**Re-applied from current main rather than rebasing the closed branch.**
The conversions are small; the conflict archaeology is what went wrong
last time — nine conflicts against #3049, several on variable names
identical to mine, and my mechanical fixup corrupted the file badly
enough that I aborted. Starting from main cost less than resolving that
and carries no risk of resurrecting a stale line.

## Census

| | before | after |
|---|---|---|
| `packages/engine/src/self-healing.ts` | **38** | **26** |
| repo-wide column guards | 84 | **72** |

## Three sweeps, existing role helpers only

| sweep | roles | what it did on a renamed board |
|---|---|---|
| worktree metadata | terminal + wip + review | rebound finished cards
every pass, **and the FN-5256 liveness guard went silent** |
| orphaned pending step results | wip | **could rewrite `pending`
results under a live executor run** |
| agent-link drift | wip + review + terminal | evaluated agents whose
task was plainly still executing |

Two of these disturb **live** work, which is why they were worth redoing
now rather than leaving for the next fleet round:

- The worktree-metadata sweep clears `worktree`/`branch` metadata. Its
liveness guard is the thing standing between that and a running shell
(FN-5256). Keyed on ids, it matched nothing on a renamed board. The
scope-override safety condition beside it now reads the **same resolved
sets**, so the two cannot disagree about which lanes are live —
previously they were two independent literal lists.
- The orphaned-step-results sweep's own header says it must never touch
an executor-owned row. The id-keyed skip made it do exactly that.
Resolved once per sweep, outside the paging loop, so a large board still
pays one resolve.

## Flagged, not guessed — the 26 that remain

Unchanged from my earlier audit and re-verified on this base:

- **Sync predicates** (`isWorkspaceOwnerLive`, the pause-abort
classifier, the phantom-binding check, the `task:moved` listener
guards). No store handle; converting means a signature change or making
a synchronous event listener async, which reorders handlers against a
synchronous emitter.
- **Already-converted fallbacks** — `own.length > 0 ? own.includes(...)
: task.column === "in-review"`. The resolved answer wins; the literal is
the documented no-metadata path.
- **The notification-route `fresh.column === "todo"` sites** — measured
previously: any `await` before the wedge resolve drops an operator
notification. Needs the wedge-episode contract, not a column pass.

## Verification

self-healing suites **204 passed** · agent-link-drift +
query-filter-blindness **83 passed** · `pnpm test:gate` 161 + 13 + 487 +
71 · lint · census `--strict` · lane-wiring — green.
2026-07-31 03:48:44 -07:00
gsxdsm
58791fac88 fix(self-healing): route pause-abort recovery on resolved columns (self-healing 38 → 34) (#3075)
First real cut into `self-healing.ts`, the last large cluster. Converts
the **pause-abort recovery router** — a coherent unit with one owner,
rather than a scattered pass.

## Census before/after

| Metric | Before | After |
|---|---:|---:|
| COLUMN guards (backlog) | 86 | **82** |
| `self-healing.ts` | 38 | **34** |

## The bug this was hiding

The router keyed on three literals — `in-review` twice (review progress,
manual merge hold) and `todo || in-progress` (active work). On a renamed
board **all three stop matching**, so a parked card in a renamed lane
falls through to `no-action` and is never recovered — silently, no log
line, and with every existing test still green because they all use
legacy ids.

## Conversion

Used the file's own resolvers. `resolveReviewColumnsFor` already
existed; added `resolveActiveWorkColumnsFor` as its sibling from the
same `columnsWithFlag` / `resolveLifecycleColumns` helpers — no new
vocabulary.

**ACTIVE WORK is hold + `countsTowardWip`, deliberately NOT the intake +
hold that the neighbouring `resolvePreWipColumns` returns.** The router
asks *"is this card mid-flight, so a requeue is right?"* — an intake
lane is not mid-flight; a WIP lane is. Reusing the intake-shaped helper
would have widened the requeue to triage rows and dropped in-progress
ones. Because the two sets **overlap on `hold`**, that mistake looks
correct in every legacy-id test. This is the trap worth knowing about
for the rest of the file: it has several resolvers, and picking the
nearest one is not the same as picking the right one.

**`columns` is required, not optional-with-a-fallback.** The router has
exactly two callers — the candidate filter and the post-re-read
re-verify — and they must agree. An optional parameter lets one resolve
and the other default, and that divergence surfaces as a sweep that
selects a card and then declines to act on it, writing nothing. Required
makes it a compile error. (Same reasoning as #3059; safe here because
both resolvers union the legacy ids internally, so "required" never
means callers invent a column set.)

**On the filter/re-verify hazard I flagged earlier:** both call sites
are the *same function*, so converting it once keeps them consistent by
construction — no split-CAS risk. Per-task resolution can't hoist out of
the loop but must not read an IR per row, so the marker test
(column-independent) stays a cheap sync prefilter and the IR is read
only for rows that pass it, over a shared cache. `parked` keeps its
exact former membership, so the log count still means what it said.

## Verification

- **Revert-proof:** reverting the three guards to literals fails 2 of 3
routing cases. The third exercises the new resolver directly, so it
cannot fail on revert — stated rather than counted as evidence.
- 4 self-healing suites, **437 tests green**
- `tsc --noEmit` clean; eslint clean
- Degraded-resolution case included, since the legacy-id union is what
keeps recovery alive on an unreadable workflow

## Still flagged in this file (not guessed)

34 remain. They are not one batch: the sweeps around
L2855/3297/8877/9018 depend on the unowned `listTasks({ column })`
decision, and L5330/5359 sit under the FN-5256 liveness guard whose own
comment says those columns can be live when the heuristic calls them
stale.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:45:52 -07:00
gsxdsm
98aac40ca8 fleet: reads.ts 2 → 0 lifecycle-column guards (#3057)
> **Rebased.** Main landed another worker's conversion of the review
gate while this was open — the overlap was a whole rewritten function,
so I reset to main and rebuilt only my remaining delta on top of their
work rather than resolving hunks. Their conversion is kept as-is.

## Census

| | column guards |
|---|---|
| before | **104** |
| after | **102** |

`reads.ts`: **2 → 0**.

## Two changes

**1. `includeColdStorage`** asks whether the *caller* is filtering to
the archive lane. Against the literal, a caller filtering to a renamed
archive lane took the false branch — cold storage was skipped and the
filtered view returned only whatever archived rows still sat in
`project.tasks`, **a short list presented as the whole archive**. Still
literal on main; converted here.

**2. Both fallbacks become named sets** instead of inline arms —
including the one on the just-landed review gate.

## The second point is the one worth the fleet's attention

This is bookkeeping correctness, not style. The census counts an inline
comparison **whether or not it sits in a fallback branch**, because its
`traitFallback` hint is advisory and never changes `kind`.

So a correctly-converted guard with an inline legacy arm **stays on the
backlog permanently**, and the number stops distinguishing real debt
from documented degraded answers.

Concretely: converting with an inline fallback is correct work that
scores **zero**. My own first pass at this file did exactly that. There
are roughly **12 such sites** across the tree — `github-tracking-state`,
`planner-overseer`, `async-mission-store-queries`,
`register-task-workflow-routes`, `restart-recovery-coordinator` — and I
have that cluster converted and ready to open next.

## Measured

| check | result |
|---|---|
| reads / get-task / stall suites | 5 files, **87 tests green** |
| renamed-archive PG suite | green |
| strict census | green; `tsc` clean |
| unconverted boards | byte-identical — the named sets hold the previous
ids |

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

## Summary by CodeRabbit

* **Bug Fixes**
* Review and archive checks now work correctly with resolved workflow
columns while retaining legacy compatibility.
  * Fresh agent activity is detected in resolved review lanes.
* Lists filtered by a resolved archive lane now include archived items
stored in cold storage.

* **Chores**
  * Updated internal lifecycle tracking baselines.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:43:03 -07:00
gsxdsm
3c12a51627 fix(core): the merge result reported a column the finaliser did not write (merge-queue-ops 3 → 0) (#3071)
Largest unclaimed census cluster in `packages/core` — three `done`
literals in `mergeTaskImpl`. Two of them produced **wrong state**, not
merely a guard that stopped firing.

## 1. The result overrode the writer

`moveToDoneImpl` resolves the board's completion lane and writes it onto
the task object:

```ts
task.column = completeColumn;   // task-artifacts-ops.ts
```

Both merge call sites then did:

```ts
result.task = { ...task, column: "done" };
```

putting the literal back over what the writer had just set. Every
`task:merged` listener — GitHub tracking, the auto-merge handoff — was
told the card landed in `done` while the persisted row said `shipped`.

The row was right and the event was wrong, which is the worse direction:
the listeners act on the event, not the row.

Fixed by reading back what the writer set (`{ ...task }`). Deliberately
**not** a second resolution — that would only be a second chance to
disagree with the finaliser.

## 2. The guard disagreed with the writer

The already-complete short-circuit asked `task.column === "done"`, while
the finaliser it guards short-circuits on the resolved `task.column ===
completeColumn`. On a renamed board those two answers differ, so a card
already resting in the board's completion lane fell through and the
merge ran again against a branch that was already landed and deleted.

Converted with the **same resolution and the same shape** — a single
first-match column, not membership — because the whole point is that
these two answers cannot differ. A workflow declaring no complete lane
resolves to `undefined`, which matches no column; the finaliser refuses
such a board explicitly one function later.

## Census

| | before | after |
|---|---|---|
| `merge-queue-ops.ts` | 3 | **0** |

## Measured

- Two new cases added to `merge-blocker-renamed-review-lane.test.ts`
(same renamed-board fixture, same PG harness) — file **5/5 pass**.
- **MUTATION**: restoring either literal fails **both** new cases and
leaves the three pre-existing ones green.
- Reached with **no git fixture**: with no branch present, `git
rev-parse --verify` fails and the function takes its own documented
*"branch not found — moving to done without merge"* path — which is
exactly the path that calls `moveToDone` and then builds the result. No
repo setup, no flake surface.
- `packages/core` targeted run: **38 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-lane-wiring` ("none added"), `check-fnxc-future-dates` clean.

## Not done here (flagged, not guessed)

The other `done`/`archived` literals still in the core census are each
blocked for a *different* documented reason, so sweeping them into this
PR would have meant guessing:

- `agent-store.ts:236` — a pure formatter over `Pick<Task,"column">`
that prints the column for a human; degrades gracefully and has no store
to resolve from.
- `async-mission-store-queries.ts` — already converted with
caller-threaded lane sets.
- `taskRevert.ts:119` — classifies a **neighbour** task; the only flags
in scope describe the modal's own task, so wiring them would answer the
question for the wrong row. Needs per-neighbour flags.
- `moves.ts:310` — a **refusal**, where a legacy-seeded superset is the
documented hazard rather than the safe direction. Wants its own change
with its own test.
- `mission-store.ts:2332` — a sync SQLite path with no async seam.

Each is real debt; none is a mechanical conversion.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:40:11 -07:00
gsxdsm
8eef8852a0 fleet: 4 long-tail fallback arms become named sets (census 101 → 97) (#3064)
## Census

| | column guards |
|---|---|
| before | **101** |
| after | **97** |

The single-guard long tail is **19 files**. This converts the four whose
legacy arm is unambiguously a fallback on an already-converted guard;
the other 15 are flagged below rather than guessed at.

## Two shapes

**`in-review-stall.ts`, `stalled-review-detector.ts`** — the resolved
answer with an inline legacy arm:

```ts
reviewColumns ? reviewColumns.has(col) : col === "in-review"
→ (reviewColumns ?? LEGACY_REVIEW_LANES).has(col)
```

**`merger.ts`, `in-process-runtime.ts`** — belt-and-braces:

```ts
col !== (lifecycle?.complete ?? "done") && col !== "done"
```

That accepted the resolved lane **or** the legacy id, stated twice. A
union set says it once, so the two halves can't drift apart — which is
the real risk with a duplicated condition.

## A finding for anyone else marking fallbacks

`in-review-stall.ts` **already carried a `DELIBERATE-LITERAL` marker**
on that arm and was counted anyway. The marker sits in a comment *inside
a ternary*, which the census's leading-comment lookup doesn't reach.

So: **naming the set works, marking it does not.** Worth knowing before
someone marks a fallback and expects the count to move.

## No behaviour change

`new Set(["in-review"]).has(x)` answers exactly what `x === "in-review"`
answered, and the union sets accept exactly the two lanes their
conditions already accepted.

## Flagged, not converted

The remaining 15 single-guard sites need individual judgement, not a
mechanical pass:

- **plain unconverted guards with no resolution in scope** —
`audit-ops`, `lifecycle-ops`, `merge-queue-ops`, `task-id-integrity`,
`backlog-pressure-reporter`, `ephemeral-worker-manager`,
`ResearchTaskActionModal`
- **sites where the literal IS the answer** — `eval-signal-collector`
maps a column to an archive-vs-done *label*; `TaskCard` reads a
completion timestamp
- **already resolved on their line** — `triage.ts`,
`restart-recovery-coordinator.ts`, both covered by open PRs

## Measured

| check | result |
|---|---|
| core stall suites | 4 files, **85 tests green** |
| engine merger/runtime suites | **1044 tests green** |
| five gates + strict census | green |
| `tsc` (core, engine) | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:37:18 -07:00
gsxdsm
c220455e3a fleet: 10 inline fallback arms become named sets (census 102 → 92) (#3061)
## Census

| | column guards |
|---|---|
| before | **102** |
| after | **92** |

Five files drop to **0** guards each. Baseline re-recorded in the same
commit.

## A cluster the census could not distinguish from real debt

**Every site here is already converted.** Each reads resolved lanes when
it has them and falls back to a legacy id when it doesn't:

```ts
reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review"
```

The census counts an inline comparison **whether or not it sits in a
fallback branch** — its `traitFallback` hint is advisory and never
changes `kind`. So ten correctly-converted guards sat on the backlog
permanently, and the number stopped distinguishing *work still to do*
from *documented degraded answers*.

Naming the fallback set fixes the bookkeeping without touching
behaviour: `new Set(["in-review"]).has(x)` answers exactly what `x ===
"in-review"` answered.

## Files

| file | sites | what they gate |
|---|---|---|
| `restart-recovery-coordinator.ts` | 4 | three shared review gates +
one `??` default |
| `github-tracking-state.ts` | 2 | complete / archived lane predicates |
| `planner-overseer.ts` | 2 | wip / review classification |
| `async-mission-store-queries.ts` | 2 | terminal complete / archived |
| `register-task-workflow-routes.ts` | 2 | wip promotion target,
archived respecify guard |

**No behaviour change is claimed and none is intended** — that's the
point. These were already right; only the accounting was wrong.

## Worth the fleet's attention

Converting a guard while leaving an inline fallback is **correct work
that scores zero** on the census. My own first pass at `reads.ts` did
exactly that — behaviourally correct, census unmoved. Anyone converting
this way is doing real work the number won't credit, and the backlog
will look stuck.

## Measured

| check | result |
|---|---|
| engine suites | **173 tests green** |
| core mission suites | **70 tests green** |
| dashboard route suites | **211 tests green** |
| five gates + strict census | green |
| `tsc` (core, engine, dashboard) | clean |

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

## Summary by CodeRabbit

* **Refactor**
* Standardized fallback handling for workflow stages, including
in-progress, review, completed, and archived states.
* Preserved existing behavior when explicit workflow column settings are
available or unavailable.
* Improved consistency across task tracking, planning, and recovery
workflows.

* **Chores**
* Updated lifecycle tracking baselines to reflect current source-file
coverage.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:34:27 -07:00
gsxdsm
befbd299a9 fleet: mark 4 reviewed literals DELIBERATE (backlog 88 → 84, comment-only) (#3066)
Comment-only. **No code changed** — 23 lines added, all comments.

## Census before/after

| Metric | Before | After |
|---|---:|---:|
| COLUMN guards (backlog) | 88 | **84** |
| DELIBERATE-LITERAL (reviewed) | 128 | **132** |

| File | Sites marked |
|---|---:|
| `packages/core/src/agent-store.ts` | 2 |
| `packages/core/src/async-mission-store-queries.ts` | 2 |

## Why marking, not converting

Both files already carried prose explaining why their literals are
correct. Without the marker the census still counts them as backlog, so
the fleet keeps dispatching workers at them — **three separate workers
have now independently re-derived the same two conclusions.** An
unmarked correct site costs a cycle every time it is re-examined, and
the cost repeats for every worker.

**`agent-store.ts`** picks a *word* for a human reader, not a lifecycle
decision: `(not active — done)` versus `(done)`. It degrades gracefully
on a renamed board — falls through to `(<column>)`, still accurate, just
less specific. Threading a resolution into a synchronous string builder
to choose an adjective is the wrong trade.

**`async-mission-store-queries.ts`** are the fallback arms of an
*already-converted* predicate, and the undefined branch is a **live
intended path**: `AsyncMissionStore.taskStore` is optional, every store
constructed without one relies on the legacy ids answering, and the
caller's two `resolveProjectColumnsForRoles(...).catch(() => undefined)`
calls mean each field can be undefined even *with* a store.

That last point is the distinction worth keeping: this is **not** the
`restart-recovery-coordinator` shape (#3059), where making a parameter
required deleted a production-dead fallback. Requiring it here would
force callers to fabricate a column set — inventing a vocabulary rather
than resolving one, which is the "guess" the fleet rules forbid.

## One mechanical note for future markers

**A marker only excuses the construct it precedes.** My first pass put
one comment above `isComplete` and moved 3 of 4 sites — `isArchived`,
two lines below, needed its own. Worth knowing before someone marks a
block and assumes it covered the siblings.

## Verification

- census: backlog 88 → 84, deliberate 128 → 132
- `agent-store-pause-marker-clear`, `agent-store-routing-policy`,
`mission-store.sync-auto-merge` — 18 tests green
- `tsc --noEmit` on `@fusion/core` clean; `pnpm lint` clean

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

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

## Summary by CodeRabbit

* **Documentation**
  * Clarified how task-column wording handles renamed board columns.
* Documented the fallback to “done” when terminal-column information is
unavailable.
  * No user-facing behavior changes.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:34:05 -07:00
gsxdsm
18fab9b8e5 fleet: name the WIP half of an existing fallback (census 88 → 87) (#3070)
## Census

| | column guards |
|---|---|
| before | **88** |
| after | **87** |

## What

`ephemeral-worker-manager.ts` answers its unresolvable-workflow default
two ways, two lines apart:

```ts
if (TERMINAL_TASK_COLUMNS.has(task.column)) return true;   // named set — not counted
return task.column !== "in-progress";                       // inline — counted
```

Both are the **same documented fallback** — the block carries one
`DELIBERATE-LITERAL` marker covering both — but only the inline one was
on the backlog, because the census reads comparisons regardless of which
branch they sit in while a set is a definition.

Naming it makes the pair consistent and stops the site reading as
unconverted debt.

## Correction to my own flag in #3064

I listed `ephemeral-worker-manager`, `backlog-pressure-reporter`,
`merge-queue-ops` and `lifecycle-ops` as *"plain unconverted guards with
no resolution in scope."*

**That was wrong for all four.** Each already imports the resolvers — 7,
4, 3 and 2 references respectively. I wrote the flag without checking,
which is the same mistake as an untested deferral rationale, just inside
a PR body instead of an issue.

Re-examined, the other three are genuinely harder rather than unresolved
— and these are the real reasons:

- **`backlog-pressure-reporter:197`** classifies a **dependency**, a
different row from the one the caller resolved. Per-dependency
resolution is needed or it repeats the wrong-row shape that `taskRevert`
is blocked on.
- **`lifecycle-ops:655`** guards an emit whose **target** is also a
literal (`to: "archived"`). Converting the guard alone leaves the pair
inconsistent — the move-target half is invisible to this census.
- **`merge-queue-ops:352`** is an early return on an already-complete
task inside a merge path that resolves lanes elsewhere; the placement
needs its own judgement about which resolution it should share.

They stay flagged, now with the real reason rather than an unchecked
one.

## Measured

| check | result |
|---|---|
| ephemeral-worker suites | green |
| four gates + strict census | green |
| engine `tsc` | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:31:03 -07:00
gsxdsm
95e4d1f246 fix(test): main is red — the archived-gate parity inventory is stale by two of my conversions (#3072)
## `main` is red

`archived-column-gate-parity.test.ts` fails on clean `origin/main`, on
the **raw-SQL** half. Not a branch artifact — reproduced by checking out
`origin/main` and running it alone.

## Both dropped sites are mine

| file | was → is | cause |
|---|---|---|
| `async-mission-store.ts` | 2 → 0 | **#3046** resolved
`archiveDefinedFeatureBootstrapDuplicate`'s two `<> 'archived'` guards
*together with* the `column: "archived"` write they gate |
| `task-store/async-archive-lineage.ts` | 3 → 2 | **#3042** deleted
`liveParentFilter`, an export with no callers anywhere |

Neither PR knew this inventory existed. The archived gate is enforced in
**three encodings** and only the census-visible one announces itself
when it moves.

Worth noting the mission-store conversion was *complete within its
function*: the `column: "archived"` write is a move **target**,
invisible to the column census, so converting the guards alone would
have been this file's split brain one level in.

## The total is re-recorded, not loosened

8 → 5, rather than relaxing to `toBeLessThanOrEqual`. A fixed total is
what makes a raw template **arriving** as visible as one leaving — and
this guard exists precisely because arrivals are what nothing else
counts.

## What this cost me, since it's the reusable part

Earlier this session I converted a TypeScript `archived` comparison in
`lifecycle-ops.ts` — the guard *and* its emit target together. **This
test caught it**, and its argument is right: converting one encoding of
the archived gate splits the brain, because the SQL halves still compare
the raw string. I reverted.

Then the test stayed red — for an unrelated reason. So the ratchet
simultaneously **stopped a bad conversion** and **was carrying a stale
number from two good ones**. Both halves of that are the ratchet
working; the second half is why a guard needs its inventory updated by
whoever moves it, not by whoever trips over it next.

## Measured

| check | result |
|---|---|
| parity suite | **red on `origin/main`**, green here |
| archived / lifecycle / parity suites | green |
| four gates + strict census | green |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:25:06 -07:00
gsxdsm
141f54e51d chore(core): mark the agent-store status formatter DELIBERATE-LITERAL (census 101→99) (#3063)
Fleet phase. `packages/core/src/agent-store.ts` was the **last** census
file with no branch, worktree, or open PR against it. Claim published by
pushing the branch before starting.

## Census before / after

| | total | this file |
|---|---|---|
| before | **101** | 2 |
| after | **99** | 0 |

`--strict` exits 0, baseline re-recorded. **Reclassification, not
conversion** — the line is unchanged.

## Already decided, in prose the census cannot read

The site was flagged earlier today by another pass, as `FLAGGED AND LEFT
COUNTED`: a pure formatter over `Pick<Task, "column">` with no store and
no task id, whose output is a human-readable status line. On a renamed
board it falls through to `(<column>)` — still accurate, just less
specific. Converting it would mean threading a lane resolution into a
string builder.

That reasoning is right and I did not revisit it. The only gap was
mechanical: a prose note is invisible to the tool, so the site kept
reading as backlog.

## This completes the sweep of unclaimed files

Third and last of these. Together with #3056 (fallback arms) and #3060
(dead sync path), **every census file that was unclaimed this phase has
now been examined, and not one of them needed a conversion.** Each was
either a three-state fallback arm — where the legacy id is the answer
when resolution fails, and removing it would break the caller — or a
site a previous pass had already reviewed and deliberately kept.

That is the finding worth carrying forward. The remaining **99** is not
a work queue: a meaningful share is correct code the tool cannot
distinguish from owed work, and every fleet pass pays to re-derive it.
Since all workers rank by the same `byFile` output, we also converge on
the same top file — which is how `self-healing.ts` drew three parallel
conversions, two of which are now unmergeable.

Two cheap changes would fix both symptoms:
1. **Mark reviewed-and-kept sites** so the count means *conversions
owed*. Two lines each.
2. **Push the branch at claim time** so `git ls-remote` is authoritative
before work starts. Costs nothing; I did it for all three of these.

## Verification

- `census --strict` exit 0; `tsc --noEmit` **0 errors**
- `check:fnxc-future-dates`, `check:lane-wiring` — exit 0
- Comment-only diff; no behaviour change

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:16:16 -07:00
gsxdsm
581e6fba43 chore(core): mark the async-mission fallback arms DELIBERATE-LITERAL (census 108→106) (#3056)
Fleet phase. Claimed `packages/core/src/async-mission-store-queries.ts`
— **the only census file with no branch, no worktree, and no open PR
against it.** Claim published by pushing the branch before doing any
work.

## Census before / after

| | total | this file | deliberate |
|---|---|---|---|
| before | **108** | 2 | 128 |
| after | **106** | 0 | **130** |

`--strict` exits 0, baseline re-recorded in the same commit.

**This is a reclassification, not a conversion.** The same two lines are
still there. A reader comparing 108 → 106 against my #3047's 126 → 121
should know only the latter changed behaviour.

## Why marking is the right answer here

Both sites are the **fallback arm** of the three-state rule:

```ts
terminalColumns?.complete ? terminalColumns.complete.has(column) : column === "done";
```

`terminalColumns` undefined means the caller could not resolve lanes.
The legacy id is then the only answer that keeps the query working at
all — converting it would delete the fallback and make an unresolvable
caller return nothing. The census counts the literal, but **the literal
is the design**.

The file's own comment shows a previous worker already reached this
conclusion. Nothing recorded it in a form the tool reads, so it stayed
in `byFile` as apparent backlog for the next pass to re-derive.

## The finding this makes concrete

I checked five unclaimed files this phase (`agent-store`,
`github-tracking-state`, `planner-overseer`, `auto-merge-finalization`,
this one). **Every site in them was either a fallback arm or an
already-documented deliberate leave** — `agent-store.ts:236` carries a
comment from today's fleet phase explaining why it stays.

So the remaining count is not a work queue. A meaningful share is
correct code the tool cannot distinguish from owed work, and each fleet
pass pays to re-derive that. Marking them is cheap, mechanical, and
makes the number mean "conversions owed" — which is what every worker
reads it as when picking a cluster.

I marked only the file I claimed. The others belong to whoever holds
them.

## Verification

- `census --strict` exit 0; `tsc --noEmit` **0 errors**
- `check:fnxc-future-dates`, `check:lane-wiring`,
`check:sql-column-literals`, `check:inert-flag-seams` — all exit 0
- No behaviour change: the two expressions are byte-identical, only
comments added

## Note on the marker's granularity

The first marker covered only `isComplete` — the census attaches markers
by *preceding comment*, so the sibling `isArchived` needed its own.
Caught by re-running the census (2 → 1, not 2 → 0) rather than by
reading. Worth knowing before marking a group of related literals.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:13:09 -07:00
gsxdsm
21ef60047e fix(engine): a second complete lane is terminal too — restore the scheduler's dependency reconciliation (main is red) (#3065)
## main is red, and this is the fix

```
FAIL src/__tests__/scheduler-renamed-hold-events.test.ts
 > dependency unblocking (failure mode is a card that waits forever)
 > finds dependents resting in the renamed hold column when a blocker completes
 AssertionError: expected [] to include 'drafting'
```

Not in the thin merge gate's `engine-core` allow-list, so CI stayed
green and only the non-blocking full suite sees it. The test file is
unchanged since #2518; #3051 converted the guard underneath it.

## What broke

#3051 turned the guard into `to === parked.complete || to ===
parked.archived`. `resolveLifecycleColumns` answers **first match per
role** — the right shape for a move *target*, the wrong shape for *"did
this card just reach a finished lane"*, which is a membership question.

Two consequences, both silent:

1. **A board with more than one complete-trait column reconciles
nothing** when a blocker finishes in the second one. The test file's own
header flags this path specifically: *"This one is NOT latency: a
dependent never gets unblocked, so it waits on a blocker that is already
done."*
2. The legacy `done`/`archived` ids stopped matching at all — the
failing assertion.

## Fix

`resolveTaskParkedColumnsSync` gains `terminal`, a membership set:
legacy `done`/`archived` seeded, then **every** complete- and
archived-trait column from the task's own IR.

Seeding legacy ids is safe in the direction that matters here. This is
an **inclusion**: a superset makes the reconciliation run on a move it
would otherwise ignore — one extra query, and it cannot wrongly withhold
work. Seeding a **refusal** is the bug (`node-override-guard.ts`
documents that one); this is not that.

Same sync IR path and same fail-soft legacy default as the single-column
answers, so event ordering and unresolvable-workflow behaviour are
unchanged — the constraint the sync resolver's own header sets.

The two sibling guards in the same listener (dispatch-oscillation reset
at what is now line 1097, and the scheduling wake at 1114) had the
identical arity defect and convert with it.

## Measured

| | result |
|---|---|
| before | `scheduler-renamed-hold-events`: **1 failed / 9 passed** |
| after | **11 passed** (one new case) |
| `src/__tests__/scheduler*` | **14 files / 143 tests pass** |
| `tsc --noEmit -p packages/engine` | clean |
| census `--strict` / `check-lane-wiring` / `check-fnxc-future-dates` |
clean, no baseline movement |

**Proved it is main's red, not my branch's:** I checked out
`origin/main:packages/engine/src/self-healing.ts` over my unrelated
fleet branch and re-ran — identical failure. Then branched this fix
straight off `origin/main`.

**Mutation-tested.** Restoring `to === parked.complete || to ===
parked.archived` fails **both** the pre-existing case and the new
second-complete-lane case. The new test is not vacuous: the second
complete lane is invisible to first-match resolution, so it cannot pass
against the old guard.

## Not done here

I did not convert the remaining `to === parked.review` / `from ===
parked.wip` single-column comparisons in this listener. Review is
genuinely two roles (`mergeBlocker` + `humanReview`) and wip has its own
limit-setting semantics — both want the same membership-vs-target
judgement applied deliberately rather than swept in behind a red-fix.
Flagged, not guessed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:12:53 -07:00
gsxdsm
998d75da3b refactor(core): resolve the hand-off archive guard by role (fleet) (#3054)
## Census

| | column guards |
|---|---|
| before (this branch) | **122** |
| after | **118** |

Baseline re-recorded in the same commit, as the ratchet requires. (Four
of the delta land with #3052; this PR carries `moves.ts`.)

## What changed

`handoffToReviewImpl` refuses a hand-off from an archived card. Against
the literal `archived`, a board whose archive lane is renamed **never
matched** — so an archived card could be handed to review, and the
invariant `HandoffInvariantViolationError` exists to protect was
silently unenforced.

The IR is resolved at the guard rather than 28 lines below where
`handoffTarget` already reads it; the later read now **reuses** it
instead of resolving twice. The hoist is safe because this function has
already awaited `readTaskRowAsync` above — no new tick boundary. That's
the specific hazard blocking the scheduler cluster, so I checked it here
rather than assuming.

Absent or trait-free IR keeps the legacy id: unconverted boards are
byte-identical.

## Fleet intelligence: the backlog is now essentially fully triaged

I worked down the census top-files list and verified each before
writing. **Every remaining cluster is claimed, fallback-by-design, or
documented-blocked:**

| cluster | guards | status |
|---|---|---|
| `self-healing.ts` | 51 | **claimed** — checked out in another worktree
(`convert/self-healing-lane-cluster-u7`) |
| `scheduler.ts` | 12 | **blocked**, documented at line 907 —
`task:moved` prologue is synchronous; hoisting reorders this listener
against every other subscriber |
| `notification-service.ts` | 5 | **blocked**, documented — needs the
wedge-episode contract serialised first; the second site needs
gate-placement judgement in `handleTaskUpdated` |
| `executor.ts` | 4 | **claimed** (`fleet/executor-lifecycle-roles`) |
| `restart-recovery-coordinator.ts` | 4 | **trait-fallback arms** — the
census counts these as already converted |
| `taskRevert.ts` | 2 | **blocked**, documented — would classify a
*neighbour* task with the modal's own flags (the wrong-row shape, worse
than the literal) |
| `project-store-ops.ts` | 2 | **blocked**, documented — the dead SQLite
twin; its first statement throws under PostgreSQL |
| `github-tracking-state.ts`, `planner-overseer.ts`,
`async-mission-store-queries.ts`, `register-task-workflow-routes.ts` | 2
each | **trait-fallback arms** |
| `auto-merge-finalization.ts` | 2 | one is the `catch`-block degraded
fallback; the other is a reason string |

So the mechanical conversions are done. What's left needs either a
design change (scheduler's event payload, notification's episode
contract) or per-row lane data that doesn't exist at the call site yet
(`taskRevert`).

**That's the useful signal for the fleet**: further census reduction
isn't a matter of more conversion passes. Forcing these would produce
exactly the "conversions that break the code and improve the number" the
learnings doc is named for.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:07:19 -07:00
gsxdsm
af470f7c05 convert(engine): self-healing lane cluster 56 -> 38 guards (repo 126 -> 108) (#3049)
## Census before / after

```
                                    before    after
self-healing.ts column guards          56        38
repo-wide COLUMN guards (backlog)     126       108
```

`self-healing.ts` was the largest single cluster by a wide margin — 56
guards against 12 in the next file. Baseline re-recorded in the same
commit; `--strict` green.

## Converted: 15 guards across 11 sweeps

Existing helpers only — `resolveProjectColumnsForRoles` with
`TERMINAL_ROLES` / `REVIEW_ROLES` / `countsTowardWip` / `hold` /
`archived`, the same shape this file already uses. No new helper, no new
resolution pattern.

What each was silently doing on a renamed board:

| sweep | behaviour before |
| --- | --- |
| `archiveStaleDoneTasks` | **both** guards inert, so every card counted
as an active dependent and the sweep archived **nothing at all** |
| `reconcileDependencyBlockingLeases` | no holder matched, so a stale
file-scope lease blocking an unmet dependency was never cleared |
| `reconcileCompletedBlockedTasks` | work whose blocker had cleared
stayed parked instead of advancing |
| `reconcileInReviewUnmetDependencies` | a card sat in review with unmet
dependencies and no rebound |
| `reclaimStaleActiveBranches` | archived cards were eligible for branch
reclaim |
| `reconcileInReviewBranchRebind` | the rebind list was empty |
| `autoReboundPausedScopeDecayDetailed` | no card was ever seen as
executing |
| `detectStalledCards` | finished cards counted as stall candidates |
| `recoverApprovedStrandedAiMergeCommit`,
`recoverDriftedAgentTaskLinks`, `cleanupStaleTempMergeWorktrees` | same
shape |

**Reused rather than duplicated:** `recoverWedgedActiveMerge` already
resolves `wedgedReviewColumns` via `resolveReviewColumnsFor` three lines
above the site I was converting, so the site now uses it instead of a
second resolution of the same question.

## One site I converted and then reverted

`clearStaleBlockedBy`'s memo closure carries an FNXC note stating the
literal is **deliberate**: the closure only decides whether to re-log an
already-logged blocker, so a renamed board costs a duplicate log line —
not a wrong lifecycle decision — and restructuring a sweep's control
flow to convert a logging decision is the wrong trade.

I read that note *after* editing the line. Restored.

Worth flagging separately: **it has the reasoning but no
`DELIBERATE-LITERAL` marker**, so the census keeps counting it and it
re-appears in the backlog as if unexamined. That is a marker gap, not a
conversion gap — the next person will make the same mistake I did.

## Not converted — flagged, not guessed

Ten of the twenty-four remaining sites are in **sync predicates with no
resolution seam**:

- `classifyPausedAbortWorkflowRecovery` (3)
- the `start()` task-moved listener (5) — compares event `from`/`to`
columns inside a sync callback
- `isWorkspaceOwnerLive` (1)
- `isPhantomExecutorBinding` (1)

Converting these means threading a flags parameter down from every
caller — precisely the unwired-optional-parameter shape this program
keeps finding inert (five were live on `main` at once per
`unwired-lane-parameter-guard`). They need a decision about *where the
resolution lives*, not a guess from me.

The other **14** are in async sweeps with a seam available and are
ordinary follow-on work in this same file.

## Verification (measured)

- self-healing suites — **816 passed / 41 files**
- `tsc --noEmit`, `eslint` — clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: `@fusion/engine` is private.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Self-healing workflows now continue functioning when workflow columns
are renamed.
* Improved recovery for stalled, blocked, paused, or disconnected
workflow states while preserving existing filters and actions.
* Temporary merge worktrees and drifted agent links are cleaned up more
reliably.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 02:54:39 -07:00
gsxdsm
0fd3e38628 test(engine): PR #3051's scheduler conversion is inert — live-PG refutation (#3058)
## Escalation — a conversion on main changed nothing

**#3051 ("scheduler.ts 12 → 2 lifecycle-column guards") is inert.** It
widened `resolveTaskParkedColumnsSync` from `{hold,intake}` to the full
role set and replaced ten handler literals with `parked.review` /
`parked.wip` / `parked.complete` / `parked.archived`. The census fell by
ten. The behaviour did not change, on any board.

Everything rests on one line in that helper:

```ts
const l = resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(taskId));
```

`resolveTaskWorkflowIrSync` resolves through the sync workflow
**selection** reader, which answers `undefined` for every task under
PostgreSQL — the shipped backend. The resolver takes its `!workflowId`
branch and returns the **default builtin IR**.

Note the shape precisely, because the obvious reading is wrong and this
PR corrected itself on it mid-run: the helper does **not** get
`undefined` and fall through to `?? legacy.review`. It gets a **real IR
that resolves real traits** — the default board's. So `parked.review` is
`"in-review"` for every card on every board, the `?? legacy` arms are
dead code, and the helper answers with full confidence. It looks
resolved at every level except the one that decides the answer.

## Evidence (live PostgreSQL, 3/3 passing)

For a card bound to a **stored** renamed workflow and sitting in that
board's review column (`checking`, carrying `human-review` +
`merge-blocker` + `merge`):

| | sync path (what every converted arm uses) | async resolver | board
actually declares |
|---|---|---|---|
| review | `in-review` | `checking` | `checking` |
| wip | `in-progress` | — | `building` |
| complete | `done` | — | `shipped` |

The async arm is in the test on purpose: it attributes the failure to
the **sync path** and nothing else.

The **control** is the point of the whole thing — on the default board
the sync answer is *correct*, by coincidence rather than resolution.
That is why every default-board scheduler test passes either way, and
how ten inert conversions read as a fix.

## Why this is worse than leaving the literals

A conversion that changes nothing is worse than an unconverted literal,
because **the literal was counted and this is not.** Ten guards left the
backlog, the file now reads as converted, and the next reader has no
reason to look again.

Two further signals that this was not a deliberate trade-off:

1. The FNXC block still standing directly above the handler (unmodified
by #3051) **contradicts the code beneath it** — it says these ten arms
cannot be converted this way and names `resolveTaskParkedColumnsSync` as
the hazard-avoidance device, not the fix.
2. #3051's own added note asserts the fix as fact: *"so on a renamed
board PR monitoring never started or stopped, failure bookkeeping never
recorded, and terminal cleanup never ran."* Those failures are real.
This conversion does not fix them.

## Scope — driven vs argued, stated in the file

- **Driven:** the roles the sync path yields for a real card on a real
stored renamed board, against a real PostgreSQL store, versus the async
resolver on the same card.
- **Not driven, and the file says so rather than substituting a spy:**
the `parked.review` arm's own side effect. Its only outputs are four
dispatch-oscillation fields that do not round-trip through `updateTask`
on this store (measured: writing `dispatchStormCount: 3` reads back
`undefined`), so there is no persisted observable. The behavioural half
is carried by the sibling
`workflow-scheduler-parked-columns-live-e2e.pg.test.ts`, which drives
the **same helper** on the hold role through to persisted state.

## The real unblock

Unchanged from the note already in the file: carry the resolved lanes
**on the `task:moved` payload**, so no listener resolves at all. That
removes the class rather than one instance, and it is the only option
that survives the synchronous-prologue constraint — these listeners run
in the same tick as a synchronous emitter, which is why an `await`
cannot simply be added.

## Verification

`test:gate` exit 0 · live-PG E2E surface **174/174** · census exit 0 ·
`pnpm lint` clean. Test-only; no production file touched.

## Recommendation

Do not revert #3051 — the widened helper is harmless and the note it
added is useful once the resolution is real. **Restore the ten guards to
the census**, or land the payload change. Either way the count must not
read as paid.

Related: **#3055, #3050 and #3049 are all converting `self-healing.ts`
concurrently** — three PRs, one file, 51 guards. Worth de-conflicting
before any of them merges.
2026-07-31 02:51:25 -07:00
gsxdsm
740fea38c2 fleet: restart-recovery-coordinator.ts 4 → 1 (dead fallbacks deleted, not converted) (#3059)
Claiming `packages/engine/src/restart-recovery-coordinator.ts`.

## Census before/after

| File | Before | After |
|---|---:|---:|
| `packages/engine/src/restart-recovery-coordinator.ts` | 4 | **1** |

## These were deletions, not conversions

All three sites were fail-soft fallbacks behind an **optional**
`reviewColumns` parameter:

```ts
return (reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review")
```

Production never took that branch — `self-healing.ts:13646-13649`
supplies the resolved set at every call site. So the correct change is
to make the parameter required and delete the literal, not to swap it
for a role lookup.

## The trap this hit, which would have shipped a crash

**Making the parameter required produced ZERO tsc errors.** That looked
like proof the fallback was unreachable. It is not: the engine
`tsconfig` covers `src` and not `__tests__`, so the type-checker cannot
see the callers that actually relied on the default. Running the tests
surfaced them immediately as `TypeError: Cannot read properties of
undefined (reading 'has')`.

This is the same class as finding 2 in
`docs/solutions/best-practices/proving-a-code-path-actually-runs.md` — a
negative result from a checker that cannot see the thing it is being
asked about. Anyone converting a `src`-only-typechecked package should
assume tsc is blind to test call sites.

The blast radius was also one site larger than grep suggested: the
`isRecoverableMissingWorktreeReviewFailure` **combiner** threads the set
to all three inner predicates. Its own comment already names why — *"a
caller cannot convert the outer question and leave one of the three
inner ones on the legacy id — the half-conversion shape this program
keeps finding."*

Tests now pass the set production always passes, preserving exactly what
each case asserted.

## Remaining 1, flagged not guessed

`L149` uses a different shape (`isReviewColumn ?? task.column ===
"in-review"`) whose callers I did not establish. Absence from grep is
not proof of no caller, so it stays counted.

## Verification

- census: 4 → 1
- `restart-recovery-coordinator` + `self-healing` — **424 tests green**
- `tsc --noEmit` clean; `pnpm lint` clean

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:51:14 -07:00
gsxdsm
06717ac3fa refactor(engine): resolve replan-target's advancement test by role (fleet, 4 sites) (#3052)
## Census

| | column guards |
|---|---|
| before | **126** |
| after | **122** |

`replan-target.ts`: **4 → 0**, and it drops out of the top-files list.
Baseline re-recorded in the same PR, as the ratchet requires.

## What changed

`hasAdvancedPastPlanning` asked "has this card moved past planning" as
four literal comparisons — `in-progress`, `in-review`, `done`,
`archived`. It now asks the same question in roles, from lanes the
**caller** resolves.

## Caller-resolved is the whole point

The module's sync twin `resolvePlannerLanes` reads
`store.resolveTaskWorkflowIrSync`, which returns the **default workflow
IR for every task under PostgreSQL**. Converting through it would have
improved the census while answering about a board the card isn't on —
the second failure shape in the learnings doc, already proven at this
exact seam by
`workflow-planner-lanes-sync-vs-async-live-e2e.pg.test.ts`.

The only production caller is `async`, so it uses
`resolvePlannerLanesForTaskAsync`.

**The caller's own inert resolution is fixed too**, not just the four
arms: `releasedToTodo` compared against `resolvePlannerLanes(...).hold`
— the sync twin — so it read `todo` on every board regardless of
vocabulary. One async resolution now supplies the planner column, the
merged-planning column and the forward lanes.

## Flagged, not guessed

The archive lane is a **separate argument** rather than a fifth
`PlannerLanes` role. Adding the field surfaced a genuine divergence
between the sync and async twins — `_workflow-vocabulary-fixture` models
no archive lane, so they disagree there — and that fixture backs **37
test files**. That divergence deserves its own change with its own
evidence; forcing it through a conversion PR would have meant editing a
37-file fixture to make my own change pass.

## Two larger clusters I did NOT claim, with reasons

I went by census size first and verified before writing:

- **`self-healing.ts` (56 guards, 44% of the backlog)** — already
claimed. Three branches hold it, one checked out in another worktree
(`convert/self-healing-lane-cluster-u7`). I'd drafted four sibling role
helpers before checking; reverted rather than collide.
- **`scheduler.ts` (12 guards)** — blocked by design and already
documented at line 907 by a prior fleet worker. The `task:moved` handler
is `async` but its **prologue is not**: no `await` between entry and the
terminal-blocker branch ~55 lines down, so hoisting a resolution turns
the prologue into a microtask and reorders this listener against every
other synchronous subscriber ("verified, not assumed"). Lazy resolution
doesn't help — the *condition* needs the lanes. Unblocking needs the
emitter to carry resolved lanes on the payload, which is a design change
rather than a conversion.

`restart-recovery-coordinator.ts`'s 4 sites are the trait-fallback arms
the census already counts as converted — converting those would delete
the legacy fallback, not add resolution.

## Measured

| check | result |
|---|---|
| replan + planner-lane suites | 11 files, **102 tests green** |
| triage suites | **374 tests green** |
| five gates + strict census | green; `tsc` clean |
| unconverted callers | byte-identical — absent lanes fall back to
`LEGACY_PLANNER_LANES`, absent `archivedColumn` keeps the legacy id |

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:39:45 -07:00
gsxdsm
107a1e790a fix(core): the review lane was resolved for two stall signals and literal for the other two (#3053)
## Claim

Largest **unclaimed** census cluster. `self-healing.ts` (56) is the
capacity worker's file and `scheduler.ts` (12) is blocked (below), so I
took **@fusion/core** — 16 bare guards across 12 files, untouched by any
open PR.

## Census before / after

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

**Unchanged, and that is the honest result — not a failed conversion.**
The repo's sanctioned device is an optional *resolved* parameter whose
default stays the legacy literal (the exemplar is
`restart-recovery-coordinator.ts`, watched by the unwired-lane-parameter
guard). The literal survives as the default arm, so the counter cannot
see the conversion.

**This matters for the fleet phase.** The census is not a progress meter
for this pattern. A worker driving the number down has only two ways to
move it, and both are wrong:

1. **Delete the fallback** (make the parameter required) — prior review
explicitly argued against this; `cli-active-count-lanes.test.ts`
deliberately covers the no-argument path.
2. **"Convert" with `resolveTaskWorkflowIrSync`** — that reader returns
`undefined` unconditionally under PostgreSQL, the shipped backend. It
drops the count while behaving *exactly* like the literal. That is the
inert-conversion class, and `merge-queue-ops-2.ts:53` already carries a
flag note saying so.

I measured the split across all 126: **18 are fallback arms of
already-converted seams; 108 are bare guards.** The headline number
conflates them.

## What changed

`reads.ts` states the invariant in its own words —

> RESOLVED BEFORE THE FIRST SIGNAL, because two adjacent signals must
not disagree.

— and then called two of the four stall signals with the literal:

| signal | before |
|---|---|
| `getInReviewStallReason` | resolved (`reviewColumns`) |
| `getInReviewStalledSignal` | resolved (`reviewColumns`) |
| `detectStalledReview` | **literal `"in-review"`** |
| `hasFreshAgentLogActivitySinceTaskUpdate` | **literal `"in-review"`**
|

On a renamed board `stalledReview` returned `undefined` for every card,
and the fresh-activity gate answered `false` — so `executingTaskIds`
stayed empty and the board showed Stalled / Merge stalled *while a
merger was visibly streaming*, the precise regression that function's
own FNXC note says it was restored to prevent.

Both now take an optional resolved `reviewColumns`. All four hydration
passes pass the set **they already had in scope one line away**; two
needed only a hoist, one reused the per-row map, one was resolving the
same set inline twice.

## Mutation evidence

| Mutant | Result |
|---|---|
| baseline | 11 passed |
| revert the detector guard to the literal | **2 failed** |
| make the parameter a widening (`reviewColumns ? true`) | **2 failed**
|

The second matters: it proves the new parameter is a real gate and not a
change that merely makes every card eligible. Both arms are asserted,
since the literal default is load-bearing for every caller outside
`reads.ts`.

## Flagged — do not guess

- **`scheduler.ts` (12 guards).** All 12 sit inside *synchronous*
listeners (`task:moved`'s sync prologue; `task:updated` is sync
outright). The only sync resolver available,
`resolveTaskParkedColumnsSync`, is already used at lines 929/1130/1157
and is **inert under PostgreSQL** — my own live-PG E2E proves it always
returns the default board. "Converting" these with it would drop the
census by 12 and change nothing. The existing note at 908–926 names the
real unblock: carry resolved lanes on the event payload so no listener
resolves at all. Left alone.
- **`restart-recovery-coordinator.ts` (4)** — already the
optional-parameter device with all three production callers passing
resolved answers. Not backlog.
- **`reads.ts:358`, `audit-ops.ts:208`, `task-id-integrity.ts:444`** —
`"archived"` here is the *cold-storage tier*, not the board column.
Trait resolution would be wrong.

## Verification

`test:gate` exit 0 · full `@fusion/core` unit suite **4880 passed** ·
typecheck exit 0 · `pnpm lint` clean · lifecycle-column census exit 0 ·
FNXC date ratchet exit 0 · lane-wiring census exit 0.

**One unrelated failure to report, not appeased:**
`src/__tests__/postgres/pg-test-harness-template-concurrency.pg.test.ts`
fails under the full suite and **passes in isolation on both my tree and
the untouched baseline** — a pre-existing full-suite concurrency flake
in the PG harness. Not mine, not in the merge gate. I did not quarantine
it: it is another worker's harness, and AGENTS.md warns that
quarantining a concurrency test can mask a real product race. Flagging
for its owner.
2026-07-31 02:39:33 -07:00
gsxdsm
24f5ffaffa fleet: scheduler.ts 12 → 2 lifecycle-column guards (#3051)
Claiming `packages/engine/src/scheduler.ts` from the census work order.

## Census before/after

| File | Before | After |
|---|---:|---:|
| `packages/engine/src/scheduler.ts` | 12 | **2** |

Measured with `scripts/lifecycle-column-census.mjs` (kind `column`
only).

## The file already had the right shape — it just under-answered

`resolveTaskParkedColumnsSync` already resolves a task's lanes from its
own workflow, **synchronously on purpose**: these run inside
`task:moved` / `task:updated` listeners, and its own comment records why
an `await` is forbidden there — it would defer everything after it to a
microtask and reorder handlers relative to a synchronous emitter. It
also already fails soft to the legacy ids.

But it only returned `{hold, intake}`, so every *other* lane question in
the same listeners was still asked with a literal. Widening it to the
full role set converted ten sites with no new abstraction, no new
resolution per site, and no change to the event-ordering contract.

## What was silently broken on a renamed board

- **PR monitoring never started** (`to === "in-review"`) and **never
stopped** (`from === "in-review"`) — a card's PR either untracked, or
tracked forever with its buffered comments never drained.
- **Terminal cleanup never ran** (`to === "done" || "archived"`).
- **The wip → hold failure bookkeeping never recorded** (`from ===
"in-progress"`).

None of these throw. They just stop happening — which is why the census,
not a red test, is what found them.

## Remaining 2, deliberately not converted

`L1097` (`task.column === "in-progress"`) and `L1171` (`task.column !==
"in-review"`) sit outside the listener where `parked` is in scope. They
need their own resolution, and resolving per call there is a different
cost profile than one-per-event; I flagged rather than guessed, per the
fleet rule.

## Verification

- census: `scheduler.ts` 12 → 2
- `scheduler-workflow-cutover` + `scheduler` — 42 tests green
- `tsc --noEmit` on `@fusion/engine` clean; `pnpm lint` clean

Behaviour on an unresolvable workflow is unchanged: the widened helper
keeps the same fail-soft legacy defaults the narrow one had.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:33:28 -07:00
gsxdsm
c9516dbd09 fix(engine): resolve archiveStaleDoneTasks lane guards by role (fleet: self-healing 56→51) (#3047)
Fleet phase. Claimed **`packages/engine/src/self-healing.ts`** — the
largest cluster at **56 of 126** total sites. Verified unclaimed first:
no open PR touches the file and no active worktree held a branch on it.

## Census before / after

| | total | self-healing.ts |
|---|---|---|
| before | **126** | **56** |
| after | **121** | **51** |

`census --strict` exits 0; baseline re-recorded in this commit so the
retired allowances cannot be regrown into.

## What converted, and why each role

`archiveStaleDoneTasks` asked "has this card finished?" by comparing
column ids, so on a renamed board it treated every finished card as live
and archived nothing — the sweep was inert on exactly the boards this
program exists to support.

- **active-dependents scan** and **temp-worktree age gate** →
`TERMINAL_ROLES` (complete ∪ archived): both ask "is this card done
with, in any sense?"
- **staleness filter** → `complete` **alone**: this sweep *archives*
finished cards, so an already-archived card is not a candidate. Using
the terminal pair here would have made the sweep consider its own
output.

**Union, not per-task, deliberately.** Over-inclusion is free at these
sites because the per-card check still discards, and the union needs no
per-task workflow selection — the failure mode
`resolveWorkflowIrForTask` has, where a card with no recorded selection
silently resolves to the built-in board. Recorded in
`docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md`.

## The half-converted state is the interesting part

Converting only the first two guards made `archiveStaleDoneTasks`
**register as a converted sweep** — the existing ratchet suite grew from
**36 to 38 tests** — and it then failed for still carrying `t.column !==
"done"`.

That is the failure mode worth naming: a partial conversion is worse
than none, because the function now *looks* converted (it calls the
resolver, it reads as role-aware) while one guard still pins it to the
legacy vocabulary. Finishing the function turned it green. I would not
have caught it from the diff.

## Verification

- `self-healing` suites — **807 pass** (41 files)
- `tsc --noEmit` — **0 errors**
- `census --strict`, `check:lane-wiring`, `check:fnxc-future-dates`,
`check:inert-flag-seams`, `check:sql-column-literals` — all exit 0

## Flagged, not guessed — the remaining 51

Deliberately left, each for a stated reason rather than an omission:

1. **Move-transition matrices** (~1489–1504): `from`/`to` pairs encoding
a legal-transition graph (`in-progress → todo|in-review|done|archived`).
These are the *shape* of the lifecycle, not a lane lookup; converting
them needs a transition-role model that does not exist yet. Guessing
here would encode a wrong graph.
2. **`getLiveTaskColumn` comparisons** (~1398, 5313–5342): compared
against a normalizing accessor that manufactures `"archived"` for
soft-deleted rows. Those are protocol values, not column ids —
converting them changes what the sentinel means.
3. **Sites without store access** in scope (several module-level
predicates): need the resolved set threaded in as a parameter, which is
a seam change per call site, not a substitution.
4. **`todo` requeue targets** (1927, 6181, 6303, 12060–12064): these
pick a destination, so they want the single `intake`/`hold` answer from
`resolveLifecycleColumns`, not a set — different arity, and several are
inside sweeps whose rebound semantics I would be changing rather than
preserving.

Each is a real conversion; none is a one-line substitution, and doing
them blind is how a guard count drops while behaviour gets worse.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:28:01 -07:00
gsxdsm
eb0ee4ae98 fleet: executor.ts 7 → 4 lifecycle-column guards (3 converted, 4 flagged out of scope) (#3048)
Claiming `packages/engine/src/executor.ts` from the census work order.

## Census before/after

| File | Before | After |
|---|---:|---:|
| `packages/engine/src/executor.ts` | 7 | **4** |

Measured with `scripts/lifecycle-column-census.mjs` (kind `column`
only), not grep.

## Converted (3)

**L17258 — the completed-task watchdog never armed on a renamed board.**
It required the card to sit in a literal `in-progress`. This does not
error; the watchdog simply never fires, which is the silent-guard class
this program exists to remove. The branch immediately above already
resolves the same lane through `resolveWipTargetForTask`, and there is
even an FNXC note there saying `latestColumn` must come from that
resolved value — so the comparison now asks the same resolver rather
than an id.

**L14940 (×2) — the duplicate-handoff finalize never ran on a renamed
review lane.** `fromColumn`/`toColumn` are parsed out of the store's
rejection message (`Invalid transition: 'X' → 'Y'`), so they carry
whatever ids that workflow declares. Comparing them to the literal
`in-review` meant a renamed lane never matched and
`finalizeAlreadyReviewedTask` was skipped, leaving the card
mid-transition with nothing to complete it. Now resolves the task's own
review role, falling back to the legacy literal when the workflow cannot
be read — so behaviour is unchanged wherever the vocabulary is
unreadable.

## Flagged, not converted (4) — per the fleet rule that behavior changes
are out of scope

**L3557 / L3581 / L3632 / L3642** are branch conditions inside the
**synchronous** `store.on("task:moved")` listener. Resolving a task's
workflow requires an `await`, which is not available in a sync
listener's condition. Moving the test into the deferred body would widen
the branch to every non-forward move and then re-narrow it — a
**behaviour change to the planning-evacuation path**, not a vocabulary
conversion. Converting them properly means making the listener async,
which wants its own commit and its own test.

I flagged rather than guessed, which is why this is 7 → 4 and not 7 → 0.

## On test coverage, stated plainly

Both converted sites are pure resolver swaps in `async` contexts,
verified by tsc, the census delta, and the existing executor suites (48
tests green). I did **not** add new fixtures: this is the file where I
twice wrote tests that passed against the *unconverted* code —
`recoverCompletedTask`'s seven early-return guards make negative
assertions succeed trivially — and reverted both times rather than claim
coverage I did not have. A fixture that genuinely drives L17258 needs a
satisfied `workflowStepResults` so the run does not divert into graph
re-entry; that is worth doing, and it is worth doing honestly rather
than as a green-looking placeholder.

## Verification

- census: `executor.ts` 7 → 4
- `tsc --noEmit` on `@fusion/engine` clean; `pnpm lint` clean
- `executor-graph-boundary`, `executor-task-done-summary`,
`executor-triage-column-audit`, `executor-step-session` — 48 tests green

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:27:49 -07:00
gsxdsm
4878bda197 fix(core): the mission bootstrap duplicate was archived into a lane the board does not declare (#3046)
## Invisible to both censuses

`archiveDefinedFeatureBootstrapDuplicate` writes `tasks.column`
**directly** rather than through `moveTask`:

```ts
.set({ column: "archived", updatedAt: … })
```

- the **lifecycle census** reads comparisons — an assignment isn't one
- the **move-target census** reads `moveTask` call arguments — this
never calls it

So on a board whose archive lane is renamed, the duplicate landed in a
column that workflow doesn't declare: a card in a lane the board can't
render, from a path that runs during ordinary feature bootstrap.

## Reuses the helper this class already has

`archivedLanesFor(taskId)` was added for the guards further up the same
file. It returns the legacy id when the task has no resolvable workflow,
so an **unconverted board is byte-identical**. No new resolution
machinery — the two `<> 'archived'` guards become `notInArray(column,
[...lanes])` and the write targets the resolved lane.

A board declaring several archive lanes is arbitrated by taking the
first, the same choice `resolveLifecycleColumns` makes. Multiple archive
lanes aren't a shape the builtin lineages produce.

## Measured

| check | result |
|---|---|
| mission-store PG suite | **36 → 38**, all green |
| new pair | differential — `filed` collides with no legacy id, and the
default-lineage control still lands in `archived` |
| mutation (hardcode the target back) | fails the renamed case |
| SQL literal gate · `tsc` | green |

## How this was found

Measuring the literal-column-**write** population for #2839: 51 raw
sites, of which 20 are the four builtin workflow IRs declaring their own
columns (correct by definition) and several more are archive-*entry
record* fields rather than board columns. This is the one I verified is
a real board write on a live path.

Worth noting the measurement itself was wrong twice first — my glob was
`packages/*/src/**/*.ts`, which requires a subdirectory and silently
skipped every top-level file in `src/` (including this one), and my
script printed only the first 14 findings so the grouping was over a
truncated list. Same scope-blindness class as #3000 and #3002, this time
in a throwaway scanner.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:22:27 -07:00
gsxdsm
8b82e77fbf chore(core): delete liveParentFilter — no caller, and it carried a legacy lane literal (#3042)
Found while enumerating archive-exclusion sites for #3041.

## Unambiguously dead

`liveParentFilter` has exactly **one** reference in the repo: its own
definition.

- not exported from `index.ts` or `index.gate.ts`
- no test imports it
- no production code calls it

It nonetheless contained `column != 'archived'`, so it was one of the 22
sites the SQL column-literal gate tracks.

## Why delete rather than convert

Converting it would mean adding lane resolution to code nothing runs —
risk with no behaviour. That's the same argument #3041 makes for *not*
converting the other two dead sites; deleting is the version of it that
also removes the literal.

## The gate it documents is not being deleted

Its docblock describes the document/artifact visibility gate
(VAL-CROSS-015). That gate is real and still enforced — by the inline
conditions inside `listLiveTaskDocuments` and `listLiveArtifacts`, which
is presumably why this helper was never wired up in the first place.
Only the unused composition goes.

## Measured

| check | result |
|---|---|
| SQL literal population | **22 → 21**; the gate ratcheted its own
baseline down and asked for the commit, included here |
| `taskstore-remaining.test.ts` (archive-lineage suite) | **27 tests
green** |
| six gates + `tsc` | green |

## Not deleted, deliberately

`listLiveTaskDocuments` and `listLiveArtifacts` are referenced **only**
by that test file. That's a weaker signal than zero references — someone
may have written them ahead of a consumer. Their literals stay counted,
which is the honest state for code whose intent I can't read from the
repo.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:11:54 -07:00
gsxdsm
511f5b7e2b fix(core): archived tasks leaked into the live feed on a renamed board (#3041)
From my own #2839, re-measured today. Of that issue's SQL-literal sites,
this is the one that decides what a **live view** shows.

## The defect

`listTasksModifiedSinceImpl` backs the SSE watcher and modified-since
polling — the incremental feed the dashboard applies to its task list.
Its `includeArchived: false` branch excluded the literal `archived`:

```ts
conditions.push(sql`${schema.project.tasks.column} != 'archived'`);
```

On a board whose archive lane is named anything else, that predicate
matches **every** row and excludes nothing. Archived cards arrive in the
live feed and reappear on the board.

Nothing errors, and a full refetch filters archived rows by another path
— so the symptom is archived work that comes back until the next reload.
That gets reported as *"the board is flaky"*, not as a bug.

## The fix

`resolveProjectColumnsForRoles` seeds the legacy ids before adding
resolved ones, so the set is never empty and an **unconverted board
excludes exactly `archived` as before**. The literal stays as the
resolution-failure fallback, where excluding nothing would be worse than
excluding the legacy id.

## Surface enumeration — three of four sites are dead

Four sites share this invariant. Verified rather than assumed:

| site | status |
|---|---|
| `reads.ts:558` (SSE / modified-since) | **live** — converted here |
| `liveParentFilter` | **no references anywhere** in `packages/` or
`plugins/` |
| `listLiveTaskDocuments`, `listLiveArtifacts` | referenced **only** by
`taskstore-remaining.test.ts` |

That's why this PR converts one site rather than four — the other three
are production-dead, and converting dead code would add risk for no
behaviour.

## Measured

| check | result |
|---|---|
| new PG suite | **4 cases** — legacy control, the renamed defect, a
live-lane negative, and the forensic `includeArchived: true` read |
| mutation (force the legacy fallback) | fails **exactly** the renamed
case; the other three hold |
| six gates + `tsc` | green |

The negative case is the one that matters most: resolving the archive
role must not start excluding **live** work, or the board silently stops
updating for real tasks — a worse failure than the leak this fixes.

## One process note

I corrupted this file mid-session by mutation-testing it while
uncommitted: a failed restore left a half-applied block, and a later
`git checkout --` discarded the fix entirely. Both were caught by
re-grepping for the symbol rather than trusting the restore. The
reliable pattern is **commit first, then mutate, then `git checkout` to
restore** — which is how the proof above was actually run.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:08:49 -07:00
gsxdsm
35729699b8 fix(dashboard): Lane and ListView sorted every column with the LEGACY role defaults (wrong card order on renamed boards) (#3016)
`sortTasksForDisplayColumn` takes four role answers and defaults each to
the legacy id. Its own header names the callers that never supplied
them:

> *"defaults to the legacy id so the callers that do not resolve flags
(Lane, ListView) keep today's behaviour exactly."*

On a renamed board, today's behaviour is the **wrong order**, silently:

| lane | what is lost |
|---|---|
| hold | priority-then-FIFO queue order — an urgent card is no longer
visibly next |
| complete | completion-date ordering |
| review | the merging card no longer floats to the top |

Nothing throws, nothing logs. The cards are simply in the wrong order —
which is exactly why this survived every existing test in these files:
their fixtures use the built-in ids, where the defaults happen to be
right.

`Board.tsx` already resolves these from `column.flags`. Mirrored here
rather than answered a second way, including its `complete && !archived`
done-like rule.

## Reverted

All **3** new `Lane` cases fail. Each picks inputs where the role order
and the generic fallback **disagree**:

- hold — equal priority, so role order is created-at and the fallback is
task-id
- complete — `columnMovedAt` DESC vs task-id ascending
- review — a `merging` card, which the fallback ignores entirely

**My first draft asserted urgent-first and passed with the fix
reverted.** The generic sort also puts urgent first, so the assertion
discriminated nothing. Recording that because it is the second time this
shape has caught me: an assertion that is *true* is not the same as an
assertion that is *load-bearing*.

## Coverage I do not have

`ListView`'s identical wiring has **no component test**. Its harness
stubs `fetchBoardWorkflows` with a never-resolving promise, and
`listColumns` derives from the resolved workflow — so a renamed board is
not drivable there without reworking that stub, which several other
tests in the file depend on. The call site is covered structurally by
the lane-wiring ratchet (baseline 19 → 17) and by the helper's own unit
tests, but that is a structural guarantee, not a behavioural one. I
would rather say so than imply the two callers are equally proven.

## Verification

Lane + ListView + taskSorting + Board **357 passed** · `pnpm test:gate`
161 + 13 + 487 + 71 · lint · lifecycle census `--strict` · lane-wiring ·
fnxc-dates (TZ=UTC) · changesets — green.


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

* **Bug Fixes**
* Fixed task sorting in lanes and list views after workflow columns are
renamed.
* Preserved correct ordering for completed, on-hold, archived,
merge-blocked, and review tasks.
* Ensured task ordering reflects each column’s configured role rather
than its previous identifier.
  * Maintained consistent ordering across board and list views.

* **Tests**
* Added coverage for renamed workflow columns and their expected task
ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 01:55:08 -07:00
gsxdsm
d4add985fe test(engine): the unwired-parameter guard has been red on main — its list is stale by one (#3033)
## The unwired-parameter guard has been red on `main`

```
A parameter LEAVING this list is the goal; one arriving is a regression — update the list only to shorten it.
  expected [ …(16) ] to deeply equal [ …(17) ]
```

Reproduced on clean `origin/main`, so it is not a branch artifact.

`packages/engine/src/scheduler.ts isWipColumn` is **supplied at both
production call sites now** — `self-healing.ts:4754` and `:5825` pass
`isWipColumn: completedWipColumns.has(blocker.column)` and the
blocked-lane equivalent, wired by #2975/#2987. The list was not
shortened in the same change.

So this is the good direction: a parameter got wired. The assertion just
was not told.

## Shortened, not re-recorded

I diffed the computed set against the recorded one rather than
regenerating:

```
DEPARTED (wired since):  packages/engine/src/scheduler.ts isWipColumn
ARRIVED (new):           (none)
```

Exactly one departure, nothing arrived — so removing that single line is
the whole fix, and it follows the file's own instruction (*"update the
list only to shorten it"*). Re-recording wholesale would have silently
absorbed any arrival too, which is the one thing this ratchet must not
do.

## How it was found

While measuring an unrelated change to the sibling census. **This suite
is outside the merge gate**, which is why a red assertion sat unnoticed
— the same reason #2969's 15 red agent-action tests survived, and worth
noting as a pattern rather than a one-off.

## What I abandoned to get here, and why it belongs in this PR's story

I was trying to remove two false positives from the sibling
`check-lane-wiring` census — `bucketForTask(task: TaskItem)` and
`otherBucketSecondaryLabel(task: TaskItem)`, both flagged only because
`TaskItem` declares `columnFlags?`, both reading it off the entity
internally.

The rule I tried was the sibling guard's own documented one: a
**required** parameter is enforced by the compiler, so it is not this
census's question. It measured perfectly — 15 sites → 13, removing
exactly those two and retaining every genuine entry.

Then it failed `lane-wiring-census-named-types.test.ts`:

```ts
export type MergeContext = { completeColumns?: ReadonlySet<string> };
export function canMerge(task: string, context: MergeContext): string { … }
```

A **required** parameter with a named options type is a shape that
census deliberately covers — `canMerge(task, {})` really can omit the
lane member. My "exact" rule was exact only against the current tree,
and it broke a tested contract. I dropped it rather than edit their test
to match my change.

The two false positives therefore stay baselined, and the cost stands as
previously recorded: a genuinely new unwired call in those two TUI files
would be masked. I do not have a rule I can prove safe, and three
attempts at this class have now traded false positives for worse false
negatives.

## Verification (measured)

- both guard suites — **18 passed / 0 failed** (was 1 failed)
- `check-lane-wiring`, `lifecycle-column-census --strict`,
`check-fnxc-future-dates` — green
- `eslint` — clean (one pre-existing warning, no errors)

Test-only; no product file touched. No changeset.
2026-07-31 01:44:11 -07:00
gsxdsm
e9b24b69e8 fix(dashboard): the duplicate banner judged the canonical by legacy lane ids (#3032)
Found by applying the check I proposed on #3028: **re-test each
`ALLOWED_OMISSIONS` entry — does the omission still fail once the excuse
is removed?** It found a stale excuse on its first run.

## The entry's blocker was never tested

```
"…TaskDetailModal.tsx::isNearDuplicateCanonicalInactive"
  reason: "…Correct supply needs a fetch — a data change. See the note at the site."
```

The reasoning gets the hard part right: passing `detailColumnFlags`
would answer about the **modal's** task, not the canonical, and would
type-check while reading as a conversion. Rejecting that is correct.

Then it concludes the seam needs a fetch — without checking what is in
scope.

- `columnFlagsByTaskId` is **already a prop of this component**
(declared `:367`, destructured `:727`, used for the fan-out map at
`:3718`), keyed by task id.
- The canonical is `tasks.find((c) => c.id === nearDuplicateOf)` — drawn
from the same loaded set the map covers. If the banner can render at
all, the canonical is in `tasks`.

So `columnFlagsByTaskId?.get(canonical.id)` is the canonical's own
flags, no fetch. **`Column.tsx:307` already does exactly this**, with a
comment making the same point about not reusing the row's flags — a
sibling call site of the same function, solved.

## What was broken

The banner's "this duplicates X" warning stayed up when the canonical
had landed in a **renamed** complete lane, because
`isNearDuplicateCanonicalInactive` fell back to the legacy ids and never
saw it as finished. Same user-visible symptom #2997 fixed for the card
chip; this is the modal.

## Verification

| state | result |
|---|---|
| clean | seams gate exit 0; 123/123 across `TaskDetailModal.rendering`
+ `Column.neardup-flags-arrival` |
| revert the supply | **gate exit 1** —
`isNearDuplicateCanonicalInactive() — supplied by 10/11 call sites;
omitted at TaskDetailModal.tsx:1 (of 2)` |

That mutation is the point: with the allow-list entry present, this
exact omission passed silently. It is now defended by the gate rather
than excused by it.

`tsc -p tsconfig.app.json` 0 errors in the file, lint clean, FNXC gate
exit 0.

## The general point

This is the second allow-list entry in two PRs whose stated blocker was
wrong — #3028 removed the other one (*"needs a published-API change"*;
the SDK is `private: true` and every consumer was in-repo).

An `ALLOWED_OMISSIONS` entry is a deferral **carrying a gate's
authority**. It reads as settled, it lives inside the checker, and it
turns "nobody tested this" into "someone tested it and concluded no". A
stale baseline *number* invites a recount; a stale *paragraph* invites
agreement. Both entries this gate carried were stale, and the note at
this call site had even been revised once — the revision corrected which
flags were wrong to pass, and left the untested "needs a fetch"
conclusion standing.

Worth a periodic sweep of the remaining entries as they accumulate; with
these two gone the list is empty, so the cheapest time to
institutionalise it is now.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:41:13 -07:00
gsxdsm
a460a9bbc0 fix(plugins,dashboard): the dependency graph drew every card with the LEGACY lane vocabulary (#3029)
## The third producer of unflagged cards — the one a host-side fix could
not reach

#3025 fixed the two producers that go through `renderTaskCard`.
`GraphTaskNode` is a third: it imports `TaskCard` **directly** through
the plugin's interop shim, so that fix bypassed it and every role helper
inside a graph card kept reading the legacy ids.

The same component also called the stuck predicate without its flags:

```ts
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);   // no columnFlags
```

so `isWipColumnRole` fell back to the literal and **no card in the graph
could ever be stuck on a renamed board**. Because `isStuck` gates
`isActive`, a wedged card rendered with the **active** styling — the
graph reported *"running"* about a task that had not moved in hours,
while the main board showed the same card as stuck.

That asymmetry between two views of one task is the defect, and it is
what the new test pins.

## One cause, so one fix

Both symptoms came from the same gap: `PluginDashboardViewContext`
exposed `tasks` and nothing about the board's vocabulary. It now carries
`columnFlagsByTaskId` — the same per-task map `renderTaskCard` already
uses, **two lines away in the same object literal**.

## I filed this twice as blocked on a public-API change. It was not.

```
packages/dashboard                        @fusion/dashboard                        private: true
packages/plugin-sdk                       @fusion/plugin-sdk                       private: true
plugins/fusion-plugin-dependency-graph    @fusion-plugin-examples/dependency-graph private: true
```

No published surface anywhere in the path — three in-repo private
packages and a hand-written `.d.ts`. **#3026 landed the general form of
that mistake while I was still making it**: a deferral's stated blocker
is a claim, and mine decayed unchecked until I finally measured it.

## Two type decisions worth reviewing

- **`Partial<TraitFlags>`** in the plugin-facing type, not the
dashboard's `ExecutorColumnFlags` — that module's own header restricts
it to `@fusion/core` and `react` imports so external plugin builds can
consume it. Same runtime object either way.
- **`MainContentProps.columnFlagsByTaskId` widened** from `{complete,
archived, intake, hold}` to the flags the map really carries. It is
built from `workflow.columns.find(...).flags`, so the four-flag
declaration was a narrower view than the value — and `countsTowardWip`,
which every wip predicate needs, was invisible through it. That narrow
type is why threading this looked impossible at first.

Absent still means legacy, matching how the host treats remote rows and
off-board columns: the degraded answer is the documented literal, never
*"this board has no wip lane"*.

## Revert proof

Dropping the 4th argument:

```
AssertionError: expected 'graph-task-node graph-task-node--acti…' not to contain 'graph-task-node--active'
      Tests  1 failed | 26 passed (27)
```

The paired case (a fresh legacy `in-progress` card still reads active)
passes both ways by design — it guards against over-detection, so I am
not counting it as coverage.

The gate agrees independently:
`plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx: 1 -> 0`,
baseline re-recorded 16 → 15 in the same commit.

## Verification (measured)

- plugin suite — **185 passed / 20 files**
- dashboard `dashboard/` + `plugins/` suites — **48 passed / 6 files**
- `tsc --noEmit` clean in both packages; `pnpm lint` clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (15, none
added), `check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: all three packages are `private: true`.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:30:47 -07:00
gsxdsm
25b3c06d2d fix(plugins): compound-engineering pipelines stalled forever on a renamed board (#3022)
Closes #3020 — which I filed **instead of** fixing, on a rationale that
turned out to be wrong.

I said the plugin had no scaffolding for faking `CePipelineStore` +
`taskStore` together. It does: `_harness.ts` already builds a real
`PluginContext` over a live PostgreSQL layer. The gap was **two missing
readers on its task-store stub**, not missing infrastructure. I checked
the harness only after filing.

## The defect

`TERMINAL_COLUMNS` is `{in-review, done}`, and the reconciler advances a
pipeline only when **every** current-stage board task is in that set. On
a board whose review and completion lanes are renamed that's false for
every task, permanently:

- the pipeline never advances a stage
- it never creates its outbound task
- it sits `running` indefinitely

Nothing errors, so it reads as work that hasn't finished. Unlike the
display defects in this family (#3014, #3017), the CE flow actually
**stops**.

## Shape

The decision is extracted to an exported `isStageTerminalColumn` because
it *is* the whole decision. Left private it could only be reached
through a pipeline-state + links + board-tasks fixture, and the half
that needed proving is that a renamed board resolves to its own lanes
through this store.

It uses `resolveReviewColumns` rather than re-deriving the union — that
helper is the documented review **set** (`mergeOrchestration ∪
mergeBlocker ∪ humanReview`), so a board splitting those across a merge
lane and a human lane is covered without this site drifting from it.

## Two things my first attempt got wrong

**The fixture spelled traits in camelCase** — `{ trait: "humanReview"
}`. Trait **ids** are kebab-case (`human-review`, `merge-blocker`,
`wip`); the camelCase names are the resolved **flags**. Those columns
therefore resolved to *no roles at all*, silently, because an unknown
trait isn't an error. `complete` is spelled identically in both
vocabularies, which is exactly what made the first run look like
*"complete works, review is broken"* rather than *"the fixture is
wrong"* — I nearly went debugging the production union.

**The harness extension is additive** and inert until a test seeds it,
so all 24 existing plugin suites see the previous shape.

## Measured

| check | result |
|---|---|
| new suite | **4/4** |
| reverting to the literal-only gate | fails **exactly 2** — the
renamed-terminal case, and a board declaring a NON-terminal column named
`done` — while the legacy control and the WIP/intake negative still pass
|
| plugin suite | **24 files, 184 tests green** |
| `tsc` + all five gates | clean |

That second row is the one that matters: the `done`-without-`complete`
board is the only shape where a real resolution and a legacy fallback
disagree, so it's what separates the fix from a lucky agreement.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:20:04 -07:00
gsxdsm
b2d4813410 test(dashboard): cover the dock's renderTaskCard, the second producer #3025 fixed (#3027)
**Stacked on #3025** (its commit is the parent here) — that PR fixes two
producers of a dock/plugin-rendered `TaskCard`, and this adds the
coverage for the second one.

## The gap

#3025 correctly fixes **both** producers, which is the Surface
Enumeration discipline working. Its test covers only `MainContent`.
Measured by deleting the identical line from `useRightDockController`:

```
MainContent.graph-popout    6 passed
RightDock                  33 passed
TaskCard.host-inventory     1 passed
```

All green with the dock's wiring gone. I checked every suite that
touches that hook; none observes the prop.

Its own test comment says:

> REVERT CHECK: drop `taskColumnFlags` from **either** `renderTaskCard`
and this reads "none".

For this producer that is not true, and it is the producer that draws
cards into the **right dock**, where an operator actually sees them. So
the pair could quietly become a single again with every test still
green.

## The test

| state | result |
|---|---|
| #3025 as merged | 2/2 pass |
| delete the dock's `taskColumnFlags={…}` line | **1 failed / 1 passed**
|

Driven through the real `renderTaskCard`, captured off the `renderProps`
the controller hands `RightDock` — it is not on the returned controller
object. `RightDock` itself is stubbed so the assertion cannot fail for
unrelated dock plumbing.

The paired negative asserts an unresolved task receives `undefined`
rather than a fabricated object. That direction matters: inventing flags
would make a card claim traits its board never declared, which is worse
than the legacy fallback it replaces — the same *report, don't guess*
reasoning as #2999's `!target` refusal.

## One harness note

My first mock replaced `../RightDock` wholesale and the hook died on `No
"readStoredRightDockOpen" export is defined on the mock` — the
controller imports its persistence helpers from that module. Spreading
`importOriginal()` and overriding only the two components fixes it.
Recorded in the file because the next person stubbing this module will
hit the same thing.

**Verified:** 2/2, `tsc -p tsconfig.app.json` 0 errors in the new file,
lint clean, FNXC gate exit 0.

If #3025 lands first this rebases to a single test commit; if the two
are taken together the stack applies as-is.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:17:07 -07:00
gsxdsm
43463f1a53 fix(dashboard): plugin- and dock-rendered cards resolved no column traits at all (#3025)
`renderTaskCard` is how a plugin view or the right dock draws a real
task card. **Both** producers built a `TaskCard` without
`taskColumnFlags`, so every role helper inside that card fell back to
the legacy id — archive/revert affordances, progress, the elapsed-time
indicator, the planning badge — for every plugin view on every board.

Both already had the per-task map in scope. `MainContent` uses it **two
lines away** for the near-duplicate canonical lookup;
`useRightDockController` reads `input.columnFlagsByTaskId` for the same
purpose. The card was simply never given it.

## One affordance, two producers

Fixed together rather than one-plus-a-follow-up — that's what the
Surface Enumeration rule is for. Finding the second producer is the only
thing that makes this an invariant fix rather than a repro-shaped one.

## Measured

| check | result |
|---|---|
| new case in the existing MainContent plugin-host harness | the
rendered card must carry its resolved traits; dropping the argument from
either producer reads `"none"` |
| mutation on MainContent's producer | fails exactly that case |
| `dashboard/__tests__` + `useRightDockController` | **35 tests green**
|
| census · inert-seam · lane-wiring · FNXC | green; lint and `tsc` clean
|

## Not done — and my earlier reason for it was wrong

`fusion-plugin-dependency-graph` still calls `isTaskStuck` without
flags, exempted in the inert-seam gate by #3002.

I filed #3003 claiming supply needed a **published-API change**. That's
wrong: `PluginDashboardViewContext` is dashboard-internal and
`@fusion/plugin-sdk` is `private: true`. I checked this time instead of
asserting it — which is how I found the actual obstacle.

The real blocker is that the plugin compiles against **different
dashboard type declarations** than the dashboard source does: it sees a
`PluginDashboardViewContext` without the field I added and an
`isTaskStuck` accepting only three arguments. I built the full chain
(context field → host supply → plugin consumption), hit those three
errors, and reverted the plugin half rather than guess at the type
plumbing inside a behaviour fix.

So the exemption stands with a corrected reason, and #3003 is updated.
That's the second filing rationale of mine to turn out wrong on
inspection this session — after #3020, which I ended up fixing in #3022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:14:13 -07:00
gsxdsm
f8155cafd7 fix(cli): the node-override guard saw only the FIRST wip lane (#3023)
Follow-up to #3019, which merged with an incomplete fix. I found this
while sitting down to write the test that PR was missing.

## The guard still never fired, one lane over

#3019 wired `fn_task_update`'s guard like this:

```ts
const nodeOverrideLifecycle = await resolveTaskLifecycleColumns(store, task.id);
wipColumns: nodeOverrideLifecycle?.wip ? new Set([nodeOverrideLifecycle.wip]) : undefined,
```

`resolveTaskLifecycleColumns` → `resolveLifecycleColumns`, whose
per-role accessor is **first match**
(`workflow-lifecycle-traits.ts:353`):

```ts
const first = (flag) => resolved.find((c) => c.flags[flag] === true)?.id;
```

The guard's contract is **every** column carrying the trait — its own
resolver uses `columnsWithFlag(ir, "countsTowardWip")`. So on a board
with a build lane beside a verify lane, a task sitting in the **second**
wip lane still slipped the mid-flight check, and an operator could still
repoint the node of a running task. That is the defect #3019 set out to
close.

Interchangeable on any single-wip-lane board, which is exactly why it
read as correct — the same arity trap #2975 removed from the surfacing
family.

## The fix

Use `resolveNodeOverrideLanes`, the guard's own resolver, which
`task-update.ts` and `branch-and-pr-entities.ts` already call. All three
callers now resolve identically and the V1/unresolvable fallback lives
in one place. Needed a one-line re-export from `@fusion/core`.

**Mutation:** forcing the resolver to first-match (`.slice(0, 1)`) fails
the new case, 1 of 32.

The new test names **two** wip lanes, because that is the only shape
that separates the two resolutions — a single-wip-lane test passes
against both, which is why #3019's gap was invisible and why I would
have written a useless test if I had not read the implementation first.

## A gate constraint worth recording

My first version passed the resolved object straight through:

```ts
validateNodeOverrideChange(task, normalizedNodeId ?? null, overrideLanes)
```

Identical at runtime, and it turned the lane-wiring gate **red**:
`check-lane-wiring` matches an object-literal argument and cannot see
through a variable, so the correct call reads as UNWIRED. #3019's header
records hitting the same constraint — and it is what pushed that PR
toward resolving the lanes inline, which is where the first-match bug
entered.

So the gate's shape requirement steered a correct instinct into a subtly
wrong implementation. The fix here spells both keys explicitly,
satisfying the gate without the bespoke resolution. Worth someone
deciding whether the census should follow a variable to its initializer
— but that is a change to a shared ratchet, and I have noted it at the
call site rather than making it.

**Verified:** 32/32 core guard suite, `tsc` 0 errors for both packages,
lane-wiring gate exit 0, FNXC gate exit 0, lint clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:08:54 -07:00
gsxdsm
5659ccace9 test(cli): pin the node-override error contract on a renamed board, which is what #3019 actually changed (#3024)
## What #3019 actually changed, pinned — and a correction to my own
claim

I described #3019 as closing a hole where an operator could re-route a
running task on a renamed board. **That was wrong.**
`TaskStore.updateTask` runs the same guard with its own resolved lanes
(`resolveNodeOverrideLanes`) and throws, so the change was refused
either way.

This test is how I found out: I wrote it to cover #3019's wiring and it
passed against a tree with that wiring removed. A test that passes with
the change reverted is not a test, so I went looking for what was really
refusing — and it was the store.

## But the two paths *are* distinguishable, which my correction then got
wrong in the other direction

In correcting myself on #3019 I said the paths were externally
indistinguishable and no test could separate them. Also wrong. Measured
both ways:

| | `details.error` |
| --- | --- |
| pre-check fires (wired) | `"task-in-progress"` — machine-readable
reason code |
| pre-check misses (unwired) | `"Cannot change node override for KB-001
while it is in progress…"` — the store's thrown prose |

So on a **legacy** board a caller could branch on `task-in-progress`; on
a **renamed** board it silently got a sentence instead. That is a real
API inconsistency, visible only to whoever was parsing it — the kind of
thing nobody notices until it breaks.

That is what these cases pin, and it is the honest description of
#3019's value: an error-contract fix, not a security fix.

## Revert proof

With #3019's wiring removed:

```
Expected: "task-in-progress"
Received: "Cannot change node override for KB-001 while it is in progress. …"
      Tests  1 failed | 1 passed (2)
```

Verified by actually reverting, not by reading the source — which is the
discipline that caught both of my wrong claims above.

The paired case ("still allows the override once the card leaves that
wip lane") passes both ways by design; it guards against over-refusal,
so I am not counting it as coverage of the contract.

## Also closes the gap I named in #3019

That PR shipped with `check-lane-wiring` as its only regression proof,
and I said a behavioural test was owed. The two are complementary and
fail for different reasons: **the ratchet** fails if the argument stops
being passed; **this** fails if it is passed and the contract still
degrades.

## Verification (measured)

- **2 passed / 0 failed**
- `tsc --noEmit` clean; `eslint` clean (one pre-existing warning, no
errors)
- `check-fnxc-future-dates`, `lifecycle-column-census --strict`,
`check-lane-wiring` — green

Tests only; no product file touched. No changeset.

## Note on the harness, for whoever writes the next one of these

Seeding a card into a renamed lane has two traps, both inherited from
`merge-blocker-renamed-review-lane.test.ts` and both recorded in this
file's header: the real API is `createWorkflowDefinition` +
`selectTaskWorkflow` (the plausible `saveWorkflowDefinition?.()` does
not exist and the optional call swallows it silently), and moving a card
takes `moveTask`, not `updateTask({ column })`. Both are guarded here by
asserting the card really is in `building` before the subject runs.
2026-07-31 01:06:09 -07:00
gsxdsm
6f936f2de7 fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019)
## The node-override guard never fired on a renamed board

`fn_task_update` called the guard with no options:

```ts
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
```

so `wipColumns` fell back to its documented default of
`{"in-progress"}`. On a board whose WIP lane is named anything else,
`wipColumns.has(task.column)` is false, the mid-flight check passes, and
**an operator can change the node override on a running task** —
precisely what that guard exists to refuse, in its own words:

> "Is this task executing right now?" — keyed on the literal, a renamed
board let an operator change the node override MID-FLIGHT on a running
task, which is exactly what this guard exists to refuse.

That note is attached to the `wipColumns` option added for this purpose.
The CLI simply never passed it.

## Two assumptions in the guard's own docs that did not hold

```
Both callers supply them. An omitted set keeps the legacy id, which is what a caller
without cheap IR access (a CLI tool, a route with only a task row) still gets.
```

1. **"Both callers"** — this is a *third* one, and it was in
`check-lane-wiring`'s known-unwired baseline the whole time.
2. **"a CLI tool … without cheap IR access"** — this handler is async
and has already awaited `store.getTask`, so one more resolve costs
exactly what `resolveTaskLifecycleColumns` already costs elsewhere **in
this same file** (the linked-lineage label at ~1239). The assumption was
reasonable in general and wrong here.

Passed present-but-conditionally-valued rather than as a conditional
argument: an omitted set still keeps the documented legacy default, and
only that shape is visible to `lane-wiring-census`, which matches an
object-literal argument and cannot see a ternary.

## Coverage — stated rather than implied

**There is no new unit test.** The regression guard is the ratchet
itself, and it is a real revert-proof: with the wiring removed,

```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:
  packages/cli/src/extension.ts: 1 unwired now, baseline allows 0
```

Verified by actually reverting it, not by assuming. Baseline re-recorded
19 → 18 in the same commit, so the allowance cannot be regrown into.

A behavioural test would need a custom workflow definition persisted
*and* selected inside the integration harness to get a card resting in a
renamed WIP lane. That is worth doing and I would take it as follow-up
harness work — but it is not part of this fix, and I would rather name
the gap than let "85 passed" imply coverage I did not write.

## Verification (measured)

- **85 passed** across `extension.test.ts`,
`extension-experiment-finalize.test.ts`,
`task-list-board-columns.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (18, none added), `lifecycle-column-census
--strict`, `check-inert-flag-seams`, `check-fnxc-future-dates`,
`check:changesets` — green

Changeset included (`patch`): `packages/cli` is the published
`@runfusion/fusion` and this changes guard behaviour operators rely on.
2026-07-31 00:52:50 -07:00
gsxdsm
5bdb8a1102 fix(dashboard): planner activity was never stamped on a renamed intake lane (#3017)
## How this was found — by re-testing a claim of mine

The learnings doc records "named legacy-id collections" as **measured
and clean**: 48 declarations, all fallback vocabularies, builtin column
lists, or already-converted seams. #3014 disproved that conclusion —
`TIME_INDICATOR_COLUMNS` was in that population and was a live defect.

So I re-measured over the shape that actually matters: **collections
used as a membership gate against a column.** Nine exist.

| site | verdict |
|---|---|
| `columnRoles.ts` ×2, `useSessionFiles.ts` | the no-flags fallback
*inside* the role helpers — correct by design |
| `branch-group-ops.ts` | seeds the legacy pair then unions resolved
lanes — already converted |
| `DocumentsView.tsx` | marked `DELIBERATE-LITERAL` fallback chain |
| `TaskCard.tsx` ×2 | fixed in #3014 |
| `plugins/…/reconciler.ts` | plugin with no trait source — same class
as #3003 |
| **`useTasks.ts`** | **no flags path anywhere in the file** |

## The defect

`useTasks` stamps `recentAgentActivityAt` only for cards in `{triage,
todo}`. The note at that set argues over-stamping is harmless because
every consumer re-checks for an intake lane before showing anything.

That's true, and it **only protects against false positives**. On a
board whose intake and hold lanes are renamed, the pair matches nothing
— so no stamp is ever written, and a correct downstream role check has
nothing to filter. The planning border and pulsing badge never appear
while the planner is actively working the card.

## The supplier ships with the seam

An optional resolver with no caller is the first failure shape in the
learnings doc, and my own gate would flag it — so `App` supplies it in
the same commit. `useBoardWorkflows` moved above `useTasks` to make that
expressible; it depends on `projectId` alone, nothing about tasks, so
reading it first is safe.

Remote rows deliberately get **no** flags — they belong to another
store, and local board-workflow metadata must never be applied to their
ids. That's the rule the footer index already follows.

## Measured

| check | result |
|---|---|
| `useTasks` suite | 124 → **126**, all green |
| reverting the gate to the legacy pair | fails exactly the renamed
case; the negative (renamed WIP is not planning) still passes |
| `App.test` + `useTasks` together | **269 green** |
| gates | all five green; lint and `tsc` clean |

## One observation I could not reproduce

The `App`+`useTasks` pair failed once, on a single unnamed test, and
passed on **four** subsequent runs including three consecutive. The
captured output showed jsdom URL-parse noise from `MissionManager`
fetches rather than an assertion failure, and the same pair is green on
unmodified `main`.

I'm not quarantining another file's test on one unreproducible
observation, but recording it rather than letting a green rerun bury it
— if it resurfaces in CI, this is the prior sighting.

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