Commit Graph

482 Commits

Author SHA1 Message Date
gsxdsm
5795d70b27 fix(engine): assignment load must be resolved per task — #2787 P1 follow-up (#2796)
Fix-forward for the P1 that arrived on **#2787 after it merged** — so it
lands as its own PR rather than a thread reply on merged code.

## The finding

`selectPermanentAgentForTask`'s `activeColumns` was resolved from the
**candidate** task's workflow and then applied to every row `listTasks`
returned. On a project running several workflows — the normal case —
assignments living in another workflow's load-bearing lanes vanished
from the tally, and the already-loaded-agent-wins bug returned through a
different door.

**A column id means something only relative to its OWN workflow.**
`blocker-fanout.ts` documents exactly this and offers a per-task
`classify`; the option is now that same shape rather than a third
invention:

```ts
countsAsAssignmentLoad?: (task: Task) => boolean
```

The scheduler resolves each assigned row against its own IR, sharing one
cache for the selection, so a board spanning three workflows reads three
IRs — not one per assigned card.

## Why this is the third round on the same parameter, stated plainly

1. I added the parameter and **never wired the caller** — inert in
production.
2. I wired it as a **union of wip+review**, which dropped hold/intake
and made it a *regression* for backlog work.
3. I resolved it from **one workflow** and applied it to all — this fix.

Each round was a smaller version of the same error: treating a lane
answer as global when it is per-task, and per-role when it is
per-membership. Worth recording because the first two rounds both looked
correct and both passed their tests — the tests asserted the renamed
case I was thinking about, not the shape of the data.

## Verification

- new cross-workflow case; reverting the predicate to a single
workflow's lanes **fails it**
- `agent-assignment` suite **14 passed**
- `pnpm test:gate` — **161 / 13 / 487 / 71** · lint clean · census
`--strict` exits 0

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:20 -07:00
gsxdsm
90f6319b79 batch-engine tail: re-land the ASYNC half; the sync-resolved half was inert (engine −15) (#2785)
Tail of `batch-engine` (#2773). That PR merged as a squash while later
engine work was still in flight, so `self-healing.ts`, `executor.ts` and
`worktree-pool.ts` landed at their pre-conversion counts. This re-lands
**only the half that is real**, and the reason the other half is not
here is the substance of this PR.

## Census, per file (measured, `--strict` verified)

| file | main | here |
| --- | ---: | ---: |
| `engine/src/self-healing.ts` | 107 | 97 |
| `engine/src/executor.ts` | 15 | 12 |
| `engine/src/worktree-pool.ts` | 3 | 2 |
| `engine/src/ephemeral-worker-manager.ts` | 1 | 0 |
| `engine/src/agent-tools.ts` | 5 | **0** |
| `engine/src/gridlock-detector.ts` | 3 | **0** |
| `engine/src/triage.ts` | 4 | 1 |
| `engine/src/mission-execution-loop.ts` | 2 | **0** |
| **net** | | **−28** |

Baseline re-recorded; `--strict` tightened exactly these 4 entries and
no others.

## Finding: a whole class of conversions in this program is INERT, and
the census scores it as progress

`resolveTaskWorkflowIrSync` returns the **default** workflow IR for
every task in production. The sync selection reader behind it is a
PostgreSQL-cutover stub:

```ts
// packages/core/src/task-store/workflow-definitions.ts:505
export function getTaskWorkflowSelectionImpl(_store, _taskId) {
  return undefined;   // "Backend mode cannot synchronously read PostgreSQL"
}
```

So a guard written as
`resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(id))?.hold`
resolves an IR, asks for a trait, and answers **from the default
workflow for every custom board** — silently. It reads as converted and
the census counts it as converted. `main` gained
`sync-workflow-ir-callsite-allowlist.test.ts` for exactly this after my
branch point; it is what caught me.

I had built three sync resolvers on that reader — `resolveMoveLanesSync`
(self-healing, executor) and a widened `resolveTaskParkedColumnsSync`
(scheduler) — reasoning that a *synchronous* `task:moved` listener needs
a *synchronous* reader. That reasoning was sound about the shape and
never checked whether the reader reads anything.

**Dropped from this PR, deliberately, and NOT re-landed anywhere:**

- `scheduler.ts` 12 → 1 (the widening; the pre-existing narrow helper on
main is untouched)
- the executor `task:moved` handler, incl. the Move-Task hard-cancel
lane comparison
- self-healing's `task:moved` fan-out,
`classifyPausedAbortWorkflowRecovery`, `reconcileInReviewBranchRebind`,
`recoverWedgedActiveMerge`, `recoverPausedAbortFailures`, and 12
single-row lane conversions

Those sites are back to their literals. The allow-list's own guidance is
the standard I applied:

> An unconverted `=== "todo"` is strictly better, because it is at least
honest about being a literal.

I did not add my call sites to the allow-list. Six entries would have
turned the gate green in two minutes and buried the defect; the list's
contract requires proving the async resolver is genuinely unreachable,
and for a fire-and-forget listener it is not — the listener can `void`
an async lane resolution the same way `NotificationService` already
does. That is the correct fix and it is a behaviour-shaped change, so it
is out of scope here.

**Fleet-wide consequence:** any conversion routed through
`resolveTaskWorkflowIrSync` is fake progress, and the census cannot see
the difference. `pnpm test:gate` can: the allow-list test is the
detector. Its passing here (161/161) is this PR's evidence that nothing
inert survived the split.

## What IS in this PR — all async-resolved

1. **`self-healing.clearStaleBlockedBy`** — lanes resolved per
**REFERENCED** task, not per iterated task. A blocker's own workflow
decides whether it is still blocking.
2. **`executor` dependency satisfaction** — resolved per **DEPENDENCY**
via `columnsWithFlag`. Preserves the load-bearing asymmetry that a
dependency in *review* already satisfies a dependent; a bulk sweep
flattens that to complete-only and deadlocks the board.
3. **`agent-tools` — the agent task tools listed FINISHED cards as
active.** `fn_task_list` says it lists "tasks that aren't done or
archived"; `fn_task_search` offers `includeDone: false`. Both filtered
on `task.column !== "done"`, so a renamed complete lane returned
finished cards as outstanding work **to an agent**, which then reasons
and acts on them. `includeArchived` was always enforced by the QUERY and
survived a rename; `"done"` was only ever a TS predicate, which is why
exactly that half broke.

Plus the two **dedup** guards in the same file. The cross-parent
diagnostic filter kept a *shipped* card as a candidate on a renamed
board, so the guard adopted it as canonical and returned `wasDuplicate:
true` — absorbing new diagnostic work into a task nobody is working on
(the eval-followup defect shape again). The defined-feature bootstrap
preflight is **not** the query-filter class: its query passes
`includeArchived: true`, so the TS predicate is the *only* archived
guard there; on a renamed archive lane the archived sibling became the
bootstrap canonical and `claimDefinedFeatureTask` then rejects the
non-live row, so a valid first task fails to be created at all.

Both dedup invariants **already had tests** — asserted against the
legacy ids only, so both passed for the very comparison being replaced.
Extended in place into vocabulary differentials rather than added as
parallel files. Two helpers rather than one parameterised one: "is this
finished?" and "is this archived?" are different questions, and merging
them would make the archived-only guard also reject completed rows.

The list/search half re-landed **with the test it originally shipped
without.** No suite exercised either tool, so the original commit's
"304/304 green" said nothing about the change — the optional-flags
failure mode exactly. Both call sites are covered; converting two copies
and testing one is the Surface Enumeration failure this program has
already hit twice.

4. **`gridlock-detector` — FALSE dependency alarms.** The gate compared
each blocker against `done`/`in-review`/`archived`; on a renamed board
all three are true for a *finished* blocker, so no dependency ever
counted as met and the detector reported dependency gridlock for tasks
that are not blocked — `notifyGridlock` then pages the operator.
Resolved per dependency using the **same five flags** as the executor's
gate (`complete`, `archived`, `mergeOrchestration`, `mergeBlocker`,
`humanReview`) — `review` is not a trait, and two gates answering "is
this dependency satisfied?" differently is a split brain. Every
pre-existing case in that file omits a workflow, so none could detect
the change; added the renamed case plus a non-vacuous companion.

5. **`triage` — its OWN copies of the same two tools.**
`createTriageTools` carries a `fn_task_list` and `fn_task_search`
byte-identical in intent to the agent-tools pair, plus a third site
filtering duplicate candidates. Same defect on all three. Reused the
(now exported) agent-tools helper rather than adding a third copy —
deliberately stronger than the two-parallel-tests reading of Surface
Enumeration, since the copies now share one implementation and cannot
drift. **Not claiming call-site coverage:** `createTriageTools` is
private and not drivable without standing up a TriageAgent; the helper
is revert-proofed, those two call sites are covered only through it.

6. **`mission-execution-loop` — a finished fix task read as LIVE,
stalling remediation.** The comment above that line states the rule it
implements: *only an open task makes duplicate triage safe to suppress.*
On a renamed board the rule inverts — a finished fix task is not
`done`/`archived`, so it reads as live, remediation for a fresh
validation failure is suppressed indefinitely, and the mission stalls
with no error surfaced.

**Not revert-proven, and I am not claiming it is.** No test reaches the
`hasLiveFixTask` branch, and the only case that mints a fix feature is
git-gated and heavyweight; building that fixture is larger than the
conversion. The change strictly *widens* the finished set (resolved
roles ∪ the two legacy ids), so default boards are byte-identical — that
is the argument for shipping it unproven, not a substitute for coverage.

7. **Four census-invisible membership guards**, each inverted on a
renamed board — `worktree-pool` (merger-managed branch reclaim could
delete a branch out from under an in-flight merge), `agent-assignment`
(assignment load counted nothing), `ephemeral-worker-manager`
(`isAgentIdle` inverted on both sides), and the dead constants their
conversion orphaned. These are `SET.has(task.column)` shapes the census
does not count, so the −15 understates them.

## Revert results (measured, each run)

| conversion | reverted → |
| --- | --- |
| `clearStaleBlockedBy` per-referenced lanes | renamed-vocabulary case
fails; stale `blockedBy` never clears |
| executor dependency satisfaction | dependent never unblocks on a
renamed review lane |
| `worktree-pool` merger-managed set | reclaim proceeds against an
in-flight merge |
| `ephemeral-worker-manager.isAgentIdle` | idle agent reads busy on a
renamed board |
| `fn_task_list` terminal filter | RENAMED case fails — shipped card
listed as active |
| `fn_task_search` terminal filter | RENAMED case fails — same,
independently |
| cross-parent diagnostic dedup | RENAMED case fails — `wasDuplicate:
true`, new work absorbed |
| bootstrap preflight archived guard | RENAMED case fails — `validate`
called with the archived sibling |
| gridlock dependency gate | RENAMED case fails — false gridlock raised
for an unblocked task |

`agent-assignment`'s widened `taskStore` type is compile-time; its
revert is a tsc failure, not a test failure — stated rather than claimed
as coverage.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, all green (161 includes
`sync-workflow-ir-callsite-allowlist`)
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean

One commit is a pure import restore: `columnsWithFlag` arrived in a
sibling commit that built on the inert resolver and was left behind. The
engine tsconfig excludes `src/__tests__/**`, so the gate was green while
tsc was not — worth knowing that on this package a green gate is not a
green build.


## Verified NOT a gap — measured, so the next worker does not re-open
them

- **`restart-recovery-coordinator` (5 counted).** Four already take an
optional `reviewColumns` set and the counted literals are the documented
**fallback** arm, which must stay for the same reason `columnRoles.ts`
keeps its id fallback. The sole production caller
(`self-healing.ts:12151-12154`) already passes the resolved set. The
fifth is documented at the site as a re-assertion behind a `listTasks({
column: "in-progress" })` query filter. Nothing to convert.
- **`notification/notification-service` (5 counted).** Already
documented in-file as deliberately counted with no exemption marker: the
wedge-episode site needs per-task serialisation of wedge handling (a
delivery-semantics change to operator notifications), and
`isManualMergeHold` needs a pre-resolved `LifecycleColumns` threaded
through `handleTaskUpdated`, which would pay resolution on every task
update. Both are behaviour/placement judgements, not conversions.
- **`planner-overseer` (3 counted).** `resolveWatchedStage`'s two
literals are fed by `pollPlannerOverseer`, which calls `listTasks({
column: "in-progress" })` and `{ column: "in-review" }` — hardcoded
**query** filters. On a renamed board those queries return no rows, so
the predicate never sees a renamed column. Converting it alone would
drop 3 from the census and change nothing an operator can observe. The
real fix is at the query layer; that is the tracked query-filter-bounded
class, not this PR.
- **`triage:695`** reads `resolvePlannerLanes` → the allow-listed sync
IR reader. Left as an honest literal per the rule above.

**Still open in `packages/engine`, deliberately not in this PR:**
`self-healing.ts` (97, of which ~31 are the query-filter-bounded class
and the rest need per-site classification in a 13k-line file),
`scheduler.ts` (12, blocked on the sync reader above), `executor.ts`
(12), and a tail of ~13 more copies of the "is this task finished?"
question across eight small files (`agent-reflection`,
`auto-merge-finalization`, `merger-scope-auto-widen`,
`backlog-pressure-reporter`, `merger-orphan-rehome`,
`merger-integration-worktree`, `plugin-runner`, `cli-agent/*`). That
tail is a clean follow-up: one question, eight call sites, and the
exported `resolveTerminalColumnsForTasks` helper already exists for it.

That is the same discipline as the sync-resolver finding: a census
number that drops without a behaviour change is not progress, and four
of these files would have handed over exactly that.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:47:44 -07:00
gsxdsm
4184fde08d batch-cli-plugins: 7 guards — 3 were a foreign enum, and fn pr create refused every card on a renamed board (#2775)
`batch-cli-plugins` — the u7 worker's mega-batch: `packages/cli` +
`plugins` + anything left.

## The batch is 7 guards, and 3 of them are not guards at all

The census's per-file list gives this batch seven sites. Reading them,
**three are a foreign vocabulary the census matches on the string
alone**:

| file | site | verdict |
|---|---|---|
| `plugins/fusion-plugin-reports/store/report-store.ts` | `next ===
"archived"` ×2 | **not a column** — `next` is a `ReportStatus` |
| `plugins/fusion-plugin-reports/store/report-types.ts` | `to ===
"failed" \|\| to === "archived"` | **not a column** — same enum, its own
terminal states |

The reports plugin has its own status lineage (`draft → generating →
review_* → approved → published`, plus `failed`/`archived`) that shares
two spellings with the lifecycle vocabulary. A report is not on a board
and has no workflow, so resolving an IR there would answer a question
nobody asked. All three are marked `DELIBERATE-LITERAL` with the reason
at the site.

**This cuts the other way from #2763.** That PR establishes the census
total as a *floor* (25 membership predicates it structurally cannot
see). This is the opposite error in the same number: a foreign enum
inflating it. The total is neither a ceiling nor a floor — it is an
estimate with error in both directions, and the per-file list is worth
reading before trusting a file's count.

## Converted (census before → after, per file)

| file | before | after |
|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | **0** |
| `plugins/…/even-realities-glasses/notifications/diff.ts` | 1 | **0** |
| `plugins/…/reports/store/report-store.ts` | 2 | **0** (deliberate) |
| `plugins/…/reports/store/report-types.ts` | 1 | **0** (deliberate) |

### `fn pr create` refused every card on a renamed board

The live defect in this batch. The gate was `task.column !==
"in-review"`, and its error told the operator to move the task to a
column their board does not have:

```
Error: Task must be in 'in-review' column to create a PR (current: signoff)
```

There is no way to satisfy that short of renaming the workflow back. Now
resolved through core's `resolveReviewColumns`, and the message names
the lanes that actually exist.

**The SET, not `lifecycle.review`.** A board may declare more than one
review lane, and a card parked in a `humanReview`-only lane is still a
card you can open a PR from. A single-id answer keeps refusing those —
the same narrowing #2728's review caught in the CLI retry gate, which is
why the test pins both lanes.

## Skipped, with the reason

**`plugins/fusion-plugin-even-cards` (2 guards) — blocked on packaging,
not on analysis.** The defect is real: `boardToDeck` filters with
`column !== "archived" && column !== "done"`, so on a renamed board
every finished card stays in the deck, fills `maxCards`, and pushes the
active cards off the display. The wearer sees a board that never
finishes anything.

I implemented the fix and **reverted it**: this plugin is not in
`pnpm-workspace.yaml` and depends only on `@fusion/plugin-sdk` — it has
no `@fusion/core` dependency, so the route cannot reach
`resolveTaskLifecycleColumns`. Adding one is a packaging change, which
this program's rules put out of scope. Shipping only the injected
parameter without a caller was the alternative, and that is precisely
the decorative conversion #2759 documents: the census would drop by 2
and the deck would keep the bug.

Flagged for whoever owns the plugin's dependency surface. The glasses
plugin next door *does* depend on `@fusion/core`, so this is a
one-plugin problem, not a plugin-wide one.

## Honest note on the glasses conversion

`diff.ts`'s completion branch is **currently unreachable** — the only
production caller (`notifier.ts`) passes `alsoNotifyOnDone: false`. So
that conversion changes nothing at runtime today. It is converted rather
than marked deliberate because the literal is not deliberate: it is
wrong, and would ship the bug the day someone turns the flag on. Stated
here rather than left for a reviewer to discover.

## Verification

- new CLI suite **4 passed**; `pr-command` + `pr-automerge-cleanup` +
`bin-pr-router` **35 passed**
- glasses plugin **181 passed (19 files)** · reports plugin **110 passed
(23 files)**
- `pnpm test:gate` — **158 / 10 / 487 / 71** · `pnpm lint` clean ·
`--strict` exits 0

**Revert proof, measured.** Restoring `if (task.column !== "in-review")`
fails 3 of the 4 new cases (`process.exit:1` on both renamed lanes, and
the refusal message reverts to naming `in-review`). The
unresolvable-workflow case keeps passing — it is the legacy path — so
the negative cases alone do not pin the fix and all four are required.

## Handoff to `batch-engine`

`packages/engine/src/project-engine.ts` **5 → 0** is finished, green,
and pushed as `handoff/project-engine-lanes-for-batch-engine`
(`34dbb35209`) for the capacity worker to cherry-pick — it is
engine-owned, not mine to land.

It fixes two live defects: a card that **had merged** reported as a
failed merge to `fn task merge` and the dashboard button (`merged:
finalTask?.column === "done"`), and the three post-finalize `column ===
"done" && mergeConfirmed` fast-path checks, which on a renamed board
sent an already-landed card down the bounce path — re-queued,
retry-counted, and in the capped branch parked `failed` with its merge
sitting on main. Plus `hasAutoHealableVerificationBufferFailure`, which
returned false for every card on a renamed board, so a buffer-overflow
verification failure was never auto-healed.

8 new tests, revert-proven (restoring the literal fails 4 of 8), gate
green.

---

## Completion pass (u7) — the batch is now closed

Two workers converged on this branch. I rebased onto the first-landed
commit rather than force-pushing over it, took its wording wherever the
conclusion was identical, and added what was missing.

### What this pass added

1. **`even-cards` (2 sites)** — the only in-scope file the first pass
left open. Marked DELIBERATE-LITERAL: the package depends on
`@fusion/plugin-sdk` only, and the SDK does not re-export the lifecycle
role helpers, so there is no IR, no store, and no trait flags to resolve
*from*. Fixing it properly means the SDK exposing role flags on the task
shape it hands plugins — a structural change, out of scope, and recorded
at the site as the correct home. Live consequence is cosmetic: a
finished card on a renamed board shows as active in the glasses deck.

2. **A red test in the `fn pr create` conversion.** The incoming version
rendered `Task must be in 'in-review' to create a PR`, dropping the word
`column`. `task.test.ts:3422` pins `must be in 'in-review' column`, so
that hunk failed `runTaskPrCreate > exits with error when task not in
in-review column`. Restoring the word makes the single-lane message
**byte-identical** to the pre-conversion one, which is what a vocabulary
conversion should be — the guard's own test now passes unmodified.
Marked at the site so it is not "simplified" back.

3. **Duplicate imports** — the two independent conversions each added
`resolveWorkflowIrForTask`/`resolveReviewColumns`, which does not
compile. Deduped in its own commit.

### Census

Measured with `--json` on `origin/main` and on this branch.

| file | before | after | action |
|---|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | 0 | converted |
| `plugins/fusion-plugin-reports/src/store/report-types.ts` | 1 | 0 |
marked |
| `plugins/fusion-plugin-reports/src/store/report-store.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-cards/src/cards/board-cards.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-realities-glasses/.../diff.ts` | 1 | 0 |
marked |

Backlog **415 → 408** (−7, exactly the in-scope count). Deliberate **40
→ 46** (+6 marked); 6 + 1 converted = 7. `--strict` exits 0. **Nothing
remains in `cli` + `plugins` + everything-else — there is no follow-up
batch behind this one.**

### One note on the `even-realities-glasses` site

Worth recording beyond "cannot resolve": its only production caller
(`notifier.ts:80`) passes `alsoNotifyOnDone: false`, so that arm is
**unreachable today**. Converting it could not have changed observed
behaviour either way.

### Verification (measured, on the merged branch)

- `pnpm --filter @runfusion/fusion exec tsc --noEmit` → exit 0
- `pnpm lint` → 0 errors
- CLI `task.test.ts` → 144 passed, including the `runTaskPrCreate` guard
test
- `@fusion-plugin-examples/reports` → 110 passed;
`even-realities-glasses` → 181 passed

**Pre-existing failures, not from this change:** the 5
`runTaskImportFromGitHub` / `runTaskImportGitHubInteractive` tests fail
identically on `origin/main` — verified by stashing this diff and
re-running (5 failed / 144 passed both ways).

---

## Census audit (unowned follow-on)

After closing the batch scope I audited whether the **392**
column-backlog number is inflated by foreign vocabularies — the class
this batch found in the reports plugin, where `"archived"` is a
`ReportStatus` rather than a board lane. If that class were widespread,
every remaining batch would be chasing sites that must not be converted.

**It is not. The number is real.** A receiver-level pass over all 392
column-category sites found exactly **3** false positives, all in
`plugins/fusion-plugin-reports` (`next`, a `ReportStatus`), all now
marked in this PR.

What was checked and cleared:

- **Property-reached foreign enums** (`step.status`, `feature.status`,
`mission.status`) — already correctly bucketed into the separate
`status` category (185), not the column backlog. Verified against
`merge-queue-ops.ts`: 11 lifecycle-spelled literals in the file, census
counts **1**, and that 1 is the genuine `.column` guard.
- **Bare step-status variables** (`status`, `currentStatus`,
`liveStatus` compared to `"done"`/`"skipped"`) — likewise excluded.
- **Every other receiver in the backlog** — `to`, `from`, `column`,
`fromColumn`, `toColumn`, `latestColumn`, `state`, `preArchiveColumn`.
All resolve to genuine task columns. `executor.ts`'s 15 sites were
spot-checked line by line: all 15 are real.

The gap the classifier genuinely cannot close is a foreign enum held in
a **bare variable** — the receiver name carries no type information, so
`next === "archived"` is indistinguishable from a lifecycle guard by AST
alone. That is why the reports sites need a marker rather than a
classifier fix, and it is now documented in
`lifecycle-column-census-ast.mjs`'s header alongside the measured scope,
so the remaining batches do not re-run this hunt.

Census tests: **43 passed**. The change is comment-only.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:38:39 -07:00
gsxdsm
8c9b84ae38 batch-core: packages/core + dashboard/src lifecycle conversion (129 → 92) (#2780)
## batch-core — `packages/core` + `packages/dashboard/src`

Shared branch: two workers are converting into it. Opening the PR
because the branch was green with none, and a branch without a PR merges
nothing.

### Census

Measured with `node scripts/lifecycle-column-census.mjs --json`.

| | guards |
|---|---|
| batch-core scope at branch point | 129 |
| batch-core scope now | **92** (51 files) |
| repo total now | 358 |

Files closed so far: `store.ts` 11→0, `task-merge.ts` 6→0,
`live-agent-count.ts` 6→0 (marked, not converted — see #2762),
`task-update.ts` 3→0, display-ordering + Wake Delta ranking 5→0,
`register-git-github.ts` 4→0.

### The `register-git-github.ts` slice

Three PR routes — `pr/create`, `pr/push-branch`, `pr/resolve-conflicts`
— plus the `CHANGES_REQUESTED` handler each compared `task.column !==
"in-review"`. On a renamed board **none** of them matched, so every PR
affordance the dashboard offers was refused for a card sitting in the
lane that board calls review, and the refusal named a column that does
not exist there.

All four now share one helper, `reviewColumnsForTask`, which gets two
things right that this program has repeatedly gotten wrong:

- **Membership, not a single id.** It takes the broad review set
(`mergeOrchestration ∪ mergeBlocker ∪ humanReview`).
`resolveLifecycleColumns` returns the *first* column per trait, so a
single-id answer silently ignores a board that declares a merge lane
**and** a separate human sign-off lane. These guards only refuse or
permit — they never move the card — so over-admitting costs nothing
while under-admitting refuses a request that should have worked.
- **An empty resolved set means UNEXPRESSED, not absent.**
`synthesizeDefaultColumns` upgrades a v1 graph by emitting every default
column with `traits: []`, so a v1-upgraded workflow resolves to an empty
review set while its `in-review` column plainly exists and holds the
card. Reading empty as "this board has no review lane" would refuse
these routes on **every pre-v2 project** — a worse regression than the
one being fixed, and invisible to any v2 test.

This is the dashboard twin of the `fn pr create` guard in
`packages/cli/src/commands/pr.ts` (#2775). The two surfaces answer the
same question and now agree — FN-5893 surface enumeration.

### Testing note: why the seam and not the routes

I wrote route-level HTTP tests first and **deleted them**. An express
fixture over `registerGitGitHubRoutes` hangs — every case, including the
pure refusals, times out at 4s, because registering the router starts
background work the fixture never satisfies. Making it run would mean
mocking git, the GitHub client, and the pollers: a mock-the-world shell,
which is what the project's do-not-add-slow-tests rule (FN-5048) says to
avoid in favour of a narrow seam.

`reviewColumnsForTask` *is* the narrow seam — it holds the entire
decision, and the four call sites now do nothing but ask it and render
its answer. Six cases pin it: the renamed lane is returned and
`in-review` is not, a two-lane board returns both, a v1-upgraded board
falls back, an unresolvable workflow falls back, and the refusal renders
lanes an operator can act on.

**Mutation-verified, both directions:** reverting the helper to the
legacy literal fails 2 of 6; treating an empty set as an answer fails 1
of 6.

One fixture bug worth recording, since it would have made the two-lane
case vacuous: the trait id is kebab-case `human-review`, not
`humanReview`, and the built-in traits must be registered via `import
"@fusion/core"` before flags resolve.

### Verification

- `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` →
0 errors
- `pnpm lint` → 0 errors
- `register-git-github.review-lanes.test.ts` → 6 passed

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:28:27 -07:00
gsxdsm
3092c9c2bb fleet: live-agent-count.ts 6 → 0 — the no-enrichment fallback, marked not converted (#2762)
Unclaimed file, no overlap with any open fleet PR. Previous PR (#2756)
is merged, so this is my one open PR.

## Census

| | before | after |
|---|---|---|
| backlog | 447 | **441** |
| reviewed | 38 | 44 |
| this file | 6 | **0** |

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

## Why marked, not converted

All six literals sit after a `??` or a `flags ? … :`. Each is reached
**only** when the caller supplied no trait flags and no enriched shape —
precisely the case `enrichRunningAgentTaskShape` (takes the IR) and
`enrichRunningAgentTaskShapeFromFlags` (takes board flags) exist to
remove. There is nothing to resolve from there, so the choice is not
convert-vs-literal; it is **known legacy answer vs a different guess.**

**And the guess is not neutral.** Running and Waiting are *complements*
over the same rows:

```ts
isWaitingAgentTask = !running && (columnIsIntakeOrHold ?? isLegacyPreImplementationColumn(column))
```

A card matching neither arm is reported as **neither running nor
waiting**, so the footer's queued total silently under-reports it.
Guessing "not WIP" or "not review" loses cards from the count; the
legacy id at least matches every pre-rename board. That is why this file
already carries a `DELIBERATE-LITERAL` marker above
`isLegacyPreImplementationColumn` with the same argument — this PR
extends it to the three functions holding the remaining fallbacks
(`enrichRunningAgentTaskShapeFromFlags`, `terminalKind`,
`isRunningAgentTask`).

**The fix for a renamed board is at the CALLER** — pass flags, or use
the IR-taking enricher. Noted at the site.

## Pattern note for the fleet

This is the third file I have taken where `N → 0` is reached by marking
rather than converting, and they share a shape worth naming: **a literal
after `??` or in the `else` of a `flags ?` ternary is a degraded-mode
answer, not an unconverted guard.** The trait path is already there and
already correct; the literal is what runs when the trait path has no
input. Deleting it does not remove a decision — it substitutes a
different one, silently, in exactly the states where nobody is looking
(first paint, un-enriched callers, pre-rename data).

## Verification

Core typecheck clean · `live-agent-count.test.ts` 11/11 · `--strict`
exit 0 · comments only, no behavior change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:57:52 -07:00
gsxdsm
9b61d795c9 fix(engine): heartbeat asked 'is this task finished?' with legacy ids — and one of the two sites writes status:failed onto completed work (#2769)
## Two heartbeat sites asked "is this task finished?" with the legacy
ids

`agent-heartbeat.ts` **4 → 0**. Neither site is cosmetic.

**Linked-task clear.** The heartbeat clears an agent's assignment once
its card is finished. Keyed on the literals, an agent on a renamed board
stayed bound to a **completed** card indefinitely — every later
heartbeat ran with stale task context instead of picking up new work,
and nothing else clears it.

**Worktree-acquisition gate.** Its failure bookkeeping runs only for a
**non-terminal** task. A card in a renamed complete lane read as
non-terminal, so an acquisition failure could stamp `status: "failed"`
and an error message onto work that was **already done**.

That second site *writes*, which drives the fallback direction: an
unresolvable workflow degrades toward "terminal", because treating a
finished card as unfinished is the expensive mistake here.

Both sit in async paths — the first has `await taskStore.getTask(...)`
three lines above — so this is an `await`, not a restructure. Extracted
to one predicate rather than converted twice: they are the same
question, and the two must not drift when one of them acts
destructively.

## How this was found, and the part worth recording

Generalising #2767. That PR marked a documented false positive the
census kept advertising, so I swept for **other** files whose lifecycle
literals were reasoned about in prose but still counted — to find out
whether the trap was systemic.

**It is not.** Of twelve candidate files, only this one carried real
unconverted guards, and its "false positive" mentions turned out to be
unrelated (detection heuristics, not column literals). The sweep mostly
came back **negative**, and that is worth saying so nobody repeats it
expecting a haul.

## Revert proof

| reverted | result |
|---|---|
| neuter the resolution (predicate → literals) | **3 failed** / 2 passed
|
| shipped | **5 passed** |

The two that survive the revert are the degraded-mode pair, which is
correct — they assert the *legacy* answer, so they must pass either way.
One case also pins that a legacy `done` id is **not** terminal on a
board that does not declare it, which is what a board-wide union would
get wrong.

## Verification

- 13 heartbeat suites — **501 passed** (496 before, +5 new)
- `pnpm test:gate` — **158 / 10 / 487 / 71** · `pnpm lint` clean ·
engine `tsc --noEmit` **0 errors** · `--strict` exits 0

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:42:00 -07:00
gsxdsm
7f3eee9db7 docs(engine): mark the usage-limit terminal filters DELIBERATE-LITERAL — a documented false positive that has now baited two workers (#2767)
## No behaviour change. This is the work order retracting a documented
false positive.

I went to convert the `done`/`archived` filters in
`usage-limit-detector.ts`, reasoning that on a renamed board a provider
rate limit would pause already-finished work. I wrote the conversion —
and only then read the note a previous worker had left directly above
it:

> *"The FIRST thing I suspected there — the `done`/`archived` terminal
filter — turned out to be a **FALSE POSITIVE**: its revert stayed green,
because the lane check already excludes finished cards."*

**They are right and I was wrong.** A terminal card is already excluded
downstream: `taskUsesProvider` resolves the task's active lane, a
finished card matches no active lane, so it resolves no providers and
cannot be affected. The suite pins exactly this — `pauses a PEER
executing in the renamed WIP column` asserts `FN-SHIPPED` is not paused.

My conversion is reverted. It changed nothing at runtime and would have
lowered the census count while behaviour stayed identical — the precise
shape this program keeps warning about, produced by me this time.

## Why a marker and not just the existing prose

The note was already there and I walked into it anyway, because **the
census kept listing this file as 4 unconverted guards**. The work order
advertised the work; the reasoning against it lived in a comment you
only reach after you have started. Prose informs a reader who is already
looking; a marker informs the *instrument*, so the file drops out of the
work order.

Two distinct reasons are recorded rather than one blanket marker,
because they are not the same argument:

- **the prefilter** is a deliberate cheap **superset** (#2672 review).
Converting it reintroduces the whole-board resolution that review
removed. Literals are safe here in the direction that matters — a
renamed board declares no `done`/`archived` id, so nothing is wrongly
*excluded*.
- **the final filter** is redundant with the lane check, and that
redundancy is already proven by an existing test.

## Census

| | before | after |
|---|---|---|
| `usage-limit-detector.ts` | 4 | **0** |
| repo backlog | 437 | **433** |
| DELIBERATE-LITERAL (reviewed) | 38 | **42** |

Every one of the 4 is a marker, not a conversion. The backlog moved
because reviewed literals left it honestly, not because behaviour
changed.

## Verification

`usage-limit-detector.test.ts` **58 passed**, unchanged before and after
· `pnpm test:gate` **10 / 158 / 487 / 71** · `pnpm lint` clean · engine
`tsc --noEmit` **0 errors** · `--strict` exits 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:31:47 -07:00
gsxdsm
d86c1f9d29 batch-engine: packages/engine lifecycle-column conversions (capacity worker's mega-batch) (#2773)
The engine mega-batch. Folds my four engine PRs and will absorb the
remaining `packages/engine` guards as commits on this branch.

**Superseded and closed:** #2722, #2741, #2766, #2770.

## Census — files converted so far

| file | before | after |
|---|---:|---:|
| `notification/notification-service.ts` | 9 | **5** |
| `runtimes/in-process-runtime.ts` | 6 | **1** |
| `eval-followups.ts` | 2 | **0** |
| `pr-comment-handler.ts` | 1 | **0** |
| `task-revert.ts` | 2 | **0** |

The last two are **census-invisible** (`Set.has(task.column)`
membership) — the class measured in #2763, which a comparison-based scan
cannot count. So the backlog number moves less than the work does,
deliberately.

## What each one actually fixed — all silent, none cosmetic

- **Notifications stopped entirely.** `handleTaskMovedAsync` compared
`data.to` to `in-review`/`done`, so on a renamed board the two
notifications operators rely on most were never sent.
- **A finished card's plan review could re-enter.** The continuation
drain's terminal test matched nothing, so a completed card's planning
continuation was handed to the executor.
- **The revert route admitted and the service refused.** The route
resolved terminal lanes; the service compared to a hardcoded pair. The
operator got a dead end from an affordance the UI and route both
offered.
- **Follow-up dedup blocked new cards forever.** A finished follow-up in
a renamed complete lane read as *open*, so the dedup matched it
permanently — defeating the intent the code documents in the line above
it.
- **The mission requeue wrote a column that may not exist**, and its
guard never matched.

## Flagged, not fixed — deliberately

- **`concurrency.ts` idle semaphore leak recovery** — the last live
caller of the running-agent predicate that does not enrich. On a renamed
board it under-counts and can reclaim a legitimately-held slot. The
enriching variant is async and this is a synchronous repair path whose
failure mode is reclaiming live work.
- **The archival `task:moved` listener** — runs on every move with no
cheap gate ahead of it; converting costs an IR resolution per move to
decide most moves are not archival.

## Notes carried from the folded PRs

Two conflicts resolved in main's favour because **main's version was
better**: `in-process-runtime`'s seam uses `terminalColumns:
ReadonlySet` (membership) where mine used `LifecycleColumns`
(first-per-role), and the test is rewritten against main's API. That
arity trap has now caught me four times, so membership is the default
shape in everything new here.

Review fixes from the folded PRs are included: the notifier's review
set, the second human-review site, the second dedup copy, the workspace
revert surface, and the file-content assertions.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **224 passed** across
the touched engine suites · engine and dashboard `tsc` clean · `pnpm
lint` clean · census `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:24:50 -07:00
gsxdsm
86a2b48968 fleet: branch-group-ops 6 → 0 — an agent asking for its next task was told there was none (#2739)
Second application of the sync-filter pattern decided in #2737.
`branch-group-ops.ts` 6 → **0**.

## The failure

`selectNextTaskForAgentImpl` picks an agent's next task by filtering the
board for its WIP lane, then its hold lane — both `task.column ===
"<literal>"`.

On a renamed board **both filters match nothing**, so an agent asking
for work is told there is none, with its own assigned tasks sitting in
the list it just fetched. No error, no log line. The agent idles.

`pauseTaskImpl` had the same shape: pausing a running card on a renamed
board left its `status` untouched, so the UI kept showing it as working.

## Consumer, not a gate — checked rather than assumed

Applying the #2724 test to this file, since it sits closer to the
persistence layer than the reconciler did: its **only** SQL predicate is
`eq(table.projectId, ...)`. Nothing here compares a column to a literal
in SQL, so there is no second encoding of these questions to diverge
from. The list arrives from `store.listTasks` and the filters select
among rows already in hand.

Async predicates were the alternative and would have turned these filter
chains into sequential awaits inside the dispatch path. One prefetch,
one IR read per distinct workflow, filters stay synchronous.

`pauseTaskImpl` resolves for the single task it holds rather than
joining a map — different entry point, one id in scope, and a map would
have exactly one entry.

## Revert proof

| reverted | result |
|---|---|
| the wip literal | renamed WIP case fails: `expected null to be truthy`
|
| the hold literal | renamed hold case fails identically |

The new test calls the impl **directly** with a store fake resolving a
renamed IR. The existing `selectNextTaskForAgent` coverage drives a real
store harness, so exercising a renamed vocabulary there means
registering a real custom workflow and moving cards through it — heavier
than the question, which is only which lane the filters name. The bind
evaluator runs for real; only the store is faked.

A third case pins that the hold filter keeps its `userPaused` exclusion,
so a filter matching every column would not satisfy the other two.

**Related coverage checked before writing a new file:**
`agent-heartbeat-worktree-renamed-hold.test.ts` covers the requeue
**target** on a renamed board, not the dispatcher's **selection**
filters — different branch of the same subsystem, so a case added there
would have read as duplicate coverage of the wrong thing.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **20 passed** across
the routing-policy and new dispatch suites · core `tsc` clean · `pnpm
lint` clean · census `--strict` exits 0.

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

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

## Summary by CodeRabbit

- **Bug Fixes**
- Improved agent task selection when workflow lane names or IDs have
been renamed.
- Agents now correctly resume assigned in-progress or queued tasks
across customized workflows.
- Prevented agents from selecting tasks paused by users, including on
boards with renamed lanes.
- Updated task pausing behavior to correctly reflect lifecycle stages
beyond default lane names.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:09:36 -07:00
gsxdsm
76d77da4d1 fleet: taskSorting + TaskReviewTab 8 → 0 — and Board was faking a column id to force done-sorting (#2744)
Two app-side clusters, 8 → **0**, plus a caller-side hack retired.

## What was broken

**`TaskReviewTab.tsx`** — three of its four questions were `task.column
=== "in-review"`, driving the **Create-PR button**, the **"frozen on
entry to review"** auto-merge hint, and **PR-feedback addressing**. On a
renamed review lane all three took their non-review branch: the button
was absent, and the hint claimed the effective auto-merge value was
*not* frozen when it was.

**`taskSorting.ts`** — `isReviewColumn` decides whether merging cards
float to the top of a lane. Keyed on the id it silently stopped doing
that on any renamed review lane, so the operator loses the "what is
merging right now" ordering with nothing failing.

Both follow the shape this code already established: **caller supplies
the trait, default to the legacy id**. `columnFlags` on the review tab
is optional and wired from `TaskDetailModal`, which already resolved it
for `canEdit` and the actions menu.

## A synthetic column id, retired

`Board.tsx` forced done-sorting by passing the **literal `"done"`** as
the column argument for any complete-flagged lane:

```ts
grouped[column.id] = isWorkflowDoneLikeColumn
  ? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
  : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, ...);
```

A synthetic id standing in for a trait — so a custom complete lane
sorted correctly only because its caller **lied about its name**. Both
call sites now pass the real column id and state the trait. (Board's own
census count stays at 2: those two literals were the synthetic ids and
are gone; the 2 remaining are different sites.)

## Revert proof

| reverted | failure |
|---|---|
| `task.column === "in-review"` on the Create-PR guard | `Unable to find
an element by: [data-testid="task-review-create-pr"]` |
| same, on the auto-merge hint | `expected 'Effective: Auto-merge off'
to contain 'frozen on entry to review'` |

A third case pins that the widened test does not treat *every* column as
review.

**None of the 45 existing `TaskReviewTab` cases could have caught this**
— `columnFlags` is optional and they all omit it, so they assert the
legacy fallback. That is the same blind spot as the reconciler's 33 in
#2737, and it keeps recurring: an optional-flags seam means the existing
suite stays green through the conversion *and* through a broken one.

## A process failure worth recording

**I lost this conversion once and had to redo it.** I overwrote four
files with their `origin/main` versions to check whether a failing test
was pre-existing, then "restored" with `git checkout HEAD -- <dir>`.
HEAD was still `origin/main` because I had not committed, so that
**discarded the work**.

Same class as the shared-stash incident two PRs back: an implicit or
positional restore reference. The fix is ordering, not care — **commit
before any baseline comparison**, so `git checkout HEAD -- <file>`
restores my work rather than main's. This PR's commit was created before
the comparison for exactly that reason, and the note is in the commit
message so the next person hits it there too.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **232 passed** across
TaskReviewTab / taskSorting / Board suites · dashboard `tsc -p
tsconfig.app.json` clean · `pnpm lint` clean · census `--strict` exits
0.

The 1 `board-mobile` failure is **pre-existing** — verified by swapping
in clean `origin/main` copies of all four files and reproducing it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:06:04 -07:00
gsxdsm
54d1a29621 fix(dashboard): the github-tracking-state classifier seam was never wired — a renamed terminal column still never closed its issue (#2754)
Not a conversion. A **conversion that was never connected to
production**, found by a parity sweep rather than by the census.

## How it surfaced

An AST pass over all **43** github/gitlab-named files, paired by name
with counts compared:

```
github-tracking-comments.ts=9  vs  gitlab-tracking-comments.ts=4   ASYMMETRIC
github-tracking-state.ts=2     vs  gitlab-tracking-state.ts=0      ASYMMETRIC
github-issue-comment.ts=1      vs  gitlab-issue-comment.ts=1
...8 more pairs, all symmetric at 0
```

The first is the pair #2715 fixes. The second pointed here — and the
asymmetry turned out not to be the interesting part.

## The defect

`decideIssueAction` has accepted an injectable `classify` since U12/R2,
and that file's own header states the bug the seam fixed:

> "A user-authored workflow whose terminal column is called something
else never closed its linked GitHub issue, and a custom archive column
never mapped to `not_planned`."

Its **only production caller** passed no classifier:

```ts
const decision = decideIssueAction(event.from, event.to);
```

So every real move fell through to `legacyColumnLifecycleClass`, and
**the documented bug was still live**. The seam was reachable from unit
tests only — which is why all 68 cases in that file were green while the
behaviour they document did not work.

Same shape as this branch's earlier finding on the tracking-comment
guard, where the guard returned *before* resolving. **Adding a seam and
wiring it are two changes; only the second one fixes anything.** Worth
watching for elsewhere in this program: a file can read as fully
converted, pass its suite, and still take the legacy path on every call.

## Ordering, inverted on purpose

`decideIssueAction` ran first, before the tracking-enabled check,
because comparing two strings is free. Resolving a workflow is not — so
the cheap property read now short-circuits and only tracked tasks
resolve, the ordering `github-tracking-comments.ts` and its GitLab twin
already settled on. Untracked tasks returned without acting before and
still do.

The two remaining literals **are** `legacyColumnLifecycleClass`, that
seam's named default, now marked `DELIBERATE-LITERAL` — and marked only
in the same commit as the wiring. While the default was the live path on
every move, exempting it would have hidden the real defect behind a
marker.

## Revert proof — it detects an *unwired* seam, not a missing one

Dropping the resolved classifier while **leaving the seam intact** fails
both new cases with 0 `setIssueState` calls. That is the whole point:
the new cases drive the **service**, not the pure decision function, so
they fail for exactly the reason the 68 existing cases could not. Those
pass either way.

## Two self-inflicted errors, recorded because both are recurrences

- **I hand-edited the baseline with python and wrote a raw NUL byte into
the JSON**, breaking the census parse. The `deliberateByFile` keys use a
real `\0` separator and must be written through `JSON.stringify`, never
string interpolation.
- **I then restored the baseline from a newer `origin/main` than my
branch point**, which made `--strict` report a `scheduler.ts: 12 → 26`
rise that was pure version mixing. Rebase first, then edit. (The real
`scheduler.ts` baseline staleness is already owned by #2712 — I checked
before assuming it was mine.)

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **71 passed** in the
tracking-state suite · dashboard `tsc` clean · `pnpm lint` clean ·
census `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:05:50 -07:00
gsxdsm
86639f2ce4 fleet: planning drain + archive writers 12 → 4 — one stale row starves planning, and a finaliser that wrote an undeclared column (#2742)
**Claimed on #2733 before starting.** `in-process-runtime.ts` +
`task-artifacts-ops.ts` — **12 → 4**.

## 1. The planning drain: one stale row stops planning for the whole
project

FN-8470's own note on this code says it: **one orphan earlier in
created_at FIFO prevented every later planning continuation from
dispatching.** So on a renamed board the literal terminal pair did not
mis-handle one card — an archived or completed card's stale work item
read as live, stayed in the due set, and **starved the drain behind
it**.

The two classifiers take an **optional** terminal set, which is this
file's own injection idiom (the specification-complete reaction already
takes a `resolveIr` dependency so the pure passes are testable without
constructing a runtime that would attach to the real project registry).

**Optional is load-bearing:** a *required* parameter would have compiled
at every existing caller and then answered "not terminal" for
everything. That is the silent direction, and both halves are asserted
in the test.

## 2. `moveToDoneImpl` writes `task.column` directly

This is the store's own finaliser, not a `moveTask` caller — so its
literal is **not** caught by `moveTask`'s unknown-column validation the
way every converted call site in this program is. It silently persisted
`done` on a board that does not declare it, and then emitted `to:
"done"` to every listener.

**This is one of the few sites where a literal writes bad state rather
than merely failing to act.** A workflow declaring no complete lane now
throws instead of inventing one — #2733's rule: a missing field on a
resolved struct *is* an answer, and `?? legacy` discards it.

## 3. The unarchive destination — three decisions in four lines, all
literal

| pre-archive column | lands in |
|---|---|
| unusable / archived | the **complete** lane |
| the **wip** or **review** lane | the **hold** lane (its worktree and
session are long gone) |
| anything else | back where it was |

The second is the expensive one: a card archived *from* the wip lane was
restored straight back *into* it **with no worktree**, and the scheduler
then counts it as a live holder **occupying a slot**. Made async — its
one production caller already is, and the sync alternative is the
PostgreSQL no-op documented in #2703.

## Also

- **The mission-error requeue** (guard *and* destination in one change):
an errored mission task stayed in the wip lane holding a slot, because
the guard never matched.
- **The planner-chat retention cutoff on archive** — the quiet direction
of this defect class: nothing breaks, data that should be deleted simply
accumulates, and the only symptom is storage growth nobody attributes to
a column name.

## The live defect is not where the census points

`reliability-metrics.ts`'s 6 guards are **pure historical readers** over
activity-log entries, and **the dashboard does not call them**. The live
path is `server.ts`'s `getTaskMovedCountsByDay({ toColumn: "in-review"
})` — a **SQL query filter**, the class the census counts separately.

So the operator's reliability panel reads zero on a renamed board
because of a *query* literal, and converting the six guards the census
reports **would change nothing an operator sees**. Converting historical
readers also risks reinterpreting past events under today's traits,
which is a different decision from converting a live guard — I am not
making it inside a vocabulary sweep.

Worth generalising for the fleet: **a file's census count and its live
exposure are different numbers.** This is the second file where the
reported guards are the inert copy and the real one is a query
(`executor.ts:5805` was the first).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 31/31 continuation suites ·
8/8 archive PG suites · in-process-runtime PG suite green · 5 new cases,
**2 red on revert** · `tsc` clean in core and engine · `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-30 07:53:28 -07:00
gsxdsm
b0b9d1b373 fleet: store.ts 12 → 11 + names the sync-dependency-loop class blocking ~10 sites across 3 clusters (#2709)
Claiming **`packages/core/src/store.ts`** (12). One conversion and a
triage — because **10 of the 12 share a single blocking shape** that is
worth naming once rather than rediscovering per file.

## Census before/after

| | before | after |
|---|---:|---:|
| `store.ts` column guards | **12** | **11** |

Baseline re-recorded; `--strict` exits 0.

## Converted: 1

**1386** — the in-review guard inside `withTaskLock(id, async () => …)`.
Already async, and `this` **is** the store, so
`resolveTaskLifecycleColumns(this, task.id)` resolves the review role
with `in-review` as the fallback. Import added; nothing else in the
method changes.

## The blocking class — 6 sites, and it is not specific to this file

**1772, 1791 ×2, 1874 ×2, 1916, 1917, 1933** all read **another task's**
column — a dependency's, a blocker's, an overlap candidate's — inside
**synchronous callbacks over a prefetched `taskById` map**:

```ts
const unresolvedDeps = (task.dependencies ?? []).filter((depId) => {
  const dep = taskById.get(depId);
  return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "archived";
});
```

This is not a substitution. Each dependency may belong to a **different
workflow**, so the role must be resolved *per dep* — N async resolutions
inside a sync `filter`, on a path that deliberately prefetches into a
map precisely to avoid per-item I/O.

Two honest options:

1. **Prefetch lifecycle columns alongside `taskById`** and pass a
resolved map into these predicates. Keeps them synchronous, one
resolution per distinct workflow rather than per dep. This is the one
I'd argue for.
2. Accept per-dep resolution and make the callbacks async — changes the
shape of dependency evaluation.

Both are design changes with real cost, so this is flagged rather than
guessed.

**The same shape appears in at least two other clusters I've worked**:
`TaskDetailModal`'s `overlapBlockerTask.column` (#2696) and
`register-task-workflow-routes`' dependency-summary pair (#2700), both
flagged for this exact reason. **Worth one decision covering all three**
rather than three separate judgement calls by three workers.

## Also flagged: 3

**1610** and **1739** — enclosing-scope async-ness and store access not
established at those points, so not guessed. **1933** belongs to the
sync-filter family above.

## Verification

`pnpm test:gate` **GREEN** (158 + 487 + 10 + 71) · `pnpm lint` clean ·
core `tsc` clean · `--strict` exits 0.

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


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

## Summary by CodeRabbit

* **Bug Fixes**
* Updated failed pre-merge review bypass validation to support custom
workflow boards.
* Tasks can now bypass the step when placed in the board’s configured
review lane.
* Improved error messages to identify the correct review column when
bypassing is not allowed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:36:00 -07:00
gsxdsm
ceca08b1c3 fleet: github-tracking-reconciler 9 → 0 — deciding the sync-filter class (prefetch a resolved map), and the reconciler closed NO issues on a renamed board (#2737)
`github-tracking-reconciler.ts` 9 → **0**, and the reference
implementation for the `.filter((task) => task.column === "<id>")` shape
I have been flagging across four files.

## I stopped waiting and decided it

I flagged this class in #2709, #2696, #2700 and #2715 as "needs one
decision" and left ~25 sites unconverted. That decision was mine to make
and I should have made it three PRs ago.

**Prefetch a resolved map, then filter synchronously.** The alternative
— async predicates — forces every caller into `for await` and turns a
list comprehension into a sequential walk. Prefetching keeps the filters
synchronous, puts the awaits in one bounded place, and lets the IR cache
do the job it was explicitly built for:

> "A self-healing pass over 400 cards spanning three workflows must read
three IRs, not 400."

The cache is **instance-scoped and shared across all four passes**, so
each distinct workflow's IR is read once for the whole run rather than
once per pass. `resolveLifecycleColumns` is pure and *not* memoized by
that cache, so this still costs one cheap struct build per task — fine
in a background reconcile, and stated rather than hidden.

No new abstraction: `resolveTaskLifecycleColumns` already takes a
caller-owned cache. The only new code is a local map builder and two
named predicates.

## What it cost before

On a board with renamed terminal lanes, **every filter here matched
nothing**. The reconciler closed **no** GitHub issues and reported
`scanned: 0` — a clean-looking pass that did nothing.

## Why this is not the split brain #2724 documents — checked, not
assumed

#2724 proves the archived gate in `packages/core` is enforced in three
encodings, so converting one alone diverges them. I checked whether that
applies here before converting:

- This file contains **zero SQL** — measured: no drizzle, no `sql`
template, no `eq`/`ne`.
- It calls `listTasks({ includeArchived: true })`, so the SQL half has
already been told to include archived rows. The filter **selects among
rows it was handed** rather than deciding liveness a second time.

**Gate versus consumer** is the distinction, and a consumer can be
converted alone.

The fourth pass needed its own check because its list comes from
`listTasksForGithubTrackingReconcile`, which *is* SQL — but that impl
filters on `deletedAt IS NOT NULL` and `githubTracking IS NOT NULL`,
**never on the column**, so there is no SQL-side encoding of this
question to diverge from.

## Why the 33 existing tests stayed green through the conversion

Their fake store has **no workflow reader**, so
`resolveTaskLifecycleColumns` catches and returns `undefined` and every
case asserts the legacy fallback — exactly what it always asserted.
**None of them could have caught this being wrong.** `workflowIr` is now
an opt-in on that fake, which is what makes the new cases real tests
rather than restatements.

| reverted | result |
|---|---|
| terminal filter back to the ids | "closes issues on a RENAMED complete
lane" fails, no `setIssueState` |
| same | renamed archived-heuristic case fails, no `setIssueState` |

## A reachability finding, recorded not acted on

In backend mode `reconcileDeletedAndArchived` returns only
**soft-deleted** rows — its own comment says the archived-tasks fallback
is a separate `AsyncArchiveLineage` subsystem, skipped there — and
`task.deletedAt` is tested *first* in the `stateReason` chain. So its
archived arm is **effectively unreachable today**. I converted it rather
than deleting it: it is the documented FN-5577 done-heuristic, and
whether that fallback should be wired here is a separate question from
what vocabulary it speaks.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **35 passed** across
the three reconciler suites · dashboard `tsc` clean · `pnpm lint` clean
· census `--strict` exits 0.

Remaining files in this class (`branch-group-ops.ts`, `store.ts`, and
the dependency pairs) can now follow this pattern instead of waiting —
with the gate-versus-consumer check applied to each, since
`branch-group-ops.ts` sits closer to the persistence layer than this one
does.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:14:54 -07:00
gsxdsm
7c408ef650 fleet: merge path 10 → 2 — a merged PR never advanced its task on a renamed board (#2733)
**Claimed on #2728 before starting.** The merge path:
`merge-queue-ops-2.ts` + `merger.ts` — **10 → 2**, both survivors
flagged with reasons.

## A merged PR never advanced its task on a renamed board

`applyPrMergedTransition` is what moves a card when GitHub reports a PR
merged. Every guard in it was a default-lineage literal, and they all
failed **in the same direction**:

| guard | renamed board |
|---|---|
| `column === "done"` → skip as already-done | never matched, so a
complete card was re-processed |
| `column !== "in-review"` → bail `wrong-column` | always matched, so a
card **sitting in review** bailed |

Net effect: **a PR merged on GitHub never advances its Fusion task.**
The operator sees a merged PR whose card sits in review forever — which
reads as a broken webhook, so it gets debugged in the wrong place
entirely. That is the most expensive property of this defect class: it
does not just fail, it misdirects.

One snapshot now covers the pre-check, the deliberate **re-read** (a
merge can land between checks), and the **move target**. The target is
asserted in the test alongside the guards, because converting guards
alone would admit the card and then move it to a column the board does
not declare.

## merger.ts

- **The orphan-stash liveness guard** classified every finished task as
unfinished on a renamed board, so orphaned stashes were never cleaned
up. Unioned with the legacy ids: too strict here leaves clutter, too
loose **discards a stash whose task is still running**, so
over-inclusion is the safe direction.
- **The worktree-conflict scan** filters by worktree *path* before
resolving lanes. The naive order — resolve, then filter — is exactly
what made the github-tracking reconciler scan proportional to task
history (#2714 review). Lesson transferred rather than re-learned.
- The deprecated `aiMergeTask` already-finalized guard.

## Two flagged, not converted

**`merge-queue-ops-2`'s sync enqueue guard** runs inside
`store.db.transactionImmediate`. A synchronous lane resolution reads
`getTaskWorkflowSelectionImpl`, which returns `undefined`
**unconditionally in PostgreSQL mode** — so a "conversion" there would
drop the census by one and behave exactly as the literal (the finding
from #2703). Converting it properly means making the path async or
pushing the trait read into SQL: store architecture, not a call site.
Left literal **with that note**, so the next worker does not turn it
into a false green.

`merger.ts`'s last comparison is the same class.

## Pre-existing red, reported not folded

**22 failures in
`packages/dashboard/src/__tests__/routes-github.test.ts`** — spec
revise/rebuild and approve/reject-plan, all asserting moves to
**`triage`, the column U11 deleted**. Verified by reverting my diff and
re-running: identical 22. Same stale-literal-in-a-test class as the two
assertions #2720 fixed, and it is 22 tests pinning a column that does
not exist — worth someone owning deliberately rather than as a rider
here.

## Verification

census **10 → 2** · `pnpm test:gate` **487 / 71** · 23/23 across three
merger suites · 4 new cases, **2 red on revert** · `tsc` clean in core
and engine · `pnpm lint` clean.

## Also examined and deliberately left alone

- **`live-agent-count.ts` (6 guards)** — every literal there is the
*documented degradation path* for a task shape that was not enriched,
and both production callers already enrich (`useExecutorStats`, `fn
project`). Converting them converts nothing; deleting them removes the
fallback that fixtures rely on. The invariant that matters is **caller
enrichment**, which is not a literal at all.
- **`task-merge.ts` (6 guards)** — `getTaskMergeBlocker` is a **pure**
function with no store; its callers inject `resolveTask`. Resolving
lanes needs a matching injected resolver, which is an interface change
across every caller. Also worth a decision first: its dependency check
accepts `in-review` as satisfied while the store's `blockedBy`
computation (#2720) does not — **two definitions of "dependency
satisfied" in one codebase**, and I am not settling that one silently
inside a vocabulary sweep.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:08:16 -07:00
gsxdsm
ab715cbd39 fleet: default-workflow-hooks.ts 7 → 0 — every duration display read ZERO on a renamed board (#2734)
Claiming `default-workflow-hooks.ts` (7 → **0**), verified free against
every open PR's diff first.

## Not a vocabulary tidy — three silent zeroes

This file's header names it for the default workflow, but the store runs
it on the flag-ON path for **every** workflow: the trait registry
resolves each hook by **trait id**, not by workflow.
`reopen-semantics-by-role.test.ts` already documents that exact hazard
for the reopen predicates. The **timing, completion and in-review hooks
had the same defect** and were not part of that conversion.

On a renamed board, with nothing thrown and nothing logged:

- **`applyTimingEffects`** accrues `cumulativeActiveMs` while a card
sits in the WIP lane. With the lane named, the exit test never fires —
so **no active time is ever accrued**, and `productivity-analytics.ts`,
`task-timing.ts` and every duration display read **zero**.
- **`applyCompletionTimingEffects`** never stamps
`executionCompletedAt`, so a finished card looks unfinished to anything
reading that field.
- **`applyInReviewEnterEffects`** returns early, leaving the recovery
counters set.

The file already had the idiom — `ctx.lifecycleColumns`,
`planningColumnsOf`, `liveWorkColumnsOf` with `LEGACY_` fallbacks — so
this adds no abstraction.

One deliberate detail: `applyTimingEffects` resolves the WIP lane **once
into a local** rather than reading it twice. The exit test and the
re-entry test have to agree about which column is WIP, or a rename makes
the accounting count an interval twice, or not at all.

## A test that would have lied to me

I wrote the new cases through `applyDefaultWorkflowMoveEffects` first,
and **all three failed on the DEFAULT lineage too**. The dispatcher
resolves hooks by trait, and neither test IR declares the `timing`
trait, so those hooks never ran at all.

That failure looks exactly like a conversion bug. Going through the
dispatcher would have been testing the trait registry's wiring rather
than this change — so the cases call the converted functions directly,
and the reason is recorded in the test.

## Revert proof — all three, each naming the renamed lineage

| reverted | failure |
|---|---|
| the `in-progress` literals | `renamed lineage accrued no active time:
expected undefined to be 300000` |
| the `done` literal | `renamed lineage did not stamp completion:
expected undefined to be '2026-07-30T00:00:00.000Z'` |
| the `in-review` literal | `renamed lineage kept its recovery counter:
expected 3 to be undefined` |

Every case runs on **both** lineages and the default one passes either
way — which is the point of running it.

## A finding I did not act on

**`evaluateMergeBlockerGuard` appears exactly once in the repo — its own
definition.** And the file header says it is "implemented as the
`evaluateDefaultWorkflowGuards` reader", which does not exist either.
The merge-blocker guard hook is **defined and never consulted**.

I converted it (trailing optional lifecycle param, matching
`DefaultWorkflowMoveContext`) but did not delete it: the header states
this file is a deliberate parallel of `store.ts`'s flag-off path so the
two can be parity-checked, which makes removing it a scope call for
whoever owns that convergence — not something to decide inside a
conversion.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **22 passed** across
`default-workflow-hooks` + `reopen-semantics-by-role` · core `tsc` clean
· `pnpm lint` clean · census `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:05:05 -07:00
gsxdsm
94d88f1d6f fix(census): the work order was sending fleet workers at non-columns (722 -> 714) (#2692)
Found while claiming `TaskDetailModal.tsx` — its census entry included
`session.agentState === "done"`, an **agent state, not a lane**.
Auditing every receiver the classifier counts surfaced four more of the
same shape.

## The misclassified receivers

| site | receiver | what it actually is |
|---|---|---|
| `register-chat-routes.ts` | `event.type === "done"` | an SSE event
type |
| `useTaskDiffStats.ts` | `mode === "done"` | a cache-key mode |
| `async-mission-store.ts` | `evidence.kind === "done"` | an evidence
kind |
| `telemetry-hub.ts` | `event.kind === "done"` | a telemetry event kind
|
| `TaskDetailModal.tsx` | `session.agentState === "done"` | an agent
state |

Each shares a **word** with a column id and nothing else. Converting one
asks the trait registry what lane an SSE event is in, which has no
answer — the same failure class as converting `role === "triage"`, which
this list already exists to prevent.

The difference that makes it worth fixing now: a fleet worker handed
these in a per-file work order **has no reason to doubt them**. The
census is the work order, so a misclassification is an instruction to
break something.

## What I did not exclude

`state` is deliberately kept. `state === "archived"` in `audit-ops.ts` /
`comments-ops.ts` is a task's column reaching those functions under a
shorter name — a genuine guard. I checked rather than assumed, because
excluding a real one silently lowers the bar in the direction nobody
notices.

## Census effect

```
column  722 -> 714
role      5 -> 14
```

Those 8 are **reclassified, not converted** — this PR changes no
production code. The baseline is re-recorded so `--strict` agrees.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0. `pnpm lint` clean.

No changeset: instrument accuracy, no user-facing change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:01:52 -07:00
gsxdsm
15f90706e6 fleet: reliability-metrics.ts 6 → 0 — historical log values, marked not converted (#2756)
Unclaimed file, no overlap with any open fleet PR — deliberately picked
to avoid adding conflicts to the queue.

## Census

| | before | after |
|---|---|---|
| backlog | 539 | **533** |
| reviewed (DELIBERATE-LITERAL) | 31 | 36 |
| this file | 6 | **0** |

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

## Why these are marked, not converted

All six ids come from `metadataColumn(entry, "from"|"to")` — the columns
**recorded on a past move event** in the activity log, not a task's
current column.

There is no workflow to resolve them against. The event was written
under whatever the board looked like at the time, and **a column renamed
since leaves every older entry carrying the old id forever.** Converting
them to a trait read would ask *"what role does the column named X play
today?"* about a record written months ago, possibly under a different
workflow — a different question with a different answer.

The failure mode matters: a trait-converted reader on a renamed board
would **zero the series** rather than fix it, silently dropping history
out of `tasksEnteredInReviewPerDay`, `tasksBouncedToInProgressPerDay`,
and `inReviewDurationMetrics`. That is worse than the literal, which at
least keeps matching the data that exists.

**The real fix for renamed boards is at the WRITER** — emit a role
alongside the id when the move event is recorded — not at this reader.
Noted at the site so whoever does that work finds it.

## A rule this generalises to

**Any reader of activity-log or run-audit metadata is a mark, not a
convert.** The census cannot distinguish `task.column === "in-review"`
(a live question, convert it) from `metadataColumn(entry, "to") ===
"in-review"` (a historical record, match it as recorded) — both are just
literals to the AST. Other fleet workers hitting log/audit readers
should expect the same call.

## Placement trap, third occurrence

My first pass marked the `const from`/`const to` declarations and moved
the count by **1 of 6** — the census excuses the construct a marker is
attached to, and the guards live in **sibling `if` statements**. Moved
the markers to the enclosing functions.

This has now caught #2645's author, me on `TaskContextMenu`, and me
again here. **Verify a marker by the count moving, not by the comment
existing** — and until every worker does, a batch reporting "N → 0" can
be off by most of N.

## Verification

Dashboard typecheck clean · reliability suites green (11 passed) ·
`--strict` exit 0 · no behavior change (comments only).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:01:02 -07:00
gsxdsm
e18a6cf00c fleet: executor.ts 57 → 15 on top of #2689 — the review/wip lanes, 4 half-conversions, 8-of-19 revert proof (#2703)
**Supersedes #2691, which I am closing.** #2689 landed the terminal-pair
batch on `executor.ts` while my PR was open on the same file — we
collided, that PR won the race, and 30 of my 70 conversions are now
identical to its work. Rather than resolve 30 conflict hunks in a
20k-line lifecycle file (unreviewable, and the wrong artifact to hand
you), I rebuilt from `origin/main`.

**`executor.ts` 57 → 15.** Repo backlog 679 → **650**.

## The four that are defects, not vocabulary

**1. `isReentrantPausedAbortedInFlightNode` resolved lanes at the END,
for its return value, while its four `in-review` eligibility gates were
literals.** On a renamed board those gates all read false — so a review
card skipped the global-pause recheck, the `autoMerge === false`
refusal, the shared-branch-member arbitration **and** the
merge-confirmed refusal — and then the lane-resolved final line answered
*"re-entrant"*. FN-7214's own comment says an auto-merge-off review row
must stay terminal.

**2. The REVERSE half-conversion.**
`routeGraphFailureToExecutionResume`'s destination was already resolved
(U7's `resolveReboundColumnFor`) behind a gate that was still three
literals — so the router refused before reaching its own working move.

| direction | what happens | visible? |
|---|---|---|
| resolved gate → literal destination | card admitted, move rejected by
a board with no such column | **yes** — the move errors |
| literal gate → resolved destination | card refused; the working
recovery never runs | **no** |

Only the second is silent, which is exactly why it survived U7's own
conversion of that destination. **When you convert a destination, check
the gate in front of it in the same commit.**

**3. `routeUnusableWorktreeGraphFailureToRecovery` skipped FN-5147's
auto-merge-off gate** on a renamed board — an automatic recovery moving
a human-review-terminal card backward. #2689 converted the terminal
guard at the top of that method; this is the other half of the same
decision, which is the general risk when two people split one file.

**4. `handleGraphFailure`'s `alreadyFinalizedToReview` /
`suppressFinalizedCompletionAbort`** read `column !== "in-progress"`, so
a completed, already-finalized row looked still-in-wip: FN-6644 /
FN-6647's suppression never fired and the row was re-parked as an
operator-action pause abort — the durability gap those tickets closed.

## Two patterns worth carrying to other files

**An inert guard rarely reports "renamed board" — it reports something
that sounds like a different problem.** `finalizeAlreadyReviewedTask`
returned `"missing"` for a card sitting in review. The completion
handoff logged *"no longer active"* for a card that was actively
executing. The stuck-requeue cleanup logged *"recovered concurrently"*
about a recovery that had not happened. Three different false
explanations, one cause.

**Directions differ inside one family, so convert per method, not per
pattern.** Most wip guards read `!== "in-progress"` and REFUSE on
no-match (renamed board → silently disabled). The rerun watchdog reads
`=== "in-progress"` and SKIPS on match — there the literal never
matched, so a rerun could fire on a card **mid-execution**. A mechanical
sweep of `!== "in-progress"` fixes the refusals and leaves that
admission in place.

Also: the resolver choice inverts within a few lines. *"Is this card in
the ONE column finalize targets?"* needs the **complete** column — the
terminal union carries the legacy ids, so a card in a column merely
*named* `done` reads as already finalized and the finalize is
**skipped**. *"Is this card already finished, so do not move it?"* needs
the **union** — over-inclusion only skips a move, under-inclusion moves
a finished card out of its terminal column. Both are recorded at their
sites.

## Revert proof

19 cases in `executor-graph-failure-lanes-resolved.test.ts`, on a board
sharing **no** column id with the default lineage (on the default board
these guards are correct by coincidence — the literals *are* the board).
**8 fail on revert.** The rest are labelled **in the file** as paired
positives, default-board no-change cases, or — in one instance — a guard
that is genuinely redundant with a later lane check. I would rather
label a case as non-evidence than count it.

Two fixture corrections are recorded at their sites, both my own
assertion failing to touch the behaviour it named: asserting a router's
return value (which was already false for an unrelated reason — fixed by
spying on the recovery call), and `allowsAutoMergeProcessing` keying on
the **global** setting rather than `task.autoMerge` (fixed the fixture,
not the assertion).

## The 15 that remain, each with a reason

- **7 `to`/`from` move-effect parameters** — a move's endpoints, not a
card's resting column. Trait-hook territory.
- **2 enumeration scans** — one is a `listTasks({ column: "in-progress"
})` query whose filter cannot be converted without the query (converting
the filter alone reads as done and changes nothing); the other loops
every task, so per-task resolution is a real cost wanting a shared memo.
- **`12325`, the dependency guard** — *"is this dependency satisfied?"*
is not any single lane role. The same question exists at
`register-task-workflow-routes.ts:3995`; both should be decided once,
together.
- **`14484`** (`fromColumn === "in-review" && toColumn === "in-review"`)
— a same-lane move check that belongs with the move-effect group above.

## Verification

`pnpm test:gate` **158 / 10 / 487 / 71** · **135/135** across the 17
suites covering these paths · `tsc -p packages/engine` clean · `pnpm
lint` clean · census `--strict` exit 0, baseline re-recorded.

No changeset: `@fusion/engine` is private and the behaviour change is
confined to renamed boards.

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


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

* **Bug Fixes**
* Improved workflow execution across boards with renamed lifecycle lanes
by resolving lane targets per board instead of using fixed column names.
* Fixed review, WIP, completion, and failure-recovery behaviors to
respect the correct board snapshot (including auto-merge and terminal
work states).
* Improved artifact-recovery protection timing and tightened
execution-resume gating for failure scenarios.
* **Tests**
* Added a new lifecycle invariant test suite covering renamed-lane
recovery, resume, pause/abort, and router-gating behavior.
  * Updated lifecycle column census baseline data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:25 -07:00
gsxdsm
61b82a2737 fleet: pure lifecycle predicates 17 → 5 — a monitoring signal that went quiet, and a blocker that waited forever (#2745)
**Claimed on #2742 before starting.** Four pure modules — **17 → 5**,
every survivor flagged with a reason.

All four are **pure functions with no store**, so the fix shape is the
injected-set contract established in #2728, not an in-function resolve.

## Three failures that never error

| predicate | what a renamed board got |
|---|---|
| `getTaskAgeStalenessSignal` | `undefined` for **every** card —
age-staleness reported nothing |
| `isStaleBlockedByBlocker` | "not stale" for a blocker that was
finished, paused in review, or retry-exhausted |
| `areAllDependenciesDone` | "not satisfied" for a dependency that had
landed |

The first is the one to sit with: **a monitoring signal that goes quiet
is indistinguishable from health.** The board looks fine while cards sit
for days, and nobody investigates a metric that isn't alarming. The
signal also chose its *threshold pair* by wip-vs-review, so both halves
were literal.

The second means the blocked card **waited forever**, silently — "not
stale" is the answer that produces no event.

The third is the **third place** "satisfied" is asked. It now gives the
same answer as the store's `blockedBy` computation (#2720) and the merge
blocker: complete or archived, unioned with the legacy ids. Three
surfaces, one rule — which is exactly why I refused to settle it inside
a vocabulary sweep the first two times it came up.

## Optional is load-bearing

Both halves are asserted for every predicate: supplying lanes makes a
renamed board work, **omitting them preserves every existing caller**.

A *required* parameter would have compiled at every call site and then
answered "not active" / "not stale" / "not satisfied" for everything.
That is the silent direction, and **no type checker catches it** — which
is the argument for optional-plus-legacy-default over a clean signature.

The restart-recovery classifiers (with-progress / no-progress /
merge-active) take the same set, and **the combiner threads it to all
three**, so a caller cannot convert the outer question and leave an
inner one literal. `isInReviewMissingWorktreeSessionStartFailure` is
deliberately untouched — #2728 converts it and duplicating that would
conflict.

## The five that remain

- **3 are the ternary trait-fallback branches** (`lanes ? … : legacy`) —
the documented degradation path the census counts by design, not
unconverted guards. I am not marking them `DELIBERATE-LITERAL` to move
the number; that marker means "a lifecycle literal reviewed and kept",
and mislabelling to flatter a count is how the instrument stops meaning
anything.
- **`recoverInterruptedRuns`' filter sits behind a `listTasks({ column:
"in-progress" })` query.** The query is the live filter, so converting
the redundant predicate moves the census and changes nothing an operator
sees. **Third file** where the reported guard is the inert copy and the
real one is a query.
- **`resolveWorkflowBypassGuards` is sync and receives only column
strings** — no task, no store. Converting it means adding lanes to
`MoveTaskOptions` and threading them from the moves path, which another
worker owns. Marked `DELIBERATE-LITERAL` as an explicit hand-off, with
the consequence named: on a renamed board the operator's drag out of the
wip lane was rejected by the transition validator, so **a card could not
be cancelled from the board at all** (AGENTS.md's Move-Task hard-cancel
contract).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 9 new cases, **5 red on
revert** · 13/13 with the archive PG suite · `tsc` clean in core and
engine · `pnpm lint` clean · census **17 → 5**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:00 -07:00
gsxdsm
e0010f241e fleet: github-tracking-comments.ts 9 → 3 — and the sync-filter class is now in a FOURTH file (~25 sites on one decision) (#2715)
Claiming the **github-tracking pair**. This converts the comments half;
the reconciler half is the flagged class, with the evidence below.

## Census before/after

| | before | after |
|---|---:|---:|
| `github-tracking-comments.ts` | **9** | **3** |

Baseline re-recorded; `--strict` exits 0.

## Converted: 6

The `event.to === "in-progress"` / `=== "done"` sites in
`handleTaskMoved`, to the **wip** and **complete** roles. One
resolution, placed **immediately after the tracking-enabled gate** — so
a move on an **untracked** task pays nothing, which is most moves in
most projects.

## Deliberately not converted: 2 — the ordering is the reason

```ts
if (event.to !== "in-progress" && event.to !== "done") return;   // line 232
```

This runs **before** the tracked-task gate. Converting it moves the
resolution ahead of that gate and makes **every task move in the
project** resolve a workflow just to decide the task has no GitHub
issue. That's a real cost on the hottest event in the system, to convert
a guard whose only job is a cheap filter. Recorded at the site.

## The remaining 1

**Line 165** — `transition === "done"` inside `formatTrackingComment`, a
**pure formatter** with no store and no task. Same shape as
`project-engine.ts:2555`. Threading a resolution into a formatter to
pick a string is the wrong trade.

## `github-tracking-reconciler.ts` (9) — not claimed here, and here is
why

All nine are:

```ts
.filter((task) => task.column === "done" || task.column === "archived")
```

Synchronous filters over task **lists**, where per-task resolution is N
awaits inside a sync predicate.

**This is the fourth file with that exact shape** — after `store.ts`
(#2709), and the dependency pairs in `TaskDetailModal` (#2696) and
`register-task-workflow-routes` (#2700). By my count **roughly 25 sites
across four files now wait on one decision**:

1. **Prefetch lifecycle columns alongside the task list** and pass a
resolved map into these predicates — keeps them synchronous, one
resolution per *distinct workflow* rather than per task. This is the one
I'd argue for.
2. Make the predicates async and accept per-task resolution.

It's a design change, and four workers guessing separately is exactly
how two halves of one rule drift apart. One decision covers all of them.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) ·
`github-tracking-comments` + `github-issue-comment` **81/81** · `pnpm
lint` clean · dashboard `tsc` clean · `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:39:12 -07:00
gsxdsm
3da8b90ed9 fleet: scheduler.ts 26 → 12 — five quiet wrong answers on a renamed board (finished deps blocked forever, PRs unwatched, missions stalled) (#2729)
## Census

| | before | after |
|---|---|---|
| `packages/engine/src/scheduler.ts` | 26 | **22** |
| repo backlog | 657 | **653** |

Baseline re-recorded in this PR; `--strict` exits 0. Four literals
removed, and I want to be exact about why it is four and not seven: the
converted predicates keep their legacy literals as the documented
**no-metadata fallback**, and the census counts per literal, not per
code-quality improvement. Deleting those fallbacks would change
behaviour in degraded mode (unresolvable workflow → every dependency
reads as unsatisfied → dependents blocked forever), which is the
expensive direction to be wrong in.

## What was actually broken

**1. Dependency satisfaction was keyed on three column ids.**

```ts
return !!dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
```

On a board whose complete column is `shipped`, a **finished** dependency
matched none of the three. `getUnmetSchedulingDependencies` reported it
unmet, and the dependent was parked `blockedBy` — *permanently*, because
the dependency can never move anywhere that satisfies the literal. Work
stops and nothing rescues it.

Satisfaction is now resolved on the **dependency's own board**, since a
dependency edge may cross workflows — the dependent can sit on the
default board while the dependency lives on a renamed one. Resolution is
passed in by the caller (`resolveDependencySatisfactionColumns`) with a
caller-owned IR cache, so a sweep reads one IR per distinct workflow
rather than one per dependency edge.

**2. The review half of the file-scope lease was never converted.**

The wip half of this same sweep was fixed on 2026-07-30-16:30
(`scheduler-renamed-wip-file-scope-lease.test.ts`). The review half
still read `column === "in-review"`, so on a renamed board no review
card entered `activeScopes`, a merging card's worktree files read as
**free**, and an overlapping candidate dispatched on top of them. One
registry, two halves, disagreeing. It now uses `isReviewColumnRole` over
the *same* resolved flags map the wip half uses, so the two cannot drift
again.

## Finding I am reporting rather than fixing

**The two satisfaction rules in this one function genuinely disagree,
and the live one is the broader.**

| rule | satisfied when |
|---|---|
| legacy (**live**) | complete ∪ archived ∪ **review lane** |
| marker (shadow) | complete ∪ archived |

#2720 settled "satisfied = complete or archived" for
`update-task-deps.ts` — which matches the **marker** rule, not the live
one. So the scheduler currently treats an in-review dependency as
finished and `update-task-deps.ts` does not.

Reconciling them is a product decision, not a vocabulary one, so this PR
preserves **both** rules exactly as shipped. Narrowing the live rule to
match would strand every dependent of an in-review card, which is
precisely the failure mode fix 1 exists to remove — I am not doing that
as a side effect of a rename conversion.

## Revert proof

Each fix reverted **alone**, tests re-run:

| reverted | result |
|---|---|
| dependency satisfaction → literals | **2 failed** / 5 passed |
| review lane → `column === "in-review"` | **2 failed** / 5 passed |
| neither (shipped) | **7 passed** |

Each revert fails exactly its renamed case *and* its "both vocabularies
reach the same outcome" invariant, while the default-vocabulary controls
stay green — so the failures are attributable to a surviving column-id
literal and not to a generally broken path. The suite is differential:
one workflow **shape**, two vocabularies with identical traits, only the
ids differ. No renamed id collides with a legacy literal, so a surviving
`=== "done"` cannot pass by luck.

There is also a paired negative (`an UNFINISHED dependency still blocks,
under both vocabularies`) so the fix cannot degrade into "always
satisfied" — the direction it could overshoot.

## Verification

- `pnpm --filter @fusion/engine exec vitest run <19
scheduler/dependency/hold-release/overlap suites>` — **174 passed**, no
regressions
- new suite — **7 passed**
- `pnpm test:gate` — **158 / 487 / 10 / 71 passed**
- `pnpm lint` clean · `npx tsc -p packages/engine/tsconfig.json
--noEmit` clean · `check:lifecycle-columns --strict` exits 0

## The remaining 22, triaged

Not guessed at — grouped by what they actually ask:

- **6 fallback branches already converted** (L258, L265, L439, L440,
L1712 and the review twin): trait-first with the literal as documented
degraded-mode fallback. These reach 0 by *marking*, not converting.
- **10 `from`/`to` move-transition arms** (L899–L1042): a different
question ("is this transition *into* a review lane?"), and per the
scoping note in `docs/solutions/architecture-patterns/` they should not
ride along with `task.column` conversions.
- **6 `task.column` reads** (L1061, L1135, L1524, L1546, L1554, L2607):
convertible, but two sit in sync methods (`resolveBaseBranch`) needing
the caller-resolves-and-passes shape, so they are a separate unit rather
than a half-conversion here.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:15:05 -07:00
gsxdsm
72d42652e5 fleet: CLI surface 16 → 0 — 'active=0' on a busy board, and a retry gate that disagreed with the dashboard (#2728)
**Claimed on #2714 before starting.**
`packages/cli/src/commands/task.ts` (8) + `dashboard.ts` (8) — **16 →
0**.

## The finding that matters: `active=0` on a busy board

The same four-line aggregation appears **four times** in `dashboard.ts`
— the TUI stats refresh, the serve summary, the status line, the
agent-stats pass. Each compared the default lineage's two ids, so on a
renamed board every one reported `active=0` while the board was plainly
busy.

**This is worse than an inert internal guard.** A recovery path that
silently stops firing is invisible until something breaks. A stats line
that says zero is **read, believed, and acted on** — *"nothing is
running, so I can restart the engine."*

The four copies are now one helper, and that is the other half of the
fix: four independent copies of a lifecycle decision is how they drift,
and these were identical **by accident, not by construction**. One IR
read per *workflow*, asserted by call count — because the returned
number is identical either way, so only counting the work can see it.

## The retry gate exists twice, and #2713 converted one of them

After #2713, `POST /tasks/:id/retry` accepted a renamed board's stalled
review card while `fn task retry` refused it with *"not in a retryable
state"* — **one operator action answering differently depending on the
surface**.

The rule, stated at the site: **converting one copy of a duplicated gate
creates a disagreement that is harder to diagnose than the original
inert guard.** Grep the classifier by name before calling a lane
converted.

## The rest

- **`fn task set-node` / `clear-node`** rewrote the node override of an
*actively executing* card, because the "is in progress" check never
matched. That guard exists because the rewrite races the run.
- **The duplicate-guard candidate filter** kept completed cards in the
comparison set on a renamed board, so a new task was reported as a
duplicate of work that had already landed — the opposite of useful.
- **The duplicate-lineage `(archived)` marker** never printed, so the
operator could not tell a live duplicate from a filed one.

## Two DELIBERATE-LITERALs, with reasons

The board-render glyph compares `col` taken from the legacy `COLUMNS`
enum **that loop iterates** — the literal matches its own receiver by
construction. The real defect is already named in the code above it: a
card in a renamed column **is not rendered at all**, which is the R8/U10
surface change, not this glyph. Converting it would hide that behind a
trait lookup while the loop still cannot see the card.

## Pre-existing, not mine

5 failures in `commands/__tests__/task.test.ts` (GitHub import) **fail
on `origin/main`** — verified by stashing this change and re-running.
Someone owns that; it should not ride in here.

## Verification

census **16 → 0** · `pnpm test:gate` **10 / 71** · `pnpm smoke:boot`
**PASS** · `tsc -p packages/cli` clean · `pnpm lint` clean ·
`task-retry` 3/3 · 4 new cases with **2 red on revert**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:04:23 -07:00
gsxdsm
b6b28c3b0b fleet: task-age-staleness 4 → 0 + the claim guard — an agent could claim a finished card, and no card was ever 'stale' (#2746)
Two core clusters. 6 converted, 2 flagged.

## Two silent failures

**No card was ever stale.** `task-age-staleness.ts` applies its signal
only to the mid-flight and review lanes — a card in a hold or terminal
lane is waiting or finished, not stale. Both lanes were named by id, so
on a renamed board the signal returned `undefined` for **every** card
and the stale-card warning never appeared anywhere on the board.

**An agent could claim a finished card.** `claimTaskForAgent`'s terminal
guard was `column === "done" || column === "archived"`. On a renamed
board neither matched, so the claim **succeeded** and the agent began
work on completed output.

## The threshold selectors are a separate literal, and half-converting
is worse than neither

`task-age-staleness` has two independent uses of `in-progress`: the
**lane gate** that decides whether the signal applies, and the
**threshold selectors** that pick which warning/critical numbers to
measure against.

Converting only the gate admits a renamed-WIP card and then measures it
against the **review** threshold — a wrong number, silently. Both are
converted, and each is revert-proofed on its own:

| reverted | result |
|---|---|
| the lane gate | 3 of the new cases fail (`expected undefined to be
defined`) |
| the threshold selectors | the threshold case fails — a renamed WIP
card gets the review threshold |

No new seam for either: the staleness signal already took a `context`
object, and its one production caller (`task-store/reads.ts`) already
holds a **per-pass IR cache** for precisely this kind of resolution.

## Cost stated rather than hidden

The `reads.ts` resolution is **unconditional**, where the hold-column
read directly beside it is gated on `task.paused`. That asymmetry is
deliberate: the lanes this needs are exactly what decides whether the
signal applies at all, so there is no cheaper gate available ahead of
it. With the shared per-pass cache that is a struct build per card, not
an IR read.

## Flagged and left counted

`formatCurrentTaskLine` is a pure formatter over `Pick<Task, "column">`
whose output **prints** the column name for a human reader — same class
as `github-tracking-comments.ts:165`. It also degrades gracefully: the
"(not active — X)" wording is lost on a renamed board, but "(X)" is
still accurate, just less specific. Threading a resolution into a string
builder to pick a word is the wrong trade.

## The recurring blind spot, fourth time

**None of the 12 existing staleness cases could have caught this** —
`lifecycle` is optional and they all omit it, so they assert the legacy
fallback. Same for the reconciler's 33 (#2737) and `TaskReviewTab`'s 45
(#2744).

This is now a consistent property of the optional-flags seam: **the
existing suite stays green through the conversion and through a broken
one.** Every file in this program needs at least one case that supplies
flags, or the conversion is untested in both directions. Worth making an
explicit review criterion rather than something each worker rediscovers.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **31 passed** across
staleness / routing-policy / dispatch suites · core `tsc` clean · `pnpm
lint` clean · census `--strict` exits 0.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:52:01 -07:00
gsxdsm
2a293ee1e0 fleet: TaskDetailModal.tsx 30 -> 7 (#2698)
## Census before / after

| | before | after |
|---|---:|---:|
| `TaskDetailModal.tsx` | **30** | **7** |
| repo backlog | 721 | **698** |

Backlog dropped by **23** — the converted count, nothing else moved.

**Not 30 → 0.** The seven survivors are enumerated below with reasons
rather than absorbed into the number.

## Converted (23)

Four role bindings declared immediately after `workflowMoveMetadata` —
their source — and 20 in-component comparisons collapsed onto them.

Two module-scope helpers (`resolveDefaultTab`,
`requiresExecutionModeReplan`) take a bare column id with no flags in
scope, so they use the fallback-only form. That is **centralisation, not
trait resolution**, and each is labelled as such at the site so it stays
greppable as "still needs its flags threaded".

## Not converted (7), each with a reason

| count | site | why |
|---:|---|---|
| 2 | `showNearDuplicateWarning` (~881) | Sits **above**
`workflowMoveMetadata`, so referencing the role bindings is a
temporal-dead-zone error. Needs the state declaration hoisted —
behaviour-safe, but it reorders hooks in a 6500-line component, which is
not a vocabulary edit. |
| 2 | two `useEffect` dep arrays (~930, ~936) | Same problem, subtler:
the callback *bodies* could reference the bindings, but a **dep array is
evaluated eagerly** at the hook call, which is above the declaration.
Same hoist. |
| 2 | `overlapBlockerTask.column` (~3559) | A **different task's**
column. The modal holds flags for its own card only; resolving the
blocker's role means fetching its flags. Out of scope. |
| 1 | `session.agentState === "done"` (~353) | An **agent state, not a
column**. Already reclassified in #2692 — this file drops to 6 counted
when that lands. |

## Late-arriving flags — checked, not assumed

`currentColumnFlags` is `null` until the workflow fetch resolves, so
every role here flips after first paint. That is the hazard that
produced four stale memos in `TaskCard` (#2688 review), so I ran an AST
pass over every `useMemo` / `useEffect` / `useCallback` in the file.

**None needed a new dependency** — the 20 converted sites are all
render-path expressions rather than memoised closures. Worth stating
explicitly, because "no dep changes" in a conversion PR usually means
nobody looked.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded here.
`tsc -p tsconfig.app.json` clean. `pnpm lint` clean. Targeted
`TaskDetailModal` suites pass (5/5).

Note: the full `TaskDetail*` glob exceeds a 10-minute run locally, so I
verified with the targeted suites plus typecheck rather than reporting a
number I did not measure.

No changeset: no user-visible change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:37:47 -07:00
gsxdsm
8f2cddc5fd fleet: update-task-deps.ts 7 → 0 — settles "dependency satisfied", and the store was writing a column U11 deleted (#2720)
**Claim announced on #2714 before starting.**
`packages/core/src/task-store/update-task-deps.ts` — **7 → 0**.

This one settles an open question and turned up a **live bug on the
shipped default board**, not just on renamed ones.

## 1. What "dependency satisfied" means — settled, not guessed

I flagged this in three files rather than swapping it three times
independently (`executor.ts:12325`,
`register-task-workflow-routes.ts:3995`, here). The answer has to be the
same everywhere or the scheduler and the store disagree about which
cards are blocked. Settled in the store, where `blockedBy` is actually
written:

- **SATISFIED** = the dependency's own board's **complete** or
**archived** column. Archived counts: it is finished work the operator
filed away, and reading it as unsatisfied blocks every dependent forever
with no recourse short of editing the graph.
- **NOT review** — a card in review is not done; its branch has not
landed.
- **Unioned with the legacy ids**, because a row can outlive the column
it is stored in.

On a renamed board the old literals matched nothing, so **every**
dependency read as unresolved and `blockedBy` was pinned to the first
one permanently — dependents never unblocked after the work landed.

## 2. The re-specification move was writing a DELETED column — on the
default board

`hasNewDependencies && column === "todo"` set `column = "triage"`. **U11
(#2515) deleted `triage`**, keeping `todo` as the merged Planning
column. Measured, not assumed:

```
resolveDefaultWorkflowIr() columns:
  todo[intake,hold,reset-on-entry]  in-progress[wip,…]  in-review[merge,…]  done[complete]  archived[archived]
```

So the store has been writing a column the shipped board does not
declare. And the emitted event hardcoded `from: "todo", to: "triage"` —
**`task:moved` is what the GitHub tracking poster, the auto-merge
handoff and the executor's listeners react to**, so every subscriber was
being told about a column that does not exist.

Now the guard reads the hold lane, the target is the intake lane, the
log line names the real column, and when intake === hold (the default
lineage post-U11) there is **no move and no event** — announcing a move
into the column the card already occupies re-runs reset-on-entry effects
in every listener.

## 3. Two existing suites taught me more than the conversion did

**`refine-duplicate-task.pg.test.ts` proved the union is required, in
one run.** My first version compared only the resolved lanes and refused
a row sitting in `done` on a board declaring `published`: *"Cannot
refine KB-001: task is in 'done', must be in 'published' or
'editorial-review'"*. That row is real, and refusing an operator action
on it is worse than accepting one extra column name. Over-inclusion is
the safe direction for "may I refine this?" — the same reasoning as the
executor's `resolveTerminalColumnsFor`.

**Two assertions expected `"triage"`.** They were not protecting
behaviour; they were protecting a stale literal that outlived its
column. Updated **with the measurement in the file**, because a
silently-changed expectation is indistinguishable from a broken one.

## Revert proof

1 of 3 new PostgreSQL cases reddens when the union is removed. Driven
through the real store on PostgreSQL because `blockedBy` is persisted
and resolution reads the workflow selection from the database — a mocked
store would prove neither.

## Verification

`pnpm test:gate` **487 / 71** · 13/13 in the two pre-existing
dependency/refine suites · 3/3 new · `tsc -p packages/core` clean ·
`pnpm lint` clean · census `--strict` exit 0 (**7 → 0** for this file).

Changeset: none. `@fusion/core` is private, and while item 2 is an
operator-visible fix on the default board, it lands as internal
behaviour with no API change — say the word if you want one anyway for
the release notes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:29:36 -07:00
gsxdsm
3b618f2530 fleet: mission-execution-loop.ts 10 → 2 (one rule, five copies; and why these converted where store.ts's look-alikes could not) (#2711)
Claiming **`packages/engine/src/mission-execution-loop.ts`** (10). Every
one of its 10 census sites is the **same rule written five times**:

```ts
linkedTask.column === "done" || linkedTask.column === "archived"
```

## Census before/after

| | before | after |
|---|---:|---:|
| `mission-execution-loop.ts` | **10** | **2** |

Converted 4 of the 5 copies (8 of 10 sites) to the complete/archived
roles via core's `resolveTaskLifecycleColumns`. Each site already had
`this.taskStore` in scope inside an async method **and already had the
linked task fetched**, so the resolution rides along with a read that
was happening anyway.

## Why these converted where `store.ts`'s look-alikes could not (#2709)

Both read **another task's** column. The difference is not whose column
it is:

- **Here** — async methods, store at hand, one task per call. A
resolution is already affordable.
- **`store.ts`** — synchronous `filter` callbacks over a prefetched
`taskById` map, where per-dep resolution means N awaits inside a sync
predicate on a path that prefetches precisely to avoid per-item I/O.

Same-looking code, opposite verdicts. The distinguishing question is
**"is a resolution already affordable here"**, not "whose column is it"
— worth stating because a fleet worker pattern-matching on the receiver
alone would get both wrong.

## Not deduped, deliberately

The right shape is one predicate used five times rather than five inline
copies — and the FNXC comments above each copy show their intent has
already drifted apart. But introducing that predicate is a **new
abstraction**, which the fleet rules exclude, and it would fold five
reviewable substitutions into one design change. Flagged as the obvious
follow-up instead of smuggled in.

## Remaining: 2

The fifth copy, at the `hasLiveFixTask` site, where the terminal check
is one clause of a longer `Boolean(...)` expression whose other clauses
I would have had to reflow. Reviewability, not difficulty.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · the four mission
suites **150/150** · `pnpm lint` clean · engine `tsc` clean · `--strict`
exits 0.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:08:10 -07:00
gsxdsm
03443297db fix(census): re-record the stale baseline — and the ratchet does NOT fail on a stale allowance (#2712)
## The immediate hole

Main's baseline allowed **27** guards in
`packages/engine/src/scheduler.ts` while the tree has **26**. A
re-introduced guard there would have kept `check:lifecycle-columns`
green.

This is the merge-order collision I flagged on #2693: **#2690 and #2693
each converted a different `scheduler.ts` site and each recorded 28 →
27.** After both merged the true count is 26, and neither re-recorded
it. Predicted in #2693's body; this is the cleanup.

## The more important finding: `--strict` reports the stale allowance
and exits 0

```
$ node scripts/lifecycle-column-census.mjs --strict
  packages/engine/src/scheduler.ts: allows 27, tree has 26
$ echo $?
0
```

So the required check is **green while the hole is open** — the script
detects the condition and does not enforce it. That is precisely the
failure mode its own message warns about:

> *"A stale allowance is a hole: those guards can be reintroduced later
and this check stays green."*

**I did not flip it.** Making `--strict` fail is a CI-policy change that
would block every PR until each stale baseline is re-recorded — and that
exact situation just blocked the queue (three PRs, #2673/#2674/#2676,
raced to un-red this check). Reversible-by-me stops short of "block
everyone's merges", so it is flagged for an owner with the reproduction
above.

Related, and worth knowing before anyone debugs a dirty tree:
**`--strict` writes the baseline as a side effect of the check.** A
plain verification run mutates
`scripts/lib/lifecycle-column-census-baseline.json`. That is how this
re-record was produced, and it is why an earlier PR of mine had to
revert an unintended baseline edit.

## What is in the diff

Re-recorded from the tree and verified against the live census before
committing:

| | baseline | tree |
|---|---:|---:|
| `column` total | 693 | **692** |
| `in-progress` | 136 | **135** |
| `scheduler.ts` | 27 | **26** |
| `deliberate` | 17 | **20** |

**The `deliberate` movement is not mine.** `RoutineEditor.tsx`,
`ScheduleForm.tsx` and `ScheduleStepsEditor.tsx` each carry a real
`DELIBERATE-LITERAL` marker in the tree (verified by grep, not inferred
from the diff), and three `deliberateByFile` keys gain their `triage`
scope. Those came from merged PRs that likewise did not re-record. This
commit records the tree's actual state; it does not endorse those
markers, and anyone auditing the deliberate list should look at those
three rather than assume they were reviewed here.

`--strict` now reports *"every file matches its baseline exactly"*.

## Verification

Baseline-only change — no source, no tests. `--strict` clean; census
reads COLUMN 692 · ROLE 5 · STATUS 186 · DELIBERATE 20 · QUERY 83.
2026-07-30 05:07:58 -07:00
gsxdsm
e46cc7f1be fleet: TaskCard.tsx 42 → 3 — the flags were already in scope, asked 39 times by id anyway (plus a live 'Move to triage' on a board with no triage) (#2726)
Claiming **TaskCard.tsx**, the largest app-side cluster in the census.
39 convert; 3 are flagged and left **counted**, one of them a live bug.

## Census

| | before | after |
|---|---:|---:|
| `TaskCard.tsx` | **42** | **3** |

Baseline shrinks by exactly the 39 converted.

## The shape of it

`taskColumnFlags` was **already threaded into this component** and
already consumed by `canEdit` and `isTaskAgentActive` — but the terminal
/ mid-flight / review questions were still answered by comparing
`task.column` to a literal, **39 times in one component**. That is how a
card ends up rendering as live work by one question and terminal by the
next on the same board.

Four booleans now resolve once, beside the existing intake/hold pair and
before the first `useState` that reads them:

```ts
const isWipColumn = isWipColumnRole(taskColumnFlags, task.column);
const isReviewColumn = isReviewColumnRole(taskColumnFlags, task.column);
const isCompleteColumn = isCompleteColumnRole(taskColumnFlags, task.column);
const isArchivedColumn = isArchivedColumnRole(taskColumnFlags, task.column);
```

Flags-first with the legacy id as the documented no-metadata fallback —
identical in shape to the intake/hold pair directly above. No new data
flow, no new abstraction.

## A live bug, flagged rather than converted

The in-review card menu pushes move targets:

```ts
for (const column of ["done", "triage"] as const) {
```

**`triage` is the column #2515/U11 deleted** when it merged intake and
hold into a single `todo` lane. On a post-U11 board this pushes a "Move
to triage" entry for a column that does not exist, and
`taskActionColumnLabel("triage")` labels a target the board cannot show.

Converting it to a role would have been the *worst* outcome — it would
have **hidden the staleness** by resolving the dead target to a live
column. Removing a visible menu entry is exactly the UI-affordance
change AGENTS requires a Surface Enumeration for (the workflow-row
chevron took FN-6115 → FN-6118 → FN-6123 for skipping it), and what it
should offer instead is a product call. Recorded at the site with the
cause.

The other two flagged: `getInReviewCompletionMs` is module-scope with
only a `Task` and no flags to consult (same class as
`project-engine.ts:2555` and `github-tracking-comments.ts:165`), and the
`isHoldColumn` fallback arm, which *is* the degraded answer.

## Revert proof — both directions, because only one is reachable by
renaming

| reverted | result |
|---|---|
| `task.column === "done"` on the archive guard | Archive **appears** on
a mid-flight card: `expect(element).not.toBeInTheDocument()` |
| same | Archive **missing** on a renamed complete lane: `Unable to find
… name "Archive"` |

The pure-rename direction is only half the property. The other half —
traits say mid-flight, column still *named* `done` — is what an
unconditional id comparison actually gets wrong, and it is reachable by
repurposing a default column rather than renaming one.

## Two process findings

**1. `git stash` is shared across worktrees, and a concurrent worker's
stash cost me this cluster once.** I stashed to measure a baseline, and
between my push and my `git stash pop` another agent working in a
different worktree of this repo pushed a stash — so `pop` (which is
positional) applied **their** `self-healing.ts` changes into my tree and
my TaskCard work vanished from it. Their entry was kept rather than
dropped, so nothing was lost; I reverted their application, left
`stash@{0}` untouched, and recovered mine with `git stash apply <sha>`.
**Positional stash refs are unsafe in this repo** — the stack is in the
common git dir, so every worktree shares it. Use an explicit SHA.

**2. Running `--strict` regenerated 26 lines, not 1.** The writer
deliberately omits the derived aggregate blocks (my own earlier change,
to stop every fleet PR conflicting on the same totals lines) but main's
baseline still carries them from an older write — so a regeneration here
would have silently deleted `totals`, `byColumnId` and `properties` as a
side effect of converting one file. I hand-edited the single entry
instead, so the aggregate removal stays owned by the PR that introduced
it. Worth knowing: **`--strict` auto-rewrites and prints "COMMIT IT", so
this rides along invisibly** for anyone who does.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **684 passed** across
TaskCard / role-invariance / workflow-resolved-columns / ListView /
columnRoles · dashboard `tsc -p tsconfig.app.json` clean · `pnpm lint`
clean · census `--strict` exits 0.

The **3 `TaskCard` failures are pre-existing** — verified twice against
a stashed clean `origin/main`, same three names. They assert CSS-var
geometry (`expected '0' to be 'var(--space-xs) var(--space-sm)'`) and
are untouched by this change.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:44:46 -07:00
gsxdsm
3577cb6adf fleet: project-engine.ts 12 → 5 — auto-merge silently declined every card on a renamed board (#2706)
**Claim announced before the work** (on #2689, alongside
`register-task-workflow-routes.ts`):
`packages/engine/src/project-engine.ts`.

**12 → 5.** Repo backlog → **685**.

## The failure mode here has no error signature

Every merge guard in this file spelled the lane `in-review`. On a
renamed board nothing throws, nothing logs a warning — **auto-merge
simply declines every card**:

| guard | what a renamed board gets |
|---|---|
| `requestInterpreterMerge` | returns `noOp: true` — *"parked cleanly in
review, awaiting human merge"* — for a card that was in review and fully
eligible |
| the merge-queue snapshot | returns an **empty list** for a queue full
of review cards, so the coordinator sees nothing to admit |
| the `taskMoved` auto-merge handoff | never fires, so nothing reaches
auto-merge in the first place |
| the pause-interruption tracker | drops every card from its
paused-review set on the next update, so a merge paused mid-flight is
never interrupted |

The operator sees cards resting in review with auto-merge **on**, and
every log line says the system did the right thing. There is no string
to search for — which is the argument for the census being a parse
rather than a grep over error messages.

## Implementation notes

- **Core's `resolveTaskLifecycleColumns` directly** — the canonical
helper, so no new abstraction and no fourth local resolver in a file
that had none.
- **The merge-queue snapshot resolves per task through a shared
`irCache`**, because a merge queue can hold cards from *different*
workflows. Per-workflow, not per-card: one IR read each.
- **The handoff and its post-grace recheck share one snapshot.** They
are halves of one decision — "did this card just enter the merge lane,
and is it still there?" — and that is exactly the split that produced
the defects in `executor.ts`.

## Revert proof

**1 of 3 cases reddens** with the literal restored.

The suite invokes the real `requestInterpreterMerge` via `.call()` on a
minimal `this` (`runtime.getTaskStore`, `allowInReviewMergeProcessing`,
`onMerge`) instead of standing up a whole `ProjectEngine` runtime. The
body under test is the shipped one, and *reaching* `onMerge` is the
assertion. I would rather explain that seam than either skip the proof
or spend the test budget booting a runtime. The default-board case is
labelled in the file as no-change evidence, not counted as coverage.

## The remaining 5, flagged not guessed

All five are `column === "done"` **merge-confirmation reads** — "did the
merge land?". That is a different question from any lane role, and it
shares its answer with the dependency guards I flagged in `executor.ts`
(`12325`) and `register-task-workflow-routes.ts` (`3995`). Three files,
one open question: **what does "landed / satisfied" mean on a board
whose terminal column is not named `done`, and is it the complete column
or the terminal union?** Deciding it once and applying it to all three
is right; swapping it three times independently is how the resolver
choice ends up inconsistent — which already happened once inside
`executor.ts`, where the correct resolver inverts between two guards a
few lines apart.

## Verification

`pnpm test:gate` **158 / 10 / 487 / 71** · **56/56** across the six
auto-merge / project-engine suites · 3/3 in the new suite · `tsc -p
packages/engine` clean · `pnpm lint` clean · census `--strict` exit 0.

No changeset: `@fusion/engine` is private, and the behaviour change is
confined to renamed boards.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:25:19 -07:00
gsxdsm
a59c6aea50 fleet: register-task-workflow-routes.ts 20 -> 5 (#2713)
## Census before / after

| | before | after |
|---|---:|---:|
| `register-task-workflow-routes.ts` | **20** | **5** |

I handed this cluster off in an earlier turn with an analysis rather
than a conversion. Coming back to it with the analysis already done made
it tractable.

## Converted with the file's own idiom

This file already had `resolveIntakeColumnForTask` /
`resolveWipColumnForTask` / `resolveReboundColumnForTask`: resolve the
column **id** from the task's workflow, fall back to the legacy id when
the IR cannot be read. I added the three roles it was missing — review,
complete, archived — in that same shape.

**Deliberately not the `columnRoles` predicate helpers** used in
`packages/dashboard/app`. Those take resolved trait *flags*, which a
route handler does not have — it has a store and a task id, and must do
an async lookup. Importing them here would mean fetching flags per
request to answer a question this file already answers a simpler way.
One idiom per layer.

Every converted handler **resolves once and reuses**, so two checks in
the same request cannot disagree about which column is the review lane.
The retry handler had three separate review checks and now shares one
resolution.

## Also fixes an inversion

`isArchived` ORed the legacy id with the resolved trait
**unconditionally**, so a column merely *named* `archived` counted as
archived even when its own workflow said otherwise. Same pattern
previously found in `TaskContextMenu` and `isPreExecutionHoldColumn`.
Now flags-first, id as fallback.

## The five survivors, each with a reason

| count | line | why |
|---:|---|---|
| 1 | 1193 | `tasks.filter(t => t.column === "todo")` — a **list** path.
Per-task IR resolution is N store reads on board load; the site already
carries a note measuring that cost. Needs the hold column resolved per
*workflow* from the board payload — a real change, not a rename. |
| 1 | 1926 | **Already flags-first**: `moveTargetIr && declaresColumns ?
columnHasFlag(...) : column === "in-progress"`. The literal *is* the
documented no-IR fallback. |
| 1 | 4872 | The fallback arm of the `isArchived` fix above — same
shape, deliberately kept. |
| 2 | 4024 | `depTask.column` — a **dependency's** column, not the
task's. Resolving it means fetching that task's IR per dependency; out
of scope. |

So three of the five are correct as they stand, and two are genuinely
deferred.

## Verification

`plan-approval-intake-column` + `stranded-refinements-routes` 13/13.
`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded here.
`tsc -p packages/dashboard/tsconfig.json` clean. `pnpm lint` clean.

Typecheck was run after **every** batch rather than once at the end —
with 15 edits across async handlers in a 6000-line file, a single late
typecheck would not tell me which batch broke it.

No changeset: no user-visible change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:17:03 -07:00
gsxdsm
f6e460acdf fleet: moves.ts 15 → 2 (hot path; one hoisted resolution, net one FEWER than before) (#2705)
Claiming **`packages/core/src/task-store/moves.ts`** — 15 guards,
largest unclaimed. This is the core move path, every task move in the
system, so the conversion is built to add **no work** to it.

## Census before/after

| | before | after |
|---|---:|---:|
| `moves.ts` column guards | **15** | **2** |
| repo backlog (this branch vs `origin/main`) | 693 | **679** |

Baseline re-recorded; `--strict` exits 0.

*(Backlog figures don't compose across my open fleet PRs — each branch
carries only its own reductions. 693 → 679 is this branch against main
as measured, not a running total.)*

## Zero added cost, and actually one fewer resolution than before

`moveTaskInternal` **already** resolves the workflow IR unconditionally
at line 400 — the `useWorkflow` gate is gone — and already derived a
lifecycle from it ~400 lines later for the trait hooks. So one hoisted
`moveLifecycle` immediately after the IR resolution serves all 14
guards, and the later local now **aliases** it instead of resolving a
second time.

Net effect on the hot path: **one fewer `resolveLifecycleColumns` call
than before this PR.**

## Converted: 14

6× `toColumn === "done"` → complete · 4× `fromColumn === "in-review"` →
review · 1× `toColumn === "in-review"` → review · 2× `toColumn ===
"todo"` → hold · 1× `toColumn === "in-progress"` → wip

Every site keeps its legacy id as the fallback. `undefined` here means
no IR on this path or a v1 column-less IR, and a move must behave
**exactly** as before when there is no basis to resolve from — this is
the transaction that arbitrates capacity, so "unchanged when
unresolvable" is the requirement, not a nicety.

## Flagged, not converted: 1

**Line 309** — `task.column === "archived"` in the handoff-invariant
check. It sits in a different function that runs **before** any IR
resolution, so converting it would mean *adding* a resolution to a path
that currently has none. That is a cost on the handoff path rather than
the free reuse everything else here gets, so it wants a deliberate
decision rather than my inclusion.

## Verification — the paths that matter, not just typecheck

Because this is the move transaction, `tsc` + lint is not sufficient
evidence:

- **`pnpm test:gate` GREEN** — 158 + 10 + 487 + 71
- `workflow-capacity-invariant` + `move-path-equivalence` **7/7** (the
in-transaction capacity gate lives in this file)
- `handoff-to-review-atomicity` **4/4**
- `store-movement` + `move-task-preserve-status` +
`task-move-hard-cancel-ordering` + `transition-pending-and-status-clear`
**16/16**
- `pnpm lint` clean · core `tsc` clean · `--strict` exits 0

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:06:02 -07:00
gsxdsm
0c7dc8c8ae feat(census): report MIXED-VOCABULARY files — the shape behind four half-conversion findings in one day (#2704)
## The pattern

Four review findings dispatched to me in a single day were the **same
defect**: a guard converted to role resolution while the function it
*feeds* still filters on the literal. The resolved guard admits a custom
column, the literal collaborator rejects it, and **nothing errors** —
the endpoint returns `repaired: 0` and reads as converted.

| PR | resolved side | literal collaborator |
|---|---|---|
| #2700 | review guard | `reconcileInReviewBranchRebind` filters `===
"in-review"` |
| #2700 | retry guard | `isInReviewMissingWorktreeSessionStartFailure`
likewise |
| #2698 | role-aware tabs | reconciliation effects still compare
`"done"` / `"in-review"` |
| #2688 | role-derived flags | memos and a `useState` capture keyed on
the stale value |

Since opening this I have been handed **two more** of the identical
shape (#2701, #2702). It is not a coincidence; it is what a conversion
phase produces by default.

## What this adds

A file where **both vocabularies are live** is where that can happen, so
the census now names those files.

**Measured: 23 of 134 guard-bearing files, holding 311 of 686 guards** —
and the top of the list is exactly where the findings landed:

```
MIXED-VOCABULARY files (a role resolver AND legacy literals): 23, holding 311 guards
   110  packages/engine/src/self-healing.ts
    57  packages/engine/src/executor.ts
    26  packages/engine/src/scheduler.ts
    20  packages/dashboard/src/routes/register-task-workflow-routes.ts
```

## Report-only, deliberately

A partially converted file is the **expected** state during a conversion
phase. Gating this would punish correct in-progress work and would be
routed around within a day. What it buys is that a reviewer of a listed
file knows to check the collaborators of anything converted — which is
what this repo's **Surface Enumeration** rule already requires, and what
each of those PRs missed.

The rule exists. The fleet work order does not mention it, so reviewers
are catching these one site at a time.

## Verification

Five tests, both directions: flags a mixed file; does **not** flag a
fully literal one (or the entire backlog lights up and the signal
carries no information); does **not** flag a fully converted one; does
not match a resolver name inside a longer identifier (the
`hold`-inside-`threshold` trap from #2677); survives an unreadable file.
**Mutation: dropping the resolver condition fails 2 of 38.**

The helper lives in the **lib**, not the CLI — importing the CLI
executes it and calls `process.exit`, so nothing defined there is
reachable from a test. I found that by trying.

38 census tests green · `--strict` and `--compare` exit 0 · lint clean ·
gate green (487 + 158 + 10 + 71). **No census numbers change.**
2026-07-30 04:02:19 -07:00
gsxdsm
339f6e7830 fix(census): stop the baseline serialising the fleet — every fleet PR conflicted with every other one (#2699)
## The problem

Every fleet PR conflicts with every other fleet PR in
`lifecycle-column-census-baseline.json` — **even when they convert
entirely different files**. I have rebased **six** of my own branches
for nothing but this file, and the resolution was *always* "take main's,
re-run `--update-baseline`". Never once a real merge.

That makes a generated artifact the serialisation point for the whole
fleet phase.

## The cause

`totals`, `byColumnId`, `properties` and `queryByColumnId` are
**derived** — recomputable from the per-file maps — and **`--strict`
never reads any of them**. It compares `byFile`, `deliberateByFile` and
`queryByFile`, and nothing else.

But every conversion changes at least one aggregate line. So those lines
were a **shared write on a file whose real content is per-file and
disjoint**. Removing them, two PRs converting different files touch no
common lines.

## Trade-off, stated because it undoes a deliberate choice

An earlier note kept the totals in the pin *"so the new number lands in
the diff where a reviewer sees it"*. That was a good reason. The signal
survives elsewhere:

- the CLI prints the totals on every run;
- `--update-baseline` prints each tightened entry by name;
- the fleet rules already require a census before/after **in the PR
body**.

Reversible if the diff-visible number proves to matter more than the
conflicts.

## Cost, stated too

Merging this makes every in-flight fleet PR re-record once. That is one
more instance of an operation they are already performing on every
rebase — a one-time cost against a recurring one.

## Verification

The end-to-end test that asserted the write via `totals.column` now
asserts the same claim via the per-file entry: the stale pin says 1, the
rewritten pin must carry the tree's real higher count for that file.
**Mutation: suppressing the `--update-baseline` write still fails it**,
so the assertion did not weaken.

71 census tests green · `--strict` and `--strict --exact` exit 0 · lint
clean · gate green (487 + 158 + 10 + 71).

## Not done

A merge driver. `.gitattributes` can name one, but registering it needs
`git config` per clone and this repo has no `postinstall`/`prepare` hook
to do that — so it would silently not apply for most people. Removing
the shared lines fixes the conflicts without needing any local setup.
2026-07-30 03:56:12 -07:00
gsxdsm
78b6b5ba37 fleet: packages/engine/src/executor.ts 85 → 57 (in progress; 4 batches, plus the structural measurement this cluster needs) (#2689)
**Claiming `packages/engine/src/executor.ts`** — the largest unclaimed
cluster (self-healing.ts and scheduler.ts are taken).

## Census

| | before | after |
|---|---:|---:|
| `executor.ts` | 85 | **75** |
| repo total | 722 | **712** |
| `done` | 195 | 190 |
| `archived` | 147 | 142 |

Baseline re-recorded in the same commit; it shrinks by exactly the
converted count (10 literals across 5 sites).

## Batch 1 — terminal-lane guards

Five identical *"this card is already finished, refuse"* guards, all the
literal pair `live.column === "done" || live.column === "archived"`. On
a renamed board neither matches, so the refusal falls through — the same
inert-guard shape as #2670.

Converted to `resolveTerminalColumnsFor`, **the helper this file already
established** at line 4509 — no new abstraction. It unions the resolved
terminal columns with the legacy pair, so each converted guard is a
strict **superset** of the literal: it can refuse in more cases, never
fewer. That is what makes this batch safe without per-site behavior
review.

## The structural measurement this cluster needs

#2683 found self-healing.ts unsafe to batch because of **sync** workflow
reads — a converted guard there would resolve through a sync path that
cannot resolve a selection in production, silently falling back to
defaults. I measured whether executor.ts has the same problem, per guard
(not per line):

| context | guards |
|---|---:|
| **async** — safe, can `await resolveWorkflowIrForTask` | **71** |
| **sync** — needs threading or is not convertible in place | **14** |
| module scope | 0 |

The 14 sync-context guards are at lines 3455, 3479, 3530, 3540, 4611,
5501, 5502, 5504, 5777, 10213, 12306 (×3), 15782 — `in-progress` 4,
`in-review` 4, `archived` 3, `done` 2, `todo` 1. **I am not converting
those in place**, and I will flag rather than guess if threading
resolved data changes behavior.

So: unlike self-healing, this cluster is **83% safely convertible**,
which is why it is worth working as a batch.

## Note on #2685

Engine code converts through core's resolvers
(`resolveLifecycleColumns`, `resolveTerminalColumns`), not the dashboard
`columnRoles` helpers. So the 680-guard helper gap #2685 fixes is
**dashboard-side** — this cluster is not blocked on it.

## Verification

engine `tsc` clean · lint clean · gate green (487 + 158 + 10 + 71).

**Pre-existing failures, not caused by this change:** five tests in
`src/__tests__/reliability-interactions` fail, all in
`SelfHealingManager.recoverStarvedRefinementTriageTasks`. I confirmed by
stashing this change and re-running on a clean tree — they fail there
too. This change touches only `executor.ts` and does not go near that
path. Flagging rather than fixing: it is someone's cluster and not mine
to alter mid-flight.

## Not done

Batches 2+ (the remaining 75). I will keep working this file in this PR
with small commits, per the fleet rules.
2026-07-30 03:25:21 -07:00
gsxdsm
e9e63d8e0f consolidate/capacity: --strict was red on main (my #2621), 14 stale baselines, routines seeding a deleted column, worktrees-off audit (#2652)
Capacity unit consolidation. Three coherent themes, small commits
inside.

## Census before/after (`node scripts/lifecycle-column-census.mjs`)

| | before | after |
|---|---:|---:|
| triage column guards (the bar) | 10 | **10** |
| `--strict` on main | ❌ **RED** | ✅ green |
| baseline staleness | 14 files stale | **0** |

This branch does **not** move the triage bar — its remaining 10 are
moves.ts (dies with the flag), the dashboard cluster, and one deliberate
site. It fixes the instrument that measures the bar, plus a live defect
the comparison count cannot see.

---

## 1. `--strict` was RED on clean `origin/main`, and it was my fault

```
packages/dashboard/src/routes/register-task-workflow-routes.ts: 22 -> 23
```

My merged #2621 added a v1-IR pre-WIP fallback answering a greptile P1
and shipped no marker or baseline update, so the program's measuring
instrument has been failing on main since it landed.

Fixed **at the site** with a `DELIBERATE-LITERAL` marker, not by bumping
the baseline. That branch runs only when the IR declares no columns and
no nodes, so there is no role to resolve — `resolveLifecycleColumns`
returns nothing and the legacy pre-implementation ids are the only
pre-WIP signal that exists there. It is *unconvertible*, not unfinished;
the sibling `else` two lines down is the trait path for every IR that
can answer. A rise that is genuinely correct belongs where a reader will
see it.

## 2. The baseline was stale for 14 files — a hole, not cosmetics

A stale allowance lets converted guards return while the check stays
green. Measured gaps:

```
self-healing.ts          allows 126, tree has 111
executor.ts              allows 112, tree has 104
moves.ts                 allows  44, tree has  39
default-workflow-hooks   allows  25, tree has   7
mission-feature-sync     allows   5, tree has   0
MissionControlPanel      allows   4, tree has   0        (+8 more)
```

**Only two of the fourteen are mine.** The other twelve are
already-merged conversions by other workers where nobody re-recorded.
Re-recorded all fourteen here rather than waiting for twelve PRs,
because until it happens the ratchet is not holding the 779 it exists to
hold. Flagging it plainly: those drops are other people's work being
locked in, not mine being claimed.

## 3. Routines created tasks into the column U11 deleted

The routine editor's "Target Column" defaulted to `triage`. That value
is submitted as the create step's `taskColumn`, and an **explicit**
column bypasses the workflow entry-column resolution added for
column-less creates (#2589) — so every routine saved with the untouched
default seeded its tasks into a column the board does not declare.

Defaulting to `todo` would be the same mistake one column over: a custom
workflow declaring no `todo` is seeded into an undeclared column just as
surely, because an explicit column overrides entry resolution whatever
its value. So the default sends **nothing** and each workflow's own
intake resolution decides.

The `triage` **option** is removed too, not merely un-defaulted — fixing
the initializer alone left the operator able to pick the deleted column
one click away, and it was the option labelled "Planning", the name the
merged `todo` column now displays. Removing it retires that label
inversion as well.

Found by scanning **membership** forms rather than comparisons: the
comparison census cannot see a `?? "triage"` default, so no count showed
this and nobody was looking. Revert-proof — restoring the default fails
with *"the default must not name a column at all"*.

## 4. "Worktrees off is INERT" had one unaudited reader

The constraint was that `maxWorktrees` become genuinely inert, "not set
very high and not skipped by convention". `resolveWorktreeCapacityLimit`
returns `null` for that, and its unit tests can only prove the
**resolver** is right — they cannot see a second reader, which is the
only way the constraint breaks.

Audited every `maxWorktrees` read that bounds anything. **Exactly two:**
`scheduler.ts` (the admission gate, via the resolver, single call site,
optional gate snapshot) and `self-healing.ts`'s `enforceWorktreeCap` —
`(settings.maxWorktrees ?? 4) * 2`, a **raw** read.

The second is **not a bug** and is left alone: it bounds worktree
*directories on disk* and only removes *idle* ones. Worktrees still
exist in OFF mode, so that bound must keep applying or idle directories
accumulate unbounded. Recorded consequence: in OFF mode the number still
governs disk retention while gating no admission — an edge you scoped
out. The note says explicitly **not** to unify the two readers: routing
hygiene through the resolver returns `null` in OFF mode and silently
removes the disk bound, which is a leak dressed as a simplification.

New ratchet requires every file bounding on `maxWorktrees` to be named
with a reason, and rejects a **stale** allowlist entry. Proven by
injecting `active >= (settings.maxWorktrees ?? 4)` into
`hybrid-executor.ts`.

---

## Deliberately NOT included

- **My own census script.** #2633 landed the canonical one, and it is
better than mine — an AST classifier *plus* an independent text
classifier with `--compare`, and a baseline that fails on unrecorded
**drops** as well as rises. Mine only caught rises. I deleted mine
rather than ship a second measuring instrument; three copies of "strip
comments" is the drift shape this program keeps paying for, so the
worktree ratchet now imports #2633's `stripComments`.
- **My TaskContextMenu fix.** Superseded, and by a better answer: main's
`isPureIntakeColumn` (intake *without* hold) keeps the merged Planning
column shown and suppresses only a bare Ideas capture, which resolves
the exact hold-lane objection coderabbit raised against my version. I
briefly clobbered that merged work by checking my old file out
wholesale, caught it in the diff, and reverted.

## Verification

`pnpm lint` clean · core + dashboard `tsc` clean · census suite 23/23 ·
worktree ratchet 8/8 · RoutineEditor 49/49 ·
`routes-task-retry-planning-column` 16/16 · `lifecycle-column-census
--strict` exits 0.

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

---

## Added after review (all four greptile threads were real, and two of
them mattered)

**The routine fix was half a fix.** `routine-runner.ts:515` *and*
`cron-runner.ts:982` both did `column: (step.taskColumn as Column) ||
"triage"` **after** the step is read, so every routine — including ones
saved through the fixed editor — still created tasks into the deleted
column. Both now omit it.

**The advanced steps editor MANUFACTURED the defect.**
`ScheduleStepsEditor.tsx` had three `triage` defaults: the new-step
template (`:64`), the per-step initializer (`:95`), and the select still
offering it (`:344`). So the path I had *not* fixed produced the bug by
default, on fresh data. Template names no column; initializer coerces a
persisted `triage`; `triage` removed from the options; empty submits
`undefined`.

**Four pre-existing tests pinned the defect** and are rewritten to the
corrected invariant rather than appeased:

| test | asserted |
|---|---|
| `cron-runner`: "defaults column to triage when taskColumn is not set"
| `column: "triage"` |
| `ScheduleStepsEditor`: "adds a create-task step..." | `taskColumn`
toBe `"triage"` |
| `ScheduleStepsEditor`: "allows saving create-task step..." | the
legacy column is **resubmitted** |
| plus the explicit-column case added beside each, so the fix cannot
swallow a deliberate choice |

**The allowlist hole was the worst finding.** `AUDITED_BOUNDS` was keyed
by FILE, so every bounding expression in an allowlisted file was exempt
— a second raw bound in `scheduler.ts` stayed green, the one case that
ratchet exists for. Per-expression now, and making it so **immediately
surfaced a real second bound the file-level version was hiding**
(`maxWorktreesGate.used >= maxWorktreesGate.limit`, safe by construction
since the snapshot is `undefined` in OFF mode). Proven by injection.

## Found while re-reading my own deletion, not reported

A **rendered tooltip** still named a deleted cap. The "Queued to plan"
badge read *"planning starts when a concurrency slot frees up
(maxConcurrent / globalMaxConcurrent)"*. The cross-project cap is gone —
capacity is two numbers per project — so it told operators their
planning waited on a limiter they can no longer find a setting for.
Names the surviving dimension only now.

## Coding (Ideas): enforcing #2651 rather than repeating it

I took the unowned coding-ideas IR merge, concluded it must not be done,
then found **#2651 had already implemented, reverted and documented
exactly that** — with better grounding than my own argument. It added no
test, so nothing stops the next person reaching the same dead end.

So this ships their reasoning as a ratchet, not a second opinion: triage
discovery keys on the column's `autoTriage`, so a merged column is
either never scanned (cards sit on a bootstrap stub until the **capacity
hold** releases them, sending **unplanned** work into in-progress —
worse than stalling) or scanning wins and the manual gate is gone. Their
scope caveat is kept: `autoTriage` is a general trait field, so only
*this preset's* collapse is dead, not manual intake as a concept. The
registry does not reject the merged shape, which is why prose was not
enough.

## Verification (re-run)

`pnpm lint` clean · core + engine + dashboard-app `tsc` clean ·
`lifecycle-column-census --strict` exits 0 ("every file matches its
baseline exactly") · routine-runner 24/24 · cron-runner 156/156 ·
ScheduleStepsEditor 41/41 · RoutineEditor 49/49 · worktree +
coding-ideas 12/12. TaskCard has 2 failures **pre-existing on main** —
confirmed identical with my changes stashed.

---

## Bears directly on the closing bar: this PR already removes the
67-guard ratchet slack

Measured on current `origin/main` with the census itself:

```
tree total: 787   baseline total: 854   SLACK: 67

FILES ABOVE BASELINE (1):
   +1  packages/dashboard/src/routes/register-task-workflow-routes.ts  (22 -> 23)

FILES BELOW BASELINE: 13, totalling 68 unrecorded conversions
   -18  core/default-workflow-hooks.ts (25->7)   -15  engine/self-healing.ts (126->111)
    -8  engine/executor.ts (112->104)             -5  core/task-store/moves.ts (44->39)
    -5  engine/mission-feature-sync.ts (5->0)     -4  core/live-agent-count.ts (10->6)
```

**The slack is not regression — it is 13 files of merged conversions
nobody re-recorded**, against exactly **one** rise. This PR re-records
the baseline **854 → 782 across 140 files**, which closes it.

**And the "+3 that slipped in" is +1, and it is mine.**
`register-task-workflow-routes.ts 22 → 23` is the v1-IR pre-WIP fallback
my #2621 added; it is justified (that branch runs only when the IR
declares no columns or nodes, so there is no role to resolve) but it
shipped with no marker and no baseline update — which is why `--strict`
has been **red on main since it merged**. Fixed here at the site with a
`DELIBERATE-LITERAL` marker rather than by bumping the baseline, because
a rise that is genuinely correct belongs where a reader will see it.

Sequencing note for the auto-lowering change: if this lands first, that
work is purely the mechanism (auto-lower, or fail with tighten
instructions) rather than a cleanup, and the two re-records will not
collide in the same file.

Also worth carrying into that mechanism, from building the same guard
here: **`--update` must refuse to RAISE.** An earlier version of mine
wrote current counts verbatim, so a developer who added a literal and
ran the documented update command locked the regression in as the new
ceiling — the mirror of the high-water problem. Lowering can be
unattended; raising should be a hand edit with the reason recorded.

## Third piece of residue from my own deletion

`updateGlobalConcurrency` in the dashboard API client PUT to
`/api/global-concurrency`, a route removed when the machine-wide cap
went. Zero callers; the only reference was the `legacy.ts` barrel
re-export. Deleted both. `fetchGlobalConcurrency` **survives on
purpose** — the GET route remains and serves live utilization telemetry
to the footer and Command Center; nothing gates on it.

That is the third: after the second raw `maxWorktrees` reader and the
"Queued to plan" tooltip. A deletion is not finished when the
enforcement goes — the client, the label and the tooltip outlive it.

---

## Re-greened the dashboard API tests: 117 failures on main, ONE root
cause

These would have polluted the closing verification pass, and nobody
owned them.

`api()` builds headers via `new Headers(...)` and returns
`Object.fromEntries(headers.entries())` — and `Headers.entries()`
**lowercases every key**, so the object reaching `fetch` is
`content-type`, not `Content-Type`. `ab87d0d80` then added
`x-fusion-client: dashboard-ui` for run-audit attribution. Both changes
are correct; neither is visible at a call site, so **114 assertions
across 7 files** kept asserting the old shape and went red together.

Fixed by naming the shape **once** in `app/test/apiRequestHeaders.ts`
rather than patching 114 literals — restating a shared fact 114 times is
what made a two-line client change look like 117 failures. Deliberately
not a loose `objectContaining`: these tests are the only thing pinning
that the attribution header is sent *at all*.

**117 → 4.** The remaining 4 are unrelated pre-existing CSS failures
(`task-detail-modal-tablet-width` ×3, `space-token-defined` ×1) —
confirmed identical on clean main with my changes stashed.

### A gap this surfaced, recorded not papered over

Three routes failed in the *opposite* direction — they send the old
shape because they call `fetch()` **directly**, bypassing `api()`, so
they never get the attribution header. `client.ts` claims the opposite:

> "Applied once here rather than per-call so no future mutation route
has to remember it."

That does not hold for a route that bypasses the helper it is applied
in. **Measured in `app/api/`: 8 files make direct `fetch()` calls and 7
include mutations (POST/DELETE)** — among them `ai-sessions.ts`'s
DELETE, which is the same class as the four-delete incident the header
was added for. So the attribution fix has a hole in exactly its
motivating case.

Not fixed here: routing those onto `api()` is a behaviour change across
the API layer and belongs to its owner, not to a test re-green. Those
assertions use a separate `API_JSON_HEADERS_NO_ATTRIBUTION` constant so
the gap stays **visible** — if a route is later moved onto `api()`, its
test fails and points at the note explaining why.

---

## This branch takes the triage bar 10 → 5, and makes `--strict` green

`node scripts/lifecycle-column-census.mjs` on this branch reports
**triage 5**, against **10** on `origin/main`. The five removed are the
ScheduleStepsEditor template/initializer/option and the RoutineEditor
default/option — the automation paths that were creating tasks into the
deleted column.

**`--strict` was also RED on clean main, twice over, and both causes
were the same mistake:** a thorough written rationale the tool cannot
read, because the marker was not where the census looks. The census
reads a comparison node's **leading comments**; a `DELIBERATE-LITERAL`
in the JSDoc above the enclosing function or declaration does not reach
the comparison inside it.

| site | why it is legitimate | why the tool could not see it |
|---|---|---|
| `columnRoles.ts:80` `isHoldColumnRole` | degrades to `columnId ===
"todo"` only when a column has **no resolved traits** — identical in
kind to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` directly above, which
escapes counting only because a Set is a membership form | rationale
written, **no marker token** |
| `MissionControlPanel.tsx` ×3 | the SDLC funnel **alias table** — maps
`to-do`/`ready`/`review`/`shipped` onto one display stage with an
explicit `other` bucket, and nothing branches on it | marker in the
JSDoc; the comparisons are arrow bodies **inside the array literal**,
which it does not reach |

The second only surfaced because converting the `triage` stage to a Set
removed its count and exposed the siblings — red gate, justification
sitting three lines above, unreachable.

Both are markers, no behaviour change. Neither is a conversion
candidate: resolving the funnel table to traits would **drop the
non-column aliases it exists to accept**.

**For the auto-lowering work:** the marker-placement rule is now the
recurring trap — three instances, three different authors, including me.
A marker that does not register is indistinguishable from no marker, and
the failure mode is a red gate with a written explanation nobody can act
on. If the census accepted a marker anywhere in the enclosing
declaration's comments, none of the three would have happened.

Baseline re-recorded per the tool's own instruction ("Re-record the
baseline in the SAME PR that lowered the count").


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

- **Bug Fixes**
- Task “Actions” menus no longer appear on bare cards in the Planning
column.
- Routines, scheduled tasks, and create-task steps now respect each
board’s configured workflow intake column instead of using a retired
default.
- Legacy tasks saved with the retired intake column are migrated to
automatic workflow resolution.
- Target-column selection now offers only “Automatic (workflow intake)”
and “Planning,” removing the obsolete option.
- Capacity/planning messaging and related UI tooltip text were
clarified; concurrency cap updates are managed per project.

- **Tests**
- Added/updated coverage for workflow intake resolution, create-task
target column behavior (including legacy coercion), capacity safeguards,
and API request consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 03:10:35 -07:00
gsxdsm
aa02db5782 fleet: scheduler.ts 28 → 27 + make the column-role predicates reachable from the other 80% of the backlog (#2690)
## Census

**722 → 721**; `packages/engine/src/scheduler.ts` **28 → 27**. Exactly
the one site converted. Baseline re-recorded in the same commit —
`--strict` flagged the stale allowance itself, and `in-progress` went
138 → 137.

## The unblocker (commit 1)

The role helpers live in `packages/dashboard/app/utils/columnRoles.ts`,
a dashboard-**app** module. Measured against the census:

| Location | Guards | Share | Helpers importable? |
|---|---:|---:|---|
| `packages/engine/**` | 316 | 43% | no |
| `packages/dashboard/app/**` | 150 | 20% | **yes** |
| `packages/core/**` | 148 | 20% | no |
| `packages/dashboard/src/**` | 78 | 10% | no |
| `packages/cli/**` | 24 | 3% | no |

**Only 20% of the backlog can call them at all.** #2685 widens the
helper *set* correctly; that is coverage, not location.
`packages/core/src/column-roles.ts` is the same flags-first /
legacy-id-fallback predicate placed where the other 80% can reach it —
core already exports `resolveColumnFlags`, so no new resolution
machinery comes with it.

**Semantics are mirrored from #2685, not invented**, so the two sets
cannot answer the same question differently: `complete` EXCLUDES
`archived`; `wip` keys on `countsTowardWip` (the same flag capacity
arithmetic uses); `review` accepts `mergeBlocker` OR `humanReview`. One
addition — `isTerminalColumnRole` for the `!== "done" && !== "archived"`
union, the most repeated shape in the backlog.

10 tests cover both modes of all 8 predicates, including the **degraded
no-flags fallback** — the half with no coverage when these lived only in
the dashboard app — plus the two cases that prove the predicate does
something rather than nothing: a renamed column carrying the right trait
answers yes, and a legacy id carrying the WRONG trait answers no.

## A trap every fleet worker converting engine code will hit

A new core export must be added to **both** `index.ts` and
`index.gate.ts`.

The `engine-core` gate project resolves `@fusion/core` to a bundle built
from `index.gate.ts` (`scripts/build-engine-core-gate-bundle.mjs`). An
export present only in `index.ts` is `undefined` at runtime under the
gate: 13 `scheduler-workflow-cutover` tests failed with `isWipColumnRole
is not a function`, in a file that does not mock `@fusion/core` at all.
The symptom points at the consumer, the cause is the barrel.

I nearly mis-attributed this. Baseline first:
`scheduler-workflow-cutover` is **42 passed on clean main**, so the 13
were mine — not pre-existing. That measurement is the only reason I
looked at the barrel instead of "fixing" the tests.

## The conversion (commit 2)

`scheduler.ts:1690`'s `isWipColumnTask` was a hand-rolled copy of
`isWipColumnRole` — it stored only `countsTowardWip` as a boolean and
re-implemented flags-first-then-legacy-id inline. It now stores the
resolved flags object and lets the shared predicate decide.

Behaviour is identical in all four states: column present with the flag
true or false (flags win), column absent from a resolved IR, and IR
resolution failed (both defer to the legacy id).

| Check | Result |
|---|---|
| `scheduler-workflow-cutover` | **42 passed** before and after |
| 21 scheduler/capacity/hold-release files | **372 passed** |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint` | clean |

## Flagged and skipped, not guessed

**`scheduler.ts:1736` — a latent legacy-vocabulary defect, not a
conversion.** `if (task.column !== "in-progress") continue;` gates the
file-scope-lease loop on the literal, ~40 lines below capacity
arithmetic that is trait-aware. On a renamed WIP column the loop
silently does nothing while capacity counts the same cards correctly.
Converting it *changes behaviour* on renamed boards (from wrong to
right), which the fleet rules put out of scope — so it is flagged here
for whoever owns that fix. It is the same class as U10's six
legacy-vocabulary defects.

**`hold-release.ts:343`** — already marked `DELIBERATE-LITERAL`. It is
the legacy half of FN-5719's dual-accept pair; converting it would make
both halves compute the same answer, deleting the compatibility signal
*and* its divergence detector while looking like a cleanup. Untouched.

**`task-merge.ts:254`** — the documented fallback for callers that have
not proven lane identity; trait-aware callers pass
`skipColumnIdentityCheck`. Untouched.

**The other 14 `scheduler.ts` sites** have no flags in scope (e.g.
`isLegacyDependencySatisfied(dep: Task | undefined)`,
`shouldHoldActiveFileScopeLease(...)` — task-only pure functions).
Threading an IR in changes signatures and call graphs: behaviour change,
out of scope. This is why the cluster is 28 → 27 and not 28 → 0, and the
reachability measurement behind it is #2687.

No changeset: `@fusion/*` are private and no `@runfusion/fusion`
behaviour changes.
2026-07-30 03:01:36 -07:00
gsxdsm
bb3bdab999 The ratchet follows the count down — a drop tightens instead of reddening the gate (coordinator item 2) (#2679)
Taken after asking twice for reassignment with no reply, and after the
same failure bit a **third** time. No open PR touches the census CLI, so
this is unowned in practice — **U12, say so if you have started and I
will close this in favour of yours.**

## What changed

A **drop** now tightens the baseline instead of failing. Failing hard
was defensible in isolation — a stale allowance is a hole, since those
guards can return up to the old count while the check stays green. What
it missed:

**The drop is almost never the failing author's to fix.** Eleven files
dropped during one merge wave, none of those PRs re-recorded, and none
of their authors did anything wrong. Measured three times since CI began
gating this: `columnRoles.ts` 0 → 1, then `executor.ts` twice.

A permanently-red gate is a bigger hole than a stale allowance, because
it gets ignored and then nothing is guarded at all. **The rise check —
the ratchet's actual purpose — is untouched and still fails hard.**

## The residual, named rather than glossed

In CI the write is discarded with the runner, so the committed baseline
stays stale until someone commits a tightened one. The exposure is
bounded (regrowth only up to the old count), printed on every run, and
strictly smaller than the exposure from a check people route around.
`--strict --exact` restores hard failure for the pinned end state.

**One writer:** the write is now a named `writeBaseline()` shared by the
tighten path and `--update-baseline`, rather than a second
`writeFileSync`. Two writers for one artifact is how they drift — a
lesson this file already learned once.

## Exercised end to end

| scenario | result |
|---|---|
| drop, `--strict` | exit **0**, `TIGHTENED`, allowance rewritten 9 → 6
|
| drop, `--strict --exact` | exit **1**, baseline untouched |
| rise, `--strict` | exit **1** |
| clean | exit **0** |

Pinned through the real CLI with an isolated baseline. Revert proof:
restoring the hard failure fails **1 of 32**.

## Two of my own mistakes, recorded

**A vacuous assertion, in the case that guards against vacuity.** I
first wrote `expect(allowedAfter).toBeLessThan(4 + allowedAfter)` — true
for every number. Replaced with a comparison against the inflated value
the fixture started from. This file documents that trap repeatedly and I
still walked into it, which is the argument for the mechanical revert
check over careful reading.

**The env override is `FUSION_CENSUS_BASELINE_PATH`**, not the
`FUSION_CENSUS_BASELINE` I used in the first draft — so the first
version of these cases silently ran against the **real** baseline and
passed for the wrong reason. A test whose fixture never took effect is
the same failure as a test whose fixture can't fail.

## Verification

32/32 census suites, `pnpm test:gate` **71/71**, `--strict` exits 0,
`pnpm lint` clean, `docs/testing.md` updated.

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

---

## Update — the base-ref ratchet (review round 2, commit `4895845579`)

The first version of this PR shipped a **named residual**: the
tightening write dies with the CI runner, so the committed allowance
stays high and a later PR can regrow guards up to it while `--strict`
prints green. I called the exposure bounded and moved on. Greptile
flagged it P1 and was right — naming a hole is not closing one.

`--strict` now stops trusting the committed number for files the branch
touched. It measures each **changed** file at the base commit
(`FUSION_CENSUS_BASE_REF`, else the PR base branch, else `origin/main`)
and fails if the file carries more guards than the base ref has. **The
enforced ceiling is what main has today**, so a stale, missing, or
long-unrecorded baseline no longer opens a window.

| decision | why |
|---|---|
| changed files only, `<ref>...HEAD` | untouched files have main's
counts by construction; censusing all ~400 at the base ref is ~400 `git
show` calls to re-derive numbers that cannot have moved. Three-dot also
stops charging this branch for guards that landed on main after the
fork. |
| a new file's base allowance is **0** | "absent at the base ref" as
unbounded would make a new file the cheapest place to hide a fresh guard
|
| fails **open** on an unresolvable ref, printing `SKIPPED` | a shallow
clone cannot produce an honest comparison; a degraded run must not read
as a clean one. The baseline comparison still applies. |
| merged into the existing `regressions` list | one failure per file,
and `--update-baseline` keeps working as the deliberate escape hatch. No
new exit path. |

**Revert proof, measured both ways.** With the base-ref block removed,
the regrowth fixture — base commit 2 guards, HEAD 5, baseline allowing 9
— exits **0** with `TIGHTENED`, which is precisely the reported
scenario. With it: exit **1**, `column-guard count ROSE`, `above its
count on the base ref`, baseline left at 9. **3 of the 4** end-to-end
cases go red on revert. The fourth passes without the fix by design — it
is the genuine-conversion case the auto-tighten exists to keep green,
and a case that reddens either way proves nothing.

The end-to-end suite builds a throwaway two-commit `git init` repo under
the temp dir, because this exploit is a property of the **plumbing**,
not of the comparison: resolving a ref, working out the changed set,
reading base source through `git show`. The comparator itself is pure
with the reader injected (`findRegrowthAgainstBase`), with its own cases
in `lifecycle-column-census-ast.test.ts` — including the one that would
silently pass everything, looking up the wrong key in
`summarize().byFile`.

**Rebased onto `origin/main` @ bc782d8d92** (the branch was forked
before the recent merge wave; its baseline read 746 against a tree of
722).

Verification on the rebased branch: census **722** / `--strict` exit 0 ·
**70/70** across both census suites · `pnpm test:gate` **71/71** · `pnpm
lint` clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:56:08 -07:00
gsxdsm
89d084e5bd fix(scripts): --compare was accusing the parser of a blind spot it does not have (#2682)
## The problem

`census --compare` fails on `origin/main` — I verified it at
`bc782d8d92` and at every commit on the branch where I found it. Its
failure message reads:

> The parser has a blind spot; its count cannot be the bar until this is
closed.

That matters because the parser's count **is** the bar the program just
used to declare the closing bar met (triage 0, backlog 722). A red
cross-check asserting the instrument is untrustworthy had to be settled
in one direction or the other.

## It is settled: there is no blind spot

**Measured — 13 divergent sites, all 13 seen by the parser:**

| parser's classification | count |
|---|---|
| `deliberate` | 4 |
| `role` | 5 |
| `status` | 4 |
| **missed entirely** | **0** |

## The bug is in the check

It compared per-bucket totals and failed when the regex's `column` total
exceeded the parser's. That conflates the two things it most needs to
separate:

- the parser **missed** a site → a real hole, the failure worth having;
- the parser **classified it better** → `role`/`status`/`deliberate`
instead of `column`.

The second is the parser's entire reason for existing. So the old form
fired *more* the better the parser got, while accusing it of the one
defect it did not have. The regex is knowingly weaker at telling an
agent role from a column guard — that asymmetry is why the parser was
adopted, and the check was penalising it.

The intent was never wrong; the comment above the check already said the
contract was "a site the REGEX found and the parser missed". Only the
implementation disagreed with it.

## After

```
text classifier:  {"column":728,"role":0,"status":182,"deliberate":14}
AST classifier:   {"column":722,"role":5,"status":186,"deliberate":17}
parser sees every site the regex does (+131 sites the regex cannot see).
14 the regex calls a column guard, the parser classifies as {"role":5,"status":4,"deliberate":4,"definition":1}.
```

Fails only on a genuinely missed site now, printing the first ten.
Reclassifications are reported rather than failed.

## Scope

Report-only. `--compare` is not in the merge gate — the gate runs
`--strict`, which is why this stayed red and unwatched. Verified:
`--compare` exit 0, `--strict` exit 0, lint clean. No census numbers
change.
2026-07-30 02:50:18 -07:00
gsxdsm
dc50425e98 docs: correct 104 future-dated FNXC timestamps across 61 files (#2680)
## What

The FNXC convention exists so a reader can place a note against the
change that motivated it. A stamp dated *after* the edit landed defeats
exactly that.

This is program-wide drift, not one author's slip — I contributed to it
in my own commits this week, which is how I noticed it.

## Measured, on this tree

**104 stamps across 61 files** dated later than the day they were
written, from one day ahead to **2026-10-19 (81 days)**:

| count | date | count | date | count | date |
|---|---|---|---|---|---|
| 50 | 2026-07-31 | 6 | 2026-08-05 | 3 | 2026-08-13 |
| 17 | 2026-08-01 | 1 | 2026-08-07 | 1 | 2026-08-19 |
| 7 | 2026-08-02 | 1 | 2026-08-12 | 2 | 2026-08-26 |
| 11 | 2026-08-03 | | | 3 | 2026-10-19 |

An earlier number I circulated was ~70. That came from a narrower
pathspec and was wrong; **104** is the measurement.

## How

Each stamp is rewritten to the date of the commit that introduced **that
line**, via per-line `git blame` — deliberately *not* stamped uniformly
with today's date. A uniform stamp swaps a wrong date for a different
wrong date and flattens the ordering that makes these comments
navigable; blame preserves it. Times of day are untouched, and a blame
date in the future is clamped rather than trusted.

## Why the verification is listed

A docs sweep across 61 files is precisely where a stray edit hides, so
the safety claims are mechanical rather than asserted:

- every changed line begins with a comment marker — **no code touched**;
- **no test asserts an FNXC date later than today**, so no `toContain`
assertion on embedded source text can be silently invalidated (several
such assertions do exist);
- CSS files, which carry several of those assertions, are outside the
pathspec.

## Verified

lint clean · merge gate green (487 + 158 + 10 + 71) · `census --strict`
exit 0 · tsc clean for core, engine, and dashboard
(`tsconfig.app.json`).

**No behavior change.** Comment text only.

## Not done here

A guard preventing recurrence. A check that rejects an FNXC stamp dated
after the commit would stop this returning, but it needs a decision
about where it runs (lint rule vs. gate) and it is a behavior change to
CI — it does not belong riding inside the sweep it would police.
2026-07-30 02:38:58 -07:00
gsxdsm
543f4a556c Tell an already-converted fallback literal from an unconverted guard — 19 of 19 dashboard scan hits were the former (#2677)
The batch phase is about to hand per-file guard lists to cheap workers,
and the census currently cannot distinguish **"not yet converted"** from
**"converted, with a documented degradation."**

## The measurement that makes this a class, not a preference

A proximity scan for *"legacy literal near a role-resolved call"* — the
heuristic that produced #2670 and #2672 from the engine — returned **19
hits across the dashboard and zero defects.** Every one was:

```ts
if (flags) return flags.hold === true || flags.countsTowardWip === true;
return column === "todo" || column === "in-progress";      // reachable only without traits
```

That literal is **correct**: it answers for callers with no resolved
column metadata, which is the case `resolveLifecycleColumns` returns
`undefined`-for-the-whole-struct to preserve. A worker told to "convert"
it would delete the only answer available when traits are absent.

## And the difference is structural, so the parser can see it

In **both** engine defects the literal sat in a **separate statement
beside resolved data**, not in a fallback branch. Proximity cannot tell
those apart; an AST can.

`traitFallback` flags the ternary form and the **early-return** form
(which is how most are actually written), and deliberately does **not**
flag a fallback whose test is itself a column-*name* check — otherwise
any `if/else` over column names would launder itself.

## Reported beside the backlog, not subtracted from it

```
COLUMN guards (the backlog):   746
  of the column guards, 9 are trait-fallback branches (already converted)
```

A fallback literal is still a literal and should go when the trait path
becomes unconditional. This only says which **kind** of work it is.

**Advisory, and structurally so:** `traitFallback` never changes `kind`,
and the count lives *outside* `totals`. My first attempt put it in
`totals` and broke two existing suites that correctly deep-equal that
shape — an advisory number does not belong in the structure that defines
the bar.

## Revert proof

Forcing `traitFallback: false` fails **3 of 33** (both fallback forms,
plus the kind-unchanged case). The two *negative* cases pass under the
revert — which is the point: they assert what must **not** be flagged,
and a classifier that flags nothing satisfies them trivially. Worth
stating, because a revert proof that only counts failures would look
stronger than it is.

## Baseline

Re-recorded: `executor.ts` 87 → 85 was **main's own drift** from #2568
landing, so `--strict` was red on main again. #2668 made the re-record
possible; the auto-tighten (coordinator item 2) is still open, and this
is the third time in this program that a legitimate merge has left the
gate red for everyone else.

## Verification

62/62 across both census suites, `pnpm test:gate` **71/71**, `--strict`
exits 0, `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

* **New Features**
* Added reporting for legacy column comparisons found in trait-fallback
branches.
* Census results now include a separate count for these fallback-related
column guards.
* Human-readable reports display the new metric alongside the existing
backlog totals.

* **Tests**
* Added coverage for fallback detection across ternary, early-return,
and conditional patterns.
* Added safeguards to prevent false positives in resolved-data and
column-name checks.

* **Maintenance**
  * Updated baseline census metrics to reflect revised classifications.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:33:19 -07:00
gsxdsm
bc782d8d92 U12: resolve the move-path compatibility flag — trait hooks unconditional, legacy branch deleted (#2655)
U12's headline goal. The raw `experimentalFeatures.workflowColumns` flag
gated **every task move**; its six seams are now unconditional and the
flag, its last two readers, and the 124-line inline legacy branch are
deleted.

## Deleted, not converted

The flag-OFF branch goes with the gate. Converting a branch we intended
to delete would have left a second definition of every column side
effect alive to drift — the defect this program has spent its length
removing.

Both readers flip in **one commit** because they are not separable: the
preflight in `workflow-task-create-ops.ts` computes the
`movePolicyPreflight` that `moves.ts` consumes and validates. Un-gating
either alone either evaluates workflow move policies — with their
plugin-gate side effects — whose result is ignored, or validates against
a preflight that was never computed.

## Evidence, not assertion

**Equivalence (precondition 1).** `moves-flag-equivalence.test.ts`
(commit 1) ran the same journey under both flag states against live
PostgreSQL and diffed the persisted row: **identical across 128 fields**
plus an equal timing shape, over `todo → in-progress → in-review → todo
→ in-progress`. Mutation-verified both ways — stamping the flag-ON
branch, and diverging the reopen hook, each fail it.

**And there was stronger evidence already on main that isn't mine.**
U2b's `move-path-equivalence.pg.test.ts` ran *every* scenario once per
path and has been green across ~10 of them: `preserveStatus`,
`preservePause`, timing accounting, `preserveProgress`,
`preserveWorktree`, engine-source rehome, `in-progress → todo`. Two
independently built harnesses agreeing is the best evidence this
question has had.

**The flag was read by nothing in production.** `experimentalFeatures`
is global-only and no module writes it, so this path had never run for
any project without a stale persisted value. That is also why a green
suite was never evidence on its own — both paths were individually valid
and only one was live.

## Two claims of mine this PR corrects

**1. Seam 2 does not introduce new rejections.** I said in #2639 and in
the census that with the flag off there is *no* target validation, so
flipping would add refusals. Reproduced the opposite: a move to an
undeclared column already rejects on the legacy path with `Invalid
transition: … Valid targets: …`. I found it because the discriminator I
wrote to prove "the flag is the cause" failed.

**2. My first equivalence test proved nothing.** It used
`updateSettings`; `experimentalFeatures` is **global-only**, so
`getSettingsFast()` filtered the write out and `useWorkflow` was false
in *both* runs. Caught by stamping the flag-ON branch and watching the
test stay green. It now writes via `updateGlobalSettings` and **asserts
the flag took effect** before the journey. U2b's harness carries the
same warning independently — `MUST be updateGlobalSettings, NOT
updateSettings`.

## The user-visible change

Move rejections now report **workflow-resolved** targets instead of the
hardcoded legacy adjacency table. Concretely: `Valid targets:
in-progress, triage, archived` becomes `Valid targets: archived,
in-progress`. That is the fix, not a regression — the legacy table still
advertised `triage`, a column the default lineage stopped declaring at
#2515, so an operator following the old message was told to move
somewhere impossible.

Likewise a move *into* `triage` is now refused rather than stranding the
card in a column with no trait flags, invisible to every trait-driven
sweep until reconciliation re-homes it.
`live-move-path-undeclared-target.test.ts` characterised exactly that
defect and carried `it.todo("should REFUSE a move into a column the
task's workflow does not declare (U2b)")` — **this fulfils it.**

## Test migration

| file | change |
|---|---|
| `move-path-equivalence.pg.test.ts` | deleted — every scenario ran once
per path; purpose fully discharged |
| `workflow-capacity-invariant.pg.test.ts` |
`setPath("inline"\|"hooks")` → `assertMovePathLive()`; the probe is
**kept** so capacity cannot pass because moves were broken for an
unrelated reason |
| `store-movement.pg.test.ts` | asserts the refusal **and** that the
legitimate backward move still works, so it reads as a narrowing |
| `raw-workflow-columns-flag-census.test.ts` | deleted per its own
instructions — it was built to fail in both directions and fired exactly
as designed: `expected [] to deeply equal [3 readers]` |
| `moves-workflow-flag-seams.test.ts` | deleted — it pinned the six
seams this removes |

## Verification

Full core suite: **33 failed / 10 files — byte-identical to main's
baseline**, with **zero** files failing exclusively on this branch. I
measured the baseline by checking out `origin/main` and running the same
command, because the first comparison I made was by count alone and
would have blamed the flip for 8 files that were already red.

`pnpm lint` clean. `pnpm test:gate` green (10 / 132 / 482 / 71). `tsc -p
packages/core/tsconfig.json` clean. Core builds.

## Left in place deliberately

The `workflowColumns` settings key stays schema-tolerated and is already
in `HIDDEN_EXPERIMENTAL_FEATURE_KEYS`, so an upgraded project carrying a
stale value renders nothing and loads cleanly. Removing it from the
schema would risk rejecting those projects for no benefit now that
nothing reads it.

---

## Rebased onto current main — and `triage` reaches ZERO

| metric | before | after |
|---|---:|---:|
| `moves.ts` column guards | 39 | **15** |
| repo column total | 745 | **741** |
| **`triage` column guards** | 1 | **ABSENT (0)** |

`triage` is now absent from `byColumnId` entirely: no unconverted
`triage` guard remains anywhere in production source. Combined with
#2664 (the last one, in `TaskContextMenu`) this closes bar item 1.

The census behaved exactly as designed on the rebase: the flip *deletes*
guards, so `--strict` reported `moves.ts: allows 39, tree has 15` rather
than leaving a stale allowance, and the re-record lands in this PR's
diff.

## Verification on the rebased tree

- Full core suite: **33 failed / 10 files — identical to main's
baseline**, zero files failing exclusively on this branch (measured by
checking out `origin/main` and diffing the failing-file sets, not by
comparing counts).
- `pnpm test:gate` green (10 / 158 / 487 / 71).
- `pnpm check:lifecycle-columns` exits 0.
- `pnpm lint` clean, `@fusion/core` builds.

## Two review fixes carried in this PR

**P1 — optionless engine moves lost their bypass.**
`resolveWorkflowBypassGuardsImpl` did `void moveSource;` — it discarded
the resolved parameter and re-read `options?.moveSource`, so
`moveTask(id, target)` resolved to `"engine"` at the call site and
computed `bypassGuards === false`. Latent while the flag gated
validation; with the gate gone, an internal executor/merger/recovery
move made without an options object would be judged as a user move.

**P2 — the absence signal.** Emitting `workflowId` unconditionally would
have stamped `builtin:coding` onto every task with no explicit
selection, reporting a fallback as authoritative. Now emits the
selection directly, so absent still means "not resolved here".


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

## Summary by CodeRabbit

- **Bug Fixes**
  - Task moves are now validated against the task’s declared workflow.
- Invalid destinations are rejected with a clear error, and tasks remain
in their original column.
  - Valid backward moves continue to work as expected.
- Move behavior and lifecycle updates are now handled consistently
across workflows.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:19:32 -07:00
gsxdsm
a099813e94 fix(test): decouple the audit-emitter assertion from log formatting (last of the 4 red PG suites) (#2675)
Last of the four long-red live-PG suites. **This one is a stale test —
the only one of the four that is.**

## The cause

`withSeverityMarker` (`logger.ts:31`) deliberately wraps every message
in a machine-readable severity marker so the TUI log pane can colour by
level. The emitted string carries a `fnlvl=warn` marker and a
`[core-async-secrets-store]` subsystem tag ahead of the real text.

The assertion pinned the raw message with `toHaveBeenCalledWith`, so it
broke when that convention landed. It was coupled to log **formatting**,
not to the behaviour it exists to check.

## The fix

Rewritten to assert what it actually cares about: exactly one warning,
whose message **contains** the subsystem-tagged text, carrying the
underlying cause.

Both halves of the behaviour stay pinned — the `resolves.toMatchObject`
above proves the secret is still created when the audit emitter fails,
and this proves the failure is surfaced rather than swallowed.

**Mutation-verified rather than assumed green:** deleting the
`severityAuditLog.warn` call in `async-secrets-store.ts` fails with
`expected "warn" to be called 1 times, but got 0 times`. A
`stringContaining` assertion that passes because it matches nothing
would be worse than the brittle one it replaces.

## The four, complete

| suite | verdict |
|---|---|
| `store-wedge-resolution` | **product bug** — `42P18`, total runtime
failure of wedge resolution in PG (#2669) |
| `workflow-settings-project-identity` | **stale docs** — resolver
contradicted its own documented order (#2671) |
| `agent-logs-and-monitor` | **real defect** — funnel mis-bucketing from
the U11 merge; half fixed in #2674, half needs a product call |
| `central-archive-secrets` | **stale test** — this PR |

**Three of four were real problems**, sitting behind "pre-existing,
fails on main too". That phrase answers *whose* problem it is, not
*what* is wrong.

## Census, again

`check:lifecycle-columns` is **still** exiting 1 on `origin/main` —
`executor.ts: allows 87, tree has 85` — the same staleness flagged on
#2674. Re-recorded here too, because the blocking check stays red for
every open PR until some PR carries it, and I do not know which of #2674
/ this one lands first.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). Suite **14/15 → 15/15**.
`pnpm check:lifecycle-columns` exits 0 after the re-record. `pnpm lint`
clean.

No changeset: test-only plus an internal baseline.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:59:53 -07:00
gsxdsm
e711fbab15 The ratchet's baseline could not be re-recorded once a file rose — the one state that blocks a correct conversion (#2668)
Unowned (no open PR touches the census CLI — only its baseline JSON) and
**live**, since #2654 gates CI on `--strict`.

## The problem

`--update-baseline` sat **behind** the rise exit, so the only supported
way to re-record was unavailable in exactly the situation that needs it.

That matters because **a conversion legitimately adds a literal.** The
correct shape for a caller that may have no traits is `flags ? flags.x :
columnId === "legacy"`, and each one raises a file's count by one.
Measured on current main: `columnRoles.ts` went **0 → 1** from precisely
that shape (added by #2647, documented at the site, correct code).

So a worker doing the right thing meets a red gate whose only escape is
hand-editing the JSON. That is how a ratchet becomes something people
route around rather than run — and then it guards nothing. This is the
same failure mode as a guard that cannot fire, arrived at from the other
side.

## The change

`--update-baseline` is an explicit operator action, so it re-records
**unconditionally** and prints what it accepted under `ACCEPTED RISES`.
Swallowing a rise silently is the real danger; refusing to let anyone
re-record is the same danger one step later, wearing a red check nobody
trusts. **The rise check is unchanged** and still exits 1 without the
flag.

**One writer now.** The old second `writeFileSync` behind the rise exit
is deleted rather than left unreachable — two writers for one artifact
is how they drift. The `!deliberateTracked && updateBaseline` special
case went with it, since the unconditional block covers the legacy-shape
migration too.

## Exercised end to end

On a real rise injected into `live-agent-count.ts`:

```
rise + plain --strict              exit 1   (the ratchet still bites)
rise + --strict --update-baseline  exit 0   "ACCEPTED RISES  live-agent-count.ts: 6 -> 7"
```

Four cases assert the CLI's own source, because exit codes are the
contract and the pure summarizer cannot express them: the write precedes
the rise check, the branches exit 0 and 1 respectively, accepted rises
are **named**, and there is exactly **one** writer.

## A note on the revert proof, because it caught me twice

My first attempt to move the block back was a **no-op**: the marker I
sliced on (`if (regressions.length > 0) {`) also appears *inside* the
update block, so the "revert" reassembled the file unchanged and the
suite stayed green. **A revert proof that does not go red can mean the
guard is vacuous *or* that the revert did not land** — and the second is
easy to miss when you are expecting the first. The real revert fails **2
of 27**, and the assertions now verify marker *uniqueness* before
slicing on it.

## Verification

- 27/27 census suites; `--strict` exits 0; `pnpm test:gate` **71/71**;
`pnpm lint` clean
- census on this tree: 748 column guards, **4 triage** (all in
`moves.ts`'s flag-OFF block, deletion-scheduled with #2655)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:46:36 -07:00
gsxdsm
a6138abeff U12: DELIBERATE-LITERAL counts key on file AND column — closing the P1 left on merged #2661 (#2666)
Closes the P1 that was still open when #2661 merged.

## The hole

A per-file integer is offset **within a single file**: remove one
reviewed `todo` exemption, add an `in-review` one beside it, and the
number never moves. The fresh guard is invisible to the column counts
too, because deliberate findings are excluded from them — so `--strict`
goes green with a new lifecycle-column guard hiding inside an existing
marker.

Now keyed on **file AND column id**.

**Proven with the exact scenario:** swapping a marked `triage` for
`done` inside `TaskCard.tsx` leaves the per-file total unchanged and now
fails with

```
packages/dashboard/app/components/TaskCard.tsx (DELIBERATE-LITERAL: done): 0 -> 1
```

## The pattern worth naming

This is the **third** time this instrument has been defeated by an
aggregate:

| version | defeated by |
|---|---|
| repo-wide `totals.deliberate` | an addition in file A offset by a
removal in file B |
| per-file integer | an addition offset by a removal **in the same
file** |
| per-file per-column | — |

Each step narrows what can offset silently, and I walked into the next
one twice by fixing the *reported case* rather than the *shape*. Writing
it down because the same reflex will produce a fourth if someone adds
another aggregate here.

**The residual is deliberate, not an oversight:** a same-file
**same-column** swap still offsets. Two `todo` exemptions in one file
are interchangeable by definition, so there is nothing a reviewer could
act on. That is recorded at the site so the next person doesn't
rediscover it as a bug.

## Migration, again

The key **shape** changed (`file` → `file\0columnId`), which is the same
hazard as a missing field: comparing new keys against old reports every
existing marker as a fresh rise and pushes people to convert
already-reviewed literals. I hit it on the first run here — `TaskCard
(DELIBERATE-LITERAL: triage): 0 -> 2` — exactly as I did one shape
earlier in #2661.

Detected by the delimiter rather than a version field, since old keys
have none, and re-seeded on the next `--update-baseline`. 15 file+column
entries recorded.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0.

Independent of #2655; either order merges.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:02:25 -07:00
gsxdsm
dca20496f4 consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)
Consolidation branch for U7, per the new one-branch working mode.
**Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck
on review threads. My other seven (#2602, #2605, #2606, #2611, #2621,
#2628, #2633) are green with **zero unresolved threads** and are
deliberately left alone for the merge sweep.

## What is in here, file by file

| file | change | guards before → after |
|---|---|---|
| `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and
degraded-resolution refusal all resolve from the task's own workflow | 2
→ 0 |
| `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns
come from the board; default no longer names the deleted column | 1 → 0
|
| `plugins/…/glasses/src/settings.ts` | quick-capture default was
`triage`, the column #2515 removed | (assignment, uncounted) |
| `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column
condition deleted | 1 → 0 |
| `packages/engine/src/executor.ts` | 8 rebound guards compare the
resolved column; 4 resume-eligibility literals share one resolver | 151
→ 143 (+4 off-bar) |
| `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — |

`plugins/` reaches **zero** column guards with this branch.

## The three threads it closes

**#2607 — five findings, all mine, all the same rule.** I kept
*qualifying* a legacy-id fallback instead of removing it:

| attempt | rule | hole review found |
|---|---|---|
| 1 | fall back to `todo` when the role is missing | moved cards to
phantom columns |
| 2 | …only if the workflow **declares** `todo` | aliased **review**
lane named `todo` |
| 3 | …and only if no other role is assigned to it | **traitless**
parking column named `todo` |

The qualifications were the mistake. Once `resolveLanes` returns a lane
set the workflow *has* a column vocabulary, so "no column carries the
hold trait" is a complete answer — refuse. `destination()` is two lines
now, with no aliasing surface left to qualify.

Plus a sixth, which is a genuinely different state: **degraded
resolution is indistinguishable from the default board.**
`resolveWorkflowIrForTask` is total by design — a missing definition
silently returns the *default* coding IR — so a card on a custom board
whose definition could not be read resolved to `todo`/`in-progress`.
`undefined` lanes cannot express that (it means "no workflow at all",
where the legacy ids *are* the answer). The actions now refuse with 409.
#2618 would replace this check with resolver provenance; it is not
merged, so this does not depend on it.

**#2635 — "seven rebound sites remain untested."** Fair; my "same shape"
note was an assertion, not coverage. Seven of the eight need a live
graph run to reach, so the *shape* is pinned instead: a static check
that no guard in front of a rebound move compares against a column
literal, with a vacuity case (the same detection run against the
original shape) and a match-count floor (≥8), because a guard reporting
success on zero matches is worse than no guard.

**#2640 — duplicate workflow resolution.** Framed as I/O; it is also a
correctness bug. Eligibility and re-entry are two halves of one decision
and resolved the workflow separately, so a workflow edit landing between
them has the halves reading *different boards*. Now one caller-owned
memo per decision — caller-owned because a process-lifetime cache would
have to guess when a mid-flight workflow edit invalidates it.

## Behavioural findings, not tidying

- **The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** `promotedFromPlannerColumn` was false
on a renamed board, so finished work resting in planning was never
promoted; the code fell through to a review handoff that role adjacency
rejects, and the card stayed stuck with its work complete.
- **Rebound guards could not see the column their own move targeted.**
U5b converted the move target; the eight `column !== "todo"` checks in
front of it were left literal, so on a renamed board the engine moved a
card into the column it was already in — and `moveTaskInternal` runs
reset-on-entry on every real move, so at the `preserveProgress: false`
site it reset step progress a second time.
- **The FN-1404 `task:move` audit row was lying**, recording `to:
"todo"` while the move target was resolved. A run-audit trail that
disagrees with the move it describes is worse than none. Not a
comparison, so no census counts it.
- **A task interrupted by an engine pause never resumed on a renamed
board** (off-bar, `in-review`/`in-progress` literals): four comparisons
decided one question and had to agree; two of them disagreed on a
renamed board, so re-entry silently never fired.

## Revert proofs, isolated per site

| reverted | result |
|---|---|
| `destination()` back to attempt 3 | 3 of 38 fail |
| degraded-resolution refusals removed | 2 of 42 fail |
| capture set back to the legacy five | 2 of 3 fail (renamed-board
suite) |
| forward exclusions → literals | 1 of 14 fails |
| missing-wip refusal removed | 2 of 14 fail |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| promotion target → `"in-progress"` | 3 of 7 fail |
| one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) |
| resume lanes → legacy trio | 1 of 5 fails |

Every conversion is paired with a negative — a forward move, a
not-a-planner-lane card, a default-lineage card, an unresolvable
workflow — so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Commit discipline

Twelve commits, each one thing: the code move (`resolvePlannerLanes` out
of `triage.ts`) is separate from every behavior change, and each review
fix is its own commit with its own revert proof.

## Verification

- `pnpm test:gate` **71/71**
- 162/162 across the glasses plugin's 19 files; 26/26 across the four
new engine suites
- engine + glasses typecheck 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

* **Bug Fixes**
* Engine recovery and retries now work correctly with renamed or
customized workflow columns.
  * Tasks in manual-intake columns are no longer automatically planned.
* Agent actions and quick capture now respect each board’s declared
columns and lifecycle stages.
* Awaiting-approval tasks are recognized regardless of their current
column.
* Command Center SDLC funnel stages now accurately reflect customized
workflows.

* **Documentation**
* Added guidance for safely changing workflow-column logic and
interpreting lifecycle-column checks.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:52:55 -07:00
gsxdsm
cef1b08af3 U12: the census baseline follows the count down — and goes in the merge gate (#2661)
Coordinator item 2. The census had the right mechanism and no teeth.

## The gap

`--strict` already fails on a rise **and** on an unrecorded drop — that
logic was correct. But nothing blocking ran it, so the baseline drifted
to **854 while the tree held 787**. That is **67 guards of regression
that would have merged silently**: a high-water mark wearing a ratchet's
name.

This is the same shape as the ceilings I tightened in #2647, one level
up. Worth saying plainly: I fixed the vitest ratchet's slack by hand and
did not check whether the *authoritative* instrument had the same
problem. It did, and by a much larger margin.

## Three changes

1. **`--strict` runs in `test:gate`.** The baseline cannot go stale
again without a red gate.
2. **Baseline re-recorded: 854 → 785** across 14 files (`triage` 38 →
9).
3. The single RISE is resolved honestly rather than absorbed.

## The +3 investigation

One file rose: `register-task-workflow-routes.ts` **22 → 23**. #2621
replaced one `task.column === "todo"` with `task.column === "triage" ||
task.column === "todo"` — a net **+1** that also reintroduced a `triage`
literal, while the PR title reported *"count 0 → 0"*.

Not an accusation. There was no gate for the author to check against,
and a hand-counted claim in a PR title is exactly the thing that goes
wrong without one. Change 1 is the fix.

**The literal is justified and stays**, marked `DELIBERATE-LITERAL`
rather than converted. It is the **v1-IR arm**: a v1 workflow yields no
role assignments, so `resolveLifecycleColumns` returns nothing and the
legacy pre-implementation ids are the only pre-WIP signal available. The
`else` branch directly below already resolves intake/hold for every v2
workflow. Converting this arm would not finish anything — it would
delete the only answer v1 boards have and admit
`in-progress`/`in-review` cards into a rebound that clears worktree,
branch and retry counters, which is the regression #2621 was fixing.

## Both directions proven

| direction | probe | result |
|---|---|---|
| rise | add `t.column === 'in-review'` | `live-agent-count.ts: 6 -> 7`,
exit 1 |
| drop | convert one guard | `self-healing.ts: allows 111, tree has
110`, exit 1 |

**The drop probe took three attempts to test honestly, and the first two
"passed" while proving nothing:**

1. I renamed a receiver (`task.column` → `Probe`) — the classifier is
**fail-closed**, so an unknown receiver is still counted and the number
never moved.
2. I targeted a site in `hold-release.ts` that carries a
`DELIBERATE-LITERAL` marker — not counted as a column guard at all, so
removing it changed nothing.

Only removing a counted comparison outright moved the number. Both false
negatives came from me assuming the probe worked because the command
exited the way I expected.

## On auto-rewrite vs fail-and-instruct

You offered either. The script already does **fail-and-instruct**, with
`--update-baseline` as the explicit re-record, and I kept it that way
rather than making the test rewrite the baseline during a run.

Reason: a silent downward rewrite means a conversion PR's own diff never
shows the number moving, so "census before/after in the PR body" becomes
unverifiable — the reviewer would have to re-derive it. Failing with the
new number in the message puts it in the diff where a human sees it, and
it costs one command.

## Verification

`pnpm lint` clean. `pnpm test:gate` green with the census in it — `every
file matches its baseline exactly` (10 / 132 / 487 / 71).

Note for the fleet launch: with `--strict` gating, **every** conversion
PR must now re-record the baseline in the same PR. That is the intended
cost, and it makes the fleet's "baseline must shrink by exactly the
converted count" rule mechanically enforced instead of a review
instruction.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:47:31 -07:00
gsxdsm
efbbc45eb0 U12: the LAST triage guard — Plan was offered on executing cards named triage (#2664)
The final `column === "triage"` in production source, and it was a live
defect rather than dead vocabulary.

## The defect

`isPreExecutionHoldColumn` ORed the legacy id with the traits
**unconditionally**:

```ts
return column === "triage" || flags?.intake === true || flags?.hold === true;
```

That is not a fallback. A resolved column merely *named* `triage`
answered true even when its own traits said work was underway — so the
context menu offered **Plan**, which re-plans, on a card that is already
executing.

Now flags-first, with the id as the documented no-metadata answer.

## Why the file's earlier conversion missed it

Every existing case in `TaskContextMenu.test.tsx` passes a column with
**no flags**, or with `hold`/`intake` set. All of them agree under both
forms, so the suite could not distinguish them. Nothing exercised a
column whose **name and traits disagree**, which is the only shape that
separates an OR from a fallback.

Three new cases cover it. Revert check: restoring the OR form fails the
first one — Plan reappears on a mid-flight card.

## The asymmetry is preserved, and now tested

The degraded set stays `{triage}` **alone**, deliberately not the
`{todo, triage}` used by `isPreImplementationColumnRole`. That helper
drives the preserve-progress prompt, where a flagless `todo` *should*
prompt because losing steps is unrecoverable. This drives Plan, where a
flagless `todo` must **not** offer to re-plan a card that may already be
planned. The file documented that difference; nothing asserted it. Now a
test does.

## On reaching zero honestly

The surviving literal is marked `DELIBERATE-LITERAL`. It is the degraded
answer, not an unconverted guard — there is no trait to read when
`flags` is `undefined`, which happens during first paint and for a card
in a column its workflow no longer declares. Deleting it would silently
withdraw Plan from exactly the stranded cards that most need
re-planning.

So **`triage → 0` means "no unconverted guards remain", not "the string
is gone"**, and I would rather say that than move a number by deleting a
fallback.

| branch | triage |
|---|---:|
| `origin/main` | 5 |
| this PR | **4** |
| #2655 (flag resolution, removes 4 in `moves.ts`) | 1 → **0** combined
|

I found it with the census's own AST classifier rather than grep — my
grep of the same tree returned only comment prose and would have had me
report the bar as met while a real defect sat in
`TaskContextMenu.tsx:179`.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded in this
PR (column 769 → 768, deliberate 12 → 13). `tsc -p tsconfig.app.json`
clean. `TaskContextMenu.test.tsx` 18/18.

Depends on nothing; stacks cleanly with #2655 and #2661.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:37:08 -07:00
gsxdsm
20878e9d5f census: count column: "<legacy>" query filters as a separate, separately-pinned instrument (backlog unchanged at 784) (#2650)
Pre-launch input for the 779-guard fleet. **The backlog number does not
move: 784 before, 784 after.** This adds a second number beside it.

## The problem it measures

A guard is not the only way a legacy column id decides behaviour:

```ts
const todo = await this.store.listTasks({ column: "todo", slim: true });
```

That is a **source query** — it selects the rows a sweep considers *at
all*. On a renamed or merged board it returns nothing, so a sweep whose
per-task predicate was correctly converted still does nothing, while
looking converted. `self-healing.ts:2849` names the pairing in prose,
and #2560 had to repair exactly that combination after a converted
predicate was left with a literal query.

The census walks comparison `BinaryExpression`s. A `PropertyAssignment`
is not one, so this class was invisible to the instrument **and to its
ratchet** — it could grow silently.

Measured: **83 query filters, 43 IR node definitions.**

I proved one live consequence earlier on #2648:
`recoverStuckMergeDeadlocks` cannot see a renamed board at all — the
renamed rows exist and none appear in its three-literal union
(`renamedInsideUnion=0`, on a live PG store).

## Why this matters *before* the fleet is briefed

The fleet rule is *"the baseline ratchet must shrink by exactly the
converted count."* In `self-healing.ts` — the largest batch at 111 —
both classes sit in the same functions, so today a worker either:

- converts only the comparisons → arithmetic is clean, and sweeps whose
source query still filters a dead literal stay blind; or
- converts the query too → the count does **not** move by the converted
amount, and a more-correct PR looks like a miscount.

The second punishes the better worker. With a second pinned number,
converting a query becomes visible work instead of an apparent error.

## Counted separately, deliberately

`totals.column` is a published shape — the baseline, the reporter, and
other workers' in-flight PRs read it, and the completion bar is defined
against it. Growing it would move a number the program is actively
driving to zero.

So the new counts live in `summary.properties` / `queryByFile`, under
their own baseline keys, with their own both-directions ratchet (same
rule as #2633's, including the stale-allowance half). `totals` keeps its
**exact** shape — two existing tests assert it with `toEqual`, and
breaking a contract others depend on mid-flight to add a number is not
worth it.

## Definitions are not queries

Workflow IR graph nodes carry `column:` to declare where a node lives —
`{ id: "review", kind: "...", column: "in-review" }`. That is the
lineage describing itself: not a lookup, not convertible, and ~43 of the
raw matches. They are told apart **structurally** (an `id`/`kind`
sibling in the same object literal), not by filename, so a definition
written anywhere classifies the same way.

## Baseline seeding, stated plainly

`--update-baseline` could not pin a **new** category: the regression
check runs before the write, and with no prior key every file reads as a
rise. I seeded the three new keys once, directly, leaving every guard
field byte-identical. The diff is purely additive — no removals.

## Finding, not caused by this change

**`--strict` is already red on clean main**:
`register-task-workflow-routes.ts` is **23** against a baseline of
**22**. Verified by stashing this branch and re-running on an unmodified
tree. Until that is reconciled the guard ratchet is passing nothing —
worth fixing before the fleet starts relying on it as the work order.

## Verification

- census suites **44 green**, 6 new cases: counted; kept out of the
backlog; definition-not-query; both instruments independent (a bug
routing comparisons into the query bucket would otherwise look clean on
both); `DELIBERATE-LITERAL` honoured; non-legacy id ignored
- `node scripts/lifecycle-column-census.mjs` → backlog still 784
- `pnpm lint` exit 0, `pnpm test:gate` exit 0 (695)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:27:18 -07:00
gsxdsm
2771408bba ci: enforce the lifecycle-column ratchet — it has never actually run (#2654)
**The ratchet was advisory.** `scripts/lifecycle-column-census.mjs`
existed only as `pnpm census:lifecycle-columns` — without `--strict` —
and **no workflow invoked it**. Nothing has ever compared the tree to
the baseline. Every "the baseline ratchet holds them" assumption in this
program rested on a check that does not run.

That explains both classes of hole:

**1. Three PRs lowered counts without re-recording,** leaving allowances
the deleted guards could return through while every check stayed green.
I've tightened them across #2593 and earlier PRs, but nothing stops the
next one.

**2. #2621 GREW the count while its own title claimed "count 0 → 0".**
It added `column === "triage"` and `column === "todo"` at
`register-task-workflow-routes.ts:2681`, taking that file to **23
against an allowance of 22**. It landed unchallenged. This is the
failure mode the ratchet exists to prevent, and it happened *inside this
program*, in a PR that asserted the opposite.

## The change

Adds `check:lifecycle-columns` (the census with `--strict`) to the
`pr-checks.yml` lint job, next to `check:changesets` and
`check:routes-modular` — the established pattern. **~1.8s over ~1950
files**, so this is not a slow-test addition.

## Proven to fail, in both directions

A guard that reports success without checking anything is worse than no
guard, so:

| injected defect | result |
|---|---|
| `const __probe = (c: string) => c === "triage"` added to `moves.ts` |
`count ROSE — moves.ts: 39 -> 40`, exit 1 |
| run against main's current baseline | exit 1 on
`mission-feature-sync.ts: allows 5, tree has 0` |

Both reverted; exit 0 restored. Note the second row: **this check is RED
on main right now**, which is the point.

## Merge order

**Stacked on #2593**, which carries the `DELIBERATE-LITERAL` marker for
the #2621 site (a v1 IR declares no roles, so no trait can answer that
question) plus the baseline re-record. Standalone on main this PR is red
— correctly. **Merge #2593 first**, then this.

I stacked rather than duplicating those two edits because I already
caused one conflict today by appending related content from two
branches, and #2651 merged a correction ahead of the section it
corrected. Same-content edits in two PRs is the same mistake.

## Census

Unchanged by this PR: **776 total, triage 5, reviewed 16** — it adds no
guards and converts none. It only makes the numbers enforceable.

## For the fleet

This should land before the 776-guard fleet launches. The brief says
"the baseline ratchet must shrink by exactly the converted count" —
until now nothing verified that claim, so a batch worker could report a
shrink that did not happen, or grow the count while converting, and CI
would agree.

---------

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