Commit Graph

12751 Commits

Author SHA1 Message Date
gsxdsm
cfe47b3754 chore(plugins): delete the superseded fusion-plugin-even-cards (#2790) (#2988)
Closes #2790 by finishing a decision that was already made and written
down.

## The issue's premise was wrong, including as I filed it

I raised this as "a package accidentally missing from
`pnpm-workspace.yaml`." It wasn't. `CHANGELOG-archive.md:9596`:

> Consolidate Even Realities plugin support into
`fusion-plugin-even-realities-glasses` and **remove
`fusion-plugin-even-cards` from the active workspace package list to
avoid duplicate user-facing integrations.**

The removal was deliberate, for a stated reason. The directory is what
got left behind. That also rules out the option I had been weighting
first — adding it back would undo a shipped consolidation and re-create
the duplicate integration it was removed to prevent.

## Unreachable by every path

| check | result |
|---|---|
| `pnpm-workspace.yaml` globs | no — never installed or built |
| CLI bundle list (`packages/cli/tsup.config.ts`) | no — 0 mentions,
while seven other plugins are named |
| runtime `plugins/*` directory-scan discovery | none exists — plugins
are enumerated explicitly |
| `package.json` | `private: true` — never published |
| imports outside its own directory | none |
| kept as a docs/authoring example | no — zero references in `docs/` or
any root `*.md` |
| successor in the workspace | yes —
`fusion-plugin-even-realities-glasses` |

## It was also polluting two ratchets

Dead code in a scanned tree is worse than dead code: both censuses are
**source-text scanners**, so they counted debt in files no build or
typecheck covers. Nobody could retire those entries through a
normally-verified refactor, and they inflated how much of the remaining
debt looked actionable.

Both baselines regenerated, and I checked each diff rather than trusting
the totals:

| baseline | change |
|---|---|
| `lane-wiring` | 26 → 23 sites, 21 → 20 files — **one entry removed**,
`board-routes.ts: 3` |
| `lifecycle-column-census` | exactly its two `board-cards.ts` entries |

Neither regeneration tightened anything unrelated — worth confirming
explicitly, because `lifecycle-column-census.mjs --strict` **writes**
its baseline as a side effect and could have folded an unrelated drop
into this commit.

**Verified:** lane-wiring, SQL-literal and FNXC gates all exit 0 after
the deletion; lint clean. 15 files removed.

## Why I went ahead

I said twice I would not delete this unilaterally. What changed is that
the trade-off dissolved — once the consolidation decision turned out to
be documented and the "is it a teaching example?" question answered by a
docs grep, there was nothing left to decide, only to execute. The
deletion is git-reversible and the standing guidance is that reversible
calls are mine to make.

Fourth time today a thing I filed as "needs someone else's judgement"
turned out to have its answer already in the repository. Cheap habit
worth keeping: before deferring, grep for whether the judgement has
already been made.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:35:25 -07:00
gsxdsm
684c324084 fix(gate): the lane-wiring census counted { reviewColumns: undefined } as wired (#2984)
## What

Follow-up to the finding @gsxdsm left on #2981, taking the direction
offered there.

Both arms of this census asked whether the lane argument was
**present**, not whether it carried anything:

```ts
isThing(task, { reviewColumns: undefined });   // property present -> counted as wired
isThing(task, undefined);                      // arity satisfied  -> counted as wired
```

The callee receives exactly what it received before: nothing. The seam
is still inert, the board still reads the legacy vocabulary — the census
just stops saying so, which is the one failure mode a ratchet must not
have.

Same defect as the positional one #2981 fixes in
`check-inert-flag-seams`, one level in. The two gates are complementary
by design — this one owns the options-object and default-valued shapes
the other is structurally blind to — so the hole had to be closed in
**both**. Neither covered it, confirmed by probing each with a control
shape.

## The direction I took, since the review raised it as a contract
question

> *tightening just relocates the dishonesty into whichever spelling
survives... especially as I have already spent three attempts learning
that heuristic tightening here trades false positives for worse false
negatives.*

Agreed, which is why this is the narrowest possible reading rather than
a heuristic:

**Only a literal `undefined` / `void 0` counts as empty.** Shorthand `{
reviewColumns }` forwards a variable whose value is not knowable from
syntax, and treating it as unwired would flag every correct forwarding
wrapper in the tree — exactly the false-positive wave that trains
readers to skip a gate. Same for a call expression, a conditional, or
anything else with a value at runtime.

That keeps the rule provable from syntax alone. It doesn't relocate the
dishonesty so much as remove the one spelling that is *demonstrably*
empty; anything ambiguous still counts as wired, so the gate stays
conservative in the direction that matters.

## No tests existed for this census

`check-lane-wiring` and `lane-wiring-census.mjs` had no unit coverage on
`main`, so both rules ship with tests rather than resting on the probe
that found them.

## Measured

| check | result |
|---|---|
| clean `main` | exit 0, unchanged — all five gates green |
| now caught | property spelled `undefined` · property spelled `void 0`
|
| correctly **not** flagged | a real value · shorthand forwarding · a
call-expression value · a middle `undefined` with a real argument after
it |
| new suite | **8 tests**; reverting both rules fails **exactly** the 3
positives, negatives hold |

## Not done here, deliberately

The second finding on #2981 — `computeBlockerFanoutMap`'s dashboard
wrapper dropping all four lane options, so the fanout display reads
legacy literals on a renamed board — is **not** in this PR. Confirming
the diagnosis: `useBlockerFanout.ts`'s `UseBlockerFanoutOptions`
declares only `staleHighFanoutAgeThresholdMs` and forwards only that,
and all three dashboard call sites (`Board`, `TaskDetailModal`,
`ExecutorStatusBar`) have the same gap.

One correction to how it's framed, though: core already has the right
seam for it. `classify` and `escalationClassify` are documented there as
*"the only correct option on a multi-workflow board"*, precisely because
the set-shaped options assume a column id means the same thing
everywhere. So the fix should thread **per-task classifiers**, not
resolved column-flag sets — otherwise it reproduces the union read that
this program's own learnings doc lists as the fourth failure shape.

What's genuinely undecided is where a per-task role answer comes from in
a sync render path: `Board` holds `columnDef.flags` for the *selected*
workflow only, and the dashboard has no per-task resolver hook. That's
the design call, and it's dashboard-batch work rather than a mechanical
edit — so I've left it for whoever owns that batch rather than guessing
at it inside a gate PR.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:07 -07:00
gsxdsm
a57f6699b3 fix(cli): fn task list never printed cards in renamed columns (#2986)
## `fn task list` never printed cards in renamed columns

```ts
for (const col of COLUMNS) {              // the legacy six ids
  const colTasks = tasks.filter((t) => t.column === col);
```

A task in a workflow-defined column matches **no iteration**, so it is
not printed. This is not a wrong label or a wrong glyph — **the card is
absent**, and the output reads as a shorter, healthy board rather than
as a bug. On a fully renamed board the command prints nothing but the
header. `COLUMNS` also still contains `triage`, which U11 (#2515)
deleted.

## Found where the previous author left it

The `DELIBERATE-LITERAL` note directly above this loop is correct about
its own glyph, and it named the deeper bug rather than hiding it:

> NOT claimed as trait-resolved, and the deeper bug is left alone:
because the loop iterates the legacy enum, a card in a workflow-renamed
column is not rendered AT ALL. That is the R8/U10 surface change […] and
a far bigger fix than this glyph.

It also predicted the coupling: *"If this ever iterates
workflow-resolved columns, that difference becomes live and the right
answer is a trait lookup, not this."* So both move together — once the
loop can yield a custom id, the terminal test **must** stop being an id
comparison. Fixing only the loop would leave a renamed done-lane
rendering as active work.

## Two deliberate choices

**Lanes come from the tasks, not from a resolved IR.** A board can span
several workflows and therefore has no single column list, and a card
must never depend on a resolution succeeding in order to be *visible*.
Legacy ids keep their familiar order and labels; anything else follows
alphabetically, so output is deterministic.

**Terminal lanes are resolved**, via
`resolveProjectColumnsForRoles(TERMINAL_ROLES)` — that is a display
question with a real answer, and this function is async with a store in
hand. Best-effort: a failed resolve falls back to the legacy pair rather
than failing the command, and an unresolved custom lane renders as
*active*. Showing a finished card with the wrong glyph is a far smaller
error than the blank board this replaces.

## Coverage, and its limit stated plainly

The lane-selection decision is extracted to an exported seam and tested
there. It is **not** end-to-end: `runTaskList` resolves a real project
context and ends in `process.exit`, so driving it would need the
mock-the-world shell `docs/testing.md` tells us to avoid when a narrower
seam exists. The call site is held by the compiler instead — the loop's
only source of lanes is that function. I have written this in the test
file rather than leaving it implied, because "5 passed" on a helper
could otherwise read as proof of the command's behaviour.

Reverted — the seam returning `[...COLUMNS]`, which is exactly what the
loop did — **all 5 cases fail**:

```
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'backlog', 'building', 'checking' ]
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'todo', 'shipped' ]
      Tests  5 failed (5)
```

## Verification (measured)

- **86 passed / 3 files** — new suite plus `bin.test.ts` and
`pr-merge-review-lane.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (26, none
added), `check-fnxc-future-dates` — green
- `pnpm check:changesets` — clean; changeset included (`patch`), since
`packages/cli` is the published `@runfusion/fusion` and this is
user-facing
2026-07-30 23:29:56 -07:00
gsxdsm
d541c3154e fix(dashboard): retire the dead .agent-detail-overlay CSS (#2915) (#2985)
Closes #2915. `.agent-detail-overlay` has had **no renderer** since
FN-8619 moved Agent Detail onto `FloatingWindow`, whose `modal` host
owns the scrim (`.floating-window-overlay--modal`). Four CSS sites plus
one inert mobile `@media` rule.

## Why this sat unfixed, and what unblocked it

I filed this earlier and deliberately did **not** delete the CSS,
because two *passing* guards pinned a selector list naming the class:

```
dashboard-overflow-containment.test.tsx:295
mobile-horizontal-pan-containment.test.ts:90
  ".modal-overlay:not(.confirm-dialog-overlay),\n  .agent-detail-overlay,\n  .agent-dialog-overlay,\n  .workflow-output-modal-overlay"
```

Neither list mentions `.floating-window-overlay--modal`. If that list
were the mechanism keeping modal scrims inside mobile horizontal-pan
containment, then FN-8619 moved every migrated modal's scrim out from
under the guard and the dead entry was **masking a live defect** —
deleting it would have been the wrong move twice over.

I said at the time I couldn't settle it without rendering at a phone
breakpoint. **That was wrong — it is answerable by reading**, and I only
went back because the same mistaken conclusion cost me a day on the
Planning coverage in #2982.

**The containment lockdown is global**, on the mobile `html, body` block
in `styles.css`:

```css
@media (max-width: 768px) {
  html, body {
    overflow-x: hidden;
    overscroll-behavior-x: none;
    touch-action: pan-y;
  }
```

`FloatingWindow.css` says so itself at its mobile breakpoint — *"Mobile
keeps the global `styles.css` pan-y lockdown so the dashboard cannot
drift"*. The per-overlay list only reasserts it for overlays that are
themselves scroll containers. Migrated modals are covered by the global
rule, so **no hole, and no masked defect**.

## Verified the guards still guard something

The risk in editing a pinned selector string is turning a real guard
into a string-equality formality:

| state | result |
|---|---|
| after this change | 15/15 pass |
| global lockdown broken (`touch-action` / `overscroll-behavior-x`
removed from `html, body`) | **2 failed / 13 passed** |

They fail on the mechanism, not the text.

## Scope note

Two of the four `styles.css` sites are **grouped selectors shared with
`.agent-dialog-overlay`, which is still live**
(`NewAgentDialog.tsx:415`). So this is a selector-list edit, not a block
deletion — easy to get wrong in a bulk sweep, which is why it is called
out here and in the FNXC note replacing the deleted rule.

The retired mobile rule set `padding: 0; align-items: stretch` on the
overlay. Not a lost feature: `.floating-window-overlay` is `position:
fixed; inset: 0` with no flex context, so those declarations had nothing
to act on — FloatingWindow positions the panel by geometry.

**Verified:** 120/120 across `agent-modals-mobile`,
`core-modals-mobile`, `AgentDetailView.core`, and both containment
guards; `tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit
0.

Dead-CSS removal with no behaviour change, so no changeset. Main health
while I was here: engine **11475 passed / 0 failed**.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:24:33 -07:00
gsxdsm
94df80bbd6 test(dashboard): restore the Planning project-switch invariant at its new home (last lane red) (#2982)
Clears the **last red test in the dashboard lane** (measured on `main`
at `41af5e5dbd`: `1 failed | 11180 passed`) and restores a leak-class
invariant that has had no assertion since FN-8619.

## What was wrong

FN-8619 moved Planning out of `MainContent` (its branch returns `null`)
into `PlanningKeepAlive`, mounted by `App`.
`MainContent.planning-project-remount.test.tsx` kept asserting there, so
it could only fail — and nothing asserted the invariant at the new
location. **Deleting the project id from `App.tsx:1956`'s key, or
loosening the gate at `:1954`, failed no test.**

That invariant is not cosmetic. Before the project-keyed host, Planning
kept a running plan's stream, selected session and sidebar list from the
**previous** project, and persisted its session under the **new**
project's storage key.

## I withdrew this test once, and that was my mistake

I wrote it earlier, mutated each guard, saw it stay green both times,
concluded it was vacuous, and handed the problem back. **That reasoning
was wrong.**

App defends this invariant **twice, independently**:

1. the `planningEverOpenedProjectId === currentProject.id` gate, which
unmounts the host for a project that never opened Planning; and
2. the project id inside the host's `key`, which forces a remount
instead of reconciling A's live instance under B.

Either alone upholds the contract. So a single-guard mutation *should*
leave a correct test green — that is defence in depth working, not a
hole. The probe that settles it is breaking **both**:

| mutation | result |
|---|---|
| both guards intact | **pass** |
| key only (drop project id) | pass |
| gate only (`!== null`) | pass |
| **both** | **fail** |

I had mutated guards, not the invariant, and mistook redundancy for
vacuity. Worth recording because it is the opposite error from the one I
have been making all day: I have caught four genuinely vacuous guards by
mutation, and that success made "survived a mutation" read as "proves
nothing" when the honest reading was "the system has a second defence."

## The assertion, and why it is `gone OR different node`

An earlier draft asserted the subtree must be **absent** after the
switch. Wrong: `planningViewActive` stays true, so the latch re-arms for
the new project and a fresh host is expected. Both outcomes satisfy the
real contract — *project A's instance is not reused* — which is what
`subtreeForA.isConnected === false` pins.

## Scope

- Planning case moves to `App.test.tsx`, which owns both halves of the
guarantee.
- `MainContent.planning-project-remount.test.tsx` keeps its **Chat** and
**Missions** cases — those still render from MainContent — and gains a
note saying where Planning went, so it is not re-added there.

**Verified:** `App.test.tsx` 143/143,
`MainContent.planning-project-remount` 2/2, `tsc -p tsconfig.app.json` 0
errors, lint clean, FNXC gate exit 0. Test-only; no product code
touched, no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:16:37 -07:00
gsxdsm
6a465e1006 docs(workflow-learnings): probe harnesses lie more often than the gates do (#2983)
## What

Probing four gates with unimagined shapes this session (#2979, #2980,
#2981) produced **two rounds of silently invalid results** — both from
the harness rather than the instrument, and both agreeing with what I
expected, which is why neither was noticed on the spot.

1. **`node gate.mjs | tail` then `echo $?` reads *tail's* exit status.**
Every probe reported "caught". The gate was in fact failing on `main`
for an unrelated reason, so the runs proved nothing. That fictional
evidence nearly shipped a double-counting change to the SQL gate.
2. **A gate that lists files with `git ls-files` cannot see an untracked
probe file.** Six census probes reported "missed" — including the shape
the census is explicitly built for, which was the tell.
Filesystem-walking gates (`check-sql-column-literals`,
`check-inert-flag-seams`) see untracked files; the census does not.

The rule that catches both in one step, now written down:

> **A probe run needs its own control.** Include one shape the
instrument is known to catch and one it must not flag. If the known-good
shape doesn't come back caught, stop — you're measuring your harness.

Worth stating plainly because the two failure modes have opposite costs:
a probe that wrongly reports *caught* retires a real hole; one that
wrongly reports *missed* sends you rewriting an instrument that was
already correct.

## Two measured negative results, recorded so nobody re-runs them

Added to the existing "Surfaces that were checked and are CLEAN"
section:

| shape | population |
|---|---|
| `switch (task.column)` with legacy `case` labels | **0 sites** |
| a legacy id hoisted into a single const, then compared | **1 site —
and it is correct code** |

The one site is `self-healing.ts:2992`, which seeds `let holdColumn =
"todo"` as its documented legacy floor and then overwrites it from
`resolveLifecycleColumns(...).hold`. The census is right not to flag it;
a naive version of this probe reports it as a defect.

The second shape was worth measuring precisely because **the same shape
had a real population in SQL** — it's what #2980 fixed. It did not
transfer. Population is a property of how people write that particular
kind of code, so each instrument has to be measured on its own rather
than by analogy to a sibling that just turned something up.

## Why this is docs and not a gate change

The census's comparison-only scope is adequate for this codebase: every
blind shape I could construct has an effectively empty real population.
Demanding new detection would have forced a large baseline change across
the program's central instrument for **zero defects** — the same mistake
as filing "48 uncounted sites" that the existing section already warns
about.

Docs only. No code, no baselines touched. All five gates green.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:16:26 -07:00
gsxdsm
16921fc518 fix(engine,core): role resolution was half-done in two shared lifecycle predicates (surfacing family + file-scope leases) (#2975)
The three surfacing sweeps stopped reporting anything for a card resting
in a board's **second** review or hold column.

A lifecycle role is a **trait**, and any number of columns may carry it.
The shared runner resolved it with `resolveLifecycleColumns()[role]` —
**first match** — then gated on it:

```ts
const roleColumn = lifecycle?.[spec.role];        // FIRST column carrying the trait
if (task.column !== resolved.roleColumn) continue; // everything else dropped
```

A workflow that splits human sign-off from the merge lane has two review
columns; one that parks dependency-blocked cards separately has two hold
columns. Cards in the second got **no stale-paused-todo, no
stale-paused-review, no in-review-stalled** diagnostic — silently, with
no error, on all three sweeps at once.

## The second bug hiding inside the fix for the first

Resolving membership but still reading `roleColumns[0]`'s declared
`recovery` applies the **merge lane's** threshold to a card sitting in
the **sign-off** lane. Each card's policy now comes from its own column,
and one of the new cases fails if it doesn't: the first role column
declares a policy that suppresses the signal, the card's own column
declares one that fires.

## Reverted

| | |
|---|---|
| **6 of 12** new cases fail | `fires for a card in the SECOND column
carrying its role` and `reads the recovery policy of the card's OWN role
column` — × 3 sweeps |
| the other 6 pass either way | non-regression halves: still fires for
the FIRST role column, still does **not** fire for a card outside every
role column. Membership must widen the gate, not move it. |

The pre-existing 45 cases were all green throughout — the
single-role-column fixture could not express the case, which is why the
table-driven file that exists to stop these three sweeps drifting apart
never caught it.

## Verification

`pnpm test:gate` 161 + 13 + 487 + 71 · surfacing family 57 · core
stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates ·
lane-wiring · changesets — all green.

## Note

`holdColumns` was missing from the lane-wiring vocabulary, so the gate
could not see that argument dropped. Added in the same commit.

While reviewing, I found and measured **two problems in #2974** (comment
posted there): six of its newly-visible sites are `satisfies`-wrapped
false positives, and baselining them means deleting a real
`reviewColumns` argument keeps the count unchanged and the gate green;
and its baseline predates #2970, re-opening the slot that PR closed.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved stale-card detection across all applicable review and hold
columns.
* Cards are now surfaced using the policies configured for their
specific lifecycle column.
* Cards outside matching lifecycle columns are no longer incorrectly
surfaced.
* Preserved existing fallback behavior when no lifecycle columns are
configured.

* **Tests**
  * Added coverage for workflows with split review and hold columns.

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

---

## Second commit: the same predicate, half-converted
(`shouldHoldActiveFileScopeLease`)

Folded in here rather than stacked — same file, same class, and a
stacked PR on an unmerged base is not mergeable. Reversible; say the
word and I'll split it.

`shouldHoldActiveFileScopeLease` is the **scheduler's** lease predicate,
shared with the self-healing repair paths deliberately so the two cannot
disagree about who holds a file-scope lease. Its two role answers are
optional parameters defaulting to the legacy ids. The scheduler's own
call sites were converted to pass resolved answers; self-healing's two
were not:

```ts
const isWipColumn    = options?.isWipColumn    ?? task.column === "in-progress";
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
```

On a renamed board neither branch matches, so the predicate returns
`false` for every card. The scheduler kept the lease; self-healing saw
none, cleared `overlapBlockedBy`, and **released a dependent to edit
files another agent still holds** — the outcome `groupOverlappingFiles`
exists to prevent.

Membership comes from the wip/review sets each sweep already resolved a
few lines above, so this adds no reads.

**Reverted:** both new cases fail with `overlapBlockedBy` = `null` — the
release itself, not a proxy. The pre-existing legacy-column case in the
same file passes either way, because `in-progress` satisfies the literal
default; that is exactly why it never caught this.

Lane-wiring baseline re-recorded `9 -> 7` in the same commit (the
ratchet refused a stale allowance, as intended).

**Verification:** gate 161 + 13 + 487 + 71 · surfacing 57 · overlap-seam
+ scheduler-lease + query-blindness 79 · core stale-paused 20 · lint ·
census `--strict` · sql-literals · fnxc-dates · changesets — green.
2026-07-30 23:13:34 -07:00
gsxdsm
2411699756 fix(gate): passing undefined for the lane answer read as supplying it (#2981)
## What

Continuing the #2979 discipline — probe a ratchet with shapes its author
did *not* have in mind — applied to my own inert-seam gate. Four probes,
three got through. Two turned out to be the sibling gate's job. This is
the one that's nobody's:

```ts
resolveSomething("KB-1", undefined)
```

The seam is a trailing optional parameter, so the gate asked how many
**arguments** a call site passes. Spelling the omission out satisfies
that count while the callee receives exactly what it received before:
nothing. The parameter is still inert, the board still reads the legacy
vocabulary — the gate just stops saying so.

Not an exotic spelling. It's what a partial wiring-up produces when
flags are threaded through an intermediate that has none to pass, and
what a mechanical positional edit produces when it fills argument slots.

## Missed by both gates — checked before touching anything

`check-lane-wiring` (#2966) covers the default-valued and options-object
shapes this gate is structurally blind to. I probed it first, and it
caught **both**, so the two remain genuinely complementary rather than
overlapping. But it counts arguments the same way here, so this shape
was uncovered by either.

| probe | inert-seam (before) | lane-wiring |
|---|---|---|
| omitted entirely | caught | — |
| default-valued param | missed | **caught** |
| options-object flags | missed | **caught** |
| explicit `undefined` | missed | **missed** ← this PR |

## The trim is trailing-only

A **middle** `undefined` still positions the arguments after it, so
those are real answers. That's the case that keeps the trim honest, and
it's pinned as a test.

## Measured

| check | result |
|---|---|
| clean `main` | exit 0, unchanged |
| now caught | explicit `undefined` · `void 0` · several trailing
undefineds |
| correctly **not** flagged | a real trailing value · a middle
`undefined` with a real value after it |
| gate's own suite | **12 → 18 tests**, all green |
| reverting to the raw argument count | fails **exactly** the 3
positives; the negatives hold |

## One note on the fourth gate

`check-fnxc-future-dates` went red on this branch — on my own comments.
I'd stamped them `2026-07-31`, which is tomorrow. Fixed by correcting
the stamps to today, not by re-recording the baseline; the baseline
already tolerates some pre-existing future stamps and adding mine to it
would have been appeasement. Worth noting that the gate earned its keep
against the person who has been writing the other gates.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:08:08 -07:00
gsxdsm
968af0822c gate: the lane-wiring ratchet did not scan plugins, dashboard/app, or any .tsx (#2978)
## The new gate re-opened a blind spot the old one had already learned
about

`check-lane-wiring.mjs` (#2966) scanned four roots and only `.ts`:

```js
const ROOTS = ["packages/core/src", "packages/engine/src", "packages/dashboard/src", "packages/cli/src"];
```

`unwired-lane-parameter-guard.test.ts` scans **six**, including
`packages/dashboard/app` and `plugins`, and its FNXC note records
exactly why:

> `plugins` is scanned, and its absence was half of a real escape. […]
an unwired `completeColumnsByTaskId` sat on `main` unreported: the guard
found 0 across 1753 files, and 0 again across 2114 once plugins were
added, because the shape was invisible too. **Fixing either alone would
still have missed it.**

That is the same trap here, and it needed **two** changes. Those trees
are overwhelmingly `.tsx`, which the file filter excluded — so adding
the roots without the extension would have scanned a handful of files
and reported a reassuring near-zero.

## What the widened scan found: 10 sites, in 8 files, audited not
blind-baselined

| site | verdict |
| --- | --- |
| `dependency-graph/GraphTaskNode.tsx` (`isTaskStuck`) | **real** —
`isTaskStuck` takes an optional 4th `columnFlags`; omitted,
`isWipColumnRole` falls back to the literal, so **no card on a renamed
board is ever shown stuck** in the graph |
| `dashboard/app/Lane.tsx`, `ListView.tsx` (`sortTasksForDisplayColumn`)
| **real**, dashboard batch |
| `dashboard/app/ModelSelectorTab.tsx` ×2
(`resolveEffectiveExecutor`/`Validator`) | **real**, dashboard batch |
| `dashboard/app/TaskDetailModal.tsx`
(`isNearDuplicateCanonicalInactive`) | **real**, dashboard batch |
| `even-cards/routes/board-routes.ts` ×3 (`boardToDeck`) | **cannot be
fixed in place** — deprecated plugin depending on `@fusion/plugin-sdk`
alone, with no resolution source |
| `even-realities-glasses/routes/board-routes.ts:141` (`boardToDeck`) |
**harmless by construction** — the `{ maxCards: 1 }` summary call slices
`active` to empty, so `terminalColumns` cannot change its output;
documented in `cards.ts` |

They are baselined rather than fixed because they span three other
batches. I did **not** fix the graph one despite it being my area:
wiring it needs the plugin prop contract to carry column flags, and the
plugin's own `dashboard-interop.d.ts` declares `isTaskStuck` with only
three parameters — so it crosses the dashboard↔plugin API boundary
rather than being a local change.

## Merge-order hazard, stated precisely

A **decrease** also exits 1 (`process.exit(1)` on the `decreased`
branch), and #2976 wires `packages/cli/src/commands/task-lifecycle.ts`,
which is present in this baseline. **If #2976 lands after this PR,
main's gate goes red** until the baseline is re-recorded.

It fails loudly rather than silently, so it is a chore not a risk.
Merging #2976 first and letting me re-record here is the cleanest order
— say the word and I will push the re-record.

## Verification (measured)

- `check-lane-wiring` — green, **19 known / none added** (was 9 across 4
roots)
- `unwired-lane-parameter-guard.test.ts` — **9 passed**, the older guard
is unaffected
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green

Gate/tooling only; no product file is touched.


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

## Summary by CodeRabbit

* **Tests**
  * Expanded lane-wiring checks to cover dashboard and plugin code.
* Added support for scanning `.tsx` files while excluding declarations,
tests, specs, and ignored directories.
  * Updated baseline coverage counts for the additional files.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:05:23 -07:00
gsxdsm
634d487c3e fix(gate): a lane id hoisted into a const evaded the SQL column-literal gate (#2980)
## What

In #2979 I argued a ratchet should be mutation-probed with shapes its
author did **not** have in mind, on the day it ships. Applying that to
my own gate: two of three probes walked straight through.

```ts
const LANE = "done";
sql`... WHERE "column" = ${LANE}`                       // MISSED

const LANES = ["in-progress", "in-review"];
sql`... WHERE "column" IN (${sql.join(LANES)})`         // MISSED
```

Both bind the query to the legacy vocabulary exactly as an inline
`'done'` does. An interpolation that wasn't a column reference collapsed
to the NUL sentinel, so the predicate dissolved before the matcher ever
ran.

**This is the shape a cleanup produces.** Hoisting a repeated string to
a named const reads as tidying, which makes it the likeliest way one of
these gets rewritten — and the gate would have gone quiet on a file that
changed only in punctuation. Third time this scanner has had that
failure (static-span join, element-access column ref, now this). The
array form isn't hypothetical: `IN ('in-progress','in-review')` was the
live workflow-analytics defect.

## The first version of this fix was wrong, and that's the useful part

Resolving *any* string-valued const double-counted the analytics files,
which build queries as:

```ts
const completedClauses = [`t."column" = 'done'`, "t.columnMovedAt IS NOT NULL"];
```

Those elements are SQL fragments **already counted where they're
written**. Resolving the const re-injected each into the outer template.
The three analytics files went `3/3/1` → `6/5/2` — and it read exactly
like a genuine find. Only **bare lane ids** are resolved now; the
fragment-array case is pinned as a test.

I also nearly shipped that version on a bad probe: `node gate | tail`
then `echo $?` reads *tail's* exit status, not the gate's. Every probe
reported "caught" while the gate was actually failing on main for an
unrelated reason. Worth repeating because the harness looked fine and
agreed with what I expected.

## Measured

| check | result |
|---|---|
| clean `main` | **22 sites, exit 0, unchanged** — no false positives
introduced |
| now caught | const string · const array via `sql.join` · as-const via
`inArray` |
| correctly **not** flagged | resolver-produced lanes · non-legacy ids ·
SQL-fragment array |
| gate's own suite | **26 → 32 tests**, all green |
| blinding the resolution | fails **exactly** the 3 new positive tests;
the 3 negatives still pass |

The negatives outnumber what feels necessary on purpose: eager
resolution is how this went wrong the first time, and the fragment-array
test is the one that would have caught it.

## Scope

Same-file `const` declarations only. Cross-file imports need a type
checker and a program-wide pass — a constant imported from another
module is **still invisible**, and `--list` output is where that gets
audited. Stating the boundary rather than half-resolving it and calling
the gate complete.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:59:56 -07:00
gsxdsm
41af5e5dbd fix(gate): the lane-wiring census could not see its own motivating case (#2956) (#2974)
#2966 shipped a gate that **cannot detect the defect named first in its
own header.**

`findLaneAcceptingFunctions` matched a lane parameter only when
`param.type` was a `TypeLiteralNode` — an inline `{ reviewColumns?: …
}`. But the real code declares these as interfaces:

```ts
export function getInReviewStallReason(
  task: Pick<Task, …>,
  context: InReviewStallContext = {},   // TypeReference — invisible
): InReviewStallSignal | undefined
```

so the function never entered `accepting` and none of its call sites
were examined.

### Measured, both directions

| | before | after |
|---|---|---|
| lane-accepting functions detected | 20 | **30** |
| `getInReviewStallReason` detected | no | **yes** |
| re-introduce #2956 (drop `reviewColumns` from one call site) | `none
added` — **passes** | **fails**: `reads.ts: 7 unwired now, baseline
allows 6` |

The gate now catches the thing it was built for.

### The baseline moves 10 → 24, and that number needs context

`10 unwired call site(s) across 8 files` → `24 across 15`. **No entry
was removed** — every previously-recorded file kept its count and 14
sites became visible for the first time:

```
core/task-store/reads.ts                        0 -> 6
engine/self-healing.ts                          2 -> 4
core/task-store/branch-and-pr-entities.ts       0 -> 1
core/task-store/task-update.ts                  0 -> 1
engine/scheduler.ts                             0 -> 1
dashboard/routes/register-task-workflow-routes  0 -> 1
cli/commands/dashboard-tui/bucket-mapping.ts    0 -> 1
cli/extension.ts                                0 -> 1
```

**These are newly VISIBLE, not newly broken** — they have been unwired
all along. I have **not** audited them, and recording them in the
baseline is not a claim that they are fine; it is the ratchet doing what
its header describes, since the census's own note says roughly half of
the original hits were legitimately unwired (identity proven by a
stronger means, sentinel columns, dead exports). Someone should walk the
14. Two stand out as worth a look first: **`reads.ts` at 6** is the file
#2956 was about, and **`scheduler.ts`** is a dispatch path.

Flagging rather than fixing, because wiring a call site that should not
be wired is its own defect and each needs the judgement call the census
header describes.

### Regression test

`packages/engine/src/__tests__/lane-wiring-census-named-types.test.ts`
pins the detector's shape — named interface, type alias, inline literal,
positional — against fixtures rather than live counts, so it does not
churn when someone legitimately wires a call site. Plus one anti-vacuity
case asserting the named-type arm is still load-bearing on real source
(`getInReviewStallReason` resolves in the live tree), so the fixtures
cannot pass while the tool has quietly stopped applying here.

**Mutation:** removing the `TypeReference` arm fails **4 of 5**.

### Also worth knowing

`findLaneAcceptingFunctions` still only visits
`ts.isFunctionDeclaration` at top level, so `export const fn = (ctx) =>
…` remains invisible. I checked — no exported arrow function currently
takes a lane argument, so nothing is missed today, and I left it rather
than widen the surface in the same change.

Resolved by **name across the corpus** instead of a type-checker
`Program`: these are plain source scans and a checker would cost a full
type-resolution pass for one lookup. Two same-named types merge, which
only ever widens what counts as wired — safe for a ratchet.

**Verified:** 5/5 new tests, `check-lane-wiring` clean at the new
baseline, lint clean, FNXC gate exit 0. Core suite on main is green
(4923 passed / 0 failed) — unrelated, but I had it running.


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

## Summary by CodeRabbit

* **New Features**
* Improved lane-wiring analysis to recognize named interfaces and type
aliases.
* Added support for wrapped configuration expressions and positional
parameters when detecting lane information.

* **Tests**
* Added comprehensive coverage for lane-wiring detection, including
named contexts and live-tree validation.

* **Chores**
* Updated baseline counts to reflect newly recognized application areas
and improved self-healing detection.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:54:31 -07:00
gsxdsm
0738fb1c8a test(a11y): the aria-label role guard was blind to the role word inside the t() default (#2979)
## What

[#2965](https://github.com/../pull/2965) fixed thirteen dialogs whose
accessible name restated its role, and shipped a source-scanning guard
so the next copy-paste could not sneak back in. Good fix, real ratchet —
its own header even documents catching one blind spot by mutation during
review.

It has a second one. The matcher looks for the role word at the **end**
of the `ariaLabel` value, which is where all thirteen had it. One
position over is invisible:

```tsx
ariaLabel={t("scripts.title", "Scripts dialog")}
```

That renders the accessible name **"Scripts dialog"** — identical
symptom, announced as *"Scripts dialog, dialog"* — but the value does
not END in the role word, because a `")` closes the call after it.

**Measured:** re-introducing this shape into `ScriptsModal` left the
shipped suite green at **13/13**.

## Why this shape matters more than the one already covered

The rendered label *is* the translator's default string. Whoever writes
the next modal naturally puts the word where the title lives, inside
`t()`, rather than appending it outside the call. The suffix form is
what the original thirteen happened to be; this is the form the
fourteenth takes.

## The fix

Every quoted literal that is actually rendered is checked with the same
matcher, not just the whole value.

i18n **keys** are excluded, or the guard fires on
`t("agents.onboarding.dialogLabel", "AI Interview")` — live in the tree
today, and it announces nothing of the sort. Key-shaped means dotted and
whitespace-free, which no real accessible name is. Keeping this narrow
is the whole difficulty: a scan that flags every translated title gets
deleted within a week.

## Measured

| run | result |
|---|---|
| baseline, unmutated `main` | **18/18 green** — no false positive
anywhere in the corpus |
| mutation A — role word inside the `t()` default | **1 failed / 17
passed** (was green before) |
| mutation B — original trailing-suffix shape | **1 failed / 17 passed**
— no regression |

Both defect shapes now fail the guard. Five matcher cases added, three
of them negative; the negatives are what keep the scan from flagging
every translated title.

## Note for the queue

**#2946 is fixed on `main` and can be closed** — I verified all thirteen
callers are clean there, the one grep hit being that i18n key. This PR
is about the guard behind it, not the fix.

Third time in this program an instrument has been blind to a case in its
own motivating class, now across three tools and three authors. The
pattern is not carelessness — each guard was checked against the shapes
its author had in mind. Mutation against a shape you did *not* have in
mind is the only thing that has caught any of them, which is an argument
for making it routine when a ratchet ships rather than when someone gets
suspicious later.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:51:45 -07:00
gsxdsm
be79fe0db6 fix(cli): PR merges silently never ran on a renamed board — the blocker was asked about in-review (#2976)
## PR merges silently never ran on a renamed board

`processPullRequestMergeTask` called its injected blocker with the task
alone:

```ts
if (getTaskMergeBlocker(task)) return "skipped";
```

So `options.reviewColumns` was undefined and the blocker's identity
check fell back to `task.column === "in-review"`. On a board whose merge
lane is named anything else it returns:

```
task is in 'checking', must be in 'in-review'
```

…which is truthy, so this function returns `"skipped"`. **Silently and
permanently** — nothing logs, nothing fails, the PR simply never merges.
`daemon.ts`, `serve.ts` and `dashboard.ts` all drain PR merges through
here, making this a third instance of the #2963/#2964 class ("merge
entry points unwired — merging was impossible on a renamed board").

Found via the baseline #2966 shipped:
`packages/cli/src/commands/task-lifecycle.ts` was a known-unwired call
site in it.

## Narrow resolution, deliberately

`resolveReviewColumns` is the **broad** set, and its own FNXC note warns
that a caller which admits on it *and then moves the card* will act on
cards the engine does not consider in review. This function merges and
moves to the complete lane — a state-changing admission — so it uses
`resolveMergeOrchestrationColumn`, the single lane the engine acts on.
That matches how `moves.ts` wires the same call.

Degradation is unchanged in both directions: `resolveWorkflowIrForTask`
substitutes the default IR rather than throwing, so a default board
resolves `in-review` and behaves identically; a v1-upgraded IR resolves
every role empty and keeps the documented legacy literal (covered by a
test).

## One shape choice worth flagging

The option is always **passed** and conditionally **valued**:

```ts
getTaskMergeBlocker(task, { reviewColumns: mergeLane ? new Set([mergeLane]) : undefined })
```

rather than making the whole argument conditional. These are identical
at runtime — the blocker treats an undefined `reviewColumns` exactly as
it treats absent options — but **only this shape is visible to
`lane-wiring-census.mjs`**, which matches an object-literal argument and
cannot see a ternary. I wrote the ternary first, and the gate still
reported the site as unwired; wiring a gate cannot check is how this
defect survived in the first place.

The gate then confirmed the fix and asked for the baseline in the same
commit:

```
[check-lane-wiring] unwired call sites decreased:
  packages/cli/src/commands/task-lifecycle.ts: 1 -> 0
```

Baseline re-recorded 9 → 8 in this commit, so the allowance cannot be
regrown into.

## Revert proof

**There was no test for this function at all** — that is why it went
unnoticed. Restoring only `task-lifecycle.ts`:

```
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, …(1) ]
AssertionError: expected 'skipped' not to be 'skipped'
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, undefined ]
      Tests  3 failed | 1 passed (4)
```

The one case that passes both ways is "still skips a card that is not in
any merge lane" — it guards against over-admission rather than proving
the fix, and I am not claiming it as coverage of the defect.

## Verification (measured)

- new suite **4/4**; with `pr-automerge-cleanup` **9 passed / 2 files**
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (8, none added), `lifecycle-column-census
--strict`, `check-sql-column-literals`, `check-fnxc-future-dates` —
green

**Changeset added** (`patch`). `packages/cli` is the published
`@runfusion/fusion` and this changes user-facing merge behaviour, so
AGENTS.md requires one. My first pass hedged and left it to a maintainer
— that was wrong, the rule is not discretionary, and it is now in the
branch.
2026-07-30 22:46:19 -07:00
gsxdsm
7fde4bb3ad fix(a11y): my #2965 gave six dialogs two elements with the same accessible name (#2977)
**This fixes a regression I introduced in #2965, found by re-running the
full dashboard lane on `main` rather than trusting the targeted runs I
did at the time.**

`AddNodeModal` and `ConnectNodeModal` are red on main:

```
→ Found multiple elements with the text of: Add Node
→ Found multiple elements with the text of: Connect to Node
```

### Cause

#2965 dropped the redundant `" dialog"` suffix from each
`FloatingWindow`'s `ariaLabel`. That was correct — `role="dialog"`
already conveys it. What I missed is that six of those modals **also**
put an `aria-label` with the *same* text on their own inner `<div>`:

```jsx
<FloatingWindow ariaLabel={t("nodes.addNode", "Add Node")} …>
  <div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
```

Before #2965 the two differed (`"Add Node dialog"` vs `"Add Node"`), so
`getByLabelText("Add Node")` matched exactly one element. Now both
match.

### Why the inner one goes, not the dialog's

Those inner labels sit on **role-less `<div>`s**, where assistive
technology ignores `aria-label` entirely — it was never conveying
anything to anyone. Removing it restores a single accessible name per
dialog and needs no test changes.

### Surface enumeration — four of the six were latent

Only two surfaced as failures; the other four have no test querying by
that name, so they would have shipped a duplicate accessible name
silently. Found by scanning every component for an inner `aria-label`
whose expression matches its own `ariaLabel` prop:

| modal | was it red? |
|---|---|
| `AddNodeModal` | red on main |
| `ConnectNodeModal` | red on main |
| `GroupTaskModal` | latent |
| `NodeDetailModal` | latent |
| `ScriptsModal` | latent |
| `WorkflowAddStepModal` | latent |

### Five more, deliberately untouched

`AgentDetailView`, `PlanningModeModal`, `SettingsModal`
(`role="region"`), `ScheduledTasksModal` (`role="listbox"`) and
`NewTaskModal` (`role="dialog"`) also carry their dialog's name on an
inner element — but those elements **have a role**, so the label is
meaningful rather than dead markup. A listbox named "Automations" inside
a dialog named "Automations" is redundant, not broken, and renaming it
is a UX decision rather than a cleanup. Left alone and recorded here.

**Verified:** 93/93 across `AddNodeModal`, `ConnectNodeModal`,
`NodesView`, `GroupTaskModal`, `ScriptsModal` and the #2965 aria guard;
`tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0.

Product-code change to a11y markup, so this is user-visible but needs no
operator-facing note — say the word if you want a changeset.

**Measured dashboard-lane state on main before this PR:** `3 failed |
11173 passed`. Two are these; the third is
`MainContent.planning-project-remount`, which belongs to #2420 and is
detailed there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:46:08 -07:00
gsxdsm
eb9cd431c1 fix(glasses): settings silently discarded the operator's own lane names, so notifications never fired (#2973)
## The operator configures their lanes, and the plugin throws it away

`TaskColumn` was the closed legacy union and `COLUMN_SET` gated every
settings read against it:

```ts
.filter((value): value is TaskColumn => COLUMN_SET.has(value as TaskColumn));
```

**`notifyOnColumns` is a free-text string array in the schema** (`type:
"array", itemType: "string"`) — the UI invites any lane name. So an
operator on a renamed board types `checking`:

1. `getNotifyColumns` filters it out — not in the legacy five
2. `columns.length === 0`, so it substitutes `DEFAULT_NOTIFY_COLUMNS` =
`["in-review"]`
3. their board has no `in-review`
4. `notifier.ts` builds `new Set(getNotifyColumns(...))`, and
`diffSnapshots` tests `notifyOnColumns.has(task.column)`

**No notification ever fires.** The feature is silently off while the
setting reads as configured, and nothing surfaces an error.

`quickCaptureDefaultColumn` had the same shape with an extra irony: it
was rewritten to `todo` *before* `normalizeCaptureColumn` saw it — and
that function already validates against the board's **declared** columns
and falls back to the workflow's own intake lane. The pre-filter
destroyed the operator's answer immediately before the code that could
have honoured it.

## Fix

Validation is now **structural** (non-empty string) rather than
**vocabulary-based**. `TaskColumn` mirrors core's `ColumnId`
(`LegacyTaskColumn | (string & {})`), so legacy ids keep autocomplete
while custom ids are admitted. `COLUMN_SET` survives only as the
quick-capture dropdown's suggestions, not as a gate.

**Deliberately not resolving the board's columns.** That needs a
board-columns endpoint `FusionApiClient` does not have — a new read, not
a rename — and that gap is already recorded in this file by an earlier
audit. This change is orthogonal to it: accepting operator input
requires no resolution at all, which is why it doesn't wait on the
endpoint.

**Trade-off, taken knowingly:** a typo'd lane is now honoured and will
match no card. That is the milder failure. Before, a *correct* custom
lane was discarded categorically, so renamed boards could not use the
feature at all; now the only broken case is one the operator typed wrong
and can see in their own settings.

## Two existing assertions asserted the defect

```ts
expect(getNotifyColumns({ notifyOnColumns: ["nope"] })).toEqual(["in-review"]);
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("todo");
```

I checked provenance before touching them: they date to the plugin's
original commits (FN-3738 / FN-3970), when the five ids genuinely were
the whole vocabulary. They carry no reasoning comment and no later
change defended custom-lane rejection as a contract — so this is a stale
assumption being corrected, not a peer's tested decision being
overwritten. Structural rejection (non-strings, blanks, whitespace-only)
and trimming are still asserted, because that part was always right.

## Revert proof

```
AssertionError: expected [ 'in-review' ] to deeply equal [ 'checking', 'shipped' ]
AssertionError: expected [ 'todo' ] to deeply equal [ 'todo', 'spaced' ]
      Tests  2 failed | 5 passed (7)
```

## Verification (measured)

- plugin suite — **196 passed / 19 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green

No changeset: the plugin is `private: true` and is not bundled into the
published CLI.
2026-07-30 22:30:38 -07:00
gsxdsm
2fd798cb36 core: every review card reported a false stall on a renamed board (#2970)
**The failure mode worth distinguishing: the rest of this family went
quiet on a renamed board. This one shouted.**

`getInReviewStallReason` satisfied its **own** lane check from
`context.reviewColumns` — then called `getTaskMergeBlocker` **without**
them. That helper re-ran its column-identity check against the literal
`in-review` and returned, for a perfectly healthy card:

```
task is in 'signoff', must be in 'in-review'
```

…which was surfaced as `{ code: "merge-blocker" }`. **Every in-review
card on a renamed board was flagged as stalled**, each citing a lane the
board does not have. That is how a signal stops being read at all.

## A second symptom, found by the revert rather than by reading

On a **genuinely failed** card, the identity message wins over the real
one. The operator saw the bogus column complaint instead of `task is
marked 'failed': merge verification failed`.

So it did not only invent stalls — it **masked the true reason for real
ones**. I would not have noticed that from the diff; it showed up
because the revert run asserted on the reason text.

## Same shape, last one in the family

The outer question was resolved and the inner one was not — the
half-conversion the helper's own comment records for `moves.ts`, and
#2963/#2964 fixed for the merge entry points. This is the last site the
audit turned up where the lane answer was already in scope and simply
not forwarded.

## Revert results

| | reverted → |
| --- | --- |
| the unforwarded call (what ships today) | **2 of 3 fail** — healthy
card reports a merge-blocker stall; failed card reports the wrong reason
|

**Fixture note worth keeping:** `paused` is deliberately *not* the
genuine-stall case. An earlier guard returns `undefined` for a paused
card before the merge blocker is ever consulted, so that case would pass
whether or not the lanes are forwarded — the vacuous shape this series
has produced eight times.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `@fusion/core` full suite **4878
passed** (457 files); `tsc` core clean; lint, lifecycle census
`--strict`, FNXC gate, changesets all clean.
2026-07-30 22:25:24 -07:00
gsxdsm
df73bbc14a fix(tests): clear the persisted Command Center sub-tab between cases (7 of 8 main reds) (#2971)
`main` is red in the dashboard backfill lane. This fixes **7 of the 8**
failures. All 8 bisect to #2420 (`4f929acc10`): parent `189f237a07`
passes 9/9, that commit fails 5.

### Cause — test-order pollution, not a product bug

#2420 made Command Center restore its sub-tab on remount, because the
view unmounts on navigation by design:

```ts
const [activeTab, setActiveTab] = useState<SubViewId>(
  () => (getCommandCenterState(projectId)?.activeTab as SubViewId | undefined) ?? "overview",
);
```

The panel's test id is derived from that tab
(`data-testid={\`command-center-panel-${activeTab}\`}`), and these files
click through to other tabs — `tokens`, `team`, `github`, `system`,
`mission-control`. Neither `beforeEach` cleared storage, so the
**first** case left `mission-control` persisted and every later case
rendered `command-center-panel-mission-control`:

```
→ Unable to find an element by: [data-testid="command-center-panel-overview"]
```

Nothing in that message points at a previous test, which is what made it
look like a component regression.

**Confirmed as ordering rather than breakage:** each failing case passes
when run alone with `-t`. The "mutation" here is `main` itself — without
the `localStorage.clear()` these files fail 7; with it, 11/11.

### Scope

Two lines plus the note explaining why they exist, so the next person
who adds a tab-switching case knows the persistence is per-project and
sticky. **No product code touched** — the persistence behaviour in #2420
is correct and stays as-is.

**Verified:** 11/11 across both files, `tsc -p tsconfig.app.json` 0
errors, lint clean, FNXC gate exit 0. Test-only, no changeset.

### The 8th failure is NOT fixed here, deliberately

`MainContent.planning-project-remount.test.tsx` fails because #2420
moved Planning out of `MainContent` (its branch now returns `null`) into
`PlanningKeepAlive`, mounted by `App.tsx`. The product side is right —
the host **is** keyed (`App.tsx:1956`):

```jsx
<PlanningKeepAlive key={`${currentProject.id}:${modalManager.planningEntryGeneration}`} … />
```

so project switches still remount and I found **no cross-project leak**.
But that test was `FNXC:ProjectSwitchModalReset` coverage for a
leak-class invariant (Planning carrying a previous project's
stream/session), and **nothing asserts it at the new location**: the
keep-alive test covers navigation reveal, not project switching, and
`App.test.tsx` has no test for the host key. Deleting that `key=` today
would fail no test.

Restoring it needs an App-level test, which is a bigger change than this
fix and worth keeping separate. Detailed on #2420.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:25:13 -07:00
gsxdsm
cadf011b25 fix(dependency-graph): an allowlist of four legacy lanes rendered a blank graph on a renamed board (#2972)
## A renamed board gets a blank dependency graph

`filterGraphTasks` gated on an allowlist of four legacy lane ids:

```ts
export const INCLUDED_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
export function filterGraphTasks(tasks: Task[]): Task[] {
  return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column));
}
```

On a board whose lanes are named anything else — `backlog`, `building`,
`checking` — **no card matches and the graph renders completely empty**.
This is not a mislabelled node or a missing edge: the entire feature is
blank, and it reads as *"this project has no dependencies"* rather than
as a bug. `triage` is in that allowlist too, a lane U11 (#2515) deleted.

## Fix: gate on the finished lanes instead

Inverted to a denylist, so the **default is the safe one**. An
unrecognised lane is active work by assumption and renders; only lanes
that genuinely mean "finished" drop out.

An allowlist fails **closed** — hide everything unknown. A denylist
fails **open** — show it. For a graph, an extra node is a far smaller
error than no graph.

## The residual, named rather than hidden

`EXCLUDED_COLUMNS` is still two literals. `DependencyGraph.tsx` is a
client React component handed plain `Task` rows as a prop, with no async
seam to resolve a workflow IR — so a board that renames its DONE lane
still shows finished cards here. That is deliberately the mild failure
direction: "one extra node", not "no graph". It is documented in the
code rather than papered over with an optional resolved-lanes parameter
no caller could fill.

The invalid-column guard is now **explicit**. Under the allowlist,
`column: undefined` was excluded as a side effect of not being in the
set; under a denylist it would sail through, so it is checked directly
and covered.

## Revert proof

Restoring only `filters.ts`:

```
AssertionError: expected [] to deeply equal [ 'FN-1', 'FN-2', 'FN-3' ]
AssertionError: expected [] to deeply equal [ 'FN-1' ]
      Tests  2 failed | 13 passed (15)
```

Worth noting explicitly: **every pre-existing case passes either way.**
They only ever enumerate the six legacy ids, so the allowlist and the
denylist agree on all of them — no existing test could have seen this
blackout. (The new empty-string case also passes both ways; it guards
the new implementation rather than proving the fix, and I am not
claiming it as coverage of the defect.)

## Verification (measured)

- plugin suite — **183 passed / 20 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green

## Checked and deliberately not changed

`GraphTaskNode.tsx` holds `column === "in-progress"` and `column ===
"in-review"`. Both are already audited with an in-code note, and both
degrade mildly rather than blanking anything — `hasExecutionSignal` also
ORs on `ACTIVE_STATUSES`, so a renamed WIP lane still reads as active
via status. Converting them needs column traits this component is not
given, so they stay noted rather than half-converted.

No changeset: `fusion-plugin-dependency-graph` is `private: true`.
2026-07-30 22:22:28 -07:00
gsxdsm
5d01164994 test(glasses): re-green 15 agent-action tests, and revive a guard that asserted nothing (#2969)
## 15 tests have been red on `main`

`startWork`, `requestReview` and `approvePlan` gained a third `moveTask`
argument, `{ moveSource: "user" }`. The assertions in
`agent-actions.test.ts` kept the two-argument form:

```
AssertionError: expected "vi.fn()" to be called with arguments: [ 'FN-1', 'building' ]
Received: [ "FN-1", "building", + { "moveSource": "user" } ]
      Tests  15 failed | 41 passed (56)
```

Reproduces on clean `origin/main`. This suite is outside the merge gate,
which is why it went red unnoticed.

## The part that is worse than staleness

Three of these are **negative** assertions, and they did not fail — they
went **dead**. `expect(fn).not.toHaveBeenCalledWith(id, column)` cannot
match a three-argument call, so it passes whether or not the forbidden
move happened. `requestReview`'s "never lands on the legacy `in-review`"
guard has been asserting nothing since the option landed.

Verified rather than reasoned about — a scratch case, run and then
deleted:

```ts
const fn = vi.fn();
fn("FN-1", "in-review", { moveSource: "user" });          // the forbidden move HAPPENED
expect(fn).not.toHaveBeenCalledWith("FN-1", "in-review");   // old form: passes anyway ✓
```

Both cases passed, confirming the old form is vacuous and the
three-argument form throws.

## Applied per action, not uniformly

Only **3 of 5** product `moveTask` calls take the option, and the split
is deliberate:

| action | source | why |
| --- | --- | --- |
| `startWork`, `requestReview`, `approvePlan` | `{ moveSource: "user" }`
| the wearer's tap is a human gesture, matching the dashboard move route
|
| `returnToAgent`, `retryTask` | default (engine) | per the Move-Task
contract a user-source move parks the row `userPaused`, defeating the
return/retry intent |

So `returnToAgent`/`retryTask` assertions are **correct as
two-argument** and are left untouched — including their negatives, which
genuinely assert because the real call is also two-argument.

Attribution was done by scoping each assertion to its enclosing `it(`
block, not to the nearest preceding call: the latter mis-attributes the
`await expect(startWork(...)).resolves` form, which reads as `expect`.
Blocks mixing a user-source and a default-source action were excluded
from rewriting (there were none).

I did **not** "fix" the 3/5 split. It is documented in the product with
its reasoning and it matches the Move-Task contract; changing it would
be a behavior change riding in a test-only commit.

## Verification (measured)

- `agent-actions.test.ts` — **56/56 passed** (was 15 failed / 41 passed)
- full plugin suite — **192 passed / 19 files** (was 180 passed, 15
failed)
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green

Tests only; no product file is touched. An FNXC note now records the
arity contract and which actions take which source, so the next
assertion added here doesn't reintroduce a dead guard.
2026-07-30 22:17:11 -07:00
gsxdsm
19deb42170 gate: ratchet call sites that never receive the lane answer (#2966)
**This is the gap that let three defects reach `main` in one day.**

`unwired-lane-parameter.mjs` catches a parameter that reaches **no**
caller. It is deliberately satisfied by a mention *anywhere*, so
**partial** wiring is invisible to it:

| | |
| --- | --- |
| #2956 | `getInReviewStallReason` wired at **0 of 4** call sites while
both siblings were wired |
| #2963 | both merge entry points unwired — merging was **impossible**
on a renamed board |
| #2964 | merge-confirmed finalization unwired — **already-landed work
parked `failed`** |

Every one was a fix that added an optional parameter without the
call-site sweep that has to follow it. The existing guard was green
throughout, correctly by its own contract.

## A census, not a guard — and that distinction is the whole design

Auditing the sites this finds showed **four of seven were legitimately
unwired**: `skipColumnIdentityCheck` callers have already proven lane
identity by a stronger means, a sentinel-column caller wants the
identity check satisfied by construction, and a dead export has no
caller to wire at all.

A check that failed on those is ~57% false positives. The sibling
guard's own header says why that is worse than a miss — *"it teaches
people to disable the check"* — and I agree, so this does not do it.

Instead it ratchets like the lifecycle census: **36 known unwired sites
across 20 files**, allowed to shrink and not to grow. A new unwired
caller raises the count and fails; wiring one lowers it and re-records.
The recurrence — adding a caller that forgets the lane answer — is
precisely what gets caught, and the legitimate sites cost one baseline
line each instead of a permanently red gate.

## Detection is AST-based, deliberately

It finds exported functions accepting a lane-named argument — directly
*or* as an options-bag member — then finds call sites passing none of
them.

Not regex: the ad-hoc scan I used during the audit produced false
negatives on multi-line calls, which is exactly how a caller gets missed
in the first place. Using a heuristic to police a defect caused by a
heuristic seemed like a poor trade.

## Verified to fail on the recurrence

A ratchet that cannot fail is worse than none, so this was measured
rather than assumed. Injecting one new unwired caller into
`self-healing.ts`:

```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:

  packages/engine/src/self-healing.ts: 9 unwired now, baseline allows 8
```

exit 1, naming the file and the delta.

## Placement

Runs as a named `check:lane-wiring` step in `pr-checks.yml` beside the
lifecycle, SQL, inert-seam and FNXC ratchets — same convention, same
failure ergonomics, ~1s.

Note the baseline records today's state, which still includes the
#2963/#2964 sites because those fixes have not merged yet. When they
land the count drops and the baseline is re-recorded downward — the
ratchet working as intended rather than a conflict.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `tsc` engine clean; lint,
lifecycle census `--strict`, FNXC gate, and the new check all clean.
2026-07-30 22:14:27 -07:00
gsxdsm
1c19540f50 fix(glasses): the summary card reported five hardcoded lanes, so a renamed board read as empty (#2968)
## The defect

`boardSummaryCardFromCounts` built its text from five hardcoded lane
ids:

```ts
`Triage ${counts.triage} Todo ${counts.todo} Doing ${counts["in-progress"]} Review ${counts["in-review"]} Done ${counts.done}`
```

`boardSummary` seeds exactly those five keys to `0`, then counts by real
`task.column`. On a board whose lanes are named anything else, **every
interpolated value is the seeded zero** — so the summary card, which is
the first card in every deck and the entire body of `GET
/board/summary`, reads:

```
Triage 0 Todo 0 Doing 0 Review 0 Done 0
```

…while the real work sits in lanes it never mentions. The wearer is told
the board is empty.

It is also wrong on the **default** board today: U11 (#2515) deleted the
`triage` lane, so `Triage 0` is permanently dead text — 9 of the 24
characters this display gets per line.

## Why there was no test

The only summary coverage in `cards.test.ts` exercised
`boardSummaryCard`, an **export no production file calls** (its sole
caller is that test). The function the deck actually ships had none.
That gap is why five hardcoded ids survived here.

## The fix, and one deliberate choice

The lanes are derived from `counts` instead of taken as an optional
resolved-lanes parameter.

That is on purpose. This program's recurring defect is precisely the
"optional lane answer + documented literal fallback" shape shipped
without wiring the caller — `unwired-lane-parameter-guard.test.ts`
documents **five live on `main` at once**, and in four of five the
parameter was unreachable because the caller held a larger defect.
`counts` is already keyed by real `task.column` values, so the
vocabulary is in hand with **no resolution, no new plumbing, and nothing
that can be left unwired**.

Zero-count lanes are dropped so the scarce line budget goes to lanes
with work; legacy ids keep their familiar labels and order, unknown ids
sort after them alphabetically so output is deterministic. `counts`
itself is untouched — the route returns it as the API body and consumers
still see every key.

## Revert proof

Restoring only `cards.ts` fails all three new cases, printing the defect
verbatim:

```
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to contain 'backlog 2'
AssertionError: expected 'Triage 0 Todo 1 Doing 0 Review 0 Done…' not to match /Triage/
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to be 'No active work'
      Tests  3 failed | 5 passed (8)
```

## Verification (measured)

- `cards.test.ts` — **8/8 passed**
- full plugin suite — **180 passed**, 19 files
- `eslint`, `tsc --noEmit` — clean
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green

No changeset: `@fusion-plugin-examples/even-realities-glasses` is
`private: true` and is not bundled into the published CLI.

## Pre-existing failure, NOT from this change

The plugin suite also has **15 failures in
`src/__tests__/agent-actions.test.ts`**. They reproduce identically on
clean `origin/main` with this branch stashed (`15 failed | 41 passed`),
so they are not caused by this PR and are not fixed by it. Flagging
separately — this suite is outside the merge gate, which is why it has
been red unnoticed.
2026-07-30 22:14:16 -07:00
gsxdsm
126cee7e6d engine: finalization parked ALREADY-MERGED work as failed on a renamed board (#2964)
**The worst symptom in this family: the branch landed, and the board
says the task failed.**

`project-engine`'s merge-confirmed finalization spread the task's
**real** column into `getTaskHardMergeBlocker` with no `reviewColumns`,
so the identity check ran against the literal `in-review`. On a renamed
board it returned `task is in 'signoff', must be in 'in-review'`, and
the caller parked the card:

```
status: "failed"
error:  "Merge confirmed but finalization blocked: task is in 'signoff', must be in 'in-review'"
```

For work that had already merged.

## Its sibling had already solved this

`auto-merge-finalization.ts` passes the **review-eligible sentinel**
instead of the card's own column, with the reasoning recorded at that
site: `getTaskHardMergeBlocker` asks *"is this card blocked by anything
other than where it sits?"*, and its callers are recovery paths for
landed work that a graph crash can leave resting in any column.
`project-engine` simply never got the same treatment.

## One name instead of two spellings

Rather than write the sentinel a second time, it is exported once as
`REVIEW_ELIGIBLE_SENTINEL_COLUMN` next to the helper whose contract
gives it meaning, and both recovery paths use it. **Two sites
independently spelling a magic value is how one of them came to be
missing it** — that is the actual root cause here, not the literal
itself.

This also answers the census, which flagged the new literal — correctly.
Its guidance (which I wrote, in #2909) is to hoist a deliberate literal
into a *declaration*, where a `DELIBERATE-LITERAL` marker actually
attaches, instead of leaving it mid-expression where the marker is
silently ignored. The shared constant is exactly that, and it lowers
`auto-merge-finalization`'s literal count too.

## Revert result

| | reverted → |
| --- | --- |
| sentinel replaced by the card's own renamed column | reproduces the
shipped string |

The middle test asserts that string deliberately — it is what landed in
`task.error`, so a regression reports what the operator would actually
have seen. A third case checks the sentinel does **not** suppress
genuine blockers: incomplete steps still block finalization in any lane.

These drive the helper directly; reaching `project-engine`'s
finalization end to end needs a live engine, a merge run and a real
repo, while the defect is entirely in *what the blocker is asked*.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `project-engine` +
`auto-merge-finalization` + the new suite, 207; `tsc` clean on core and
engine; lint, census `--strict`, FNXC gate, changesets all clean.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed merge-confirmed tasks being finalized correctly when boards use
renamed workflow columns.
* Prevented already-merged tasks from being incorrectly marked as failed
due to custom review-column names.
  * Preserved enforcement of genuine incomplete-step blockers.

* **Tests**
* Added coverage for finalization on renamed lanes and legitimate merge
blockers.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:06:24 -07:00
gsxdsm
8e0219d573 fix(merger-ai): gate the no-commits dep-sync skip on the branch diff — the P1 #2501 shipped without (#2958)
## #2501 merged without its P1 fix; this is that fix, alone

#2501 has landed. Its review threads were resolved — I judged and fixed
them — but its head was a fork branch I could not push to, so **the
fixes were never in it**. Confirmed on `main` at `c1c1b964af`:

```
merger-ai.ts:902:      if (ctx.noCommitsExpected === true) {      ← bare flag, no diff gate
merge-dependency-sync.ts: export const LOCKFILE_CANDIDATES → 0 matches
```

Rebasing dropped this PR's five duplicated base commits, so it is now
**one commit**: the review fix and its regression.

## The defect on main

The dep-sync skip trusts `ctx.noCommitsExpected` alone, and **only ever
runs on a branch that has commits** — the `rev-list --count`
short-circuit ~50 lines above returns `outcome: "empty"` at zero ahead,
so control reaches it only when the branch is AHEAD.

Nothing revalidates the flag. Both downstream empty-lane guards carve
no-commits tasks out explicitly — `merger-ai.ts:1372` (#2259
already-landed proof) and `:1994` (FN-8141 executor veto) — and both
guard the *opposite* direction: commit-expected task, empty branch. The
inverse has no check.

So a task marked no-commits whose executor committed a manifest or
lockfile change gets its dependency install **and** its frozen-lockfile
validation skipped, and the change lands unvalidated.

## The fix

The flag says *look*; the branch diff decides. A `main...branch` diff
touching `package.json` or any `LOCKFILE_CANDIDATES` entry falls through
to the normal sync and emits an audit row with `skipOverridden: true`.
An unreadable diff **also** syncs — matching the hard-fail contract
documented directly above that block, rather than treating absence of
evidence as evidence of safety.

`LOCKFILE_CANDIDATES` is exported instead of duplicated, so the skip and
the installer cannot drift on what counts as a dependency change.

**Mutation-verified:** reverting to trust-the-flag fails exactly the new
case and nothing else. The existing *"lands successfully with
noCommitsExpected: true and actual changes"* case is untouched and still
passes — `feature.txt` is not a dependency file, so an ordinary source
change on a no-commits task still skips. The new case differs only in
*which* file the branch touches.

## Also carried over from the #2501 review

**coderabbit's env nit** — `process.env.X = undefined` stores the string
`"undefined"`, leaving a previously-absent var truthy and leaking into
later tests. `restoreEnv` applied at both sites.

**Both entry paths** — deferred with reasons:
`runAiMerge`/`landWorkspaceTask` sit behind real worktrees, sessions and
a merge agent, and the cheap version is a mirrored-implementation test
that cannot fail on a revert (this repo has deleted two of those). The
fix above also means propagation is no longer the only thing between a
stale flag and an unvalidated lockfile.

## A correction to my own work

My first version of the regression committed the lockfile while the
fixture had left the tree on `main`, so the `main...branch` diff could
not see it and the case **passed for the wrong reason**. Corrected, with
the reason recorded in the test.

## Verification

- `merger-ai-no-commits-deps-skip` — **5/5**, mutation-verified
- `merge-dependency-sync-lockfile-heal` — **10/10**
- engine typecheck — 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 22:05:04 -07:00
gsxdsm
04159ff9ed fix(tests): three CSS-shape cases pinned modal DOM the FloatingWindow migration retired (#2967)
Two cases in `agent-modals-mobile` asserted DOM shapes that the FN-8619
`FloatingWindow` migration deliberately retired. Both are **stale
expectations, not product regressions** — established below rather than
assumed, because "delete the failing assertion" is exactly how a real
bug gets buried.

### 1. `AgentDetailView` — the suite asserted both sides of the same
fact

```js
expect(document.querySelector(".agent-detail-overlay")).toBeTruthy();   // here — RED
```
```js
expect(document.querySelector(".agent-detail-overlay")).toBeNull();     // AgentDetailView.core.test.tsx:97 — GREEN
```

No component renders that class (`grep` across `app/**/*.tsx` outside
tests: zero hits). One of these two had to be red, and the one matching
the product is the `toBeNull` sibling. This case now asserts the panel
class it is actually named for — `.agent-detail-modal`, which **is**
live and **is** what the mobile `@media` block in `AgentDetailView.css`
targets — and pins the scrim as still-retired.

I did **not** re-point the overlay half at `.floating-window-overlay`.
That would only re-assert FloatingWindow's own contract (already covered
by `FloatingWindow.test.tsx`) while saying nothing about Agent Detail
being mobile-targetable.

### 2. `AgentGenerationModal` — the class belongs to a different
component

It demanded `.agent-dialog-overlay`. That class is **still live** —
`NewAgentDialog.tsx:415` renders it — which is why the stale expectation
looked plausible and survived. But this modal is a `FloatingWindow` with
`modal` (`AgentGenerationModal.tsx:162`), so its scrim is
`.floating-window-overlay--modal`. Now asserted explicitly, because "a
modal blocks the app beneath it" is a real FN-8619 contract worth
pinning.

### Why the dead CSS is still here

`.agent-detail-overlay` has 4 CSS definitions and one inert mobile
`@media` rule. I deliberately did **not** delete them in this PR:

- Two **passing** tests (`dashboard-overflow-containment.test.tsx:295`,
`mobile-horizontal-pan-containment.test.ts:90`) pin a selector *string*
that lists `.agent-detail-overlay`. Deleting the CSS turns two green
tests red.
- That raises a question I cannot answer without rendering, and I will
not boot an instance to find out: **those containment lists do not
mention `.floating-window-overlay--modal`.** If mobile horizontal-pan
containment is meant to cover modal scrims, the migration may have moved
the scrim out from under its guard. That is a product question for
whoever owns FN-8619, and it is the substance of #2915.

Worth recording: the retired `.agent-detail-overlay { padding: 0;
align-items: stretch }` mobile rule is not a lost feature.
`.floating-window-overlay` is `position: fixed; inset: 0` with no flex
context, so those declarations have nothing to act on — FloatingWindow
positions the panel by geometry instead.

**Verified:** 22/22 in this file, `tsc -p tsconfig.app.json` 0 errors,
lint clean, FNXC gate exit 0. Test-only, no changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:01:07 -07:00
gsxdsm
2502878166 fix(a11y): 13 dialogs announced their role twice ("Settings dialog, dialog") (#2965)
Thirteen modals set an `aria-label` that restates the role they already
carry. `FloatingWindow` renders `role="dialog"` and
`aria-label={ariaLabel}` on the **same element**
(`FloatingWindow.tsx:628` and `:631`), so `"Settings dialog"` is
announced as **"Settings dialog, dialog."**

Fixed at all 13 call sites, plus a guard so the next copy-paste fails
instead of shipping.

### Two independent lines of evidence

I found this by inspection. Then, chasing unexplained dashboard
failures, I hit `NodesView.test.tsx`:

```
Unable to find an accessible element with the role "dialog" and name "Add Node"
  ...
  Name "Add Node dialog":
```

Two tests were already asserting the **correct** name and failing
because the product had drifted to add the suffix. So this is not a
style preference — it is a defect with pre-existing tests that were red.
**Those 2 failures go green here**, and they were among the ones I had
not yet accounted for.

### The guard was vacuous, and mutation is the only reason I know

My first version used one regex with `[^`"']*?` for the label body. It
passed. It was worthless.

Every real call site interpolates a translator call:

```jsx
ariaLabel={`${t("scripts.title", "Scripts")} dialog`}
```

Those inner **double quotes terminate the character class**, so the
pattern matched **none of the thirteen offenders**. It only matched
hand-written samples like ``{`Settings dialog`}`` that happen to contain
no quotes — which is exactly what I had put in the case table. Re-adding
the suffix to `ScriptsModal` in its original form left the suite
**green**.

Extraction is now structural (brace matching), and the case table
carries the real quote-bearing shapes, including the nested-brace
`NodeDetailModal` form and the `+ " dialog"` concatenation variant.

**Mutation now behaves:**

| state | result |
|---|---|
| clean tree | 13/13 pass |
| suffix re-added to `ScriptsModal` (faithful form) | **fails**, naming
the file and the offending value |

I would have shipped a guard that could not fail on the defect it was
written for. It is the same error the guard exists to prevent — a cheap
proxy standing in for the real measurement — so the reasoning is
recorded in the file rather than quietly fixed.

### Verified

- `NodesView` + `FloatingWindow` + the new guard: **110/110**, then
**13/13** for the guard after the rewrite
- `tsc -p tsconfig.app.json`: **0 errors** · lint clean · FNXC gate exit
0
- **No i18n key or default string changed** — all 15 `t()` keys
byte-identical across the diff; only the literal outside the call was
dropped, and the now-pointless `` {`${…}`} `` wrappers were unwrapped

### Not fixed here

`agent-modals-mobile` (2) and `core-modals-mobile` (1) still fail on
this branch — they fail identically on `main`, are CSS-structure
assertions unrelated to aria naming, and belong to the #2915
dead-`.agent-detail-overlay` family. Left alone deliberately rather than
bundled in.

No changeset: user-visible a11y correction with no API or setting
change, and the release-notes audience is operators. Say the word if you
want one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:58:23 -07:00
Drew Donaldson
c1c1b964af fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)
## Bug
Chat-session tool surface missing task-mutation tools that exist outside
of chat, even when the agent's permission record grants them.\n\nRepro:
agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive /
tasks:delete / tasks:merge / tasks:retry / tasks:update true in its
permission record with permissionPolicy.presetId = unrestricted and
task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete /
fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause:
packages/dashboard/src/chat.ts createChatFusionToolset() built a
hardcoded narrow chat-only allowlist while heartbeat registered the
complete lifecycle surface unconditionally.\n\nFix:\n- Add exported
factories in packages/engine/src/agent-tools.ts for missing lifecycle
tools: fn_task_archive, fn_task_unarchive, fn_task_delete,
fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate,
fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote,
fn_trait_list, fn_ask_question, fn_reflect_on_performance,
fn_read_evaluations, fn_update_identity, fn_send_message,
fn_read_messages.\n- Wire those factories into
createChatFusionToolset(). Mission/ideation mutations stay behind
missionMutationGated. Agent-scoped tools still require agentId.\n-
Re-export from packages/engine/src/index.ts.\n- Regression test:
packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3
passing). Existing chat.test.ts (14/14 passing).

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

- **New Features**
- Chat now exposes task lifecycle actions—including archive, retry,
pause, duplicate, merge, and dependency updates—when permitted by the
agent’s action controls.
- Added support for identity updates and evaluation viewing in
agent-linked chats.
- Existing read-only tools remain available, while restricted actions
stay hidden when authorization is unavailable.
- **Tests**
- Added regression coverage for authorized and unauthorized chat tool
surfaces, including preservation of read-only capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:51:09 -07:00
flexi767
dd930c8d7d fix(cli): qualify cross-fork PR heads (#2377)
## Summary

- resolve the repository receiving pushes through `git remote get-url
--push origin`
- qualify pull-request head branches with the fork owner when the push
owner differs from upstream
- preserve the existing unqualified head for same-repository workflows

## Root cause

Fusion correctly resolved the PR target from origin's fetch URL, but
assumed the pushed branch lived in that same repository. With an
upstream fetch URL and a fork push URL, GitHub requires
`fork-owner:branch`; the unqualified branch is rejected.

## Validation

- CLI task lifecycle tests: 48 passed
- `@fusion/core` typecheck
- `@runfusion/fusion` typecheck
- strict changeset validation


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

* **Bug Fixes**
* Pull requests created from branches pushed to contributor forks now
correctly qualify the PR head with the fork owner when the push remote
differs from the upstream owner.
* Improved PR head handling across both group/shared-branch and per-task
pull request creation paths.
* **Tests**
* Updated and expanded lifecycle tests to cover “origin push to fork”
scenarios using push URL–based repo resolution.
* **Documentation**
  * Added a patch release note for the fork-aware PR head fix.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: v <v@v.speedport.ip>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-30 21:50:58 -07:00
gsxdsm
4f929acc10 fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)
Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

**Keying / component identity (U1–U3)**
- Streaming chat segment key no longer embeds `entries.length` — an
expanded thinking block stays expanded while entries stream into it
(R1).
- Dock task list keys `TaskCard` rows by `task.id` (occurrence suffix
only for the duplicate-id anomaly) instead of `id-index` — no remount on
reorder/filter/status change (R2).
- `ProviderStatusBadge` / `GitHubStatusBadge` hoisted out of
ModelOnboardingModal's render body (R3); MCP server rows key by
`server.name` alone (R4).

**Keep-alive layer (U4–U6)**
- New shared `KeepAliveView` wrapper: visible = in-flow flex child;
hidden = out-of-flow `position:absolute; inset:0` with
`visibility:hidden; pointer-events:none` + `aria-hidden` (never
`display:none`, so xterm geometry never collapses).
- Planning Mode renders as a kept-alive sibling of the MainContent
switch after first open (per-project latch mirroring Quick Chat). While
hidden, the session-list SSE, recovery poll, and elapsed ticker suspend
via a new `active` prop; reveal re-subscribes and refreshes the sessions
list once. Payload-carrying entry points (initial-plan handoff, resume)
and project switches remount via a new
`modalManager.planningEntryGeneration` key, preserving pre-keep-alive
fresh-open semantics. `recordResumeEvent` instrumentation records
`remount` on first activation and `route-active` on reveal.
- Task-detail Terminal / Worktree-terminal / Planner-chat tabs stay
mounted-but-hidden after first open (per-task latches; task switch/close
still disposes fully). `SessionTerminal` gains `active`: reveal refits +
forces a font remeasure, and if the WS died while hidden it re-runs the
full attach lifecycle (dead-socket recovery).
- Popped-out task windows hide via FloatingWindow `hidden` instead of
leaving the render array; `TaskDetailContent` gains `active` so hidden
popups close their SSE/EventSource channels while the terminal WS stays
open. `visiblePoppedOutTaskEntries` remains the Escape-shortcut
consumer.

**Planning Mode internal-transition audit (U7)**
- Audit findings: session-list mode and mobile list/detail flips are
CSS-class transitions over one always-mounted detail pane (no
state-discarding unmounts); re-selecting the active session is an
early-return visibility restore; session switching intentionally reloads
from the session row (stream re-attach for generating sessions);
remaining index keys are on stateless lists. No product-code defects
found; regression tests now lock the always-mounted invariant on desktop
+ mobile.

**Cheap-view state (U8)**
- CommandCenter (active sub-tab + date range) and DevServerView
(selected script/task + typed-but-unsent command) persist per project
via `modalPersistence` and restore after their (intentional) unmount
round-trips. Also fixed the candidate auto-fill effect clobbering a
customized non-empty command.

## Symptom Verification
- **Original symptom:** streaming thinking blocks collapsed mid-stream;
terminals reconnected and lost scroll/input on tab flips; Planning Mode
lost in-flight interviews on navigation; popped-out windows vanished
off-view; dock cards remounted on reorder.
- **Exact reproduction:** (1) expand a thinking block during a stream;
(2) run a command in the Terminal tab, flip to Plan and back; (3) start
a planning interview, navigate Board and back; (4) pop out a task with
board/list-only scoping and switch views; (5) change a dock task's
status.
- **Assertion it is gone:** component-identity/instrumentation tests in
TaskChatTab, SessionTerminal, TaskDetailModal
(worktree/planner-chat/tabs), PlanningModeModal keep-alive +
internal-transitions, App keep-alive round-trip, and
App.taskPopupViewGating assert no remount and preserved state for each
repro, across desktop and mobile breakpoints.

## Verification
- File-scoped vitest: 23 files / 1091 tests green (all touched suites
plus FloatingWindow, TerminalModal, TaskPlannerChatTab,
lazy-loaded-views guard, App suites).
- `pnpm verify:fast`: PASS (13 steps — scoped typecheck/build, CLI
build, boot smoke).
- `pnpm check:changesets`: passes; changeset
`fix-dashboard-remount-churn` (`@runfusion/fusion` patch, labeled
format).
- Known pre-existing failures NOT caused by this branch (verified
failing at base a224c1111 in a clean worktree): 7 tests in
`TaskDetailModal.oversight-controls/oversight-mobile/models-progress-workflow`.
- jsdom cannot prove rendered-grid correctness for xterm reveal; per the
plan's risk note, manual browser verification of terminal reveal remains
recommended.

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


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

* **Bug Fixes**
* Switching views or tabs no longer resets Planning Mode, task details,
terminals, planner chats, or popped-out task windows.
  * Streaming content remains expanded and stable as new entries arrive.
* Hidden views suspend background activity and resume correctly when
shown.
  * Terminal sessions reconnect automatically when needed.

* **Improvements**
  * Command Center and Dev Server selections persist per project.
  * Custom Dev Server commands are preserved while browsing suggestions.
* Improved stability when reordering task lists and updating server
states.

* **Documentation**
* Updated dashboard guidance for hidden, retained task pop-ups and view
transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:47 -07:00
gsxdsm
189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.

The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.

Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.

## Scope

This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.

The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.

## What changed

8 bisectable commits:

- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.

## Review follow-ups

**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.

It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).

**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.

## Behavior changes requiring operator review before rollout

1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.

## Verification

Independently re-run against the branch, not just self-reported:

- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage

`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.

### A mutation check that failed to fail

Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.

## Known limitations (stated, not papered over)

- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.

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

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

## Summary by CodeRabbit

* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:37 -07:00
gsxdsm
7712e0ada2 engine: merging was broken outright on a board with a renamed review lane (#2963)
**Not a degraded message — no task on such a board could be merged at
all.**

`getTaskMergeBlocker`'s column-identity check *returns a blocker* when
the task's column is not a review lane. Both merge entry points called
it without `reviewColumns`, so the check ran against the literal
`in-review`:

```
Cannot merge FN-1: task is in 'signoff', must be in 'in-review'
```

`aiMergeTask` (`merger.ts`) and `runAiMerge` (`merger-ai.ts`) turn that
into a thrown error. Every merge on a renamed board fails, with a
message naming a column the board does not have.

## This exact defect was already found once

The helper's own FNXC comment records it, in `moves.ts`:

> *"so on a renamed board that move threw `Cannot move FN-1 to done:
task is in 'signoff', must be in 'in-review'` even though the transition
had just been validated as legal. A half-conversion, where the outer
question is resolved and the inner one is not."*

That fix added the `reviewColumns` option and wired `moves.ts`. **These
two callers were missed** — same shape, one layer out. A fix that adds
an optional parameter is only as good as the call-site sweep that
follows it.

## How it was found

By enumerating the call sites of every lane-taking helper, rather than
trusting the `unwired-lane-parameter` guard. That guard is deliberately
conservative — a mention of the parameter *anywhere* satisfies it — so
**partial** wiring is invisible to it, and `reviewColumns` is mentioned
plentifully elsewhere. This is the method #2956 used on a sibling
defect, applied to every seam I have touched.

## Two sites deliberately unchanged

- **`moves.ts`** passes `skipColumnIdentityCheck: true`. It has already
proven lane identity from resolved IR traits, so supplying lanes *as
well* would be contradictory rather than additive — the helper's comment
is explicit that the two options answer different questions.
- **`isTaskReadyForMerge`** has **zero** production callers. Adding a
parameter there is precisely the unwired-parameter anti-pattern this
program keeps removing.

## Revert result

| | reverted → |
| --- | --- |
| `reviewColumns` at either call | reproduces the shipped string exactly
|

The middle test pins that string deliberately: it is the
operator-visible failure, so if the wiring regresses the test says what
the operator would have seen. A third case checks that supplying lanes
does **not** switch the identity check off — a card in the wip lane is
still blocked, and the message names the resolved lanes rather than a
column the board lacks.

The cases drive `getTaskMergeBlocker` directly: reaching it through the
merge entry points needs a real repo, worktree and merge run, while the
defect is entirely in *which columns the blocker is asked about*. The
wiring itself is covered by tsc and the guard.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `merger` + `merger-ai` +
`self-healing` suites 461; `tsc` engine clean; lint, census `--strict`,
FNXC gate, changesets all clean.
2026-07-30 21:50:32 -07:00
ischindl
8d6acf1314 fix(RUFU-018): add noCommitsExpected dep-sync skip and corepack/pnpm env passthrough (#2501)
Manually land RUFU-018 fix bypassing the AI merge pipeline.

## Summary
- Add `noCommitsExpected` flag to `LandRepoContext`; skip dependency
sync when set
- Forward `COREPACK_HOME`/`PNPM_HOME`/`npm_config_registry` in
`installWorktreeDependencies`
- Add comprehensive tests for both changes

This unblocks all downstream RUFU audit tasks.

## Surface Enumeration
- Providers/bridges: `installWorktreeDependencies` called from
`landOneRepo` (AI merge) and legacy `merger.ts`; `landOneRepo` called
from `runAiMerge` and `landWorkspaceTask`
- Data states: `noCommitsExpected` can be `true`, `false`, or
`undefined` — both callers use `=== true` strict check

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

## Summary by CodeRabbit

- **New Features**
- Improved support for tasks that do not produce commits by skipping
unnecessary dependency installation during merges.
- Preserved normal merge and review behavior when dependency
installation is skipped.

- **Bug Fixes**
- Dependency installation now correctly preserves relevant
package-manager and system environment settings.
- Reduced installation failures caused by missing or unavailable
package-manager configuration.

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

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-30 21:50:25 -07:00
gsxdsm
01f081e8aa engine: restore the stall-signal lane wiring #2951 dropped (and the test that proved it) (#2961)
**My defect, shipped in #2951 — and the same family as the one #2956
just fixed.** Found by auditing my own seams after that, not by a
failing check.

## What is on `main` right now

`surfaceInReviewStalls` reads the project's review columns (converted in
#2951), then calls `getInReviewStallReason` **without** `reviewColumns`.
The classifier falls back to the literal `in-review`, returns no signal
for a renamed-lane card, and the sweep surfaces nothing.

That is the textbook **missed pair** this program has a ratchet for: a
widened read handing every renamed-board card to a literal classifier.
The resolve work happens and is then discarded. On a renamed board an
operator sees no stall warnings at all.

#2951's conflict resolution dropped two things together:
- the per-card `stallLanes` map and the `reviewColumns` argument
- **the test that proved the wiring**

## Why nothing caught it

**A deleted test cannot fail.** I verified that rebase by comparing the
68 conflict *hunks* — stripping FNXC stamps, confirming 0 of 68 had real
content differences — and then ran the gate. The gate passed precisely
because the proving test had gone with the code it proved.

I verified the conflicts. I did not verify the outcome. Those are
different things, and the difference is invisible when the evidence
disappears alongside the feature.

The `unwired-lane-parameter` guard cannot catch this either, by design:
it is deliberately conservative — a mention of the parameter *anywhere*
satisfies it — so **partial** wiring is outside its reach.
`reviewColumns` is mentioned plenty in `reads.ts`, so the guard is green
while this call site goes unwired.

## How I found it

The check #2956 used on the sibling defect, applied to every lane seam I
have touched: enumerate each function's **call sites** and confirm each
one carries the parameter. That enumeration also flags several other
call sites without `reviewColumns`/lane arguments (`merger.ts`,
`moves.ts`, `auto-merge-finalization.ts`, `merger-ai.ts`,
`project-engine.ts`) — I have **not** touched those here; they need
per-site judgement about whether the lane answer is even available, and
that is a separate change rather than a sweep.

## Revert result

| | reverted → |
| --- | --- |
| `reviewColumns` at the call (i.e. exactly what #2951 shipped) | fails
the restored test |

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; blindness suite 71;
`self-healing.test.ts` 412; `tsc` engine clean; lint, census `--strict`,
FNXC gate, changesets all clean.
2026-07-30 21:45:17 -07:00
gsxdsm
1f0d371228 fix(tests): three more portal query-root failures (pr-tab, worktree-terminal, milestone-slice) (#2959)
Three dashboard test files asserted against `render()`'s `container`,
but the components under test mount through `createPortal` — so
`container` is **empty** and every query returns nothing. Same root
cause as the earlier portal batch; these are the three that were still
held back.

| File | Before | After |
|---|---|---|
| `TaskDetailModal.pr-tab` | failing | pass |
| `TaskDetailModal.worktree-terminal` | failing | pass |
| `MilestoneSliceInterviewModal` | failing | pass |

**Measured: 39/39 passing**, rebased on current main (`3461ae7a92`).
Lint clean, FNXC date gate exit 0.

### Why this stayed hidden

The queries were a **mix** of `container.querySelector(...)` and
`screen.*`. `screen` queries `document`, so they kept working — a
portal-mounted modal makes only the `container` half go blind. The
result is a file that looks half-alive rather than obviously broken, and
the failures present as five different-looking symptoms (`null`,
`undefined`, `+0`, `[]`, `-1`) that don't read as one bug.

Grouping candidate files by **`container.querySelector` call count**
rather than by symptom is what identified these correctly, and — the
part that mattered — correctly *excluded* the neighbouring files that
were failing for unrelated reasons.

### One thing to know if you repeat this

A blanket `container` → `document` replace is wrong: it also rewrites
`renderResult.container.querySelector` into
`renderResult.document.querySelector`, which is not a thing. That broke
two already-passing tests on my first attempt. This uses two separate
passes with a lookbehind so only the bare receiver is rewritten.

### Scope

Test-side only — **no product code changes**, so no changeset. This does
not fix the *cause* (tests are still free to query the wrong root); a
lint rule for that is worth considering separately, but it would need to
distinguish portal-mounting components from ordinary ones, and I did not
want to guess at that boundary inside a test-fix PR.

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

* **Tests**
* Improved modal and task detail accessibility test reliability by
querying rendered elements from the document.
* Updated coverage for keyboard navigation, Pull Request status
indicators, tab ordering, and onboarding provider cards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:42:33 -07:00
gsxdsm
fd795883c5 feat(missions): per-mission taskPrefix override for triaged task ids (#2347)
## Summary
Maintainer re-land of
[#2334](https://github.com/Runfusion/Fusion/pull/2334) (fork
`flexi767:feat/per-mission-task-prefix`) after resolving merge conflicts
with current `main`.

Fork push was unavailable despite `maintainerCanModify`, so this branch
carries the conflict resolution.

### Feature
- Optional per-mission `taskPrefix` for triaged task ids (inherits
project prefix when unset)
- Dashboard MissionManager + routes + store/triage plumbing
- Postgres migration for `project.missions.task_prefix`

### Conflict resolution
- Main claimed migration **0026** (bigint counters) and **0027**
(workflow IR pin)
- Mission task-prefix migration renumbered **0026 → 0028**
- Baseline `0000_initial.sql` includes `task_prefix` on missions
- `legacy.ts` keeps code-org re-exports; `missions.ts` carries
`taskPrefix` on create/update types

## Test plan
- [ ] CI green (lint/typecheck/build/gate)
- [ ] Create mission with custom prefix; triage feature → task ids use
that prefix
- [ ] Clear mission prefix via PATCH null; new tasks inherit project
prefix

Closes / supersedes #2334 once this lands (or re-point the fork PR).

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

* **New Features**
* Missions can now set an optional per-mission task ID prefix
(overriding the project default).
* Added task prefix support to mission create/edit UI and dashboard
APIs, including normalized uppercase values and validation.
* **Bug Fixes**
* Improved commit hook generation for custom prefixes and special
characters, with safer shell handling to prevent unsafe interpretation.
* **Chores**
* Added PostgreSQL migration and schema-applier support to persist and
propagate mission task prefixes, including upgrade/backfill coverage.
* **Tests**
* Added backend and UI/API test coverage for task-prefix creation,
clearing, and ID minting behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 21:35:23 -07:00
gsxdsm
e6e70a2562 test(engine): delete two temp-cleanup mechanisms guarding a leak the harness already prevents (#2960)
`scheduler-paused-dispatch-refusal.test.ts` carried **two** tracking
arrays and **two** `afterEach` hooks, both collecting the same
`mkdtempSync` path and removing it twice. One was added per review round
on #2779 — I wrote both, and neither round noticed the other.

The obvious fix is to merge them into one. **I checked whether the leak
was real first, and it isn't.**

### Measured

`packages/core/src/__test-utils__/vitest-setup.ts` **redirects
`os.tmpdir()`** to a per-worker sink and sweeps it by owning pid. So
`tmpdir()` inside a test does not resolve to the real temp root at all.
Probing the paths this file actually creates:

```
/var/folders/.../T/fusion-test-workers-8Tv8um/redir-5845/fusion-paused-dispatch-ZUhUVL
```

| run | fixtures created | left behind |
|---|---|---|
| cleanup as shipped | 4 | 0 |
| **cleanup disabled** | 4 | **0** |

The sink is reclaimed either way. Both mechanisms were appeasing a
review comment about a problem that could not occur.

### Why deleted rather than merged

A cleanup that cannot be observed to clean anything is not a cheap
safety net — it is a claim the file cannot back, and it misreports which
layer owns temp lifetime. Keeping one "just in case" would leave the
next reader believing this file manages its own fixtures. If the
redirect is ever removed, cleanup belongs in the shared setup for
**every** test, not re-added file by file. An FNXC note records the
measurement and says exactly that, so a third round doesn't re-add a
third copy.

### A note on my own measurement

My first check was `ls $TMPDIR/fusion-paused-dispatch-*` before and
after — it reported zero leaked with cleanup **on**, which I nearly took
as "cleanup works." It also reported zero with cleanup **off**. That
contradiction is the only reason I looked further; the glob was
measuring a directory the fixtures never reach. The before/after count
would have "confirmed" a working cleanup just as readily as a redundant
one.

**Verified:** 4/4 pass, `tsc` 0 errors, lint clean, FNXC gate exit 0.
Test-only, no product change, no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:29:39 -07:00
gsxdsm
3461ae7a92 docs(gate): record why the SQL-literal gate deliberately does not scan .sql (#2957)
Comment-only. No behavior change.

## Why this is worth a commit

#2954 fixed the FNXC-date gate's walk: its extension filter listed the
file types stamps were **expected** in rather than the ones they
**occur** in, so it was blind to `.sql` and `.css`. That is a tempting
pattern to generalize, and `check-sql-column-literals.mjs` is the
obvious next candidate — a gate about *SQL* column literals that scans
only `.tsx?`.

Applying the same fix here would be wrong, and quietly so.

## The two gates are not the same kind of tool

The FNXC gate is a plain-text regex scanner, so widening its extension
list is trivially correct. This one is **AST-based**:
`ts.createSourceFile(..., ScriptKind.TSX)` followed by a walk over
string and template nodes.

A `.sql` file is not TypeScript. Adding the extension would not widen
coverage — it would feed DDL to the TS parser and traverse whatever
lenient-mode nodes fell out. The gate would then **report coverage it
does not have**, which is strictly worse than not looking, because the
silence would read as "SQL is clean."

## Measured before deciding

38 tracked `.sql` files contain exactly one lifecycle-looking literal:

```
0022_ideation.sql:19  CONSTRAINT ideation_sessions_status_check
                      CHECK (status IN ('open','converged','archived'))
```

That is the **ideation-session** status enum — a different domain that
happens to reuse the word — not a `tasks.column` comparison, and not
something this gate would flag even if it could parse the file. **Zero
real offenders.**

So the honest scope is recorded as: raw SQL is **unwatched**, and the
trigger that would make it worth watching is a data backfill (`UPDATE
tasks SET column = ...`) landing in a migration. If that ever happens it
needs a separate raw-text matcher against the exported `COMPARISON`, not
an entry in the extension filter.

## Verification

- `check-sql-column-literals` → exit 0
- `check-fnxc-future-dates` → exit 0, "478 known, none added" (the new
stamp is dated today, local)
- `scripts/__tests__/check-sql-column-literals.test.mjs` → **26 pass, 0
fail**
- `scripts/__tests__/check-inert-flag-seams.test.mjs` → **12 pass, 0
fail**
- `eslint` clean

## Why a comment rather than a doc

Per AGENTS.md, decisions of this shape belong next to the code they
constrain. The failure mode is specifically someone reading the walk,
noticing `.sql` is missing, and "fixing" it — so the note has to be at
the filter, where that person is looking, not in `docs/solutions/`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:14:24 -07:00
gsxdsm
6c1f074773 fix(core): the in-review stall signal never got the board's review lanes — 0 of 4 call sites (#2956)
## Main is red, and the red is pointing at a real defect

`unwired-lane-parameter-guard` fails on `origin/main` after #2951. This
is not a stale allow-list — the parameter genuinely never reaches the
function.

#2951 added `reviewColumns?: ReadonlySet<string>` to three signal
modules and wired two of them completely. **`getInReviewStallReason` was
wired at none of its four call sites.** Measured by brace-matching each
call's option literal:

```
getInReviewStallReason      L227=NO   L390=NO   L599=NO   L729=NO
getInReviewStalledSignal    all 4 wired
getStalePausedReviewSignal  both wired
```

## The user-visible consequence

`reads.ts` computes two adjacent signals for the same card. On a board
declaring a **separate merge lane beside its human-review lane**,
`inReviewStall` read the *first* review column only, while
`inReviewStalled` — three lines below — read the *set*.

**The same card is "in review" for one signal and not the other.** Two
signals disagreeing is worse than both being legacy, and it is invisible
on every builtin board because there the review set has exactly one
element.

At three of the four sites the resolve sat *below* the call, which is
why the parameter could not be passed. Those are hoisted.

## I have to correct my own earlier report

On #2951 I said *"3 of 4 call sites wired, `reads.ts:227` is the gap."*
**That was wrong.** I had measured with a 12-line proximity grep, which
bled into the adjacent `getInReviewStalledSignal` call and counted its
`reviewColumns:` as the first call's. Brace-matching the literal shows 0
of 4. The defect was four times larger than I reported, and the cause
was exactly the anti-pattern I have spent this session filing against
other people's guards — a proximity window standing in for structure.

## Naming the context types

The guard keys an interface member to its **owner symbol** and only
counts a mention from a file that also names that owner, so passing the
property inline reads as unwired even when every site supplies it.
`satisfies InReviewStalledContext` / `satisfies
StalePausedReviewContext` on the option literals is real type-checking,
not a decorative import — lint rejected the decorative version,
correctly.

## New test, because the existing guard cannot see this

Measured: **deleting the `reviewColumns:` line from a fixed call site
leaves `unwired-lane-parameter-guard` at 9/9 green**, because the file
still names the type. So the wiring I just fixed had no coverage at all.

The new ratchet brace-matches each call site's option literal:

| mutation | result |
|---|---|
| remove lanes from one call site | **1 failed** — *"1 of 4
getInReviewStallReason call sites omit reviewColumns"* |

It also asserts it **found** call sites before checking them — a parse
that matched nothing would be vacuous, which is the failure mode this
guard family keeps producing. (It caught me mid-change too: an earlier
scripted edit left the file syntactically invalid and the source-text
test still passed 3/3. It is a wiring ratchet, not a substitute for
`tsc`.)

## Verification

Core **4861 passed / 0 failed** · guard **9/9** with `KNOWN_UNWIRED`
**unchanged** · `pnpm test:gate` **exit 0** · lint clean · core `tsc`
**0 errors**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:11:42 -07:00
gsxdsm
ed907e93dc chore: tighten the FNXC future-date baseline as the clock advances (#2953)
Retires `packages/core/src/task-store/reads.ts`'s allowance of 2. Its
FNXC stamps are no longer in the future, so the headroom they held is
removed rather than left sitting where a genuine regression could hide
inside it.

## This is the drop path working, not a fix

The gate watches a **clock-dependent population**: stamps age out of
"future" on their own, with no code change. That is why the drop path
auto-lowers the baseline and exits 0 instead of failing.

The alternative — failing on drops, the way a code-measured ratchet
should — would have turned this repo red on a day nobody touched it, and
the noise would have trained everyone to re-baseline without looking.
**A ratchet may only fail on drops when its measurement depends solely
on code.** This one does not, so it tightens silently and a commit like
this one records the new floor.

## What I verified

- Second consecutive run exits 0 with no further diff — the tightening
is idempotent, not an oscillation between two states.
- Diff is a single removed line; the entry is deleted rather than set to
`0`, so the file re-enters as a genuine addition if a future-dated stamp
lands there again.
- `467` known future-dated stamps remain across 237 files, unchanged.

## What this does not do

It does not reduce the future-dated stamp count — those 467 are still
there and still wrong. They came from agents (me included) stamping FNXC
comments a day or two ahead. This only reclaims the allowance for one
file that has aged out. **The count will keep falling on its own without
anyone fixing anything**, so it should not be read as cleanup progress;
the gate's value is blocking *new* future stamps, which it still does.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:56:13 -07:00
gsxdsm
255741e9ab fix(gate): scan every file type that carries an FNXC stamp (#2954)
The walk's extension filter was `/\.(tsx?|m?js|cjs|md)$/` — the file
types stamps were **expected** in, rather than the ones they **occur**
in. Wherever the convention spread on its own, the gate could not see
it.

## How I found it

Chasing four stamps dated `2026-10-19` — three months out, so unlike the
rest of the population they would not age out on their own. All four
were in `packages/core/dist/`, which the gate correctly skips as
generated. The *source* they were compiled from is a `.sql` migration,
which the gate skips for a different and much worse reason: it was never
scanned at all.

## Why `.sql` is the expensive omission

A migration's stamp records **when a schema change landed**. That is the
case where a wrong date misleads most — it is the file you read to
reconstruct the order schema changes happened in. 69 migration files
carry stamps; 10 were future-dated and none were visible.

`.css` had drifted furthest by volume: **1023 stamps across 123 files**,
almost all from the dashboard CSS split. `.html`, `.ya?ml`, `.json`,
`.sh` are included too; they add coverage but contribute no baseline
entries.

## The 9 new baseline entries are newly VISIBLE, not new

5 `.css` + 4 `.sql`. Every one predates this change and would have been
caught had the gate ever looked. Recording them is a
**reclassification**, the same distinction the census draws for its
DELIBERATE-LITERAL marker — a baseline that grows here is the gate's
coverage improving, not the codebase regressing. Reading the rise as a
regression would be exactly backwards.

## Verified by mutation, not by reading

- A future-dated stamp appended to `ChatView.css` → gate **exit 1**.
- A future-dated stamp appended to `0036_chat_session_tags.sql` → gate
**exit 1**.
- Both reverted → **exit 0**.

Without this, both probes pass silently.

## Two notes on the diff

- **Zero removals.** My first attempt rewrote the baseline with sorted
keys, which turned unmoved lines into add/remove pairs and made it look
like entries were being dropped. Rebuilt in walk order so the diff is
additions only.
- `reads.ts` is deliberately left at `2` here even though it now
measures `0`. That drop belongs to #2953; duplicating it across two open
PRs is how this queue got tangled before. The gate auto-tightens it at
runtime and still exits 0.

## What this does not fix

The **478** future-dated stamps still in the tree. They are
agent-written (mine included) and most are one or two days out, so the
count falls on its own as the clock advances — it should not be read as
cleanup progress. This PR only makes the gate able to *see* the SQL and
CSS ones, so no new stamp can land there unnoticed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:53:25 -07:00
gsxdsm
b8e5d42b7a chore(gate): move the FNXC date ratchet beside its three siblings (#2952)
The half that #2948 and #2950 did not cover. Both of those fixed today's
redness; **#2949** landed the un-redding first, so both are now
conflicting and redundant. This is the placement, which is what made
today's failure so expensive.

## Why it hurt

`check-fnxc-future-dates` was wired into `pretest` **and** `test:gate`,
with no `check:*` script and no `pr-checks.yml` step. So a baseline
frozen below the tree it froze did not produce "one CI step is red" — it
produced:

- `pnpm test:gate` → exit 1, merge gate down for everyone
- `pnpm test` → refuses to run before a single test executes

## The precedent

All three sibling ratchets are dedicated `pr-checks.yml` steps.
`lifecycle-column-census` always has been; `check-sql-column-literals`
and `check-inert-flag-seams` moved there in #2941. The census's own
header states the reason, and it is the one that matters here:

> a permanently-red gate is a bigger hole than a stale allowance,
because it gets ignored and then nothing is guarded at all

## The change

```
check:fnxc-future-dates          script, beside check:inert-flag-seams
"FNXC stamp dates"               step in pr-checks.yml, after the other three
removed from                     pretest / pretest:full / test:gate
```

**Enforcement where it matters is unchanged** — `pr-checks.yml` is the
blocking gate, so a newly added future-dated stamp still cannot merge.
What changes is that a baseline mismatch stops halting work unrelated to
it.

## Deliberately not touching

The drop behaviour. This gate **already** tightens on a drop rather than
failing — the #2888 pattern, already correct here. I checked rather than
assuming it needed the same fix its siblings did.

## Verification

`pnpm check:fnxc-future-dates` exit 0 · `pnpm test:gate` green (now
without this check in it) · lint 0 · step confirmed adjacent to the
other three ratchets.

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

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

## Summary by CodeRabbit

* **Chores**
  * Added automated validation for FNXC stamp dates to lint checks.
* Updated test and validation scripts to run the date check through a
dedicated command.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:33:00 -07:00
gsxdsm
8e5e1147d2 core,engine: the last literal lifecycle query — and the three stall signals that disagreed (#2951)
**This is the last one.** `surfaceInReviewStalls` was the final literal
`listTasks({ column })` in production — I verified it by direct scan,
not by census arithmetic: **1 remaining before this, 0 after.**

It tells an operator that a card is stalled in review. On a renamed
board the stall was real and the board simply never said so.

## It came last on purpose

Converting the read alone would have been **worse than leaving it**.
`getInReviewStallReason` gated on the literal `in-review` itself, so a
widened read hands every renamed-board card to a classifier that drops
it — the missed-pair class, wearing the shape of a clean one-line
conversion.

## What was actually there

Three sibling signals decorate the same row, and they **disagreed about
which lane it is in**:

| signal | before |
| --- | --- |
| `getInReviewStalledSignal` | singular `reviewColumn` — resolved, but
**first-per-role** |
| `getStalePausedReviewSignal` | singular `reviewColumn` — same |
| `getInReviewStallReason` | **no seam at all** — literal |

So one row could be judged in-review by one signal and not by another.
And the singular ones are the **arity trap**:
`resolveLifecycleColumns().review` is the *first* column carrying a
review role, so a board with a separate merge lane beside its
human-review lane had a second review column matching none of them.

All three now take `reviewColumns` (membership), resolved **once per
row** through `resolveReviewColumns` — the union of the three review
roles — so they cannot disagree by construction. The singular/literal
paths remain as the no-metadata fallback, so a caller passing nothing is
byte-identical to today. Ten call sites in `reads.ts` wired from that
one answer; the singular resolver is deleted.

## Revert results

Each applied alone and re-run:

| conversion | reverted → |
| --- | --- |
| the resolved read | fails — the card is never listed |
| `reviewColumns` at the call | fails — the classifier drops the renamed
card the widened read just found |

That second row is the whole point: it proves the pair had to move
together, which is the thing I got wrong twice earlier in this series.

## Second commit: a red on `main`, not from this branch

`check-fnxc-future-dates` landed and **`main` fails it** — verified by
running the script on a clean `origin/main` checkout rather than
inferring. Nine files carry stamps dated after today, so every worker's
gate fails on a check none of their changes caused. Several are mine: I
had been stamping tomorrow's date across this whole series, which is
precisely the out-of-order record the check exists to prevent.

Scope held deliberately: a repo-wide sweep touched **266 files** across
docs, scripts and every package. I ran it, backed it out, and limited
this to the nine files the check actually flags — a mechanical rewrite
that size during a queue freeze would conflict with every in-flight
branch, which is worse than the red it fixes.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71 (green **only** with the stamp
commit); `@fusion/core` full suite **4810 passed**; engine self-healing
+ blindness + both ratchets **758 passed**; `tsc` clean on core and
engine; `pnpm lint`, `check:changesets`, `lifecycle-column-census
--strict`, `check-sql-column-literals` and `check-fnxc-future-dates` all
clean.


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

## Summary by CodeRabbit

- **Bug Fixes**
- Review-stall detection now recognizes renamed and multiple review
columns while retaining support for the legacy review column.
  - Paused tasks continue to be excluded from stall detection.
- Self-healing review-stall sweeps now search all configured review
lanes and avoid duplicate task results.

- **Tests**
- Added regression coverage for renamed and legacy review-lane queries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 20:30:18 -07:00
gsxdsm
f49e487d91 feat(core): untraited-project lane opt-in — and main was red on the FNXC gate (#2949)
Two things, and the second is why the first does not ship alone.

## The opt-in

`resolveProjectColumnsForRoles` gains `untraitedProject:
"declared-columns"`. When **no** workflow in the project expresses
**any** lifecycle trait, every declared column id joins the answer.

This is the three-state rule at **project** scope — the last item on the
deferred list, recorded at three self-healing call sites (#2869, #2876).
A board that renames its lanes and declares no traits contributes
nothing today, so its cards are **absent from every role-keyed query**,
and the correct per-card fallback downstream never runs for them. A
fallback cannot rescue a card the query never returned.

**Not "no workflow declares this role."** A project that expresses
traits and has no review lane has *answered*; widening there would
invent lanes it deliberately lacks. Mutation-verified both directions —
widening unconditionally fails 1 of 12, making the option a no-op fails
1 of 12.

**Opt-in, not default**, because the safe direction differs per caller —
the finding in `project-union-versus-per-task-lanes.md`:

| caller | over-inclusion costs |
|---|---|
| sweep | nothing — the per-card check discards the extra rows |
| aggregator | an inflated number an operator reads (#2864, #2866) |
| action site | a card routed or notified under a vocabulary that is not
its own (#2852, #2891) |

Making it the default moves all three at once, in the one direction two
of them must not. Verified byte-identical without the option, so this
lands with **no caller changes** and each site adopts it on its own
reasoning.

## Main was red, and my own gate caught me first

I dated the new comments `2026-07-31` while today is `2026-07-30` —
**the exact defect `check-fnxc-future-dates` exists to prevent,
committed while writing the feature.** The gate I added yesterday failed
my own commit.

Correcting mine surfaced that the merged sentinel batch, #2947, and
three engine test files carried future-dated stamps too, so **the gate
was failing on `main` for everyone**, not just here.

All corrected to real dates rather than raising the ceiling. The stamps
were simply wrong, and a baseline bump would have recorded the error as
permitted — which is the failure mode that ratchet exists to prevent.

Core and engine `tsc` clean, `pnpm lint` clean, census `--strict` 0,
FNXC gate 0 (469 known, none added), gate green (161/487/13/71).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:17:18 -07:00
gsxdsm
b1bd571682 batch-sql-ratchet: the census / gate-ratchet family — collection branch, fold here (#2941)
## Family branch for consolidation directive item 4

`batch-sql-ratchet` did not exist and ~10 open PRs are waiting for a
collection point, so this establishes it. **Fold your census/ratchet
commit here and close your own PR as superseded.**

```bash
git fetch origin batch-sql-ratchet
git checkout -B batch-sql-ratchet origin/batch-sql-ratchet
git cherry-pick <your-sha>
# verify scoped, not full suite:
pnpm --filter @fusion/core exec vitest run src/__tests__/archived-column-gate-parity.test.ts --silent=passed-only --reporter=dot
git push origin HEAD:batch-sql-ratchet
```

**Candidates I can see open right now** (owners: please fold + close):

| PR | branch |
|---|---|
| #2938 | `fix/comments-ops-sentinel` |
| #2935 | `fix/task-artifacts-sentinels` |
| #2933 | `chore/commit-tightened-census-baseline` |
| #2931 | `fix/async-comments-sentinels` |
| #2928 | `fix/audit-ops-sentinel-marker` |
| #2925 | `live-task-column-lanes` |
| #2923 | `fix/task-id-integrity-sentinel` |
| #2921 | `fix/plugin-store-migration-marker` |
| #2894 | `gate/sql-literals-match-census-placement` |

That is **10 → 1** once folded. I have not cherry-picked anyone else's
commits — folding someone's work without them verifying it is how a
batch lands broken.

---

## What is in it so far (mine, from #2924)

**Clears a live main red:** `archived-column-gate-parity` fails on
`origin/main` today.

```
AssertionError: TypeScript encoding changed.
  async-comments-attachments.ts: 8 → 5
```

#2886 fixed a real bug — archived-document guards failing in *opposite*
directions on a renamed lane — by replacing three `column ===
"archived"` comparisons with `isArchivedLane(column, archivedColumns)`.
The AST scan counts raw comparisons, so the tally dropped.

**What I did not do is record it as three sites converted**, because
measured, it is not:

```
grep -rn "archivedColumns:" packages/core/src packages/engine/src --include="*.ts" | grep -v __tests__
→ (no matches)
```

No caller passes it. The parameter defaults to `LEGACY_ARCHIVED_LANES =
new Set(["archived"])`, so every call resolves to the literal it
replaced — byte-identical behaviour, resolved branch dead.

That matters for this guard's whole argument: its header warns that
converting the TypeScript half while the Drizzle and raw-`sql` halves
still compare the string is a split brain *"no test would catch, because
every builtin workflow spells the column `archived` so the two halves
agree by accident on every board we ship."* **There is no split brain
today precisely because the resolved half is unwired** — it becomes one
the moment a caller threads real lanes in without the SQL sides moving.
Recorded inline so `5` cannot be read as "3 sites done"; flagged on
#2886.

Verified not a split brain: the Drizzle and raw-sql inventories are
unchanged and both pass — worth stating because those assertions run
*after* the TypeScript one, so a plain red says nothing about them.

Scoped edit to `AUDITED_TS_SITES` by line range: these paths appear in
more than one inventory here, and an unscoped replace would quietly edit
the raw-sql side too, making the parity guard agree with itself (the
trap I hit in #2817).

Guard still bites: appending a real `task.column === "archived"` to an
audited file fails it. Core **4852 passed / 0 failed**, lint clean,
test-only.

Closing #2924 as superseded by this.

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

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

* **Bug Fixes**
* Improved task delegation messages when workflow pickup cannot be
confirmed.
* Delegation results now clearly indicate when a task has not been
verified for pickup.

* **Quality Improvements**
* Added validation checks to catch future-dated markers and inconsistent
SQL-column usage.
* Refined workflow checks to distinguish stale configuration from
incomplete configuration.

* **Documentation**
* Updated lifecycle conversion guidance with more accurate audit
findings and limitations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:59:14 -07:00
gsxdsm
c3df0f641b executor: orphaned tasks were never resumed after a restart on a renamed board (#2947)
`resumeOrphaned` is the only path that recovers tasks after a crash or
restart. On a board with renamed columns it recovered **nothing**.

## A missed pair, not an unconverted read

```ts
const tasks = await this.listWipLaneTasks();          // resolved by role — already converted
const inProgress = tasks.filter(
  (t) => t.column === "in-progress" && …,             // literal — discards everything the read found
);
```

The read was already resolved. The filter directly beneath it
re-asserted the literal on the rows that read returned, so the sweep
found the orphans and threw them all away.

**This is the worse half of the class, and it hid well:**

- the read *looks* converted, so scanning for `listTasks({ column: "…"
})` finds nothing;
- the census scores only the comparison, so the backlog number moves the
**wrong way** as you convert;
- a **structural test already existed** pinning "the read asks for
resolved lanes" — `executor-resume-query-lanes.test.ts` — and it was
green the entire time the sweep was dead. A test asserting the read
exists says nothing about the filter beneath it.

The failure only surfaces after a crash, when an operator is already
investigating the crash and has every reason to blame that instead.

## The ratchet, generalised

#2944 ratcheted this class inside `self-healing.ts` after review found
one instance and a follow-up audit found five more. This generalises it
to every engine source: a function that resolves lanes **and** compares
a column id in the same body is a pair.

Excluded, deliberately:
- the **fallback arm** of a resolved ternary (`lanes ? lanes.has(c) : c
=== "done"`) — the correct shape;
- four files whose literals are deliberate, each with the reason
recorded: `ephemeral-worker-manager` (unresolvable-workflow default),
`triage` (the U11 orphan case), `scheduler` and `replan-target` (sync
listeners on the inert sync IR reader, already pinned by
`sync-workflow-ir-is-always-default.pg.test.ts`);
- `self-healing.ts`, because it has a **dedicated** ratchet that is
strictly more precise. Two ratchets allowlisting the same site is one
fact with two owners, free to drift — the exact failure mode this
program keeps hitting. One file, one ratchet.

It carries a positive control: a wrong source path would make every case
pass by scanning nothing.

**I swept the rest of the engine with it and executor.ts was the only
genuine hit** — everything else is documented-deliberate or blocked on
the inert sync reader.

## Revert results

Each measured by restoring the literal filter and re-running:

| | reverted → |
| --- | --- |
| behavioural case | fails — the renamed card is dropped and the sweep
returns before touching it |
| the ratchet | fails, naming the site: `resumeOrphaned:
executor.ts:5974` |

A non-vacuous companion (card in the review lane → not resumed) rules
out a filter that matches everything: a card in review has no session to
resume, and re-dispatching it would restart finished work.

**Measured:** `executor.ts` column guards 8 → 7; baseline re-recorded
downward.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; executor prompt/soft-delete/resume
suites plus the new ratchet, 357 passed; `tsc` engine clean; `pnpm
lint`, `check:changesets`, census `--strict` and
`check-sql-column-literals` clean, each run explicitly.
2026-07-30 19:59:03 -07:00
gsxdsm
8503a2b12f batch-census-sentinels: six sentinel-marker PRs in one (supersedes #2921 #2928 #2931 #2935 #2938 +1) (#2943)
Fifth family, not in the four you listed — it was about to sit while the
others consolidated. **Six folded; two need arbitration.**

## Folded (cherry-picked clean)

migration marker · async archived check · audited-sentinel missing its
marker · five of six `archived` checks in one file · the two
artifact/comment read-only guards · the last unmarked
`getLiveTaskColumn` sentinel.

One root cause, which is why they belong together: **a literal compared
against a SENTINEL value is not a lifecycle-lane guard** — the census
counts it, and the fix is a marker, not a conversion.

## The baseline conflicted on every cherry-pick

All six re-recorded `lifecycle-column-census-baseline.json`
independently. I resolved by **regenerating once from the folded tree**
rather than merging six hand-edits: the baseline is a derived artifact,
so the measured value is the only correct resolution, and hand-merging
derived JSON is how a wrong ceiling gets locked in.

That is the strongest case for the family model I can give you: six PRs
touching one derived file conflict pairwise regardless of merge order —
15 possible pairs — and auto-rebase would have churned them serially.

## NOT folded — one line for arbitration

**#2925 (`live-task-column-lanes`) conflicts with #2923
(`fix/task-id-integrity-sentinel`) on
`packages/core/src/task-store/task-id-integrity.ts`.** #2923 marks a
sentinel there; #2925 converts lanes. Different intents, same file. I
did not guess which wins — land one, rebase the other, fold both after.

## Verification

`--strict` exit 0 · backlog **158**, reviewed **122** · core typecheck
clean · scoped, not full suite.

## Queue

**52 → 39** after my two folds (this + #2940 portal). The ~24
"self-healing … on a renamed board" family is still the dominant block.

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

## Summary by CodeRabbit

* **Documentation**
* Clarified lifecycle-state terminology and migration markers throughout
task and project management documentation.
* Documented the distinction between archived-task sentinels and
workflow column identifiers.
* Updated lifecycle documentation tracking to reflect the latest
coverage.

* **Bug Fixes**
  * No runtime behavior changes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:41:09 -07:00
gsxdsm
8b75a42d22 batch: self-healing sweeps were blind on renamed boards (26 sweeps, folds 23 PRs) (#2944)
**Consolidation of 23 open PRs into one.** Every one shared a single
root cause and mostly touched a single file; 23 CI runs for that was
indefensible.

Folds and supersedes: #2867 #2869 #2876 #2879 #2883 #2891 #2899 #2901
#2902 #2905 #2906 #2914 #2916 #2918 #2919 #2920 #2922 #2927 #2929 #2932
#2934 #2937 #2939.
(#2865, #2882, #2897, #2909, #2912 already merged and are not
re-folded.)

## The root cause

A self-healing sweep selects its work with `listTasks({ column:
"in-review" })`. On a board whose lanes are renamed that returns
**nothing**, so the sweep never runs — no error, no log line, no failed
task. Several sweeps had already had their *predicates* converted to
resolved lanes, which dropped a census count and changed nothing,
because the query above the loop had already returned an empty list.

**26 sweeps converted.** Each one: read the project's columns for the
role, then decide each card against **its own** workflow, with the
legacy ids unioned so a board mid-rename is never skipped.

## What each sweep stops silently failing to do

| | |
| --- | --- |
| stale merger status | one finished card held the **merge queue** for
everything behind it |
| stale `blockedBy` / completed-task release | dependents stayed blocked
on work that had already finished — the board stops moving |
| workspace partial lands | a task left with **some repos merged and
some not** |
| mid-merge retry stamp | the card stalled *and* the operator's manual
Retry was gated by the same stamp |
| in-progress limbo / no-progress failures | dead cards held a work slot
forever |
| partial-progress retry | real work parked failed with its **retry
budget unspent** |
| orphaned-execution signal | visibility only — the one signal pointing
at an orphan went silent |
| zero-commit audit | went **half-blind**: the error arm kept working,
the lane arm did not |

Plus: ghost review cards, transient merge failures, misclassified
failures, branch misbinding, missing-worktree failures,
merged-but-unfinished finalization, done-metadata repair, self-owned
branch conflicts, orphan-only scope violations, post-done wedges, idle
assigned agents, PR-conflict worktree ownership, and orphaned workspace
worktrees.

## Two defects the conversion itself introduced, both caught and fixed

1. **Missed pairs.** Widening a read without converting the guards
beneath it is *worse than not converting*: the sweep starts admitting
renamed-board cards and then mis-decides every one. Review caught a
second guard on a re-read row; the audit that triggered found **five
more**, one of which gates the `reviewProof` triple-proof — a renamed
review card would have been moved backward with the safety check
silently skipped. Column guards 86 → 81.
2. **Duplicate processing.** The literal reads were disjoint by
construction; resolved reads are not, so a column carrying two role
flags put one card in two buckets — duplicate moves, duplicate audit
rows, inflated counts.

Both now have ratchets.
`self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts`
**derives** its sweep list (a sweep counts as converted when its body
calls `resolveProjectColumnsForRoles`), so it cannot go stale, and it
carries two positive controls because a broken regex finds no offenders
and a broken derivation iterates nothing — an empty loop registers no
tests and reads green.

## Deliberately unchanged

- 22 `moveTask` destinations carrying `recoveryRehome: true` —
`moves.ts` exempts these so a card stranded in an undeclared column
stays rescuable.
- One literal in `clearStaleBlockedBy`'s log-dedup closure (allowed by
name in the ratchet, with the reason).
- `surfaceInReviewStalls` — hot list-read path, needs a batched
prefetch; that is a performance design decision, not a conversion.
- `scheduler.ts` and `replan-target.ts` — built on
`resolveTaskWorkflowIrSync`, which returns the default IR for every task
in production. Converting there produces inert code.

## The fold itself is worth one note

All 23 branches appended to the **same test file at the same anchor**,
so every automatic strategy — git 3-way, `merge-file --union`, and three
hand-written resolvers — interleaved them mid-block. Two attempts
committed conflict markers before I caught it. The file is therefore
**reconstructed**: head authored once, body assembled as the union of
each branch's own intact top-level segments keyed by test title, with
the nested `already-merged hard blocker` describe appended whole
(flattening it orphaned its helper). Verified by *parsing after every
step* rather than trusting the merge — which is how each interleaving
was caught.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71. Scoped suites 592 passed
(self-healing, the blindness suite at 68 cases, the ratchet, and the
notification suite). `tsc` engine clean; `pnpm lint`,
`check:changesets`, `lifecycle-column-census --strict` and
`check-sql-column-literals` all clean, each run explicitly.

Each folded conversion was individually revert-proven on its original
branch — the read reverted alone, and the per-card verdict reverted
alone — and those measurements are recorded in the commit messages
carried into this branch.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:40:57 -07:00
gsxdsm
7b68f20501 batch(docs): fold the three workflow-learnings / annotation PRs into one (#2942)
## Family batch — replaces #2926, #2892, #2887

Per the consolidation directive: the u9/e2e **docs family**, folded into
one branch and one CI run. Three PRs, five commits, **five files,
comment and markdown only**.

| folded PR | commits |
|---|---|
| #2892 `docs/union-vs-per-task` | the project union and the per-task
answer are not ranked; date correction |
| #2926 `docs/date-my-measured-claims` | date the measured claims (one
was wrong); date the grep-vs-AST measurement in the SQL gate header |
| #2887 `docs/archived-state-literals` | mark the three archived STATE
literals as deliberate |

Cherry-picked in original order with authorship preserved; all five
applied clean, no conflicts.

## Scope is provably comment-only

```
docs/solutions/workflow-learnings/lifecycle-conversions-that-score-as-wins.md
docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
packages/core/src/task-store/async-maintenance.ts        ← FNXC DELIBERATE-LITERAL annotation
packages/core/src/task-store/workflow-definitions.ts     ← FNXC DELIBERATE-LITERAL annotation
scripts/check-sql-column-literals.mjs                    ← header prose only
```

Every added line in `packages/` and `scripts/` is inside a comment —
checked by filtering the diff for declarations, conditionals and
returns, which returns nothing. The two core files gain
`DELIBERATE-LITERAL` markers explaining that `'archived'` is a **state**
marker there, not a lane: the sweep collects rows Fusion itself archived
or soft-deleted, so widening to the resolved archived set would pull
live cards into a cleanup pass.

## Verification (scoped, per the directive — not the full suite)

- `pnpm lint` — clean
- `check-sql-column-literals` — exit 0 (the file it annotates)
- `check:lifecycle-columns` — exit 0 (the markers it adds are
census-visible)
- `sync-workflow-ir-callsite-allowlist.test.ts` — 3/3

## A correction worth recording

Mid-fold I saw a changeset, `self-healing.ts` and a test file in `git
diff origin/main..HEAD` and nearly reported the batch as impure. They
were **main's own commits** — `origin/main` advanced between branch
creation and the diff, so the comparison was against a stale base.
Rebasing onto current `main` reduced it to the five files above. Worth
flagging for anyone else folding a family today: with `main` moving this
fast, diff the branch **after** rebasing or the file list will lie to
you.

## Closing the originals

#2926, #2892 and #2887 are superseded by this and are being closed. I
hold no PRs of my own in this family — all mine merged — so this fold is
on behalf of the family rather than a rollup of my own work.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:12:26 -07:00
gsxdsm
b4ed12e9c8 batch-u7-lane-fixes: three core/engine renamed-board fixes folded (was #2925, #2930, #2936) (#2925)
**Consolidated per the queue freeze.** Three single-fix PRs of mine
folded into this one branch; #2930 and #2936 are closed as superseded.
Net effect on the queue: **3 → 1**.

All three are the same root cause — a lifecycle lane compared against a
legacy id — and all three carry a measured revert proof. Verified scoped
(not full suite) on the folded branch: `tsc --noEmit` clean, `pnpm lint`
clean, SQL-literal gate green, census `--strict` green, and 61 tests
across five suites plus the guard at 9/9.

---

### 1. `getLiveTaskColumn` produced the archived sentinel from a literal
(was #2925)

`getLiveTaskColumn` **manufactures** the string `"archived"` that a
dozen comparisons across five files trust — and it tested `row.column
=== "archived"`. A live row in a renamed archived lane read as **live**,
so the gates hiding an archived card's artifacts and document listings
never closed.

Fixing those twelve comparisons individually would have been wrong twice
over: **they are sentinels, and the defect was in the producer.** One
line, once, and all twelve become correct. `resolveArchivedLanes` moved
to `project-lane-vocabulary.ts` — three private copies of one fact is
how the "write guard says yes, publication guard says no" disagreement
happens at scale.

*Revert proof (real PostgreSQL):* restore the literal → `expected [ {
…(14) } ] to deeply equal []`.

**Caught myself shipping the unwired shape here:** I added the parameter
to seven functions and wired none of their impl callers — the exact
inert-conversion defect this program exists to remove. The failing test
is the only reason I noticed.

### 2. Mission delivery repair refused a completed card (was #2930)

`getTerminalTaskEvidence` tested only `column === "done"`, so a
completed card on a renamed board classified as `nonterminal` and
`reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL: … not
shipped`. Valid operator work refused — with the message naming the real
column while the check couldn't see it.

The **type** blocked the fix from the far end: `TerminalTaskEvidence`
pinned `column: "done"` / `"archived"`, so the resolver couldn't report
the real column without a compile error. `kind` already carries the
role, so `column` is free to carry the truth.

*Revert proof (real PostgreSQL):* restore the literal →
`TerminalTaskReconciliationError: … not shipped`.

I had deferred this twice on the premise that `AsyncMissionStore` "holds
a layer, not a store". It holds an **optional `taskStore`**, and the
single production construction site supplies it.

### 3. The unwired-lane guard reported two FALSE entries (was #2936)

`unwired-lane-parameter-guard.test.ts` has been **red on main** since
#2875, flagging two `InReviewDurationLanes` properties as unwired when
the impl demonstrably supplies both. Cause: my own owner-scoping rule
requires a mention from a file naming the declaring symbol — correct for
a function, structurally impossible for an interface passed as an
inferred object literal.

Fixed at the caller (name the type) after trying the tool three ways:
relaxing type-owned properties hid **12** genuine entries; resolving
owners to consuming functions hid **6**. Each refinement traded the
false positive for false negatives — the sign a co-occurrence heuristic
has hit its limit. Recording two *wired* parameters in `KNOWN_UNWIRED`
was rejected: that puts non-debt in the debt list, which is how a
ratchet starts lying.

Guard back to **9/9**, baseline unchanged at 17. **This un-reds main.**

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:07:09 -07:00
gsxdsm
97c987c40b batch-portal-test-fixes: both portal query-root failures in one PR (supersedes #2911, #2913) (#2940)
Family fold: #2911 + #2913, cherry-picked clean. One root cause
(portalled modals queried the render container instead of
document.body). Scoped verification: 167/167. NOTE: the portal family is
2, not ~8 — the real jam is ~24 self-healing 'renamed board' PRs plus an
unlisted census/sentinel family of ~9.

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

## Summary by CodeRabbit

* **Tests**
* Updated modal test coverage to correctly validate content rendered
through portals.
* Improved assertions for task counts, timestamps, mobile detail views,
and keyboard-related behavior.
* Added test comments clarifying portal-based rendering and assertion
behavior.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:06:57 -07:00
gsxdsm
c6767cb258 self-healing: foreign-only contamination never cleared on a renamed board (fourteenth sweep) (#2891)
`recoverForeignOnlyContaminatedInReviewTasks` classifies a branch
carrying **only foreign commits** and clears the contamination park that
nothing else clears. Two literal reads meant that on a renamed board it
classified nothing, and the task stayed parked indefinitely.

## The two redundant guards were the interesting part

Both filters carried a `task.column === …` check that was **redundant**
while the query pinned the column. Under a resolved read they stop being
redundant and become the per-card verdict — so they convert here rather
than being deleted. Deleting them would have silently widened the sweep,
which is the failure this whole class is about.

## Dedupe matters more here than elsewhere

The concatenated candidate list is deduped (the P1 reviewed on #2879).
It bites harder in this sweep because the two filters have **different
predicates**: a column carrying both a review role and the wip role
could satisfy both and classify one branch twice.

Explicit `has` guard rather than `new Map(entries)` — that constructor
keeps first insertion *order* but the **last** value for a repeated key,
so it reads as first-bucket precedence while doing the opposite.
(Corrected in #2879 and #2883 for the same reason.)

## Revert results

Each applied alone and the file re-run:

| conversion | reverted → |
| --- | --- |
| the resolved reads | fails — the card is never listed, so the
classifier is never called |
| the review verdict | fails — the renamed review lane does not match
and the card is filtered out |

Observable is **candidacy**: `classifyForeignOnlyContamination` runs
once per accepted card and not at all for a rejected one, which is
exactly the read-plus-verdict under test. It is a static named import,
so it is intercepted with a scoped `vi.mock` (spyOn cannot rebind an
already-resolved ESM binding); only that one export is overridden, so
the sweeps in this file that use `inspectBranchConflict` are unaffected.

A non-vacuous companion (same card in the board's hold lane → never
classified) rules out a read that returns everything.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71, plus `self-healing.test.ts` 412;
`tsc` engine clean; `pnpm lint`, `check:changesets`, census `--strict`
clean, each run explicitly.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:26:06 -07:00