Commit Graph

12555 Commits

Author SHA1 Message Date
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
b9f3fb0ff2 docs(core): resolveReviewColumns is the BROAD set — and one of its consumers must NOT migrate onto it (#2750)
## A flaw in the helper I merged in #2730

Found by trying to do the migration I had been advocating for three
rounds. One **name** was answering two questions:

| | question | answer |
|---|---|---|
| **broad** | "is this card in a lane where review happens?" | every
`mergeOrchestration` lane + every `mergeBlocker`/`humanReview` lane —
**this function** |
| **narrow** | "is this card in *the* review lane the engine acts on?" |
`resolveLifecycleColumns().review` = `columnsWithFlag(ir,
"mergeOrchestration")[0]` — **one** lane |

The narrow answer is what the executor, the scheduler and
`project-engine` act on. **A caller that admits on the broad set and
then MOVES the card moves cards the engine does not consider in
review.**

## The correction I owe

I have been arguing across #2722, #2723 and #2728 that the inline review
unions should converge on this helper. For the notifier that is right —
over-admission there just means an extra notification.

For `register-task-workflow-routes.ts` it is **wrong**. That resolver is
deliberately narrower (#2723): its re-engagement *moves* the card, so
admitting a second merge lane is a state change the engine will not
agree with. Its local copy is **not drift from this helper — it is the
other question.** Migrating it would reintroduce precisely the
over-admission that PR's review round reasoned away.

I was about to make that change. Reading both implementations side by
side is the only thing that stopped me, and "consolidate the duplicates"
would have looked like an obvious cleanup to the next person too.

## What this PR does

Nothing to behaviour. It writes the distinction down **at the helper**,
where a consumer reaching for "the review columns" will see it, and pins
the difference with a test.

The test needs a board declaring `mergeOrchestration` **twice** — no
default lineage does, which is exactly why the two answers look
identical everywhere else and why the conflation survived review.

**Mutation: narrowing this helper to the first merge lane — the
consolidation someone would reasonably attempt — fails the test.**

## Verification

30 trait tests green · core `tsc` clean · lint clean (0 errors) · gate
green (487 + 158 + 10 + 71). No census movement.

## Not done here

Migrating the notifier and the CLI copies onto this helper. Those
genuinely should converge, but both live in open PRs (#2722,
#2728/#2736) with live review threads; switching them under their
authors mid-flight is worse than letting them adopt it once this
distinction is documented.

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

## Summary by CodeRabbit

* **Documentation**
* Clarified the distinction between broad review-capable lanes and the
workflow’s primary review lane.
* Documented how review lane selection affects workflow state handling.

* **Tests**
* Added coverage confirming that review detection includes all matching
merge lanes.
* Verified lifecycle review selection continues to use only the primary
merge lane.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 06:35:27 -07:00
Phil Larson
8e50967279 fix: restore current main regression invariants (#2755)
## Summary
- align the renamed-review CLI regression with the classifier’s
resolved-boolean contract
- keep GitHub tracking controls expanded across same-task detail and
sparse SSE updates
- strengthen the sticky tracking regression to wait for the sparse
update

## Test plan
- `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/cli-active-count-lanes.test.ts`
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/restart-recovery-coordinator.test.ts`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run
app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx`
- `pnpm --filter @fusion/dashboard typecheck`
- `pnpm --filter @runfusion/fusion typecheck`
- `pnpm check:changesets`
2026-07-30 06:24:57 -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
a09263ae8c test(dashboard): the mobile board needs RESOLVED lanes — U12/R9 deleted the legacy board (22 → 0) (#2740)
## Root cause

`Board.tsx:874` renders `BoardWorkflowSkeleton` whenever `boardWorkflows
=== null || boardWorkflows.workflows.length === 0`.

All three files depended on the **legacy board**, which U12/R9 deleted —
along with the `flagEnabled` conjunct in that condition. So
`flagEnabled: false` now selects nothing, and the board sits at
`"Loading workflow lanes"` with no lanes, no task cards, and no
Auto-merge toggle.

Every failure surfaced as `Unable to find an accessible element with the
role "checkbox" and name "Auto-merge"` — which points at the query, not
at the board state. The `aria-label` in the error's role dump is what
gave it away.

## Three causes, in sequence — each only visible after fixing the one
before

| # | File | Cause |
|---|---|---|
| 1 | `auto-merge-toggle-blank.mobile` | mocked `workflows: []` →
empty-lane skeleton |
| 2 | `auto-merge-toggle-blank.mobile-integration` | builds its mock
with `createDashboardApiMock`, which **spreads the real module** — so
`fetchBoardWorkflows` was the real network call, never resolving under
jsdom |
| 3 | both | even with a payload, lanes arrive on a **promise** while
these synchronous tests assert immediately after `render()` |

(3) is the one worth remembering: the legacy board rendered
**synchronously**, so no await was ever needed. `renderBoardWithLanes`
flushes a microtask inside `act` — a microtask and not a timer advance,
because this suite runs on fake timers.

## Also re-pinned: a source-scan guard pinning deleted code

`board-mobile` asserted `ref={setBoardRef}` appears **3** times across
"legacy, selected, and aggregate" renders. The legacy `<main
className="board" id="board" ref={setBoardRef}>` render has **0**
occurrences now, so both the count *and* its `toContain` were pinning
removed markup — the second would have failed as soon as the first was
fixed.

Now **2**, with the legacy markup asserted **absent** so it stays gone,
and the test renamed to the two renders that exist. The guard's real
point — that both share one scroll-snap hook — still holds.

## Measured

| Check | Result |
|---|---|
| `components-a` group | 22 failed → **0** (51 files, **1195 passed**) |
| lane payload emptied again | **all 8** cases in the first file fail —
load-bearing |
| `pnpm lint`, dashboard app `tsc` | clean |

## One wrong turn, recorded

My first attempt declared the payload as a module `const` and referenced
it from the `vi.mock` factory. Factories are **hoisted above const
declarations**, and the file reported `"no tests"` rather than a
hoisting error — a failure mode that reads as a collection problem, not
a reference problem. Inlined instead.

## Dashboard status

With #2735 (50 → 1) and this (22 → 0), dashboard goes from 88 failures
across 9 files to **17 across 6**. The remainder are unrelated: CSS
`toHaveClass` / computed-colour assertions, a missing
`wf-add-step-modal` testid, and the one GitHub-tracking affordance
question flagged in #2735.

Per #2732 these lanes are currently **never executed in CI** — the shard
aborts on the first failing package — so this is cleared ahead of them
starting to run.
2026-07-30 06:00:11 -07:00
gsxdsm
e9d7945f71 test(dashboard): TaskDetailModal renders through a PORTAL — 87 container queries had the wrong root (50 → 1) (#2735)
## One root cause behind 49 of 50 failures

`TaskDetailModal` now renders inside `FloatingWindow`, which uses
`createPortal`. Its DOM lands on `document.body`, **not** inside the
`container` that `render()` returns. So every
`container.querySelector(...)` returned `null`, and the tests died with:

```
Error: Unable to fire a "click" event - please provide a DOM element.   (29 of them)
```

That message points at the click, not the root — which is why 50
failures looked like 50 problems.

**Isolated by probe, not by reading.** I rendered the modal and printed
`container.innerHTML.length`:

```
ZZ EDITBTN: ABSENT
ZZ ROOTLEN: 0
```

`0` says *nothing rendered here*, which is a different fact from *the
button is missing* — and the two are indistinguishable from the
assertion. Before that I had gone down two wrong paths: checking whether
`.modal-edit-btn` had been renamed (it hadn't — still in the source),
then whether `canEdit` was false (it wasn't — the card is in `todo`,
which is in the legacy editable set). The probe ended the guessing.

## The fix

87 `container.querySelector` / `querySelectorAll` calls → `document`.
Correct for **both** shapes in this file: `container` is itself inside
`document`, so the tests that render `TaskDetailContent` directly keep
working, and the portaled `TaskDetailModal` renders now resolve.

Plus one assertion looking for a class the shell no longer has:
`.modal-overlay` is now only the unrelated **refine** overlay
(`TaskDetailModal.tsx:6612`); FloatingWindow's shell is
`floating-window-overlay`. The "renders immediately" case now asserts
`.task-detail-modal` — the modal's own class, which is what that test is
about.

## Measured

| | before | after |
|---|---:|---:|
| this file | 50 failed | **1 failed** / 97 passed |
| `components-b` group | 66 failed / 6 files | **17 failed / 6 files** |

**The same six files fail before and after**, so nothing regressed. Lint
clean, dashboard app `tsc` clean.

## Scoped deliberately, not applied blanket

The other five files in this group fail from unrelated causes — CSS
`toHaveClass` / computed-colour assertions and a missing
`wf-add-step-modal` testid. `TaskCard.test.tsx` has **280**
`container.querySelector` uses and only **2** failures: it is not
portaled, so applying this pattern there would have been a 280-line
change fixing nothing. The pattern went only where the portal is.

## Still failing — flagged, not forced green

`keeps disabled githubTracking state sticky across follow-up sparse task
prop updates` cannot find the *"Enable GitHub tracking"* checkbox. Its
card is `in-progress`, which **is** in
`GITHUB_TRACKING_EDITABLE_COLUMNS`, so `showGithubTrackingSection`
(`TaskDetailModal.tsx:1739`) is gated by something else — gitlab
tracking state, the active tab, or edit mode.

That is a question about *when the affordance is shown*, not a
query-root bug. Making it pass would mean deciding that behaviour inside
a mechanical repair, so it is left for a separate look.

## Context for the remaining dashboard reds

Per #2732, these groups are currently **never executed in CI** — the
shard aborts on the first failing package
(`ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`), so everything scheduled after it
is skipped. This PR takes the largest single cluster out before those
lanes start running for real.
2026-07-30 05:52:26 -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
2fe82cb644 feat(core): resolveReviewColumns — the shared answer four consumers each invented separately (#2730)
## The pattern

`resolveLifecycleColumns().review` is a **single id derived from one
flag** (`mergeOrchestration`). The domain is not that shape — a lane can
host human review without orchestrating a merge, and a board may declare
more than one review lane.

So every consumer asking *"is this card in review"* re-derived its own
answer, and they drifted:

| PR | surface | how it diverged |
|---|---|---|
| #2713 | routes | terminal columns needed membership; fixed there only
|
| #2722 | notifier | a `humanReview`-only lane resolved to nothing — the
operator's review notification **never fired**, silently |
| #2723 | routes | the union was broader than core's single id |
| #2728 | CLI | `fn task retry` refused a card `POST /tasks/:id/retry`
accepted — the same operator action answering differently per surface |

Four files, four patches, **two of them mine** — plus a fifth site
inside #2722 that my own first pass missed, which is exactly the
enumeration failure I had been pointing out in other people's PRs. Each
recurrence cost a review round, and the next consumer would have
invented a fifth answer.

## The change

```ts
export function resolveReviewColumns(ir: WorkflowIr): string[]
```

**Additive on purpose.** `.review` is untouched, so nothing that reads
it changes behaviour. This is the *missing* helper, not a reshaping of
the existing one — `.review` stays correct for its own question ("which
single lane hosts the merge gate"); this answers the other one ("is this
card **already** in a review lane").

**Monotonic**, which #2723's review round argued about at length: a lane
carrying **both** `humanReview` and `mergeOrchestration` is included.
Adding a trait must never *remove* a lane from this set, or a card stops
counting as in review because its column gained an unrelated capability.

**Returns empty rather than defaulting to `in-review`.** The legacy
fallback belongs to the caller, which knows whether refusing or
admitting is the safe direction for its own guard — the notifier and the
retry gate fail in opposite directions, and baking one choice in here
would make one of them wrong.

## Verification

Six tests, both directions: human-review-only lane, multiple lanes,
monotonicity, de-duplication, empty result, and agreement with the
shipped coding workflow. One asserts the divergence directly —
`resolveReviewColumns` finds the lane while `.review` returns
`undefined`.

**Mutation: dropping the two extra flags fails 2 of 26.**

26 core trait tests green · core `tsc` clean · lint clean · gate green
(487 + 158 + 10 + 71). **No census movement** — this adds capability and
converts nothing.

## Follow-up, not done here

Migrating the four consumers onto it. Each is an open PR with its own
review thread, and switching them under their authors mid-flight would
be worse than letting them adopt it. The helper is the prerequisite;
adoption is theirs.
2026-07-30 05:40:54 -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
ddba730a59 fix(core): 8 reds across 4 files — incl. a real FN-8603 contract violation and a ratchet row pinning deleted code (#2725)
## Measured

Full `@fusion/core` suite: **8 failed / 6 files → 1 failed / 1 file**
(4611 passed). `pnpm typecheck` exit 0 across every package, `pnpm lint`
clean, gate **726**.

The one remaining failure is **not mine to fix** — see the last section.

## Four causes; two are product-side, not test drift

**1. A real FN-8603 contract violation.** `tool-output-budget.ts:116`
had a bare `console.warn`, breaking the rule that production diagnostics
route through the shared logger so severity markers and `FUSION_DEBUG`
gating survive. `log-severity-spam-contract` caught it exactly as
designed. Now `createLogger("tool-output-budget")`, kept at `warn` — an
invalid operator-supplied budget is a real misconfiguration, not routine
chatter.

**2. A ratchet row pinning deleted code.** The manifest pinned a `local
reattached project ${project.id}` demotion in `central-core.ts` whose
call site was deleted by `5ae6332563` ("collapse dead SQLite dual-path
code"). Verified absent from **all** of `packages/core/src`, not merely
moved. A manifest row for deleted code can only ever fail — it ratchets
nothing — so it is removed with that provenance recorded in place.

**3. An intentional settings overlap.** `agentToolOutputMaxChars` now
appears in both scopes. Admitted to the parity list because
`settings-schema.ts:462` states the intent outright: *"Project settings
participate in the existing effective-settings merge, allowing a
project-specific tool-output cap … to override global policy."* Placed
in `GLOBAL_SETTINGS_KEYS` order, as that test requires.

**4. `maxPostReviewFixes` 3 → 10 — the third file pinning the stale 3.**
Driven off the exported `DEFAULT_MAX_POST_REVIEW_FIXES` rather than a
fourth literal copy. That constant exists *because* the declaration
default and two inline `3`s had already drifted apart once; adding
another copy would guarantee a fourth drift.

## duplicate-guard: a narrow seam instead of a rebuilt mock

Its 3 failures were `Cannot read properties of undefined (reading
'projectId')` — the fake modelled the **deleted SQLite path**
(`db.prepare().all()`) and recovered the window by parsing a captured
cutoff string. It broke when the query moved to `asyncLayer` + Drizzle.

Rebuilding a Drizzle chain to recover a number the policy already
returns would be mock-the-world for no gain, so the window policy is now
one exported pure function — `resolveFingerprintWindowMs`, the
**byte-identical** expression — that both the store query and the tests
call. Two side benefits: the ±5s timing tolerance is gone (exact
assertions), and the `Math.max(1, …)` floor now has coverage the old
cutoff-parsing shape could not see.

**Load-bearing, verified by mutation:** restoring the old 5-minute
ceiling fails 3 of them; deleting the floor fails the new case.

## The remaining failure is a deliberately-deferred product decision

`agent-logs-and-monitor.pg.test.ts > aggregateActivityAnalytics …`
expects funnel stage `todo` count 2 and gets 0. This is **already
diagnosed and deferred by another worker**, in
`activity-analytics.ts:604`:

> *"The merged column landing in `triage` while the `todo` stage stays
empty is a SEPARATE and larger question — it makes the funnel show a
phantom 100% drop between Triage and Todo on every default board since
U11 — and it is deliberately not settled here. Changing which stage the
Planning column reports would retroactively alter how historical
analytics read… Flagged for a product decision on PR #2669."*

The merged Planning column carries `["intake","hold","reset-on-entry"]`
and `stageForTraits` prefers the earliest stage, so `intake` wins.
Either fix — remapping the stage, or changing the expectation — silently
settles how historical analytics read. I left it alone rather than pick
a side inside a test-repair PR.

## Two "flaky" files that are NOT flaky — and I nearly mislabelled them

`pg-test-harness-template-concurrency.pg.test.ts` and
`moves-intake-only-hard-cancel.pg.test.ts` each failed in one full-suite
run and not another, which reads as flake and would have earned a
quarantine entry plus a 14-day deletion clock under the standing rule.

Measured in isolation instead:

| File | alongside other PG suites | alone |
|---|---|---|
| `pg-test-harness-template-concurrency` | fails intermittently | **4
passed, 3/3 runs** |
| `moves-intake-only-hard-cancel` | failed once | **2 passed** |

So this is **shared-PG-template contention between concurrently running
suites**, not an inherent flake in either test. Quarantining them would
have started a deletion clock on healthy coverage and hidden a real
harness-parallelism interaction. Flagged for whoever owns the PG
harness; no quarantine entry added.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved duplicate-detection window handling with consistent defaults,
limits, and minimum values.
* Invalid tool output limits now produce standardized warning messages
while preserving fallback behavior.
* Updated settings and workflow validation to accurately reflect
supported configuration defaults and scopes.

* **Tests**
* Strengthened coverage for duplicate-detection windows and
configuration parity.
* Removed an outdated logging severity expectation tied to a
no-longer-applicable message.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 05:33:27 -07:00
gsxdsm
647bac5b21 test(dashboard): lowercase header keys + the client stamp (1 red → 0) — and 88 dashboard failures CI never runs (#2732)
## The fix

`app/api/client.ts` builds request headers through a `Headers` object —
which lower-cases every key — and stamps `x-fusion-client: dashboard-ui`
on every dashboard-originated request so the server can attribute the
caller (the FN-8609 delete-attribution surface).

This test asserted `"Content-Type"`, and `expect.objectContaining`
compares keys **case-sensitively**, so it failed on casing alone.

Rather than only lower-casing the key, it now pins the **client stamp**
too — that header is the point of the feature and nothing else in this
file covered it.

| Check | Result |
|---|---|
| `plugin-setup-api.test.ts` | 1 failed → **3 passed** |
| client stamp neutered in `client.ts` | **1 failed** / 2 passed —
load-bearing |
| `pnpm lint`, dashboard app `tsc` | clean |

## Method correction — I nearly filed 40 phantom failures

Measuring this package with a raw `vitest run` reports **~40 failing
files**. That number is worthless: `@fusion/dashboard`'s own `test`
script is `node scripts/run-quality-tests.mjs`, which runs the quality
projects as **separate invocations** with per-group heap sizes and
exclusions. Running every project in one process fails en masse for
reasons unrelated to the code.

Correct command — `pnpm --filter @fusion/dashboard test` — gives **9
failing files / 88 failing tests**, exit 1.

## The finding: those 88 failures are never executed in CI

I first concluded "CI shows no dashboard failures, so these are
local-only." **That was wrong, and the reason matters.**

CI does schedule the dashboard quality groups — they are distributed
across all four shards as individual `test:quality:*` invocations. Shard
2's plan, for example:

```
[ci-test-shard] shard 2/4: @fusion/core [2/2], @fusion/dashboard run test:quality:app:components-b,
                @fusion/dashboard run test:quality:app:backfill-1, ...
```

But only two invocations ever get a `(watchdog budget 1500s)` start
line: the plugins group and `@fusion/core [2/2]`. `components-b` never
starts, because the shard aborts on the first failing package —
`ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`, present in shards 1, 2 and 4.

**So the dashboard quality groups are not passing — they are unrun**,
behind a package that fails first. Consequences:

1. **Fixing core/engine/CLI will unmask 88 dashboard failures.** My
merged PRs move shards 1/2/4 toward green; as each earlier package stops
failing, these groups begin executing for the first time. Expect the
shard counts to *rise* before they fall — that is progress, not
regression.
2. **Reading shard conclusions is misleading.** A shard says "core
failed"; it does not say "and everything scheduled after core never
ran."

Where the 88 live (all files currently unowned):

| File | Failures | Lane / shard |
|---|---:|---|
| `TaskDetailModal.inline-editing-and-integrations` | 50 |
`components-b` / shard 2 |
| `auto-merge-toggle-blank.mobile-integration` | 13 | `components-a` /
shard 3 |
| `auto-merge-toggle-blank.mobile` | 8 | `components-a` / shard 3 |
| `TaskDetailModal` | 6 | `components-b` / shard 2 |
| `SecretsView` | 4 | `components-b` / shard 2 |
| `WorkflowNodeEditor` | 3 | `components-b` / shard 2 |
| `TaskCard` + `TaskCard.badge-wrap` | 3 | `components-b` / shard 2 |
| `board-mobile` | 1 | `components-a` / shard 3 |

Dominant symptoms: `Unable to fire a "click" event - please provide a
DOM element` (29), `Unable to find an accessible element with the role
"checkbox" and name "Auto-merge"` (13), `expected null to be truthy` (7)
— consistent with a small number of shared render/affordance causes
rather than 88 independent bugs, but I have not isolated them.

**I am not starting that repair in this PR.** It is a 9-file, 88-test
area needing per-cluster diagnosis, and bundling it behind a one-line
header fix would produce exactly the shallow work this program keeps
rejecting. Filed here with the correct measurement command, the
lane/shard mapping, and the reason CI has been silent about it.
2026-07-30 05:33:00 -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
5791dfeeb7 fix(dashboard): the routes and the engine disagreed about what "review" is — a converted guard that still contradicts core (#2723)
Built **on top of #2713**, which converted this file to trait-resolved
membership sets while my #2701 was open on the same file. Their design
is better than the single-id resolver I had — the arity argument in
their comment (a single id answers "where should this card GO", a SET
answers "is this card ALREADY there") is correct, and I have closed
#2701 rather than contest it.

Two things survive that #2713 did not cover.

## 1. The routes and the engine disagreed about what "review" IS

Core's `resolveLifecycleColumns().review` — the answer the **executor**,
the **merger** and **project-engine** all act on — resolves review from
`mergeOrchestration`. This file's resolver looked only at `mergeBlocker`
/ `humanReview`.

The default lineage hides it, because `in-review` carries all three:

```
in-review[merge-blocker, human-review, stall-detection, merge]
```

A board that declares only `merge` on its review lane resolved as
**review in the engine** and **not review in the routes**. The executor
treats the card as in review; comment re-engagement, the retry gate and
branch-binding recovery all refuse it.

**That is the same defect class as a literal, one level up:** the guard
is converted, it reads a real trait, and it still disagrees with the
authority. Unioning all three makes this resolver a **superset** of
core's, so the routes cannot refuse a card the engine considers in
review.

How I found it is worth stating: my renamed-board fixture declares only
`merge` on its review lane, and the ported suite failed against main. I
nearly "fixed" the fixture to add `mergeBlocker` — which would have
papered over a real cross-layer inconsistency to make my own test pass.

## 2. The 400s name the board's own columns

#2713 converted both gates but left the messages saying `in-review` /
`in-progress`. **Being told your card must be in a column that does not
exist is worse than a wrong guard** — a wrong guard is a bug report; a
wrong column name sends the operator looking for something that was
deleted.

## Tests

7 cases ported from #2701 and adapted to the membership-set shape. **3
red on revert** of the `mergeOrchestration` union.

## A pre-existing failure I am reporting, not folding in

`register-task-workflow-routes.move-bypassguards.test.ts` **fails on
`origin/main`** — 400 where it expects 200. Verified by stashing my
change and re-running, so it is not mine. Someone owns that regression;
it should not ride in on a lane-consistency PR.

## Verification

`pnpm test:gate` **10 / 71** · the ten other
`register-task-workflow-routes` suites pass · 7/7 new · `tsc -p
packages/dashboard` clean · `pnpm lint` clean · census `--strict` exit
0, no count change (this PR converts nothing new — it makes an
already-converted resolver agree with core).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:25:28 -07:00
gsxdsm
277a034e4b test(engine): two files missed the logger-mock debug sweep (38 red → 0) + a deleted API still pinned (#2716)
## What was red

| File | Failures | Error |
|---|---:|---|
| `notification/__tests__/notification-service.test.ts` | 26 |
`schedulerLog.debug is not a function` |
| `runtimes/__tests__/child-process-worker.test.ts` | 12 |
`runtimeLog.debug is not a function` |

`debug` is part of the logger surface (`logger.ts:25`) — the channel the
noisy-line demotion moved subsystem chatter onto, gated on
`FUSION_DEBUG`. A mock that omits it throws on the **first** demoted
call, failing every case in the file for a reason unrelated to what any
of them assert. These two were missed by the earlier sweep across 27
engine files.

## Measured

| Check | Result |
|---|---|
| the two files | 38 failed → **38 passed** |
| `debug` removed from the mocks again | **38 failed** — the entries are
load-bearing |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint` | clean |

Census unchanged — test files only.

## Two real drifts under the mock gap

Both re-pointed at what the product actually does, not relaxed:

**1. Suppression lines are `debug`, not `log`.**
`notification-service.ts` routes all five of its `"suppressed ..."`
messages through `schedulerLog.debug`. Two assertions looked on `.log` —
where the product no longer writes. Verified by grepping the product for
the message before editing the test, rather than assuming the mock was
the whole story.

**2. `centralCore.getGlobalConcurrencyState` is DELETED, not missing.**
The cross-project cap was dropped deliberately (`central-core.ts:2124`,
`FNXC:CapacityModel 2026-07-28-23:30`) together with
`updateGlobalConcurrency`, `acquireGlobalSlot`, `releaseGlobalSlot` and
the `concurrency:changed` event — capacity is two numbers **per
project** now. That comment also records the slot pair was already dead:
no production caller ever invoked it, so `currentlyActive` was never
incremented by real work.

The worker's stub provides `getLiveRunningAgentCounts` and
`recordTaskCompletion`, so the case is re-pinned to the former. It had
been pinning an API the product removed on purpose — the assertion would
have kept "passing" a shape that no longer exists if the stub had
happened to retain a same-named field.

## Flagged, deliberately not changed

`ipc/__tests__/ipc-host.test.ts` and `ipc/__tests__/ipc-worker.test.ts`
carry the **same incomplete logger mock** but are currently green (56
passed) because no demoted line is reached on their paths.

Adding `debug` there cannot be shown to fail today, so it is recorded
here rather than slipped in as an unfalsifiable edit. They go red the
moment any code they exercise demotes a line — which is how the 29 files
before them broke. Found by scanning every engine logger mock for the
pattern, not by guessing.
2026-07-30 05:18:19 -07:00
gsxdsm
5e1f1df4e3 docs(dashboard): correct a false "deleted column" claim that shipped in #2726 (#2727)
Comment-only correction. **#2726 (mine) landed a factually wrong FNXC
note in main** and this retracts it at the site.

## The false claim

#2726's note — and its PR title — asserted a **live stale-target bug**:
that `triage` is a column U11/#2515 deleted, so TaskCard's in-review
move menu pushes `"Move to triage"` for a column that no longer exists.

It is not deleted:

```
packages/core/src/builtin-coding-workflow-ir.ts:49
  { id: "triage", name: "Planning", traits: [{ trait: "intake" }] },
```

`triage` (intake) and `todo` (hold) are still **separate columns** on
the default board, and `triage` also exists in `builtin-pr` and
`builtin-lead-generation`. I was carrying a merged-planning-column shape
from other work in this program and asserted it against the tree without
reading the IR. **There is no stale-target bug.**

I found this while preparing to report the "bug" as a review comment on
#2688 — checking the builtins before filing is the only reason it did
not propagate into a second PR. It had already shipped by then, which is
why this is a follow-up rather than an edit.

## What the note says now

What is actually true of the site: `column` is the **loop variable over
the function's own hardcoded `["done", "triage"]` array**, so the
comparison picks a label from a list this code just wrote itself.
Resolving a trait for that string would be meaningless. This is the same
reading #2688 arrived at independently, and it is the correct one.

The **array** is a real open question — it names move targets by id
rather than by role, so a workflow that renames those lanes gets targets
it cannot show. That needs a Surface Enumeration per AGENTS (removing or
changing a visible menu entry), so it stays flagged rather than fixed. A
real question, unlike the one the old comment invented.

## Why a whole PR for a comment

The FNXC convention exists so the *reasons* in this codebase stay
trustworthy. A note that names a specific PR as having deleted a column,
and a specific menu as broken, is exactly the kind of thing a future
reader acts on — the cost of leaving it is someone "fixing" a working
affordance, or discovering the note is wrong and trusting the
surrounding notes less.

Also worth recording: **#2726 duplicated #2688**, which claimed
TaskCard.tsx first (09:41Z) and took it to 42 → 0. I did not check the
remote branch list before starting and mine merged first, so #2688 now
conflicts against work it predates. Wasted effort on my side, and a note
for the fleet: `git branch -r | grep fleet` before claiming.

## Verification

Comment-only — no behaviour change. Census `--strict` exits 0 (unmoved),
dashboard `tsc -p tsconfig.app.json` clean, `pnpm lint` clean.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:11:21 -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
8fb7c933d5 docs(core): pin the LifecycleColumns arity contract — two shipped bugs came from it (#2721)
Found by **auditing for the pattern** after hitting it twice, rather
than waiting for a third instance.

## The contract, previously undocumented

`LifecycleColumns` names **one** column per role even when a workflow
declares several. Nothing validates that a trait appears at most once —
`columnsWithFlag` returns an array and `resolveLifecycleColumns` takes
its head. So a workflow may legitimately declare two intake lanes, or
split `mergeBlocker` and `humanReview` across a merge lane and a
separate sign-off lane, and the struct names only one of each.

That makes the fields safe for one question and unsafe for another:

| | question | correct? |
|---|---|---|
| **safe** | *"where should this card **go**?"* | a move target must be
exactly one column |
| **unsafe** | *"is this card **already** there?"* | membership — use
`columnsWithFlag(...).includes(...)` |

## Two shipped bugs, both the unsafe use

Both in #2713, both reading like ordinary conversions:

- a task in a **second terminal column** was rejected with a 409
- a task in a **human-review lane split from the merge lane** was
classified as outside review entirely, suppressing comment re-engagement

I fixed the first, did not generalise, and hit the second one review
round later. That is the actual failure mode this PR addresses — not the
individual bugs, which are already fixed.

## Two call sites left for their owners

Named in the doc comment rather than changed here, since they belong to
other clusters. Both are correct **only while their workflow declares
one column per trait**:

- `packages/engine/src/self-healing.ts` — `columns.intake` /
`columns.hold`
- `packages/core/src/builtin-workflows.ts` — `lifecycle.intake`

## The test asserts the contrast, not just the arity

A reader who sees only *"it returns one id"* learns the wrong lesson.
The test builds a workflow with **two intake and two complete columns**
and shows that exactly one qualifying column **fails an equality check**
against the struct while the **membership form gets it right** — the
shipped-bug shape, reproduced in miniature.

## Verification

`workflow-lifecycle-traits.test.ts` **20 → 22**. `pnpm test:gate` green
(10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p
packages/core/tsconfig.json` clean. `pnpm lint` clean.

Docs + test only — no behaviour change, no census movement, no
changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:00:26 -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
39523403c7 test(engine): isolate the fast-mode red — v1 seam→column normalization skips start (1 → 0) + an unanswered pause-guard question (#2719)
## The fast-mode failure, finally isolated

```
expected [ 'review' ] to deeply equal [ 'start', 'review' ]
```

This resisted **three earlier diagnosis attempts** because nothing about
the assertion, the fast-mode flag, or the node kind points at the real
mechanism: **column normalization of a v1 IR.**

**Isolated by mutation, not by reading.** Swapping the node's `config: {
seam: "review" }` for the sibling test's `config: { executor: "skill",
... }` makes `start` reappear in `visitedNodeIds`. So `config.seam` is
the trigger — which is not a thing the failure text suggests looking at.

The chain:

1. v1 normalization places nodes into synthesized default columns **by
seam** (`workflow-ir.ts:150` — `review` → `in-review`, seam-less →
`todo`).
2. The card rests in `in-progress`, which this three-node graph has **no
node for**.
3. `resolveColumnResumeNode` (`workflow-graph-executor.ts:473`)
therefore resumes at the next node **forward** — the review node —
instead of re-entering at `start`.

That resolver's `>=` comparison is commented for exactly this case: *"a
card can rest in a column the pipeline has no node for … and must then
resume at the next node forward."*

**So the product is right and the expectation was stale.** Visited is
`["review"]`. `pnpm test:gate` **726 passed**, lint clean, census
unchanged.

I also recorded in-file *why the sibling skill-executor case
legitimately still expects `start`*: its seam-less node normalizes into
`todo`, which is **behind** `in-progress`, so there is no forward match
and entry falls back to `start`. That contrast is an accident of config
rather than a deliberate difference — so the two expectations must
**not** be "aligned", which is the obvious-looking wrong move for the
next person here.

## Flagged, NOT fixed: `executor-prompt`'s 3 failures are a real design
question

These assert that `execute()` refuses to dispatch a user-paused row
during a global pause:

```
expect(mockedCreateFnAgent).not.toHaveBeenCalled();   // actually called 1 time
```

The test's own comment (from #2371) states the behaviour as "a paused
todo task is no longer dispatched at all". **The guard lives in the
scheduler, not in `execute()`** — `scheduler.ts:1515` is the
`globalPause` gate, and it is a hard stop that never reaches
`.execute(`. These three tests call `executor.execute(task)`
**directly**, so no guard fires.

That is not merely a test-layer mismatch, which is why I am not "fixing"
the assertions. `execute()` has **five call sites outside the
scheduler**:

- `executor.ts:3333`, `:3495`, `:5702`, `:5858` — internal re-dispatch
paths
- `runtimes/in-process-runtime.ts:2255` — `void
this.executor.execute(task)`

Whether each of those is separately gated determines whether work can be
dispatched on a user-paused row, or during a global pause, by a path
that never consults the scheduler. Two legitimate resolutions exist and
they differ in behaviour:

1. give `execute()` its own pause guard (defence-in-depth — but must not
break legitimate internal re-dispatch), or
2. retire the direct-`execute()` assertions and cover the invariant at
the scheduler layer.

Picking either silently inside a test-repair PR would either change
lifecycle behaviour around **user pause** — a safeguard this program has
re-ratified and told me not to narrow — or delete coverage of it. So it
stays flagged with the call-site evidence for whoever owns the pause
contract.

This is the same file and question I flagged much earlier in the
program; it is now backed by the specific line numbers rather than a
suspicion.
2026-07-30 04:41:06 -07:00
gsxdsm
11aba0394e docs(solutions): converting a column literal to a role makes it async — the four forms that ship green (#2710)
Four review rounds across `TaskCard.tsx` and `TaskDetailModal.tsx` each
found a **real defect**. None was in the conversion itself — every one
came from the same property change. The fleet has ~600 guards left to
convert against the same helpers, so this is written down rather than
left in four commit messages.

## The property that changes

```ts
task.column === "in-progress"   // stable for the lifetime of the render tree
isWipColumn                     // derived from fetched trait flags — CHANGES after first paint
```

Column trait flags arrive from a board-workflows fetch. Until it lands
they are `undefined` and every role helper falls back to the legacy id.
So a converted role is `false`, then `true`, within one mounted
component.

## The four forms

| | form | symptom |
|---|---|---|
| 1 | **stale memo** — deps still keyed only on `task.column` | timers,
labels, completion dates frozen at first-paint values (4 instances in
TaskCard) |
| 2 | **frozen `useState` initializer** | the section does not start
collapsed — it *appears later, already collapsed*, on a card nobody
touched |
| 3 | **eager action on a guess** — effect mutates state before flags
resolve | a tab opens and instantly bounces; the correction never lands
because the action destroyed the state it would have corrected |
| 4 | **stale identity** — flags resolved, but for the *previous* entity
| roles resolve from another task's workflow: confidently wrong rather
than merely stale |

**Form 4 defeats the obvious fix for form 3.** A `metadata === null`
guard asks whether data *loaded*, not whether it describes the entity
currently open — and it only appears in components that stay **mounted
across entity changes**, which is why TaskCard never showed it and the
modal did.

## Why a doc rather than four commit messages

All four ship **green**: types pass, existing tests pass, and the
**default board behaves identically** — because on the default lineage
the legacy fallback and the resolved role agree. They diverge only on a
**renamed board**, which is precisely the case the conversion exists to
support.

So the failure mode is: census count reaches zero, everything is green,
and the feature is broken exactly where the programme was meant to fix
it. A reviewer catching these one at a time is the expensive path, and
it has now cost four rounds on two files.

Also relevant: **this repo has no `react-hooks/exhaustive-deps` rule**,
so form 1 has no automated backstop at all.

## Contents

A checklist a converter can run against a component file, and the
concrete fix shape for each form — including tagging fetched metadata
with the id it describes, and applying that guard to the **role
bindings** rather than only the effects (reordering effects fixes the
call sites you noticed and leaves the bindings stale for everything
else).

Follows the convention already established by the engine-side scoping
note in `architecture-patterns/fleet-self-healing-cluster-scoping.md`,
which records the equivalent hazard for sync workflow reads.

## Verification

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

Docs-only; no changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:37:19 -07:00
gsxdsm
a59576607a test(engine): 11 reds from two intended product changes the tests still pinned (#2717)
## The 11 failures, two causes

Four files, all confirmed red on clean `origin/main`, all unowned.
Neither cause is a product defect.

**A. U11 merged the two pre-implementation columns.** `builtin:coding`,
`builtin:stepwise-coding` and `builtin:brainstorming` now declare
`todo,in-progress,in-review,done,archived` — **no `triage`**. So the
entry column is `todo`, the former `triage → todo` graph hop no longer
exists (there is no boundary to cross), and the replan rebound
(`executor.ts:4392`, via `resolveReboundColumnFor`) targets `todo`.

Verified by resolving each built-in IR and printing its column ids, not
inferred from the failure text.

**B. `maxPostReviewFixes` was raised 3 → 10** via
`DEFAULT_MAX_POST_REVIEW_FIXES` (`builtin-workflow-settings.ts:555`).
Two files still pinned 3.

| File | Before | After |
|---|---:|---:|
| `workflow-graph-optional-step-fix` | 5 failed | **39 passed** |
| `builtin-workflows-lifecycle` | 3 failed | **94 passed** |
| `agent-tools-intake-column` | 2 failed | **4 passed** |
| `workflow-settings-fallback-alignment` | 1 failed | **3 passed** |

`pnpm test:gate` **726 passed**; lint clean; census unchanged (test
files only).

## Three choices so these don't re-break on the next rename

Swapping `"triage"` for `"todo"` everywhere would have worked and been
wrong in three places:

1. **The replan log assertion no longer embeds a column id.**
`executor.ts:5366` *interpolates* the resolved column into the message,
so a literal there pins a column name inside prose — guaranteed to break
again. It now matches the message shape plus the attempt/budget counter,
while the destination column stays pinned by the `moveTask` assertion in
the same test.
2. **The budget case drives off the imported
`DEFAULT_MAX_POST_REVIEW_FIXES`.** That constant exists *because* the
declaration default and two inline literal `3`s had already drifted
apart once — its own comment says raising the declaration alone "would
have left every unset-settings path on the old value". A third copy in a
test would repeat exactly that mistake.
3. **`agent-tools-intake-column`'s two guards are RE-PINNED, not
deleted.** They hold the default workflow's landing column stable and
fired on an intended change, so they still have a job. Deleting them
removes the only check; leaving them on `triage` pinned a column that no
longer exists.

## What I deliberately did not touch

`builtin-workflows-lifecycle` has **18** expectations and only **3**
were failing. I changed only those three: the other 15 pass unchanged,
which proves those workflows genuinely still declare `triage`. A blanket
rename across the file would have broken them — the same trap that bit
me earlier in `starved-refinement`, where renaming a shared fixture
default broke a test that had been passing.

Similarly in `workflow-settings-fallback-alignment`: case (b), which
scans engine source for literal `?? <n>` fallbacks, **already passed** —
there are no literal fallbacks left for that key because the read sites
import the constant. Only the human-readable audit table had lagged.
That table is the drift detector, so updating it keeps it honest rather
than pinning a value the product abandoned.

## Still red in this area, not in this PR

`executor-fast-mode-workflows` (1) and `executor-prompt` (3) remain.
They are not column- or budget-drift; `executor-prompt`'s three call
`execute()` directly and raise a real design question (whether
`execute()` should carry its own pause guard, or whether those
direct-call assertions should retire) that I am not answering silently
in a test-repair PR.
2026-07-30 04:28:50 -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
dbb53eebaa test(cli): four suites broke on mocks that predate the PG cutover and U11 (12 red → 0) (#2702)
## What was red

`project.test.ts` (8), `extension.test.ts` (2),
`project-lock-retry.test.ts` (1), `extension-workflow-tools.test.ts` (1)
— **12 failures** on clean main, all from full-suite shard 4/4. **No
product defect in any of them.**

### Cause 1 — an incomplete mock that the fail-soft catch disguised (9
cases)

`getTaskCounts` now **enriches** each row before counting, so a renamed
wip column still counts as running work (`FNXC:WorkflowLifecycleColumns
2026-07-30-12:20`). But `resolveWorkflowIrForTask` and
`enrichRunningAgentTaskShape` were absent from the `@fusion/core` mocks.
Calling an undefined function threw, and `getTaskCounts`'s
**deliberately fail-soft** catch converted that into `{ byColumn: {},
runningAgentCount: 0 }`.

So the failures presented as "task counts are zero" — indistinguishable
from a real counting bug. Worth flagging beyond this PR:
`project-lock-retry.test.ts` exists specifically to prove a transient
lock does *not* "silently masquerade as zero tasks" (FN-7731/FN-7740),
and an incomplete mock produced that exact symptom by a different route.
**That catch will hide the next real enrichment failure the same way.**

### Cause 2 — column-vocabulary drift (3 cases)

A column-less `createTask` used to land in `triage`; post-U11 it lands
in `todo`, the merged Planning column. Three assertions were pinned to
the old landing column, or to `COLUMN_LABELS` values **the product never
prints** — the stale mock said `"Triage"` / `"To Do"` where core says
`"Planning"` / `"Todo"`. A label assertion could pass against a string
that exists only in the mock. The mock's labels are now copied from the
real table.

## Measured

| Check | Result |
|---|---|
| the four files | 12 failed → **0** (107 passed) |
| whole `@runfusion/fusion` package | **122 of 126 files green, 1656
passed** |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint`, CLI `tsc --noEmit` | clean |

**Census unaffected — 721 both with and against my diff**, verified by
reverting the four files and re-measuring rather than assuming test
files are unscanned. (I also caught that `--strict` *writes* the
baseline locally; that write is reverted, so this PR does not touch the
baseline.)

## Not touched

`task.test.ts`'s **5 failures**. That file is claimed by
`feature/tool-permission-gates`, and its 5 are exactly the ones shard
4/4 reports — so shard 4/4 goes 17 → 5, with the remainder belonging to
that PR.

## Two judgement calls recorded in-file rather than made silently

**The `extension-workflow-tools` guard is RE-PINNED, not deleted.** It
asserted "a task created on `builtin:coding` lands in `triage`" — a
byte-identical regression guard that fired because the change was
*intended* (U11's merge). Deleting it would remove the only check that
this landing column stays stable; leaving it pinned a column the product
no longer declares. Re-pinning to `todo` keeps it doing its job.

**The broad-listing assertion now names the group that actually leads
the output.** It asserted a *second* group header inside a listing that
truncates to a text budget. That only ever passed because `triage` sorts
before `todo` in `COLUMNS`, so its group fitted before truncation —
fixture ordering masquerading as a bounding assertion. Per-column
coverage of every group is still asserted by the three filtered cases,
which do not truncate. I also seeded those 8 tasks in an explicit third
column so that case still covers a three-way filter instead of
collapsing to two groups.
2026-07-30 04:10:06 -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
a037ca93c7 test(engine): un-red the reliability-interactions tier (5 → 0) + a guard that could not fire (#2707)
## How this was found

Full-suite **shard 3/4** reports no `Tests N failed` summary at all —
its log ends mid-`@fusion/engine [1/2]` on a watchdog heartbeat, so the
red reads as infrastructure noise. It is not.

Facts that ruled out the infrastructure explanations, before touching
any test:

- watchdog budget is **1500s**; the engine slice died after **~6.5–10.5
min** (varies run to run) — not a timeout, and not a fixed one
- job `timeout-minutes: 60`, ran **9.7 min** — not the job timeout
- `concurrency.cancel-in-progress: false` — not cancellation
- annotation says **exit code 1**, not 137 — not an OOM kill
- across **four consecutive runs** the last file named is always
`reliability-interactions/explicit-duplicate-marker-sweep.test.ts`

Running that file locally reproduces real failures. The summary line is
simply missing from the CI log's final chunk (the last ~5s of output
never appears), which is what disguised a normal test failure as a
crash.

## Four causes, none a product defect

| File | Failures | Cause |
|---|---:|---|
| `explicit-duplicate-marker-sweep` | 2 | fixture seeds `column:
"triage"` |
| `starved-refinement-x-approval-gate` | 1 | same |
| `starved-refinement-x-triage-poll` | 1 | same |
| `executor-pending-review-skip-retry` | 1 | review handoff now passes
move **options** |

`triage` is no longer declared on any workflow post-U11, and these
sweeps filter by **role** — so cards seeded there carried no intake role
and the sweeps reported 0.

## Measured

| Check | Result |
|---|---|
| `reliability-interactions` | 5 failed → **0** (103 files, **530
passed**) |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint`, engine `tsc --noEmit` | clean |

Census unchanged — test files only.

## The real find: "honors the disable flag" could not fail

Forcing `enabled = true` in `resolveExplicitDuplicateMarkerTasks` — i.e.
making the sweep **ignore the disable flag entirely** — left all 16
cases **green**.

The fixture never set `triageDuplicateResolution`, so the sweep had no
resolution action to take and the duplicate survived whether the flag
was honoured or ignored. The assertion held for a reason unrelated to
the test's name. It had been red only because of the column literal,
which would have made "fix the literal, go green" a repair that left a
guard guarding nothing.

Setting the resolution mode makes that same mutation delete the
duplicate and the case fail. Verified both directions:

| | mutation applied |
|---|---|
| before | 16 passed — **guard cannot fire** |
| after | **1 failed** / 15 passed |

Recorded in-file with the measurement, so nobody strips the setting back
out as redundant.

## Two assertions strengthened rather than relaxed

- The disable-flag and failed-delete cases now assert the column is
**unchanged from a value read before the sweep**, rather than equal to a
literal. They cannot pass because a seed happened to land where the
assertion looked, and they survive the next column rename. The
invariants those cases own are "the flag stops the sweep" and "the task
whose delete threw survives" — the column id was always incidental.
- The handoff move asserts its **provenance** (`nodeId:
"review-pending-handoff"`, `preserveProgress: true`) instead of the
`expect.anything()` its siblings in that file use, so a move to the same
column by another path cannot satisfy it.

## Still open for whoever owns CI

Shard 3/4's log loses its final chunk, which is why a plain test failure
presented as a crash and stayed unexplained across at least four runs.
Anyone triaging full-suite from shard conclusions alone will keep
mis-reading this one; the failure has to be reproduced locally to be
visible.
2026-07-30 03:59:13 -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
af058b0276 test(core): two delete suites still modelled the deleted SQLite path (15 red → 0) + a possibly-lost dependents gate (#2697)
## What was red

`task-delete-caller-attribution` (13 failed) and
`task-delete-nonblocking-cleanup` (2 failed) on clean `origin/main` —
**15 failed / 8 passed**. Every case threw the same thing:

```
TypeError: store.deleteTaskBackend is not a function
```

`deleteTaskImpl` / `deleteTaskIfImpl` are now **thin delegators** onto
the PostgreSQL backend; the SQLite arms both fakes modelled were deleted
(`FNXC:SqliteDualPathCleanup 2026-07-26`). One fake was matching raw SQL
strings (`UPDATE tasks SET "column" = 'archived'`). Nothing reached the
logic under test.

Rewritten onto the PG path using the same three mocked persistence seams
`task-delete-notice.test.ts` already uses — that file is green on main,
so this is the established pattern here, not a new one. The **real**
backend impls are wired in rather than stubbed, so the delegation is
what carries the behaviour.

## Measured

| Check | Before | After |
|---|---|---|
| the two files | 15 failed / 8 passed | **21 + 3 passed** |
| all 6 core delete/archive files | — | **67 passed** |
| `pnpm test:gate` | — | **726 passed** |
| `pnpm lint`, core `tsc --noEmit` | — | clean |

**Load-bearing, not decorative:** removing the lineage gate from
`deleteTaskBackendImpl` makes the gate test fail. My first attempt at
that proof was worthless and I caught it — the patch didn't apply
because the block appears twice in the file, so the "3 passed" I got
back proved nothing. Retargeted to the occurrence inside
`deleteTaskBackendImpl` and it failed correctly.

## Deleted rather than repaired

`task-delete-nonblocking-cleanup`'s two original cases asserted that
delete **schedules branch cleanup**. That behaviour no longer exists:
`_scheduleDeleteBranchCleanup` has exactly **one** reference in the tree
— its own definition — and the only live `store.cleanupBranchForTask`
call is the **archive** path (`archive-lifecycle-2.ts:306`).

Reshaping the fake until those passed would have pinned behaviour the
product does not have. The file now covers the gates that *did* survive:
idempotent re-delete, the lineage-child gate, and exactly one
`task:deleted` audit row.

The dead `_scheduleDeleteBranchCleanup` is left in place — deleting
source is a separate change from fixing tests, and it is already
unreferenced so it is inert. Flagged for whoever wants the cleanup.

## FLAGGED, not guessed — a possibly-lost safety gate

The removed version asserted `TaskHasDependentsError` when deleting a
task that other live tasks depend on. **That error is neither imported
nor thrown anywhere in `archive-lifecycle-2.ts`** — the PG backend
raises only `TaskHasLineageChildrenError`, `TaskNotFoundError`, and
`TaskSelfDeleteError`.

Two readings, and it is a product question rather than a test fix:

1. the delete path now **rewrites** dependency references (there is a
`removeDependencyReferences` option and a `rewriteDependentsForRemoval`
impl) and blocking was dropped deliberately; or
2. the gate was **lost in the cutover**, and a task can now be
soft-deleted out from under its dependents.

Asserting either shape would encode a guess, so the question is recorded
in-file next to the tests. If it is (2), that is a data-integrity
regression worth its own fix — I did not want to bury it in a
test-repair PR.

## One of my own assumptions, corrected in-file

I asserted `deleted.deletedAt` was populated on the returned task and it
failed. The PG backend returns the **pre-delete snapshot** — its own
comment says so, because the lifecycle emit and the audit row both need
the previous column. The comment now records that, so nobody "fixes" the
impl to satisfy the wrong expectation.

Census unchanged (722) — test files are not scanned.
2026-07-30 03:47:20 -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
3d28e264c1 test(self-healing): three suites seeded a column id the product stopped declaring (7 red → 0) (#2695)
## What was red

`self-healing-advanced-triage`, `self-healing-agent-link-drift`, and
`self-healing-starved-refinement` — **7 failed / 19 passed** on clean
`origin/main`. All three fail the same way: the sweep returns `0`
recoveries where the test expects `1`.

## Cause

Those three sweeps were converted from `listTasks({ column: "triage" })`
to **role** filters (`filterByPreWipRole(..., ["intake"])`). Each store
fake has no workflow-selection readers, so the sweep resolves the
**default IR** — in which `triage` is not a declared column at all. The
seeded cards carried no intake role, the filters returned no candidates,
and the sweeps did nothing.

The product change was correct; the fixtures were asserting against a
column id the product had stopped declaring. `self-healing.ts` even
warns about this exact shape in its own comment: *"Converting a
predicate while leaving its source query on a literal produces a sweep
that LOOKS converted and does nothing."* The fixtures are the mirror
image — a seed on the old literal makes a correctly-converted sweep look
broken.

**Verified, not assumed.** I resolved the default IR and printed the
flags rather than reasoning from column names:

```
todo        {"intake":true,"hold":true,"resetOnEntry":true}
in-progress {"countsTowardWip":true,"abortOnExit":true,"timing":true}
in-review   {"mergeBlocker":true,"humanReview":true,...}
done        {"complete":true}
archived    {"archived":true,"hiddenFromBoard":true}
```

`todo` is the intake column post-U11 (the merged Planning column).
`triage` does not appear.

## Measured

| Check | Before | After |
|---|---|---|
| the three files | 7 failed / 19 passed | **26 passed** |
| all 40 `self-healing-*` files | 3 failed files / 7 failed tests | **40
passed / 695 tests** |
| `pnpm test:gate` | — | **726 passed** |
| `pnpm lint` | — | clean |

Restoring the deleted column id reintroduces the failures, so the seeds
are load-bearing rather than cosmetic.

**Pre-existing, untouched:** the same 9 vitest *"unhandled errors"* and
the identical non-zero exit appear on clean main. Verified by reverting
only these three test files and re-running — unrelated to the fixtures,
so flagged rather than folded in.

## One deliberately surgical spot

In `starved-refinement` only the starved refinement's own seed moved. My
first pass renamed the file's default seed and **broke a test that was
passing** — the auto-approve-all case calls `recoverApprovedTask`
directly (no role filter) and asserts a move **into** `todo`, so seeding
it in `todo` makes that move degenerate. I reverted and moved one seed
instead.

That leaves a real question I did **not** answer: post-U11 intake and
hold are one column, so a `triage → todo` move may no longer encode
anything. Deciding that means changing what the test is *about*, which
is a behaviour judgement on someone else's assertion. **Flagged in-file,
not guessed.**

Also worth noting for whoever converts the remaining backlog: line 43 of
that file pairs a `triage` seed against an explicit `todo` seed as a
contrast. U11 collapsed those two columns, so the contrast the fixture
was drawing no longer exists in the product — not a defect, but any
fixture built on intake-vs-hold being distinct is now suspect.

Census unchanged (722) — test files are not scanned by the census.

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

## Summary by CodeRabbit

* **Tests**
* Updated self-healing workflow test scenarios to reflect the current
intake column.
* Improved test coverage for triage, agent-link drift, and starved
refinement handling.
* Added documentation clarifying workflow column and role-based
filtering assumptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 03:25:06 -07:00
gsxdsm
c6c947c1c2 test(fleet): prove resolved traits beat a matching legacy id (#2694)
Review finding on #2688, landing separately because the helper set
merged in #2685 (so the test file is on `main`) and #2688's branch is
checked out in another worktree.

## The gap

My false cases for the four new role helpers all passed a
**non-matching** id:

```ts
expect(isCompleteColumnRole(undefined, "shipped")).toBe(false);
```

A broken `trait || legacyId` implementation satisfies that just as well
— no trait **and** no id match, so `false` either way. Those cases prove
the fallback fires. They do not prove **precedence**.

## The discriminating shape

Trait says `false`, id says `true`:

```ts
expect(isCompleteColumnRole({ complete: false }, "done")).toBe(false);
expect(isArchivedColumnRole({ archived: false }, "archived")).toBe(false);
expect(isWipColumnRole({ countsTowardWip: false }, "in-progress")).toBe(false);
expect(isReviewColumnRole({ mergeBlocker: false, humanReview: false }, "in-review")).toBe(false);
```

Flags-first returns `false`; an OR returns `true`. That is the only
shape separating the two implementations, and it is the one that matters
in production — **a resolved column whose trait is explicitly off must
not be overridden by its name.**

**Mutation-verified:** rewriting `isCompleteColumnRole` as
`flags?.complete === true || columnId === LEGACY_COMPLETE_COLUMN_ID` now
fails with `expected true to be false`. Under the previous cases it
passed.

## Worth naming

This is the same one-sided-test defect I have been flagging in other
people's work, in mine. The helpers were already correct — only the
evidence was weak, which is the harder version to catch, because
everything is green and the assertion *looks* thorough.

It generalises to the fleet: any converted site tested only with "no
flags, non-matching id" proves the fallback and nothing about
precedence.

## Verification

`columnRoles.test.ts` **14 → 15**. `pnpm test:gate` green (10 / 158 /
487 / 71). `pnpm check:lifecycle-columns` exits 0. `pnpm lint` clean.

Test-only; no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 03:22:04 -07:00
gsxdsm
dfbab18fd5 fix(scheduler): a renamed wip column held NO file-scope lease — two agents could edit the same files (#2693)
## The defect

`activeScopes` is the file-scope lease registry the dispatch path reads
(`scheduler.ts:2167`) to decide whether a candidate overlaps work
already in flight. Two column-id literals kept it empty on any board
whose columns are renamed:

1. the lease loop gated on `task.column !== "in-progress"`;
2. `shouldHoldActiveFileScopeLease` keyed **both** its branches on
`in-progress` / `in-review`, so it returned `false` for *every* card on
a renamed board.

Forty lines above that loop, the same sweep resolves `countsTowardWip`
from the workflow IR for capacity arithmetic. **The scheduler was
simultaneously right about capacity and wrong about leases.**

Consequence: a second task sharing a file scope **dispatched instead of
queueing** — two agents editing the same files, which is precisely what
`groupOverlappingFiles` exists to prevent.

## Measured, differential

Same workflow *shape* under two vocabularies with identical traits; only
the column ids differ, so any difference is attributable to a surviving
literal. No renamed id collides with a legacy one, so a surviving `===
"in-progress"` cannot pass by luck.

| | default vocabulary (control) | renamed vocabulary |
|---|---|---|
| fix reverted | queued on lease ✓ | **dispatched into the wip column**
✗ |
| fix applied | queued on lease ✓ | queued on lease ✓ |

`2 of 3 fail` reverted → `3 of 3 pass` applied. The control passes on
**both** sides, so a change that breaks overlap protection generally
cannot hide behind this test.

I checked the test wasn't vacuous before trusting it: instrumented the
run to print the actual `moveTask` calls, and confirmed the renamed case
really produced `[["FN-CAND","building"]]` — a genuine dispatch — rather
than the candidate simply never being considered. Both failure modes
look identical in the assertion.

## Why optional booleans, not a flags object

`shouldHoldActiveFileScopeLease` is **exported** and shared with the
self-healing / repair paths (`self-healing.ts:4488`, `:5406`) — its own
comment says those "must use this same predicate so stale
`overlapBlockedBy` cleanup does not preserve blockers the scheduler
would ignore". So the role questions became optional parameters that
**default to today's literals**: a caller that resolved the traits
passes the answer, a caller that has not gets exactly current behaviour.
No existing call site changes meaning, and no dependency on #2690.

## Verification

| Check | Result |
|---|---|
| scheduler / capacity / hold-release / overlap / self-healing | **59
test files green** |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint`, engine `tsc --noEmit` | clean |

`self-healing-advanced-triage`, `-agent-link-drift`,
`-starved-refinement` are **7 failed / 19 passed both before and after**
— verified pre-existing on clean `origin/main` by reverting only
`scheduler.ts` and re-running. Flagged, not fixed, and not in scope
here.

## Census

**722 → 721**, `scheduler.ts` 28 → 27. Baseline re-recorded in the same
commit.

To be precise about what that −1 is: the *loop* literal is gone, while
the two literals **inside** the predicate remain by design as the
documented defaults. So this is not "scheduler is now trait-aware" — it
is one site, plus the seam that lets callers be.

## Merge-order note

**#2690 also records `scheduler.ts` 28 → 27**, converting a *different*
site (`isWipColumnTask`'s hand-rolled flags-first copy, `:1690`). The
two are independent and do not double-count: if both land,
`scheduler.ts` is **26**, and whichever merges second will conflict on
`scripts/lib/lifecycle-column-census-baseline.json` and must re-record
to 26 rather than resolve to 27. Flagging so the merger does not take
one side blindly.

## Still broken, flagged for an owner

The **in-review** half. `activeScopes` is also populated for review-lane
cards via `t.column === "in-review"` (`scheduler.ts:1751`, `:1757`), and
this PR leaves those literals in place: the sweep's flags map holds only
`countsTowardWip`, so no review-role answer is available to pass in.
Fixing it needs the flags-object change in #2690, after which the same
optional parameter added here carries it. Until then a renamed review
column still holds no lease.
2026-07-30 03:19:02 -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
ae23be79f7 fleet: scheduler.ts 28 → triaged (NOT converted) + repo-wide reachability measurement — the work order sorts on a number that doesn't predict convertibility (#2687)
## Claim

`packages/engine/src/scheduler.ts` — the largest **unclaimed** cluster
(28). Triaged, **not converted**, for the reason below. Census
unchanged: **722 → 722**. No baseline movement is claimed, because
nothing was converted.

## Why not converted

Three of us independently hit the same wall on our first file — #2683
and #2684 (`self-healing.ts`), #2685 (helper coverage). This measures
the whole backlog **once** so the remaining workers don't each pay that
cost.

Two constraints gate conversion. Neither is visible in the per-file
counts the work order sorts on.

### 1. Location — the helpers aren't importable from 80% of the backlog

`isIntakeColumnRole` / `isPreImplementationColumnRole` /
`isHoldColumnRole` live in
`packages/dashboard/app/utils/columnRoles.ts`, a dashboard-**app**
module.

| Location | Guards | Share | 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 |
| plugins | 6 | 1% | no |

**150 of 722 (20%)** can call them at all. Widening the helper *set*
(#2685, correctly) does not move this number — it's the module's
location, not its coverage. Core already exports `resolveColumnFlags`,
so a core-side predicate module would be *the same* abstraction made
reachable, not a new one. It is a prerequisite for the other 80% and
**not sufficient** — see below.

### 2. Flag scope — the binding constraint

A role predicate needs resolved trait flags. Most guards run in
functions handed a bare task row with no IR to resolve from. Threading
one in changes a signature and its call graph: a **behavior change, out
of scope**.

File-level proxy over the 572 non-dashboard guards: **339** in files
that reference an IR/flags resolver, **233** in files with none.

**That proxy overstates convertibility, and the overstatement is the
finding.** Reachability varies *within* one file, so a file-level
verdict is unusable. In my claimed cluster:

| Site | Context | Convertible? |
|---|---|---|
| `scheduler.ts:1690` | `resolveWorkflowIrById(...)` +
`resolveColumnFlags(c)` in the same block | **yes** |
| `scheduler.ts:231` | `isLegacyDependencySatisfied(dep: Task \|
undefined)` | no — task only |
| `scheduler.ts:341` | `shouldHoldActiveFileScopeLease(...)` | no — task
only |

So "convert the file" is not a unit of work that exists in this backlog,
and the rule *"the baseline must shrink by exactly your converted
count"* cannot be satisfied per-file until the count is per-site.

## A guard that must be skipped, not guessed

`packages/core/src/task-merge.ts:254`:

```ts
if (!options.skipColumnIdentityCheck && task.column !== "in-review") {
```

The parameter is `Pick<Task, "column" | "paused" | ...>` — no IR,
deliberately. The in-source FNXC comment records that callers who *have*
resolved the `merge-blocker` trait pass `skipColumnIdentityCheck` rather
than spoofing `{ ...task, column: "in-review" }`. The trait-aware path
already exists *beside* this literal.

Converting it wouldn't remove a legacy id — it would delete the fallback
the option was introduced to make explicit. **Flagged and skipped.**

## Suggested census upgrade (not done here)

Emit per-site whether trait flags are resolvable in the enclosing scope.
That turns the work order from "largest file" into "largest
**convertible** cluster" and makes baseline shrinkage predictable. I did
not touch `scripts/lifecycle-column-census.mjs` — it is the shared
instrument and changing it unannounced would invalidate everyone's
in-flight before/after numbers.

## Method correction worth propagating to every fleet worker

Claim-collision scans must compare a branch to its **merge base**, not
to `origin/main`. `git diff origin/main origin/<branch> -- <file>`
reports a difference when the branch is merely *stale* (the file didn't
exist at its base) — it flagged dashboard and CLI PRs as touching engine
E2E files. I nearly skipped a free cluster on that false signal.

`feature/code-organization-wave17` is excluded from collision checks:
**1556 files, 150 commits behind main, already `DIRTY`**. It must rebase
wholesale regardless, and counting it as a claim marks *every* cluster
in the backlog as taken.

Docs-only — no source, no test, no census change.
2026-07-30 02:53:18 -07:00
gsxdsm
da77e61118 A rate-limited provider kept getting hammered — the executor and merger lane checks were still literals (#2672)
`taskUsesProvider` resolves a task's **active lane** to decide which
providers it is running on. The **planner** half was converted to traits
— its own note in that function describes this exact failure and says it
was fixed — and the **executor** and **merger** halves were left as
`task.column === "in-progress"` / `=== "in-review"`.

So on a renamed board an actively-executing card resolved **no
providers**, and a provider rate limit never paused it: the engine kept
sending work to the limited provider with that card.

## Measured

Renamed board (`building` = wip, `checking` = review), limit triggered
by a peer:

| lane | before | after |
|---|---|---|
| executor | `["FN-TRIGGER"]` | `["FN-TRIGGER","FN-PEER"]` |
| merger | `["FN-TRIGGER"]` | `["FN-TRIGGER","FN-PEER"]` |

The trigger was paused either way — but only through the
*always-include-the-trigger* fallback, and **that is what made the hole
quiet**: one task always gets paused, so the behaviour looks like it
works.

Resolved from the **same per-workflow IR cache** the planner lane
already uses, so this adds no reads. Both halves fail soft to the legacy
literal, so an unresolvable workflow behaves exactly as before.

## How it was found — the part worth keeping

A scan for *"legacy literal within a few lines of a role-resolved call"*
flagged this file. That is the same heuristic that produced #2670, and
it is now 2 for 3.

The first thing I suspected here was the `done`/`archived` terminal
filter. I wrote that fix, and **its revert stayed green.** That is not a
dead end — chasing *why* it would not go red showed the lane check
already excludes finished cards, so the terminal literal there is
genuinely redundant, and the real defect was one line over in the lane
check itself. **A revert that stays green is information: either the
guard is vacuous, or you are looking at the wrong line.**

I dropped the unprovable change and kept the provable one. The
`done`/`archived` filter is deliberately unchanged, with that reason
recorded in the test.

## Revert proofs, isolated

- wip lane back to the literal → **1 of 55 fails** (the executing peer)
- review lane back to the literal → **1 of 55 fails** (the merger peer)

Three paired cases pass under both, so "pause everything with that
provider" cannot pass for "resolve the lane": a card parked in the
renamed planning lane is **not** paused on an executor limit, a wip card
is **not** paused on a merger limit, and the legacy vocabulary still
pauses peers when no workflow resolves.

## Verification

- 55/55 `usage-limit-detector`; `pnpm test:gate` **71/71**; engine
typecheck clean; `pnpm lint` clean
- census unchanged at 748 — the fail-soft literals remain by design,
which is why the count is not the measure of this fix

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:50:30 -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
d75de0fb80 fleet: complete the column-role helper set — 680 of 722 guards had no helper to convert to (#2685)
**Fleet blocker, measured before claiming a file — this unblocks 94% of
the work order.**

## The gap

The work order says *"conversion pattern: the existing role helpers
ONLY; no new abstractions"*. Measured against the census, those helpers
cover **42 of 722** backlog guards:

| role | guards | helper? |
|---|---:|---|
| `intake` / `hold` → `todo` | 42 | ✅ `isIntakeColumnRole`,
`isPreImplementationColumnRole`, `isHoldColumnRole` |
| `in-review` | 200 | ❌ |
| `done` | 195 | ❌ |
| `archived` | 147 | ❌ |
| `in-progress` | 138 | ❌ |

**680 guards — 94% — had no helper to convert to.** Every fleet worker
hits this on their first file. I hit it claiming `TaskCard.tsx`, whose
42 guards are `done` 13, `archived` 12, `in-progress` 9, `in-review` 7,
`todo` 1.

## Why this is not "a new abstraction"

It is the **same** abstraction — flags-first, legacy id only as the
documented no-metadata fallback — applied to the roles it did not yet
cover. The alternative is inlining a flags-plus-fallback expression at
680 sites, which recreates exactly the copy-paste drift these helpers
exist to remove: **three inline copies in `ListView` are what started
this file.**

Widening `ColumnRoleFlags` threads nothing new through any call site.
Callers already pass these flags — `TaskContextMenuColumnFlags` declares
all of them — the interface had only *declared* the two the earlier
helpers needed, so the type was dropping the rest on the floor.

## A correction I made mid-change

My first draft of that comment claimed the flags were "already carried
on `ColumnRoleFlags`". `tsc` disproved it immediately — `complete`,
`archived` and `countsTowardWip` did not exist on the type. Corrected
rather than quietly patched, because that claim *was* the justification
for calling this a completion rather than an addition.

## Each distinction is asserted, not just documented

- **`isCompleteColumnRole` does not count `archived`** — an archived
card is finished but not *completed*; surfaces counting throughput would
double-count it.
- **`isWipColumnRole` keys on `countsTowardWip`**, the same flag
capacity arithmetic uses, so a board cannot have a column that counts
toward WIP for capacity but not for this predicate.
- **`isReviewColumnRole` accepts either `mergeBlocker` or
`humanReview`** — separable traits, but every converted caller asks "is
this card in review", for which both qualify. A caller needing one and
not the other should read the flag directly rather than widen this.

Both directions are asserted in every case, so a helper returning
`false` unconditionally cannot pass.

## Verification

`columnRoles.test.ts` **10 → 14**. `pnpm test:gate` green (10 / 158 /
487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p
tsconfig.app.json` clean. `pnpm lint` clean.

No census movement — this adds capability, converts nothing. My
`TaskCard.tsx` conversion (42 → 0) follows on top of it.

No changeset: internal helpers, no user-facing change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:47:26 -07:00
gsxdsm
3e80dcb8ef fix(test): a Vite prefix-match alias silently unresolved a core subpath (greens full-suite shard 1) (#2686)
## What

`full-suite.yml` shard 1 on main fails with **zero test failures** — it
dies on a resolution error:

```
Failed to resolve import "@fusion/core/task-delete-attribution" from "packages/dashboard/app/api/client.ts"
```

**Root cause.** Vite string aliases match by **PREFIX**. So `find:
"@fusion/core"` → `core/src/index.ts` rewrites
`@fusion/core/task-delete-attribution` into
`core/src/index.ts/task-delete-attribution`, which cannot resolve. The
narrower subpath alias has to come *first*.

The module exists and *is* correctly declared in
`packages/core/package.json` exports — this is purely a test-config
trap, and `packages/dashboard/vitest.config.ts` already documents it in
a comment. Six configs alias `@fusion/dashboard` (whose
`app/api/client.ts` imports that browser-safe leaf) while lacking the
narrower alias, so they inherited the trap. This carries the same
one-line pattern to all six.

## Measured

`dependency-graph` — the project actually red on main:

| | Test files | Tests collected |
|---|---|---|
| before | 3 failed \| 17 passed | 147 |
| after | **20 passed** | **180** |

**33 tests were never collected** — neither passing nor reported as
failing. That is the part worth flagging: an unresolved import removes
tests from the run silently, and the shard's own summary printed no
`Tests N failed` line at all, which is why this red looked like
infrastructure noise rather than a real defect.

No regressions: `reports` 110, `cli-printing-press` 41,
`compound-engineering` 317, **gate 726** — all green. `pnpm lint` clean.

`@fusion/desktop` is `1 failed | 264 passed` **both before and after**;
verified pre-existing on clean `origin/main` by reverting just that one
config and re-running. Cause is `@fusion-plugin-examples/roadmap` entry
resolution, unrelated — **flagged, not fixed.**

## Deliberately not changed

Engine's *second* `@fusion/core` alias (the `.gate-bundle/core.mjs`
entry) is untouched: that lane bundles core on purpose, and pointing it
at source would defeat the isolation the gate bundle exists to provide.

## Full-suite triage this came out of (for whoever owns the rest)

Reading the four red shards of the last completed run on main
(`30523568756`):

| Shard | Real cause | Owner |
|---|---|---|
| 1/4 | **this PR** — resolution error, 0 test failures | — |
| 2/4 | 23 failed: `store-wedge-resolution.pg`,
`central-archive-secrets`, `task-delete-caller-attribution`,
`task-delete-nonblocking-cleanup` | #2669 / #2675 cover the first two |
| 3/4 | **watchdog SIGKILL** mid-`@fusion/engine [1/2]` — no test
failures, no summary | unowned |
| 4/4 | 17 failed, all in `@runfusion/fusion` CLI (`project.test.ts` 8,
`task.test.ts` 5, `extension.test.ts` 2, +2) | unowned |

Two of the four shard reds contain **no failing test at all**, so
"main's full-suite failure count" cannot be read off the shard
conclusions — it has to be read off `Tests N failed` summary lines, and
shards 1 and 3 emit none.
2026-07-30 02:47:14 -07:00
gsxdsm
bb30d37e59 fleet: self-healing.ts 110 → scoped (NOT converted) — sync workflow reads make this cluster unsafe to batch (#2683)
Claiming the largest unclaimed cluster per the work order, then
**handing it back sized rather than half-converted.** Docs only; census
unchanged (722 / triage 0).

## The cluster

`packages/engine/src/self-healing.ts` — **110 guards**, largest single
file in the order.

```
by column:   in-review 48 · in-progress 20 · done 17 · todo 13 · archived 12
by receiver: column 100 · to 7 · from 3
```

## Why the mechanical conversion is unsafe here

**The engine has no synchronous way to learn a task's workflow.**
`resolveTaskWorkflowIrSync` returns the DEFAULT IR for every task in
production — `getTaskWorkflowSelection` returns `undefined`
unconditionally (a PG-cutover stub), so the reader always takes its
`!workflowId` branch. It is typed non-optional, so **no caller can
detect the substitution.**

A conversion routed through it: compiles, reads better than the literal,
**counts as census progress**, and is wrong for every custom workflow,
silently. That is strictly worse than leaving the literal — the literal
is at least honest about being one. It is the "guard that cannot fire"
pattern wearing better clothes, and the ratchet would score it as a win.

The correct form uses `resolveTaskLifecycleColumns(store, taskId)`
(async, store-aware), which needs resolved lanes **in scope per
method**. Sampled sites (926, 932, 984) do sit in `async` methods so it
is reachable — but that is a per-sweep restructuring, not a per-line
substitution, and these sweeps iterate task lists, so a naive per-task
resolve turns one sweep into N store reads.

**In-tree precedent:** `triage.ts` `discoverReadyPlanningTasks` solved
this exact problem — store-free `couldBeCandidate` prefilter, bounded
(8) concurrent resolve over the survivors, decision stays synchronous
over a resolved map. Any batch here should follow that shape per sweep.

## Recommended split, by SWEEP not by column

110 sites cannot honour *"census before/after, baseline shrinks by
exactly the converted count"* while also restructuring six-plus sweeps
in one PR.

1. **the review/merge sweeps** (`in-review` 48) — largest, and the one
where a wrong lane silently changes **merge eligibility**. First and
alone.
2. **WIP/rebound sweeps** (`in-progress` 20, `todo` 13).
3. **terminal sweeps** (`done` 17, `archived` 12) — read
`complete`/`archived`; most mechanical of the three.
4. **the 10 `from`/`to` sites** — these are MOVE-transition arms, not
task-column reads. Different question (*"is this transition into a
review lane?"*), so they must not ride along with the `task.column`
work.

## Why I am not doing item 1 myself

I am near the end of a long session — this is the same context in which
I produced a confidently-wrong structural finding earlier today
(retracted in #2667, where I trusted a hand-rolled brace counter over a
comment in the file). A 48-site restructuring of the merge-eligibility
sweeps is exactly the work that should not be done by a worker in that
state, and the fleet rules' *flag-and-skip* discipline is the right call
over guessing.

**What a fresh worker gets from this PR:** the site census, the
async-scope survey, the hazard with its root cause, the in-tree pattern
to copy, and a four-way split with the risky piece isolated. That is the
expensive part of the job already done.

## Fleet rule this cluster proves, worth adding to the brief

**Never resolve a workflow synchronously in a converted guard.** Use
`resolveWorkflowIrForTaskWithProvenance` (branch on `source`) or
`resolveTaskLifecycleColumns`; if neither is reachable at the site, flag
and skip.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:44:27 -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
30e287a29e fix(test): decouple a second logger assertion from log formatting (#2681)
Second instance of the defect fixed in #2675 — found by applying the
same attribution pass to the **core** suite's long-red files rather than
counting them.

## The defect

`withSeverityMarker` (`logger.ts:31`) deliberately wraps every message
in a machine-readable severity marker so the TUI log pane can colour by
level: an `fnlvl=<level>` marker plus a `[core-merge-policy]` subsystem
tag ahead of the real text.

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

## Two instances is a pattern

`toHaveBeenCalledWith` on a logger is brittle **by construction** in
this codebase, because decorating the message is the logger's entire
job. Any assertion pinning an exact logged string will break the next
time the format changes — and both instances found so far were long-red,
i.e. nobody noticed they had stopped testing anything.

Worth a lint rule or a shared helper if a third appears. I have not
added one for two instances.

## What is preserved

Warn-once semantics and the requirement that the warning names both the
legacy value and its replacement are unchanged and still fully asserted.
Only the exact-prefix coupling is removed.

**Mutation-verified:** deleting the `severityAuditLog.warn` call in
`merge-policy.ts` fails with `expected "warn" to be called 1 times, but
got 0 times`. A contains-check that passed because it matched nothing
would be worse than the brittle assertion it replaces.

## Still red in the core suite, not addressed here

Two neighbours in the same cluster, both needing an owner's context
rather than a guess:

- `settings-parity.test.ts` — `expected [ 'testMode', 'voiceInput',
…(15) ] to deeply equal [ …(14) ]`. A settings key now appears in
**both** global and project scope without being listed as intentional.
That is either a real scoping mistake or a stale allowlist, and the
difference matters.
- `workflow-ir-settings.test.ts` — `expected 10 to strictly equal 3` on
the moved-key catalog.

Both are genuine signals, not noise. Flagging rather than guessing, same
as the funnel decision on #2674.

## Verification

`settings-defaults.test.ts` **39/40 → 40/40**. `pnpm test:gate` green
(10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `pnpm
lint` clean.

Test-only; no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:30:20 -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
87a4dbdc60 test(u9): a release-leg E2E fixture that diagnoses itself (same defect found 3x independently) (#2678)
## What

The planned-spec release-leg fixture defect has now been diagnosed
**three times independently** — #2634 (`workflow-lifecycle`), #2643
(`workflow-merged-board`), and again in `workflow-planning-lane`. Each
time it cost real time, because it presents as a *scheduler* bug rather
than a fixture bug.

The mechanism: `createTaskWithReservedId` leaves a bootstrap seed (`#
<id>\n\n<description>`), `isUnplannedForExecution` (`hold-release.ts`)
refuses to move an unplanned card out of any intake- or hold-trait
column, and the sweep reports `held: [{ reason:
"move-rejected-or-no-slot" }]` while releasing nothing. That is **the
gate working.**

This extracts the write into `seedPlannedSpec`
(`_planned-spec-fixture.ts`) which **self-checks against the real
predicate the gate uses** (`isUnplannedSeedPrompt`, imported — not
restated) and throws naming the fixture as the cause. Three call sites
converted; their ~12-line comments collapse to a pointer, so the
diagnosis and both dead-end hypotheses live once, at the seam that
causes them.

## Measured (real PostgreSQL, not estimated)

| Check | Result |
|---|---|
| 3 converted families + new ratchet | **41 pass / 41** |
| Guard neutered (`if (false)`) | **4 of 5** ratchet tests fail |
| Original defect reproduced faithfully | **3 of 7** planning-lane tests
fail |
| `pnpm lint`, engine `tsc --noEmit` | clean |

**The ratchet fails on the original defect.** It drives the two real
seed shapes through the production builders (`buildBootstrapPrompt`,
`buildRefinementSeedPrompt`) rather than local imitations, so it cannot
keep passing if the seed shape drifts — which is exactly the drift the
fixture absorbs. The one test that survives the neutered guard is the
happy path, which should not move.

**The fixture is load-bearing, not decorative.** Proven by reproducing
the defect end-to-end: production `buildBootstrapPrompt` on the created
row with the guard bypassed → 3 planning-lane tests fail, including its
own control case.

A false mutation is worth recording, because it nearly produced a wrong
"not load-bearing" verdict: a *hand-written* seed passed 7/7.
`isUnplannedSeedPrompt` is **byte-equality** against a prompt built from
the task's own title/description, so only a byte-exact seed reproduces
it. A *missing* prompt does not either — `isUnplannedForExecution`
catches the read error and returns `false`.

## Deliberately not converted

`workflow-rebound-family`'s `PROMPT.md` write. It is a
content-preservation artifact asserted byte-identical across a re-home,
not a release-gate fixture; the helper would overwrite the very bytes
under assertion.

## Reversible decisions taken (per standing authority)

- **`opts.content` seam.** Exists so the ratchet can drive a known seed
and prove the throw fires. Without it the guard could only ever be
observed passing — the "guard that reports success without checking
anything" failure mode. Documented as test-only.
- **`title`/`description` optional.** The check is shape-based: both
recognised seed forms are `<heading>\n\n<description>` with no section
headings, so the written spec cannot match either for *any* description.
Omitting them cannot mask a positive, and a test asserts that directly.
- **`merged-board`'s spec text lengthened** to match the other two (both
were already non-seed, so behaviour is unchanged; verified by the
41-pass run).

## Method correction worth propagating

My collision scan was wrong and I nearly acted on it. `git diff
origin/main origin/<branch> -- <file>` reports a difference when a
branch is merely **stale** (the file did not exist at its base), so it
flagged dashboard and CLI PRs as touching engine E2E files. Diffing
against each branch's **merge base** is correct. Re-run under the fixed
method: all five files here are uncontested, and
`feature/code-organization-wave17` genuinely does touch
`packages/engine/src/triage.ts` (so that one stays hands-off).

## Lane

`.pg.test.ts` under engine-default, `pgDescribe`-skipped without
PostgreSQL — the merge gate is unaffected. Throwaway per-file database,
never port 4040, no temp-root walk.
2026-07-30 02:19:17 -07:00
gsxdsm
07c29757a3 The archived half of the terminal pair, end to end (#2568's fix landed first and is better) (#2670)
**Live on `main`.** `parkCompletedBlockedTask` opens with *"is this card
already finished?"* and answered it with:

```ts
if (task.column === "done" || task.column === "archived") return false;
```

On a renamed board neither matches, so the guard was **inert** — and
inert here is not a missed rescue, it is active damage: the very next
block rebounds the card to its planning lane. **A completed card sitting
in a renamed complete or archived column was moved backwards out of
it.**

## Why this survived two reviews

#2644 (mine) converted the **rebound** half to resolve its target by
role. This **terminal** half stayed a literal. A role-resolved rebound
behind a name-matched guard means the renamed board takes the rebound
and never the guard — the same half-conversion shape as the evacuation
branch, except the two halves were owned by different changes, so
neither review saw both.

Worth keeping as a review heuristic for the remaining conversions:
**when a converted site sits next to an unconverted one, the conversion
can make the neighbour worse — and a diff showing only one of them looks
complete.**

## Relationship to #2568

The fix exists there, stranded four deep in a stack whose bottom (#2544)
has not merged, so nothing in that chain has reached `main`. This
re-lands **only** the guard, directly against `main`. I have noted it on
#2568 so its author can drop that hunk rather than resolve it twice; I
took no other part of that PR (its extraction and the terminal-pair
ownership change are still theirs).

## Deliberate choices

- **Fail-soft to `["done", "archived"]`** — an unresolvable workflow
behaves exactly as the literal pair did.
- **An unclassifiable column is NOT terminal.** Being unable to prove a
card is finished must not be the same as proving it is; that is what
keeps a stranded card moving.

## Revert proof

Restoring the literal pair fails **2 of 5** — the renamed complete and
archived cases. Three paired cases pass under the revert, so neither
"never park" nor "always park" can pass for resolving the lanes: a
genuinely mid-pipeline card is still parked, the legacy pair still
answers when no workflow resolves, and an unclassified column still gets
the park.

## Verification

- 5/5 new; 22/22 with `executor-rebound-already-there` and
`executor-task-done-blocked`
- `pnpm test:gate` **71/71**; engine typecheck clean; `pnpm lint` clean
- census: `executor.ts` **87 → 85** column guards; baseline re-recorded
in the same commit

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

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

* **Reliability**
* Improved handling of completed/blocked tasks when workflow column
naming changes, ensuring tasks aren’t parked or moved incorrectly in
archived or non-terminal lanes.
* **Tests**
* Added Vitest coverage to verify terminal-lane decisions, including
cases where workflow resolution occurs mid-operation and lane placement
changes during the wait.
* **Maintenance**
* Updated lifecycle column census baseline numbers to match current
workflow usage.
<!-- 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:16:30 -07:00