Commit Graph

12796 Commits

Author SHA1 Message Date
gsxdsm
511f5b7e2b fix(core): archived tasks leaked into the live feed on a renamed board (#3041)
From my own #2839, re-measured today. Of that issue's SQL-literal sites,
this is the one that decides what a **live view** shows.

## The defect

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

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

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

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

## The fix

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

## Surface enumeration — three of four sites are dead

Four sites share this invariant. Verified rather than assumed:

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

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

## Measured

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

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

## One process note

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:08:49 -07:00
gsxdsm
0b10f6ccd3 test(docs): validate nested TOC anchors, and fix the slugify that hid one (#3039)
**Stacked on #3036** (its commit is the parent). That PR fixes a guard
that had been red on `main`; this closes the gap it leaves and, in doing
so, turned up a second defect in the helper.

## 1. Nested anchors were accepted but never resolved

#3036 makes the parser recognise sub-entries — correct, and it fixes the
red. But it validates only their link *shape*. Measured on that branch:

| corruption | result |
|---|---|
| **nested** entry → `#kb-nonexistent-anchor` | **passes** |
| **top-level** entry → `#kb-nonexistent-anchor` | fails |

A TOC guard exists so links resolve. Checking that for one class of
entry and not the other leaves a dead sub-link to be found by a reader
clicking it.

## 2. Resolving them exposed the slugify bug

Adding the check failed immediately — on the **real document**, against
a heading that exists:

```
Nested TOC anchor #theming--overlay-layering-for-dashboard-views matches no heading
```

The document is right; the helper was wrong. `slugifyHeading` collapsed
whitespace **runs**:

```js
.replace(/\s+/g, "-")     // theming-overlay-layering-...
.replace(/\s/g,  "-")     // theming--overlay-layering-...  ← GitHub, and the doc's own link
```

GitHub emits one hyphen **per space**. `### Theming & Overlay Layering
for Dashboard Views` loses the `&` and keeps both spaces, so the true
anchor carries a double hyphen.

**This was latent, not dormant-and-harmless:** the two spellings differ
only when punctuation is stripped from *between* words, and all eighteen
numbered section titles are punctuation-free — so every existing use of
the helper agreed. The first heading with an `&` in it would have
produced a false failure against a correct document, which is the shape
most likely to get a guard edited rather than believed.

## Mutations (all four)

| mutation | result |
|---|---|
| clean | 4/4 pass |
| nested anchor broken | **fails** ← was green before this PR |
| top-level anchor broken | fails |
| malformed top-level line | fails |
| `slugify` reverted to collapsing | **fails** — the helper fix is
load-bearing |

Lint clean, FNXC gate exit 0. Test-only.

## Note

This is the fifth guard in this batch to ship with a hole found by
mutating it rather than reading it, and the second where fixing one
class of input revealed the checker had been quietly wrong about
another. The pattern is consistent enough to be worth expecting: **when
a guard starts examining something it previously skipped, the first
thing it finds is usually its own bug.**

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved heading links to match GitHub-style anchors when punctuation
separates words.
* Enhanced nested table-of-contents validation to confirm links point to
headings in the document.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:06:03 -07:00
gsxdsm
49fb39e644 fix(ci): prune six phantom paths from the shard-timing snapshot, red on main since their features died (#3040)
## Six phantom paths in the shard-timing snapshot

```
AssertionError: timing snapshot references missing test files:
packages/core/src/__tests__/dependency-blocked-todo-report.test.ts
packages/core/src/__tests__/workflow-parity.test.ts
packages/engine/src/__tests__/dependency-blocked-todo-reporter.test.ts
packages/engine/src/__tests__/reliability-interactions/meta-archive-guard-composition.test.ts
packages/engine/src/__tests__/reliability-interactions/meta-chain-auto-close.test.ts
packages/engine/src/__tests__/self-healing-meta-archive-guards.test.ts
```

The guard's own note says why this matters past the red assertion:
*"stale or phantom-path entries silently degrade CI shard balancing and
can hide merge-gate regressions."*

**The balancing cost is not academic.**
`self-healing-meta-archive-guards` carried **20700 ms**. A shard has
been budgeting 20.7 seconds for a file that cannot run, plus five more.

## Deleted, not moved — checked one at a time

A moved test should be **repointed** to keep its measurement; only a
deleted one should be dropped. So I resolved each rather than assuming:

| file | deleted by |
| --- | --- |
| `dependency-blocked-todo-report` / `-reporter` | `710d56b2db` — U4
trim, the feature deletion |
| `workflow-parity` | `4158cf1ab7` — Phase A workflow-owned lifecycle |
| `meta-archive-guard-composition`, `meta-chain-auto-close`,
`self-healing-meta-archive-guards` | `0e3d2a2265` — meta-task
auto-archive/auto-close deletion |

All six died with their features. None has a surviving equivalent
anywhere in `packages/`.

## Pruned, not regenerated

Regenerating means a full measured suite run and rewrites all 2499
entries — burying six deletions inside a wholesale diff nobody can
review. `capturedAt` is **6.8 days old against a 30-day budget**, so the
snapshot is otherwise current and the other **2493 measurements are
untouched**.

The diff is exactly six deleted lines, `capturedAt` unchanged.

## Revert proof

Pruning a list is worthless if the assertion cannot fail afterwards, so
I re-introduced a phantom entry:

```
with a fake path   →  ℹ pass 11   ℹ fail 1   (references missing test files)
removed            →  ℹ pass 12   ℹ fail 0
```

## Third of the red suites, and still not a sweep

Mechanical, like `verify-fast` (#3038). Unlike
`workflow-reliability-release-check`, whose acceptance map cites 13 test
files with **8 missing** and **2 of 5 rows carrying zero surviving
evidence** — that one needs its owner, and a repoint would launder the
gap (diagnosed on #3036).

Same failure phrasing, opposite safety. That is why these are going one
at a time.

## Verification (measured)

- this suite — **12 passed / 0 failed** (was 1 failed)
- snapshot integrity — 2493 files retained, `capturedAt` unchanged, no
reformatting

No changeset: CI tooling data.
2026-07-31 02:05:51 -07:00
gsxdsm
19b97e9f09 test(verify-fast): the pretest-validator mirror is stale by two, and has been red on main (#3038)
## The pretest-validator mirror is stale by two

```
assert.deepEqual(PRETEST_STATIC_CHECK_SCRIPTS, PRETEST_CHECKS);
+   'scripts/check-no-cwd-relative-dashboard-test-reads.mjs',
+   'scripts/check-capacity-pool-id.mjs',
```

Production found ten validators; the test's mirror listed eight. **The
world was right and the assertion was stale** — the good direction.

`PRETEST_STATIC_CHECK_SCRIPTS` is *derived* from `package.json`'s
pretest chain, so `verify:fast` picked both new validators up
automatically when they were added. They are real scripts, they run in
pretest, and verify:fast was already running them. Only the mirror
needed telling.

Added in production order, since the assertion is a `deepEqual` and
order is part of it. Plain strings for these two — the surrounding
entries are split and re-joined to keep banned phrases (the port-kill
and nohup literals) off a single source line for the policy scanner, and
neither new name contains one.

## The guard is load-bearing — verified against the source of truth, not
the mirror

Syncing a mirror is worthless if the assertion can no longer fail, so I
mutated `package.json`'s pretest chain rather than the test:

```
dropped check-capacity-pool-id from pretest  →  ℹ pass 16   ℹ fail 1
restored                                     →  ℹ pass 17   ℹ fail 0
```

So it still catches a validator silently leaving `verify:fast`, which is
the regression that actually matters. `package.json` is restored; the
diff here is the test file only.

## Second of seven, and the contrast is the point

This is the second of the seven red `scripts/__tests__` suites I found
on clean `main` (after #3036). It was genuinely mechanical.

`workflow-reliability-release-check` was not, and I did **not** fix it —
diagnosed on #3036 instead:
`docs/custom-workflow-reliability-acceptance-map.md` cites 13 test files
of which **8 are missing**, and **2 of 5 rows have zero surviving
evidence**, including *"a custom workflow can be authored/imported,
rejected on invalid IR, saved, discovered, selected, and reloaded"*. The
obvious repoint would have turned it green while the behaviour stayed
unverified.

That contrast is why I am taking these one at a time rather than
sweeping them green: two of seven look identical from the failure line,
and only one of them is safe to fix without owning the subject.

## Verification (measured)

- this suite — **17 passed / 0 failed** (was 1 failed)
- `eslint` — clean

Test-only. No changeset.
2026-07-31 01:57:59 -07:00
gsxdsm
35729699b8 fix(dashboard): Lane and ListView sorted every column with the LEGACY role defaults (wrong card order on renamed boards) (#3016)
`sortTasksForDisplayColumn` takes four role answers and defaults each to
the legacy id. Its own header names the callers that never supplied
them:

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

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

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

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

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

## Reverted

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

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

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

## Coverage I do not have

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

## Verification

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


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

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

* **Tests**
* Added coverage for renamed workflow columns and their expected task
ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 01:55:08 -07:00
gsxdsm
6a5bd7a86f chore(gate): drop the stale inert-seam exemption — the seam it covered is wired (#3037)
The gate reports this itself:

```
[check-inert-flag-seams] STALE allow-list entries — supplied now, or no longer declared:

  plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx::isTaskStuck
    — no unsupplied call site remains; remove its ALLOWED_OMISSIONS entry
```

#3029 wired that call site. The entry now claims to tolerate something
that doesn't exist.

The gate **warns rather than fails** here by design — failing would
punish whoever fixed the seam — which is precisely why a stale entry has
to be deleted deliberately instead of waiting for a red build to force
it.

## The text is worth losing on its own account

That exemption justified the omission as needing *"a published-API
change."* I later revised it to *"build plumbing."* **Both were wrong**:
the blocker was a hand-maintained interop declaration frozen at the
pre-conversion three-argument signature (#3003 → #3029).

An exemption whose stated reason has been disproven twice is worse than
no exemption — it's a confident note that stops anyone re-deriving the
answer. Same failure mode as the stale "do not re-probe" doc entry
corrected in #3018, and the untested deferral rationales recorded in
#3026.

## Measured

| check | result |
|---|---|
| inert-seam gate | **23 seams, all supplied**, exit 0 **with no
waiver** |
| gate's own suite | 18/18 |
| lane-wiring · plugin-interop-drift · FNXC | green; lint clean |

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved validation of task status handling by removing an exception
that could allow an incomplete call site to go undetected.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:54:57 -07:00
gsxdsm
f411d55591 test(docs): the PLUGIN_AUTHORING TOC guard rejected legal nested entries, and has been red on main (#3036)
## A legal Markdown sub-entry turned this guard red

```
AssertionError: Invalid TOC line: - [Theming & Overlay Layering for Dashboard Views](#theming--overlay-layering-for-dashboard-views)
```

That line is an ordinary nested TOC entry, indented under item 8 of
`docs/PLUGIN_AUTHORING.md`. The parser did `.map(line => line.trim())`
**first** and then required every line to match the top-level `N.
[title](#anchor)` shape — so indentation, the one thing distinguishing a
sub-entry from a malformed top-level one, was destroyed before it could
be used.

**The doc was never wrong.** Only the parser was, and it has been red on
`main` since the entry was added.

Indentation is now read before trimming. Sub-entries are still required
to be well-formed links; they just do not participate in the numbering
or the count.

## Both guard directions verified by breaking them

A looser parser that skipped anything unrecognised would have made the
failure go away while quietly ending the guard's usefulness — so I
checked it still fails in both directions:

| mutation | result |
| --- | --- |
| top-level `9.` rewritten as a bullet | still fails (`Invalid TOC
line`) |
| nested entry replaced with un-linked prose | still fails (`Invalid
nested TOC line`) |

## The wider finding, which matters more than this fix

I found it by sweeping `scripts/__tests__` against clean `main`: **688
passing, 7 failing test files.**

| suite | failing assertion |
| --- | --- |
| `ci-test-shard-timings` | committed timing snapshot references live
test files |
| `dependency-security-floor` | pnpm overrides pin transitive protobufjs
to a safe floor |
| `engine-vitest-gate-policy` | pg gate canaries remain a subset of the
enabled suite |
| `plugin-authoring-docs` | **this PR** |
| `release-prompt-gate` | release dry-run exits before proceed
confirmation |
| `verify-fast` | defaults to every canonical pretest validator |
| `workflow-reliability-release-check` | manifest references existing
seam files |

All sit **outside the merge gate**. That is now the third instance of
this pattern I have hit — #2969's 15 red agent-action tests and #3033's
stale ratchet list were the others — and it is clearly systemic rather
than incidental.

I fixed only the one in plugin territory. The rest span CI sharding,
**dependency security** (that protobufjs floor is a security assertion
currently not holding), release gating and workflow manifests. Each
needs its owner's judgement about whether the assertion or the world is
wrong, and a drive-by "make it green" is exactly how a real signal gets
erased — `dependency-security-floor` especially.

## Verification (measured)

- this suite — **4 passed / 0 failed** (was 1 failed)
- `eslint` — clean

Test-only; the doc is untouched. No changeset.
2026-07-31 01:52:13 -07:00
gsxdsm
83294a6958 fix(test): the protobufjs security floor was asserted against an empty object (reads like a deleted pin; it moved) (#3035)
`scripts/__tests__` has **seven** files failing on main. None are in the
merge gate, so nobody is blocked — which is exactly how this survived.
This fixes the one that names a real risk.

## It reads like a deleted security pin. It isn't.

```
AssertionError: package.json pnpm.overrides: protobufjs range undefined
                must include an explicit semver version
```

#2220 moved pnpm overrides from `package.json` to `pnpm-workspace.yaml`
for pnpm 11 readiness. The assertion kept reading `package.json`, where
`pnpm.overrides` is now `{}`.

**Nothing was ever exposed**: `pnpm-workspace.yaml` still pins
`protobufjs: ^7.5.8` and the lockfile resolves `7.6.5`, comfortably
above the 7.5.5 floor. The sibling assertion that checks lockfile
resolutions has been passing the whole time — which is the only reason
this was survivable.

But no reader could distinguish this message from a genuinely removed
pin without doing the archaeology, and a guard that is permanently red
while naming a real risk teaches its readers that red means nothing
here. That is worse than no guard: it launders a real removal into
background noise.

## Measured both ways

| change to `pnpm-workspace.yaml` | result |
|---|---|
| pin removed | **fails** — `protobufjs range undefined must include an
explicit semver version` |
| pin lowered to `^7.4.0` | **fails** — `range ^7.4.0 is below required
floor 7.5.5` |
| unchanged | passes |

## Second assertion

`package.json` must declare **no** `pnpm.overrides`. If overrides move
again — or come back — that fails and points at the next reader, instead
of letting the floor go silently unchecked. That is precisely the
failure mode #2220 produced, and nothing would otherwise catch a second
occurrence.

## The other six

Left alone deliberately; each needs its own diagnosis and they are
unrelated to one another (I checked — the "seven files, one failure
each" pattern looked like a common cause and is not):

- `workflow-reliability-release-check` — manifest references
`workflow-definition-store.test.ts`, which no longer exists. **Likely a
real signal**: a release check pointing at a deleted seam file covers
nothing. Best next one to pick up.
- `ci-test-shard-timings` — snapshot references missing test files;
affects shard balancing.
- `verify-fast`, `engine-vitest-gate-policy` — validator/canary list
drift.
- `plugin-authoring-docs` — a TOC anchor for a heading containing `&`.
- `release-prompt-gate` — dry-run no longer exits before the proceed
confirmation.

## Verification

`node --test scripts/__tests__/dependency-security-floor.test.mjs` **4
passed** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · changesets —
green.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Updated security validation to correctly read protobufjs version
overrides from the workspace configuration.
* Added safeguards to detect outdated override locations and help
prevent future security-check regressions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 01:46:56 -07:00
gsxdsm
d4add985fe test(engine): the unwired-parameter guard has been red on main — its list is stale by one (#3033)
## The unwired-parameter guard has been red on `main`

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

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

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

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

## Shortened, not re-recorded

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

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

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

## How it was found

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

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

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

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

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

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

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

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

## Verification (measured)

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

Test-only; no product file touched. No changeset.
2026-07-31 01:44:11 -07:00
gsxdsm
ccf562f178 gate: compare mirrored INTERFACES too, and delete the dead prop that found (#3034)
> **Re-landing the second half of #3031.** That PR merged into #3029's
branch and only its first commit reached `main` — the arity rule
shipped, the interface rule and its finding did not. Verified on `main`:
the gate reports *"7 mirrored function(s)"* with no interface count, and
the dead prop below is still there.

## What

The arity rule covers exported functions. The same files also mirror
**interfaces**, which is the larger surface — six copies of
`PluginDashboardViewContext` alone.

**One direction only.** A mirror may declare *fewer* properties, and all
six do (6, 8, 7, 7, 3, 6 against the real nine) because a plugin mirrors
the fields it uses. Demanding equality would fail every plugin for not
using everything — which is how a check gets ignored and then deleted. A
property the real type **doesn't have** is the drift that matters: a
rename nobody propagated, where the plugin keeps compiling and reads a
field the host never sends.

## Its first interface run found a live one

```
dashboard-interop.d.ts:67  TaskCardProps.workflowStepNameLookup is not a property of the real TaskCardProps
```

Git history says it **was** one when FN-2466 and FN-7039 added this
threading. The dashboard removed it later; nothing propagated that to
the plugin's hand-written declaration. So the plugin built a lookup map
from `context.workflowSteps` on every render, threaded it through two
components, and handed it to a `TaskCard` with no such prop.

Deleted rather than exempted — a new gate shouldn't ship with a waiver
for its own first finding. Behaviour-preserving: the value never reached
anything.

## Measured on `main`

| check | result |
|---|---|
| population | **7 functions + 10 interfaces across 6 plugins**, all
matching after the deletion |
| control probe | phantom property **caught**; clean tree exits 0 |
| anti-vacuity | now also requires a non-zero *interface* comparison |
| gate's own suite | **5 → 8** |
| dependency-graph suite | 179 green; `tsc` clean |
| other five gates · census | green |

## Running total for this check

Three real drifts, none of which any other instrument reported:

1. `isTaskStuck` stuck at three parameters through the whole lane
conversion (#3003)
2. `taskStuckTimeoutMs?: number` vs the required `number | undefined` —
in **two independent authors'** declarations
3. `workflowStepNameLookup` outliving its removal from `TaskCard`

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:43:58 -07:00
gsxdsm
e9b24b69e8 fix(dashboard): the duplicate banner judged the canonical by legacy lane ids (#3032)
Found by applying the check I proposed on #3028: **re-test each
`ALLOWED_OMISSIONS` entry — does the omission still fail once the excuse
is removed?** It found a stale excuse on its first run.

## The entry's blocker was never tested

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

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

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

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

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

## What was broken

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

## Verification

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

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

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

## The general point

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

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

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

---------

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

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

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

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

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

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

## One cause, so one fix

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

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

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

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

## Two type decisions worth reviewing

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

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

## Revert proof

Dropping the 4th argument:

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

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

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

## Verification (measured)

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

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

---------

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

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

## The defect

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

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

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

## Shape

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

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

## Two things my first attempt got wrong

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

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

## Measured

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:20:04 -07:00
gsxdsm
20e3731eb1 docs(workflow-learnings): a deferral's stated blocker is a claim, and it decays like a measurement (#3026)
Two pieces of work were filed rather than fixed in one session, each
with a specific technical reason. **Both reasons were wrong**, and in
both cases the real obstacle was smaller than the stated one.

| filed rationale | reality |
|---|---|
| "the plugin has no scaffolding for faking its stores" (#3020) |
`_harness.ts` builds a real `PluginContext` over a live PostgreSQL
layer; the gap was **two missing readers on a stub** — fixed in #3022 |
| "supplying this needs a published-API change" (#3003) | the type is
dashboard-internal, `@fusion/plugin-sdk` is `private: true`; the actual
obstacle is stale type declarations between two in-repo packages |

The first one matters most: the filed issue was a **pipeline that stalls
forever** on a renamed board. The cost of that excuse would have been a
real stall sitting open behind a plausible-sounding note.

## The shape

Both times the blocker was asserted **from the shape of the problem**
rather than tested. *"This needs infrastructure that doesn't exist"* and
*"this crosses a published boundary"* are each checkable in about five
minutes, and neither was checked before I wrote a paragraph explaining
why the work couldn't proceed.

## Why it's worth writing down

Filing is often right — someone else owns the contract, the fix needs a
decision, the data genuinely isn't there. What makes it wrong is filing
on an **untested** blocker, because a filed issue with a confident
rationale is the one thing nobody re-derives. It reads as settled.

That's the same mechanism as a stale "do not re-probe" note (which this
document already records, and which I had to correct in #3018), one
level up: there a *measurement* went stale, here a *decision* did.

## The rule

**Before writing the blocker down, spend five minutes trying to hit
it.** If it's real you'll hit it immediately and can describe it
precisely — which makes the issue more useful. If it isn't, you have the
fix instead of the issue.

Docs only. No code, no baselines.

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

## The gap

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

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

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

Its own test comment says:

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

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

## The test

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

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

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

## One harness note

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

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

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

---------

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

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

## One affordance, two producers

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

## Measured

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

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

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

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

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

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

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

## The guard still never fired, one lane over

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

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

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

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

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

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

## The fix

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

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

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

## A gate constraint worth recording

My first version passed the resolved object straight through:

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

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

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

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

---------

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

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

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

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

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

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

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

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

## Revert proof

With #3019's wiring removed:

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

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

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

## Also closes the gap I named in #3019

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

## Verification (measured)

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

Tests only; no product file touched. No changeset.

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

Seeding a card into a renamed lane has two traps, both inherited from
`merge-blocker-renamed-review-lane.test.ts` and both recorded in this
file's header: the real API is `createWorkflowDefinition` +
`selectTaskWorkflow` (the plausible `saveWorkflowDefinition?.()` does
not exist and the optional call swallows it silently), and moving a card
takes `moveTask`, not `updateTask({ column })`. Both are guarded here by
asserting the card really is in `building` before the subject runs.
2026-07-31 01:06:09 -07:00
gsxdsm
5897d87e95 fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021)
#3013's merge of same-named declarations fixed a false **negative** and
introduced a false **positive**.

`ModelSelectorTab` declares its own two-parameter
`resolveEffectiveExecutor(task, settings)` — a pass-through with nothing
lane-related — while an unrelated exported function of the same name in
`effective-model-resolution.ts` takes `columnFlags`. Both local calls
were reported unwired against a signature they have never had.

Only **exported** declarations enter the accepting map, so the rule is
exact: if the calling file declares the name itself and the map entry
came from a different file, the call resolves to the local declaration
and is not a lane call here.

## The version I did not ship

My first attempt re-ran the detector over the single calling file and
used that result. It scored *better* on this tree — **19 → 16** instead
of 19 → 17, also clearing `bucket-mapping.ts` — and I threw it away.

A single-file pass cannot resolve an **imported** options interface. A
locally-declared function with an imported context type would quietly
stop being lane-accepting, and every call to it would stop being
checked. That is a false negative, which is the one failure a ratchet
must not have; the better-looking number came from the gate seeing less.
The global pass still does all type resolution here — only the *choice*
of declaration is local.

## Measured

| | |
|---|---|
| new tests | 3 |
| against the old census | **1 of 3 fails** — the positive |
| baseline | **19 → 17**, exactly the two `ModelSelectorTab` sites |

Both negatives pass either way and they are the ones that matter: a file
declaring its **own** exported lane function is not shadowed by itself,
and a file declaring nothing is judged normally. Shadowing must not
become a way to disappear a genuine unwired call.

## Still flagged, honestly

`bucket-mapping.ts:75` stays in the baseline. `bucketForTask(task:
TaskItem)` is only lane-accepting because `TaskItem` *declares*
`columnFlags` — the lane data rides on the domain object, so passing
`task` forwards it inherently. That is a different limitation
(options-bag vs domain-entity parameters) and I have not tried to fix it
here; it accounts for 2 of the remaining 17 along with
`otherBucketSecondaryLabel`.

## Verification

`node --test scripts/__tests__/check-lane-wiring.test.mjs` **19 passed**
· `pnpm test:gate` 13 + 161 + 487 + 71 · lint · lifecycle census
`--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green.
2026-07-31 01:03:24 -07:00
gsxdsm
6f936f2de7 fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019)
## The node-override guard never fired on a renamed board

`fn_task_update` called the guard with no options:

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

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

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

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

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

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

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

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

## Coverage — stated rather than implied

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

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

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

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

## Verification (measured)

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

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

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

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

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

## The defect

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

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

## The supplier ships with the seam

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

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

## Measured

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

## One observation I could not reproduce

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

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:50:07 -07:00
gsxdsm
eecc87c31e docs(workflow-learnings): the "named legacy-id collections are clean" entry was wrong (#3018)
It hid two real defects — and it explicitly told the next reader not to
re-probe them.

## What the entry did

Counted **declarations** (48, then 49) and concluded the population was
benign because each one is a fallback vocabulary, a builtin column list,
or an already-converted seam.

All true of the declarations. **The declaration isn't where the defect
lives.**

## Measure the use, not the declaration

A collection used as a **membership gate against a column**. Nine exist,
and two were live user-visible defects sitting inside a population this
doc had marked clean:

| site | defect |
|---|---|
| `TIME_INDICATOR_COLUMNS.has(task.column)` — `TaskCard` | elapsed-time
indicator never rendered on a renamed board (#3014) |
| `PLANNER_ACTIVITY_COLUMN_IDS.has(task.column)` — `useTasks` | planning
border and pulsing badge never appeared (#3017) |

The other seven are genuinely fine, and the reasons are kept because
they're the shapes worth recognising: the no-flags fallback *inside* a
role helper, a seam that seeds the legacy pair then unions resolved
lanes, a marked `DELIBERATE-LITERAL` fallback chain, and a plugin with
no trait source at all.

## The tell

One question separates the two groups: **does a flags path exist in this
file at all?**

Both defects had none — the gate was the only decision, with nothing to
degrade from. Every benign case had a resolved path sitting right next
to the literal.

## Why this is worth its own PR

A "do not re-probe" note that is wrong is **worse than no note**: it
converts one person's incomplete measurement into everybody's blind
spot. That's the same failure this document already records for
`sortTasksForDisplayColumn`, one level up — there an annotation told
readers to skip a *row*, here it told them to skip a *population*.

I wrote the original entry, and I'd read past it twice myself before
#3014 forced the re-measurement.

Docs only. No code, no baselines.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:49:56 -07:00
gsxdsm
f0875a79c6 fix(dashboard): finished cards on a renamed board never showed a completion date (#3007)
The **fifth** instance of the async-memo shape #2998 documents, and the
one that survived #3001's sweep.

`lifecycleDates` gates its `completed` value on `isCompleteColumn ||
isArchivedColumn` — both derived from the async `taskColumnFlags` prop —
while listing neither:

```js
}, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs]);
```

First paint runs with the flags undefined, the role helpers fall back to
the legacy ids, and on a board whose complete lane is named anything but
`done` that answers false. The flags arrive, `task.column` has not
changed, nothing recomputes, and the card renders **no "Completed
<date>" line at all**.

## Why #3001's sweep called this covered

That PR recorded `mergeSignature` as *"the last live site … nine
persistent candidates, seven covered transitively or by a dependency
that already carries the flags."* This memo was presumably in the
covered pile, and the reasoning is nearly right: it **does** list a
dependency that changes — `lifecycleNowMs`.

But that value is driven by a timer scheduled with
`millisecondsUntilNextLocalMidnight` (FN-8561, so compact date labels
turn over at the viewer's midnight). **A dependency that changes once a
day is not coverage for a value that must be correct on first paint.**
The card shows no completion date for the rest of the session.

That distinction is worth adding to the doc's property 2: *does a listed
dependency change* is the wrong question — *does it change when the
resolved value arrives* is the right one.

## Verification

| state | result |
|---|---|
| clean | 2/2 pass |
| revert the dep fix | **1 failed / 1 passed** |

The control case (a `done` board) passes either way by design, so a
failure in the renamed case means "renamed board", not "nothing
renders".

**One trap worth recording**, since it nearly cost me the finding: my
first `completedLine()` used `time[datetime]:last-of-type`. When only
the *Created* line renders, that selector returns **that** element — so
the pre-resolution absence assertion silently passed against the wrong
node. The test now matches on the element's own `Completed` label. A
positional selector cannot express "this specific line is missing".

`tsc -p tsconfig.app.json` 0 errors, lint clean, 8/8 across all three
renamed-lane TaskCard suites.

## Note

`main` is currently red on the FNXC gate for an unrelated reason
(#2994's impossible-hour stamps landing after #2995); fixed in #3006.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:47:14 -07:00
gsxdsm
083f8f5c5b fix(glasses): every card on a renamed board badged "todo", including cards in review (#3015)
## Every card on a renamed board badged `todo` — including cards in
review

```ts
export function statusBadge(column: Task["column"]): string {
  return COLUMN_BADGES[column] ?? "todo";
}
```

`COLUMN_BADGES` maps the six legacy ids to themselves. On a board whose
lanes are named anything else **every lookup misses**, so every card
badges `todo` — a card sitting in review tells the wearer it is
un-started, and the whole board carries one identical badge.

On a display with room for a single word, that is worse than an
unrecognised lane: it is a *confident wrong answer* rather than a
missing one.

Reached from `taskToCard` (the main card) and `notificationCard` (the
notification badge).

## Fix, and the dead weight it exposed

The badge **is** the column id, so the function now says so:

```ts
export function statusBadge(column: Task["column"]): string {
  return column;
}
```

That also retires `COLUMN_BADGES`. Once the fallback is the id, a table
mapping each legacy id to *itself* decides nothing — six lane literals
sat in this file doing no work. It was module-private with `statusBadge`
as its only consumer and it was a pure identity, so behaviour on legacy
boards is byte-identical: this is not a behaviour change riding along
with a cleanup, it is the dead weight the fix exposed, removed rather
than left as a decoy.

Mirrors `columnLabel` in the CLI (`COLUMN_LABELS[column] ?? column`) for
the same reason: a board that calls its lane `checking` should read
`checking`. No resolution needed; the id is in hand at the call site.

Note the census count for this file does **not** move — those six were
object keys, not comparisons, which is exactly the scope the census
documents for itself.

## This is a miss in my own #2968

That PR fixed the summary card's counts **in this same file** and never
looked one function further at the per-card badge those counts sit
above. Worth saying plainly, because it is the practical reminder behind
the census finding I have been repeating all run: *a file having had a
defect fixed is not evidence about its neighbours* — and here the
neighbour was nine lines away, in a function I had read.

## Revert proof

```
AssertionError: expected 'todo' to be 'checking'
      Tests  1 failed | 9 passed (10)
```

The paired case ("still badges the legacy ids exactly as before") passes
both ways by design — it guards against the fallback change altering
known boards, so I am not counting it as coverage of the defect.

## Verification (measured)

- plugin suite — **198 passed / 19 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: the plugin is `private: true` and is not bundled into the
published CLI.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Status badges now accurately display a card’s actual lane, including
previously unrecognized lanes.
  * Preserved existing badge behavior for known lanes.
* **Tests**
* Added coverage to verify accurate lane reporting and legacy behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 00:47:02 -07:00
gsxdsm
9ad3a2a93a fix(gate): name the offending FNXC stamp and which rule it broke (#3009)
**`main` is currently red**, so every open PR shows a failing Lint job
that is not its own fault. #3004 is how I found it — its gates all pass
in isolation and fail against main.

## Cause: an hour that does not exist

```
FNXC:OperatorScriptLaneAssumptions 2026-07-30-26:10
                                              ^^ hour 26
```

Four of them, across three files, from #2994.

## The part worth fixing is the message, not the stamps

This gate counts **two** defects — a date after today, and an impossible
clock time — but the failure text only ever explained the first:

```
scripts/reconcile-task-state-consistency.mjs: 2 future-dated FNXC stamp(s), baseline allows 0

A stamp dated after today (2026-07-31) records the change as happening in the future...
```

Every stamp in that file is dated `2026-07-30` or earlier — all valid
past dates. So the message sends you to inspect stamps that are fine,
and the natural conclusion is *the gate is broken*, not *the stamp is*.
I spent several minutes reproducing the regex by hand and getting
`future count = 0` before instrumenting the real script and finding
`hits += impossibleClockTimes(source)`.

A gate that detects the right defect and describes a different one is
worse than a slightly less sensitive gate, because it spends the
reader's trust. Now:

```
  scripts/reconcile-task-state-consistency.mjs
    FNXC:OperatorScriptLaneAssumptions 2026-07-30-26:10  (impossible clock time)
```

**Mutation-verified**: restoring one `26:10` stamp reproduces the
failure, and the message names it.

## The stamps: `2026-07-31-02:10`, not `23:59`

Hour 26 on the 30th is the informal spelling of 02:10 the next day.
Clamping to `23:59` would keep the file's stamps in a plausible order
but silently move the event; this preserves what the author meant.
Reversible either way — say the word if you would rather they were
clamped.

## The 176-file baseline drop is unrelated

`475 -> 183 known`. The clock crossed midnight, so yesterday's stamps
are no longer future-dated, and the ratchet auto-lowers on drops by
design. It rides along because the gate must leave a baseline matching
reality — an allowance nothing occupies is somewhere a real regression
can hide. It is not part of the fix.

## Verification

- `check:fnxc-future-dates` — exit 0 (was **exit 1 on main**)
- `check:lifecycle-columns`, `check:sql-column-literals`,
`check:inert-flag-seams`, `check:lane-wiring` — all exit 0
- eslint clean

## Worth someone's attention beyond this PR

`#2994` landed four impossible timestamps. The gate caught them, but
only after the clock crossed midnight changed which files it reported —
meaning the impossible-time check was live but effectively invisible
until it collided with an unrelated drop. It is worth asking whether
that check has ever produced a message anyone acted on before today.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:39:05 -07:00
gsxdsm
3a016b1f17 fix(scripts): four FNXC stamps carried hour 26, and main has been red on them (#3010)
## `main` is currently red on `check-fnxc-future-dates`

Four stamps read `2026-07-30-26:10` — an hour that cannot exist.

They're exactly what #2995 taught this gate to catch. That PR landed the
hour validation (`00-23`) *after* #2999 had already merged these four,
so the gate started reporting a defect that was already sitting there
rather than one introduced afterwards. **The guard is working**; nothing
was checking before it.

```
scripts/lib/backend-db.mjs:41
scripts/reconcile-task-state-consistency.mjs:8, :51
scripts/__tests__/reconcile-task-state-consistency.test.mjs:109
```

Corrected by **literal normalisation** — 26:10 on the 30th *is* 02:10 on
the 31st — rather than flattening them to an arbitrary in-range hour.
AGENTS.md specifies `yyyy-MM-dd-hh:mm`, and the stamp exists to give a
readable why-does-this-exist trail, so the ordering is the part worth
preserving.

## The baseline tightening rides along, and it's a date rollover

Stamps written yesterday as `2026-07-31` were future *then* and were
baselined as such. Today they're past, so **176 files ratchet to zero**.
Nobody did anything.

The gate rewrites the baseline as a side effect and exits 0, so leaving
it uncommitted dirties the tree on every subsequent run **for everyone**
— which is why it belongs in this commit rather than a later one.
Re-recording on a decrease is the rule this gate and its siblings
already state.

Worth knowing about the design, since I wrote it: this churn recurs
whenever a day boundary passes with future-dated stamps in the baseline,
and it shrinks only as people stop writing them — which is the behaviour
the gate exists to produce. **93 files still carry a non-zero
allowance**, so the drain isn't finished. If it stays noisy once those
clear, the gate's fail-on-tighten contract is the thing to revisit, not
the stamps.

## Measured

| check | result |
|---|---|
| gate | red before, **exit 0 after**, stable across two consecutive
runs |
| baseline | −176/+25 entries, all date-rollover |
| inert-seam · sql-literal · lane-wiring · census | all green |
| reconciler's own suite | green |

## One correction to a claim I made earlier this session

While investigating I reported the gate as hanging for 600s. It wasn't —
the harness killed the process (exit 144) and the empty output made it
look like a stall. The gate completes in seconds. Noting it because I
nearly filed a performance bug against a healthy script.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:36:21 -07:00
gsxdsm
3b55e2c96c fix(dashboard): the time indicator was gated on a hardcoded legacy lane set (#3014)
## This is a correction to #2996, and that's why it exists

#2996 fixed the **subscription**: `wantsLiveTimeIndicator` kept a
pre-load answer, so the card never joined the shared ticker. I described
it as making renamed-lane cards *"show their live elapsed-time
indicator."*

It made them **eligible to**. They still rendered nothing, because a
second gate rejects them first — and I didn't look past the seam I'd
just fixed.

```ts
const TIME_INDICATOR_COLUMNS = new Set<ColumnId>(["in-progress", "in-review", "done"]);
```

Both the `timeIndicator` memo and the `chipFarRight` layout test
`task.column` against that set directly, so a card in a renamed WIP,
review or completion lane returns `null` whatever its resolved traits
say.

## Why no check saw it

The census counts **comparisons** against legacy ids. This is a `Set`
literal — a **definition**. Nothing in the backlog ever pointed here,
which is the same blind spot that hid `BLOCKER_ESCALATION_COLUMNS` until
someone read the code rather than the report.

## The fix

The gate becomes a role question, with the legacy set kept as the
**no-flags fallback** and marked `DELIBERATE-LITERAL`. A card whose
traits haven't resolved — first paint, or a lane its workflow no longer
declares — behaves exactly as before.

## Measured

| check | result |
|---|---|
| test written first | red for the right reason — control and negative
passed, only the renamed case failed (`expected false to be true`) |
| after the fix | 3 passed |
| reverting the memo gate to the raw set | that case fails again |
| five `TaskCard` suites | **418 tests green** |
| gates | all five green; lint and `tsc` clean |

## The negative case

Resolving traits must not put a live timer on every lane. A card in the
renamed **intake** lane hasn't started, so it stays out — otherwise the
fix trades a missing indicator for a running clock on work that hasn't
begun.

## Worth noting for the pattern

Two of my last four findings came from re-examining my own merged work
rather than from new code: this one, and the `bounded` heuristic
correction in #3012. Fixing one seam and declaring the symptom gone is
its own failure mode — the user-visible behaviour needed *both* halves,
and I only checked the half I'd touched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:36:09 -07:00
gsxdsm
e9f587c363 docs(workflow-learnings): correct the "bounded" heuristic — a clock-shaped dep is not a fast one (#3012)
The severity heuristic I wrote in #2998 sorted dependencies **by name**,
and #3007 is the counterexample.

## What I got wrong

I classified `lifecycleDates` as *bounded* because its dep list contains
`lifecycleNowMs`, and deferred it in #3001 with the line *"any wrong
answer there survives only until the next update."*

That value is driven by a **local-midnight boundary timer** — one tick
per card per day. So a finished card shows no completion date for up to
**twenty-four hours**. @gsxdsm found it after I'd written it off.

`nowMs`, `Ticker` and `lastFetchTimeMs` span a live 30-second ticker, a
per-fetch stamp, and a daily boundary. Sorting them by name puts a
day-long defect in the same bucket as a 30-second one.

## The sharper half

A card in a **completion lane doesn't subscribe to the shared live
ticker at all** — that's exactly what the ticker's eligibility check is
for, and what #2996 fixed. So the "fast" dependency that would have
rescued this population is the one thing that population never receives.

The corrected question is: **which dependencies refresh *for this
population*** — not which ones appear in the list. Two of my three
severity calls in that sweep leaned on a dep that the affected cards
structurally never get.

## Why this is worth a PR rather than a quiet edit

The doc is what the next person triages against. #3001 explicitly told
them the four "bounded" sites were deprioritised **by design** — on
reasoning that was wrong for at least one of them. Leaving that in place
means someone defers a day-long defect on my say-so.

Docs only. No code, no baselines.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:33:25 -07:00
gsxdsm
ffe9898710 fix(gate): the lane-wiring census could not see three of the shapes it asks for (incl. a false positive it started reporting) (#3013)
**The gate could not see three of the shapes it exists to check** —
including the wiring I landed this week in #2990 and #3004. Found by
using it: the baseline still listed `useBlockerFanout.ts`,
`ExecutorStatusBar.tsx`, and `TaskDetailModal.tsx` as unwired *after*
those PRs merged.

### 1. Conditional shapes

Passing lanes only when they resolved is the **correct** way to write
these — an empty trait index means "not loaded yet", not "nothing is
terminal", so the caller must fall through to the documented legacy
default rather than fabricate one. Both idioms that produces were
invisible:

```ts
computeBlockerFanoutMap(tasks, flags ? { columnFlagsByTaskId: flags } : {})   // ConditionalExpression
computeBlockerFanoutMapCore(tasks, N, { ...(flags ? { classify } : {}) })     // SpreadAssignment, name === undefined
```

Either branch supplying the lane now counts. **Neither branch supplying
it is still unwired** — that negative is tested.

### 2. Vocabulary

`columnFlagsByTaskId`, the per-task trait index the dashboard threads,
was never added. It answers every lane question at once, so a call site
dropping it reverts to the legacy vocabulary wholesale — and the gate
would have stayed silent.

### 3. Name collisions — the false positive

Declarations were `set` by name, so the **last one parsed won**. Core's
`computeBlockerFanoutMap(tasks, n, opts)` and the dashboard wrapper
`computeBlockerFanoutMap(tasks, opts)` put their lane options at
**different argument indices**, so core's callers were checked against
the wrapper's signature: `task-priority.ts:141` passes `terminalColumns`
at index 2 and was reported unwired.

I caught this because adding the vocabulary entry in (2) made it appear.
A ratchet that reports a correctly-wired site is worse than one that
misses it — the first person to open one learns the number is noise.
Both shapes are now merged; a call satisfying either counts.

### Measured

| | |
|---|---|
| new tests | **6** — every positive paired with its negative |
| against the old census | **3 of 6 fail** — exactly the three
positives; the negatives pass either way, which is why they exist |
| baseline | **23 → 19** — four sites recognized as *already* wired; no
site newly excused |
| new flags | none |

### Verification

`node --test scripts/__tests__/check-lane-wiring.test.mjs` **16 passed**
· `pnpm test:gate` 161 + 13 + 487 + 71 · lint · lifecycle census
`--strict` · fnxc-dates (TZ=UTC) · changesets — green.

Seven other `scripts/__tests__` files fail on main independently of this
change (`verify-fast`, `dependency-security-floor`,
`engine-vitest-gate-policy`, `plugin-authoring-docs`,
`release-prompt-gate`, `ci-test-shard-timings`,
`workflow-reliability-release-check`). None are in the merge gate and
none are touched here — noting them because I looked, not because this
PR affects them.
2026-07-31 00:33:13 -07:00
gsxdsm
86c5a89169 fix(dashboard): dropping into a renamed intake lane reset progress without asking (#3011)
The **sixth** instance of the async-memo shape #2998 documents — and the
only one that **loses work** rather than mis-rendering.

`handleDrop` gates the "Preserve Progress?" confirmation on the lane's
role:

```js
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column);
if (shouldPrompt) { const keepProgress = await confirm({ … }); … }
```

…but its `useCallback` deps were `[addToast, allTasks, column, confirm,
onMoveTask, tasks, t]` — no `columnFlags`. The board resolves workflow
traits after first paint, so the DOM keeps the closure built during the
pre-load render, where `columnFlags` is `undefined` and the helper falls
back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`. A renamed intake lane is
not a member.

**Result: a card with completed steps dropped into that lane moves with
`shouldPrompt === false`.** The user is never offered "Keep Progress",
and the steps are reset silently.

## How this was found

It is the last unverified candidate from the derivation-aware scan I
posted on #2998, where I explicitly declined to file it as a bug without
checking. Checking it is what turned it from a scanner hit into this.

## Severity, stated honestly

`allTasks` and `tasks` are in the dep list and change identity on any
task-list refresh, so the stale closure is rebuilt within seconds on a
busy board. The exposure is the quiet gap right after the traits land —
**bounded**, like the near-duplicate chip (#2997), not permanent like
the ticker (#2996) whose only refreshing dependency fired at local
midnight.

Bounded still matters here because the cost is not a wrong pixel: it is
completed steps discarded without a prompt, and the window is exactly
when someone has just opened a board and starts dragging.

## Verification

| state | result |
|---|---|
| clean | 2/2 pass |
| revert `columnFlags` from the deps | **1 failed / 1 passed** |

The paired negative asserts a non-pre-implementation lane still moves
**without** prompting — a fix that prompts everywhere turns the dialog
into noise that gets clicked through, costing the same progress it
protects.

The observable is `confirm`, not `onMoveTask`: whether the user was
*asked* is the contract, and asserting on the move alone passes either
way.

`tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC
gate exit 0, 4/4 across both `Column` flags-arrival suites.

## Running tally of this shape

ticker (#2996) · near-duplicate chip (#2997) · fan-out trait index
(#2993) · merge signature (#3001) · lifecycle dates (#3007) · this. Six,
in two components plus the fan-out path. #3001 called the merge
signature "the last live site"; it was the last of *that* sweep's nine
candidates, and two more have surfaced since from a different scan.
Worth knowing before anyone declares the class closed again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:30:26 -07:00
gsxdsm
f10261f424 fix(scripts): the contamination audit scanned four legacy lanes and claimed it had (#3005)
## An audit that scanned four legacy lanes — and claimed it had

Two halves of the same wrong answer.

**The query allowlisted the lanes:**

```sql
WHERE deleted_at IS NULL AND "column" IN ('triage','todo','in-progress','in-review')
```

On a board whose lanes are named anything else that matches **nothing**,
so the audit scans zero rows and reports zero contamination — a clean
bill of health from a scan that never happened. `triage` is in that list
too, a lane U11 (#2515) deleted.

**And the report asserted the coverage it did not have:**

```js
scannedColumns: ["triage", "todo", "in-progress", "in-review"],
```

printed regardless of what the query returned. When I first surveyed
this script I called that field "the one thing keeping it from being
fully silent" — it turns out it was a **claim, not an observation**, so
it was not keeping it honest at all. It is now derived from the rows
that came back.

## Fix: exclude finished lanes instead of allowlisting active ones

Inverted so the default is the safe one — an unrecognised lane is active
work by assumption and **is** audited; only lanes that genuinely mean
finished drop out. An allowlist fails **closed** (skip everything
unknown), a denylist fails **open** (look at it), and for an audit one
extra finished branch is a far smaller error than auditing nothing.

Filtered in JS rather than by building a dynamic SQL exclusion: it keeps
**one** place deciding what "finished" means, and removes the last
raw-SQL lane literal from this file.

## Revert proof

```
✖ scannedColumns reports the board's real lanes, not a fixed legacy claim
✖ reports each scanned lane once, and nothing at all for an empty board
ℹ pass 1   ℹ fail 2
```

## A demonstration of #3000, for free

This PR removes a 4-literal raw-SQL clause, and
`check-sql-column-literals` here reports **22, unchanged and green** —
because this branch predates #3000 and the gate still walks `packages/`
only. That is precisely the blind spot #3000 closes, reproduced a second
time.

## Merge order

This removes the 4 literals #3000 baselines. Landing this **after**
#3000 drops that count and its gate fails on DECREASE — that gate
auto-rewrites the baseline and asks for the commit, unlike
`check-lane-wiring` which needs an explicit `--update-baseline`. Either
order works; one of them needs a re-record, and I am happy to push it.

## Verification (measured)

- `node --test` — **3 passed / 0 failed** (1 pre-existing + 2 new)
- `node --check`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-fnxc-future-dates` — green

No changeset: root `scripts/` is repo tooling, not part of the published
package.

## Territory status

This was the last item I know of in `scripts/`. The four operator
scripts holding lane assumptions — `recover-stale-blocked-by` (#2992),
`reconcile-task-state-consistency` (#2994),
`reconcile-leaked-soft-deletes` (#2999) and this one — are now either
resolved or, where a script genuinely cannot resolve lanes, made loud
rather than silent.
2026-07-31 00:27:41 -07:00
gsxdsm
52a66297fc fix(gate): main is red — normalize #2994's four impossible-hour stamps (#3006)
**`main` is currently red on the FNXC gate.**

```
$ node scripts/check-fnxc-future-dates.mjs   # on origin/main
  scripts/reconcile-task-state-consistency.mjs: 2 future-dated FNXC stamp(s), baseline allows 0
  scripts/lib/backend-db.mjs: 1
  scripts/__tests__/reconcile-task-state-consistency.test.mjs: 1
exit 1
```

#2994 carried four `2026-07-30-26:10` stamps. I flagged them on that PR
before it merged; #2995 (the hour check) landed first, so the merge
order turned the warning into a red gate rather than a red PR.

Clamped to `23:10` — same rule as the nine before it: hour to `23`,
minutes preserved, so ordering within each file survives. This is a
normalization with a stated rule, not a claim about the true minute.

**Verified:** FNXC gate exit 0, `reconcile-task-state-consistency` 8
pass / 0 fail. Comment-text only.

### Worth fixing at the source

Thirteen impossible-hour stamps across six PRs in two days, and the
hours climb — `24:40` → `25:30` → `26:10`. They are being written as a
continuing sequence past midnight rather than read off a clock, which is
a reasonable instinct and produces an invalid stamp every time.

The trap is that the honest spelling does not work either: a genuine
post-midnight stamp needs *tomorrow's* date, and the gate compares
against the **local** calendar — so `2026-07-31-00:40` written from
UTC-7 is future-dated and fails for a different reason. Clamping to
`23:xx` is currently the only spelling that satisfies both, which is not
obvious and is why this keeps recurring.

If it recurs again, the fix is probably in the error message rather than
more normalization PRs: the gate could name the valid range and the
timezone it compares against, so the next author sees the constraint at
the moment they hit it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:16:39 -07:00
gsxdsm
cd237ae760 gate: the SQL column-literal ratchet never scanned scripts/, where the raw SQL actually is (#3000)
## The gate could not see the one place raw SQL is actually written by
hand

`check-sql-column-literals` walked `packages/` only and took `.tsx?`.
Every operator script is a repo-root `.mjs`.

I found it by removing a raw-SQL lane literal in #2999 and watching this
gate report:

```
[check-sql-column-literals] 22 known SQL column literal(s), none added.
```

Unchanged, and green. Its own header promises the opposite — *"a LOWER
count fails too so the baseline is ratcheted down"* — so the silence was
the tell.

**Two changes, and either alone still sees nothing:** the root and the
extension. Adding one without the other scans nothing new and reports a
reassuring zero — the same trap #2978 hit when widening the lane-wiring
census.

## Newly visible: 6 sites, audited not blind-baselined

| site | verdict |
| --- | --- |
| `audit-branch-cross-contamination.mjs:182` — `"column" IN
('triage','todo','in-progress','in-review')` | **real** — the
contamination audit scans only the legacy active lanes, so on a renamed
board it scans nothing and reports no contamination. Read-only, and it
does print its `scannedColumns`, which is the one thing keeping that
from being fully silent. |
| `reconcile-leaked-soft-deletes.mjs:53, :73` | already fixed by
**#2999** — the PR that exposed this gap |

## Proven able to fail, not just to count

A guard that has only ever printed a number is a number. A temporary
`.mjs` holding one forbidden comparison:

```
scripts/zz-probe-tmp.mjs: 1 SQL column literal(s), baseline allows 0
```

and the gate returned to green once removed.

## One claim I withdrew

I initially wrote that the `ScriptKind` move to `JS` for `.mjs` was
needed because *"TSX treats `<` as JSX and would misparse an ordinary
comparison"*. I could not demonstrate it. I tried three JSX-ambiguous
shapes — `x <div> y`, `f<b, c>(d)`, and a literal sandwiched between `<`
and `>` comparisons — and TSX recovered from all three with counts
identical to JS.

So `JS` is used because it is the correct kind for the file, **not**
because a miss was observed, and the code now says exactly that. The
opposite claim would have been easy to make and wrong, and this gate's
whole value is that its statements about its own coverage are true.

## Merge order

**#2999 removes both literals in `reconcile-leaked-soft-deletes.mjs`.**
Landing it *after* this PR drops the count, and this gate fails on
DECREASE (by design), needing a re-record. Merge #2999 first, or say the
word and I will re-record here.

Note the widening is self-protecting afterwards: if someone narrows the
walk back to `packages/`, the recorded `scripts/` entries vanish from
the scan and the gate goes red on decrease.

## Verification (measured)

- gate — green, **28 known / none added** (was 22 across `packages/`
only)
- its own suite — **32 passed**
- `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-fnxc-future-dates` — green

Gate/tooling only; no product file touched.
2026-07-31 00:13:55 -07:00
gsxdsm
1de0141ab8 fix(dashboard): Task Detail's blocking count read the LEGACY lanes (last of the three fan-out surfaces) (#3004)
Third and last of the three surfaces calling the blocker fan-out
wrapper, completing the sweep started in #2990 (Board + Executor bar).

## What was wrong, precisely

The dependent **list** is lane-independent — core pushes `dependentIds`
without consulting lanes — so this section looked broadly right. Two
things beside it are not:

- `overlapBlockedTodoCount`, rendered as **"FN-X is blocking N todo
task(s) via blockedBy overlap"** — counted against the literal `todo`,
so on a renamed board it read **0 while cards were genuinely blocked**.
- the `stale` marker on each blocking dependent — decided against
`terminal`/`review` lanes the operator does not use.

A wrong number sitting beside a right list is the easiest kind to miss,
which is why I checked what the modal actually consumes before deciding
this was worth a PR rather than assuming the whole section was broken.

## Why a prop and not a hook

This was the surface I deferred in #2990 because it had no trait index
in scope. Two options:

- `useBoardWorkflows` inside the modal — rejected. The hook documents
that it does **not** dedupe across consumers: each call installs its own
visibilitychange/focus listeners and its own SSE subscription. That is a
new fetch and subscription per modal open, to answer a question the app
has already answered.
- **Thread the index that already exists** — `App` builds
`footerColumnFlagsByTaskId` for the footer; this forwards it through
`AppModals` as an optional prop. Chosen.

Optional throughout: a card with no entry keeps the documented legacy
fallback, so the remote-node case (where local workflow metadata must
never be applied to foreign ids) and the pre-load window stay
byte-identical.

## Reverted

The new case fails on the rendered text — the modal cannot find `"FN-B
is blocking 2 todo task(s) via blockedBy overlap"`. The pre-existing
legacy-column case above it passes either way, because `todo` satisfies
the literal default; that is exactly why it never caught this.

## Verification

TaskDetailModal.rendering + ExecutorStatusBar + useBlockerFanout **206
passed** · dashboard app suite 11986 passed / 5 skipped (581 files) ·
`pnpm test:gate` 161 + 13 + 487 + 71 · lint · census `--strict` ·
lane-wiring · fnxc-dates · changesets — green.

## One note for whoever owns the FNXC gate

`check-fnxc-future-dates.mjs` **rewrites its baseline as a side effect
and still exits 0**. Today's date roll dropped 183 stamps out of
"future", so any run dirties
`scripts/lib/fnxc-future-dates-baseline.json` in the working tree. It
cost me a stash conflict before I noticed. Not bundled here — it is
repo-wide midnight drift, not this change — but a check that mutates
tracked state on a read is worth a look.
2026-07-31 00:13:43 -07:00
gsxdsm
d861923355 fix(gate): the inert-seam ratchet never scanned plugins/, where real lane logic lives (#3002)
#3000 showed this gate never scanned `scripts/`. I went auditing my own
instrument after that, and the roots have a **second** hole: the walk
was rooted at `packages/` alone, so every lane parameter a plugin
declares or calls sat outside the ratchet entirely.

## Measured with a control

| probe | before | after |
|---|---|---|
| unwired seam under `packages/` | caught | caught |
| **identical** seam under `plugins/` | **missed** | caught |
| clean tree | exit 0 | exit 0 |

## The sibling gate already knew

`check-lane-wiring` lists `plugins` in its roots, and its header records
the incident that put it there: an unwired `completeColumnsByTaskId` sat
on `main` unreported because the glasses plugin wasn't scanned.

This gate re-opened the same hole rather than inheriting the lesson. Two
scope holes in one instrument is the actual finding — **the roots
deserve the same scrutiny as the matcher, and until now they had none.**
Every blind spot found in this gate so far has been in the matcher;
nobody, me included, thought to probe what it walks.

## Newly visible — audited, not blind-baselined

`plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx` calls
`isTaskStuck()` without the resolved flags, while **six of the seven**
other call sites supply them. That's exactly the partial-supply shape
this gate exists to catch, hidden purely by scope.

It's exempted rather than wired, and the reason is the interesting part.
The plugin has **no lane-trait source anywhere**: it's mounted as a
dashboard view through `PluginDashboardViewContext`, and
`DependencyGraph` receives `tasks: Task[]` and nothing else. Passing the
argument here would pass `undefined` — an unsupplied optional parameter,
which the learnings doc's first failure shape calls strictly worse than
the literal it replaces, because it reads as converted and answers
legacy forever.

Correct supply needs the plugin **view context** to carry per-task
flags: a published-API change. That's the same "needs a data change"
category as the existing `TaskDetailModal` entry, not the "awkward means
wire it" case the exemption rule refuses. I checked that distinction
against my own rule before taking the exemption, because the rule exists
to stop exactly this kind of convenient reading.

Filed for the plugin-API owner rather than bodged here.

## Measured

- seam population **22 → 23** with plugins in scope
- gate's own suite: **18/18 green**
- lint and the FNXC gate green

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:08:21 -07:00
gsxdsm
72f5f8e51a fix(gate): the FNXC stamp gate never validated the hour, so 25:30 passed (#2995)
`check-fnxc-future-dates.mjs` validates the **date** portion of a stamp
and never looks at the clock time:

```js
const STAMP = /FNXC:[A-Za-z0-9_-]+\s+(\d{4}-\d{2}-\d{2})/g;
…
for (const match of source.matchAll(STAMP)) if (match[1] > today) hits += 1;
```

The capture stops before the hour, so a stamp may carry **any** `hh:mm`
and pass. Found while pre-flighting #2992, whose new comments read
`2026-07-30-25:30`.

## It is not one typo

Four stamps **already on `main`** carry a clock time that cannot exist:

```
packages/cli/src/__tests__/task-list-board-columns.test.ts:2     -24:40
packages/cli/src/commands/task.ts:29                             -24:40
packages/cli/src/commands/task.ts:636                            -24:40
scripts/check-lane-wiring.mjs:18                                 -24:00
```

Three separate authors, so this is the gate's blind spot rather than one
person's slip — and #2992 adds two more, which is how I noticed.

AGENTS.md specifies `yyyy-MM-dd-hh:mm`. The stamp's whole purpose is to
make the FNXC record a readable chronology of *why* code exists; a
timestamp that cannot exist quietly costs it that, and nothing was going
to catch it.

## The fix

Hours `00-23`, minutes `00-59`, counted per file **alongside** the
future-dated population rather than as a separate gate — same defect
class (a stamp that does not describe a real moment), and one ratchet is
cheaper to keep honest than two.

**Mutations, both directions:**

| stamp | result |
|---|---|
| `2026-07-30-25:00` | **flagged** |
| `2026-07-30-23:75` | **flagged** |
| clean tree | `475 known future-dated stamp(s), none added`, exit 0 |

## On the four existing stamps

Normalized by clamping the impossible hour to `23`, minutes preserved,
so relative ordering within each file survives. **That is a
normalization with a stated rule, not a claim about the true minute** —
`-24:40` most plausibly meant "just past midnight", but writing
`2026-07-31-00:40` would be future-dated against today's local calendar
and fail the very gate this PR extends. Clamping keeps every stamp real,
ordered, and non-future; the exact minute was already unrecoverable.

**Verified:** FNXC gate exit 0, lane-wiring gate exit 0,
`task-list-board-columns` 5/5, lint clean.

Comment-only changes to the CLI files (stamp text inside FNXC blocks),
so no behaviour change and no changeset.

Noted separately on #2992 so its two new stamps get corrected there
rather than landing and immediately failing this gate.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:03:02 -07:00
gsxdsm
cf6062c524 fix(dashboard): finished cards on a renamed board never refreshed their diff stats (#3001)
Last live site from the stale-lane-dependency sweep recorded in #2998.
**Nine persistent candidates; this and #2996 were real.** The other
seven are covered transitively or by a dependency that already carries
the flags — each checked by hand rather than filed, which is the whole
point of that doc.

## The defect

`mergeSignature` is the key `useTaskDiffStats` uses to notice that a
merge changed what a finished card should display. It early-returns
`undefined` unless `isCompleteColumn`, which derives from the
`taskColumnFlags` **prop** — and its dependency list was three `task.*`
fields, none of which is that prop or carries it.

The flags arrive after first paint, so:

1. first computation runs with flags `undefined`;
2. the role helper falls back to the legacy id —
`isCompleteColumnRole(undefined, "shipped")` is **false**;
3. the key is `undefined`;
4. for a card **already merged when the board loaded** — the common case
for anything sitting in a completion lane — neither `mergeDetails` field
changes afterwards either;
5. nothing recomputes, and the hook never learns a merge landed.

A legacy board hides it: `column === "done"` answers true on the very
first paint.

## Measured

| check | result |
|---|---|
| test written first | red for the right reason — control and negative
passed, only the arrival case failed (`expected undefined to be
defined`) |
| after the fix | 3 passed |
| dropping the dependency again | that same case fails |
| `TaskCard.test` + new suite | **391 tests green** |
| gates | census + FNXC green; lint and `tsc` clean |

The observable is the options object handed to `useTaskDiffStats`, so
the assertion is on the value this component is responsible for
producing rather than on what the hook does with it.

## The negative case

Recomputing must not hand a signature to cards that aren't finished. An
in-flight card has no merge to key on, and inventing one would have the
diff-stats hook treat unfinished work as landed.

## The sweep is now closed

For anyone picking this up later: the four "bounded" sites from #2998's
triage remain unexamined **by design** — their dependency lists all
contain a fast-refreshing value (`allTasks`, a live clock), so any wrong
answer there survives only until the next update. That's a judgement
about priority, not a claim that they're correct.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:02:50 -07:00
gsxdsm
e78bf20d55 docs(workflow-learnings): a sixth shape — the resolved value arrives after a memo has answered (#2998)
## The shape

Three defects this session, all the same, none visible to any instrument
here:

A lane value resolved **asynchronously** (the board fetches workflow
traits after first paint) is read inside a `useMemo`/`useCallback` whose
dependency list omits it. The first computation runs with the flags
`undefined`, the role helpers correctly fall back to legacy ids, and on
a **renamed** board that answer is wrong. When the flags arrive nothing
in the dep list changed, so the memo never recomputes.

| defect | severity |
|---|---|
| blocker fan-out trait index (#2993) | permanent — empty index for the
mount |
| card live elapsed-time indicator (#2996) | permanent — never
subscribes |
| near-duplicate chip (#2997) | bounded — self-heals on the next task
refresh |

A legacy board hides all three: there the fallback already answers
correctly on the first paint, so the stale list costs nothing. **Every
instance is renamed-board-only**, which is why they accumulated — and
this repo has no `react-hooks/exhaustive-deps` rule, so the class is
invisible to lint.

## Two properties decide severity, both readable off the dep list

1. **Does any dependency refresh quickly?** `allTasks`, a live clock, a
task identity — any of them rebuilds the closure on the next update,
making the wrong answer a bounded window. The chip keys on `allTasks`
and recovers; the indicator keys on `task.column`, which never changes,
so it never does.
2. **Is the value covered transitively?** A dependency that itself lists
the flags gets a new identity when they arrive, and that propagates.

## A gate was built and rejected — the part worth writing down

The scanner reports **19 sites; two were real.** Property 2 is why:
transitive coverage is invisible to any purely syntactic check and would
need a real dependency graph.

`TaskCard`'s context-menu memo omits all three role flags and is
**nonetheless correct** — it depends on `taskActionMenuModel.actions`,
and that model lists `taskColumnFlags`, so the whole chain recomputes. I
checked that before filing it, which is the only reason this PR isn't a
bug report about missing Archive/Revert menu entries.

Freezing 19 would have baselined mostly noise and trained everyone to
skip the report — the exact failure this document already records for
`sortTasksForDisplayColumn`, where an annotation saying "ignore these"
hid a real defect for days. **A good investigative tool is not
automatically a good ratchet**, and the next person deserves to know the
turn was considered rather than missed.

The triage that does work is cheap: run the scan, then ask the two
questions above. Nine of nineteen survive question 1; hand-checking
those is an afternoon, not a project.

Docs only — no code, no baselines.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:00:06 -07:00
gsxdsm
5adf0d955a fix(scripts): the soft-delete reconciler wrote a literal archived into boards that do not have one (#2999)
## A repair script that wrote a column the board does not have

Under `--apply`, against an operator's live database:

```js
await tx.execute(sql`UPDATE project."tasks" SET "column" = 'archived' WHERE id = ${row.id}`);
```

On a board that does not declare `archived`, that is not a mislabel — it
parks the row in a column the workflow does not have, **manufacturing
exactly the undeclared-column state this migration keeps repairing
elsewhere**.

The selection was wrong in the same direction, which made the write far
worse. "Leaked" meant `column !== "archived"`, so on a renamed board
**every** soft-deleted row looked leaked — including the ones resting
correctly in that board's own archived lane. The repair then rewrote
them. The tool's fix *was* the damage.

## Three changes, because fixing one would have left the others deciding

**The SQL pre-filter carried the same literal** (`AND "column" !=
'archived'`), so the query and the planner each imposed the legacy
vocabulary independently. Dropped it — soft-deleted rows are a small
set, so selecting them all and filtering in the pure planner costs
nothing and leaves **one** place that decides what "archived" means.

**The filter takes the set**; a row resting in *any* of the board's
archived lanes is not leaked.

**The write resolves per task**, because the destination must be that
card's own lane, not a board-wide pick. A row whose archived lane cannot
be resolved is **skipped and reported**, never written with a guessed
id. A recovery script that declines to act on rows it does not
understand is recoverable; one that writes a plausible wrong value is
not.

Verified rather than assumed — a store that answers nothing resolves to
the default lifecycle:

```
lifecycle from unanswering store: {"intake":"todo",…,"archived":"archived"}
```

so a legacy board repairs exactly as before.

## Correcting myself

On #2994 I wrote that this follow-up "needs the same `importCore` seam".
It doesn't: `openBackend` already returns `{ core, store, … }` and this
script already destructures `core`. No new plumbing was required. I
posted that correction on #2994 too, since acting on it would have
wasted someone's time.

## Revert proof

```
✖ a soft-deleted row already in the board's RENAMED archived lane is not leaked
✖ a board with several archived lanes treats all of them as resting places
ℹ pass 5   ℹ fail 2
```

The other two new cases pass both ways by design — they guard the legacy
meaning and the still-catches-a-real-leak direction — so I am not
counting them as coverage of the defect.

## Verification (measured)

- `node --test` across all three script suites — **17 passed / 0
failed**
- `node --check`, `eslint` — clean
- `check-sql-column-literals`, `lifecycle-column-census --strict`,
`check-lane-wiring`, `check-fnxc-future-dates` — green

No changeset: root `scripts/` is repo tooling, not part of the published
package.

## Gate blind spot found while verifying this, NOT fixed here

I removed a raw-SQL lane literal and expected
`check-sql-column-literals` to drop from 22 — its own header says *"a
LOWER count fails too so the baseline is ratcheted down"*. It stayed at
**22 and green**, because it walks `PACKAGES` only and **never scans
`scripts/`**.

That is the same shape as the lane-wiring gap #2978 closed (it scanned
neither `plugins` nor `dashboard/app`).
`scripts/audit-branch-cross-contamination.mjs:185` still holds `WHERE …
"column" IN ('triage','todo','in-progress','in-review')`, invisible to
the gate. Left as a separate follow-up rather than bundled into a
product fix.
2026-07-30 23:59:54 -07:00
gsxdsm
faf4245c7b fix(dashboard): the duplicate chip kept pointing at work that had already landed (#2997)
Second live defect from the same sweep as #2996 — memoized hooks reading
a lane value absent from their dependency list. **13 hits, triaged by
hand, 2 real.**

## The defect

`resolveNearDuplicateCanonicalInactive` decides whether a card's
*"duplicate of X"* chip is hidden because the canonical is finished. It
calls `getTaskColumnFlags`, whose identity changes when the board's
workflow traits arrive — while its own dependency list was `[allTasks]`
alone. So it kept the closure created during the **pre-load** render,
over an empty trait map.

With no traits the role helpers fall back to legacy ids, so a canonical
sitting in a renamed complete lane reads as still **active**, and the
chip stays up advertising a duplicate of work that has shipped.

## Severity, stated honestly

The closure is rebuilt whenever `allTasks` changes identity, which any
task-list refresh does. So this is a **bounded window**, not a permanent
wrong answer — unlike #2996, where the dependency that would have
refreshed it (`task.column`) never changes. On a quiet board the window
is the gap until the next update.

I'd rather say that plainly than let it read as equally severe because
it's in the same family.

## The hoist is required by the fix, not tidying

`getTaskColumnFlags` sat *after* this callback, with a note explaining
that the body only runs during render so the const is initialised by
then. That's true of the **body** and false of the **dependency array**,
which evaluates eagerly — so the reference could not be listed at all
until the declaration moved.

The existing note reasoned carefully about declaration order and said
nothing about staleness, which is exactly how it read as considered.
Both hoisted callbacks close over props only, so the move carries no
behaviour.

## Measured

| check | result |
|---|---|
| test written first | red for the right reason — arrival case `expected
false to be true`, negative passed |
| after the fix | 2 passed |
| **dropping the dep while keeping the hoist** | arrival case fails
again |
| `Column.test` + new suite | **87 tests green** |
| gates | census + FNXC green; lint and `tsc` clean |

That third row is the one that matters: it isolates the test as
load-bearing on the **dependency**, not on the code move that had to
accompany it.

The observable is the prop `Column` computes, not the chip markup —
`Column` is the producer here, and asserting on `TaskCard`'s rendering
would test the consumer of a value this component gets wrong.

## The negative case

Re-resolving must not degrade into "every canonical is inactive". A
canonical still in a live lane keeps its chip, or the fix silently hides
**real** duplicate warnings — worse than a stale one, because then
nothing points at the collision at all.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:54:35 -07:00
gsxdsm
5ce23b2187 fix(dashboard): the live elapsed-time indicator never started on a renamed board (#2996)
## How this was found

By generalizing the memo-dependency defect in the blocker fan-out
(#2993) into a sweep for memoized hooks that read a lane value absent
from their dependency list — rather than treating that one as a one-off.
**13 raw hits, 12 benign** (refs, or values reached through a covered
object). This is the one that's a live defect.

## The defect

`wantsLiveTimeIndicator` decides whether a card subscribes to the shared
time ticker. It reads `isWipColumn`, `isReviewColumn` and
`taskColumnFlags` — all derived from the `taskColumnFlags` **prop** —
while its dependency array listed only `task.*` fields.

Those flags arrive **after first paint**: the board resolves workflow
traits asynchronously. So:

1. first computation runs with flags `undefined`;
2. role helpers fall back to legacy ids — `isWipColumnRole(undefined,
"building")` is **false**;
3. the card declines the ticker;
4. flags arrive, but `task.column` hasn't changed, so nothing in the dep
array changed;
5. the memo never recomputes. **No live elapsed time, for the life of
the mount.**

## Why it survived

On a legacy board the fallback already answers `true` on the very first
paint (`column === "in-progress"`), so the memo's initial value is
correct and the stale list costs nothing. The defect is
**renamed-board-only**.

This repo also has no `react-hooks/exhaustive-deps` rule, so the entire
class is invisible to lint — and a disable directive for that rule fails
CI, so these lists are maintained by hand.

## Measured

The test was written **first** and was red for the right reason before
any fix — the control and the negative passed, and only the renamed case
failed:

| stage | result |
|---|---|
| before the fix | `expected false to be true` (renamed case only) |
| after | 3 passed |
| `TaskCard.test` + `cli-states` + `oversight` + new suite | **456 tests
green** |
| gates | census + FNXC green; lint and `tsc` clean |

The assertion is on `useLiveTimeTicker(enabled)` — `enabled` *is*
`wantsLiveTimeIndicator`, so it observes the subscription itself rather
than a proxy for it.

## The negative case is the one that matters

Recomputing must not degrade into "every card subscribes". A card in the
renamed **complete** lane must stay off the shared ticker, or the fix
trades one stalled indicator for sixty cards waking a backgrounded tab —
the exact cost the shared-ticker refactor documented at this site (it
replaced 60 per-card `setInterval`s precisely because mobile browsers
discard a page that never goes idle).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:51:50 -07:00
gsxdsm
ac67b8d585 fix(scripts): the FN-4000 consistency reconciler failed in BOTH directions on a renamed board (#2994)
## The FN-4000 consistency reconciler failed in *both* directions

`findTaskStateInconsistencies` keyed both checks on legacy lane
literals, and they break in opposite ways:

```js
const hasDoneTransient = task.column === "done" && (status failed || error || worktree || blockedBy || …);
if (task.status === "failed" && task.column !== "in-review") { … }
```

| check | on a renamed board | effect |
| --- | --- | --- |
| `hasDoneTransient` | **never fires** | a finished card still holding
`status:"failed"`, a worktree, a blockedBy or live recovery counters is
never reported and never normalized — precisely the stale state FN-4000
exists to clear |
| `failed-status-outside-in-review` | **fires for every failed card** |
no column equals the literal, so the report lists the whole board |

The second is the more dangerous of the two: a tool that reports nothing
looks broken, but a tool that reports everything looks like it is
working.

## Wiring, and why the resolver is injected rather than built inline

Lanes are resolved **per task** (a board can span workflows) and passed
in. Resolving inside the loop would drag `importCore()` — and therefore
a built `packages/core/dist` — into every unit test of a pure
reconciliation loop.

`main` wires the real resolver whenever it opened a real backend, so
this is **not** the inert optional-parameter shape this migration keeps
finding. A caller injecting its own store (tests) has no staged dist and
falls back to the documented legacy literals, which is exactly today's
behaviour.

`importCore` is now exported from `scripts/lib/backend-db.mjs` so
operator scripts reach core helpers through the **same staged-dist seam
`openBackend` already uses**, rather than each growing its own dist path
— `@fusion/core` is not resolvable from repo-root `scripts/`, which is
what made the obvious import fail.

The normalization move now targets the card's **own** column: naming
`"done"` was only ever a way of spelling *"where it already is"*, since
the move exists to trigger the store's done-normalization.

## One of my test expectations was wrong before the code was

My first version asserted that a card in a renamed complete lane with
`status:"failed"` yields only the transient-state finding. It yields
**both** — and that is correct, because a failed card outside the review
lane genuinely is flagged. I isolated the case (dropping
`status:"failed"`, keeping the worktree) so it pins one behaviour
instead of blurring two, rather than "fixing" the expectation to match
whatever came out.

## Revert proof

Restoring the four literals:

```
✖ reports stale transient state in a RENAMED complete lane
✖ does NOT flag a failed card that is sitting in the board's own review lane
✖ runReconciliation normalizes a renamed complete lane by moving the card to its OWN column
ℹ pass 5   ℹ fail 3
```

The remaining two new cases pass both ways by design — "still flags a
failed card outside the resolved review lane" and "unresolved lanes keep
exactly the legacy behaviour" guard against over-correction, so I am not
counting them as coverage of the defect.

## Verification (measured)

- `node --test` — **8 passed / 0 failed** (3 pre-existing + 5 new)
- sibling script suites (`recover-stale-blocked-by`,
`reconcile-leaked-soft-deletes`) — **7 passed**, unaffected by the
shared-lib export
- `node --check`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-lane-wiring`, `check-fnxc-future-dates` — green

No changeset: root `scripts/` is repo tooling, not part of the published
package.

## Still not addressed in this territory

`reconcile-leaked-soft-deletes.mjs` carries a raw `UPDATE
project."tasks" SET "column" = 'archived'` — on a renamed board that
writes a column the workflow does not declare, creating the
undeclared-column state this migration keeps repairing elsewhere. It
holds a raw backend rather than a store, so it needs the same
`importCore` seam this PR exports; left for a follow-up rather than
bundled here.
2026-07-30 23:49:05 -07:00
gsxdsm
65f4e8533e fix(dashboard): blocker fan-out classified every board against the LEGACY lanes (finished cards shown as blockers; escalation never fired) (#2990)
The dashboard's `computeBlockerFanoutMap` wrapper called core with **no
lane answers at all**:

```ts
return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, {
  staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs,
});   // no terminalColumns, no reviewColumns, no holdColumn, no classify
```

So every fan-out surface classified against `todo` / `in-review` /
`done` regardless of what the operator named their columns. Core defines
**active by exclusion — not terminal** — so on a renamed board a
**finished** card never became terminal and stayed an active blocker
forever. The Executor bar's highest-overlap blocker and the task modal's
blocking-dependents list both kept naming work that had already landed.

**Escalation was worse.** `shouldEscalate` requires the blocker to sit
in an escalation lane (wip ∪ review), which unresolved means
`in-progress`/`in-review` only — so a stale blocker holding up many
cards **never escalated**. The fan-out numbers themselves stayed
correct, which is what makes it easy to miss: the metric says there is a
problem and the mechanism that acts on it is switched off.

## Shape

**Per task, not a board-wide union** — the reason `blocker-fanout.ts`
documents on `classify`: an id means something only relative to its own
workflow, and this board renders several at once. `Board` builds the
index exactly as `App.tsx` already does for the footer
(`footerColumnFlagsByTaskId`): task → its own workflow → that workflow's
entry for the column the card rests in.

**Escalation = wip ∪ review**, mirroring `scheduler.ts`'s own
construction. The two must agree — the scheduler decides a blocker
escalates and the dashboard is where an operator sees it.

**An empty trait map means "not resolved yet", not "nothing is
terminal."** The pre-load window and the remote-node case keep the
documented legacy default rather than fabricated lifecycle state.

## Reverted

| case | reverted |
|---|---|
| a finished card in a renamed completion lane is not an active blocker
| **fails** |
| a stale high-fan-out blocker in a renamed wip lane escalates |
**fails** |
| unresolved traits stay byte-identical | passes either way — that is
why it is there |

## Two notes

- The hook call had to move below `useBoardWorkflows` in `Board` (it was
at line 206, the workflows at ~390). `blockerFanoutMap` is consumed only
in JSX, so the hook order change is unconditional and stable.
- The unresolved-card fallbacks are hoisted into three named helpers
with `DELIBERATE-LITERAL` markers on the **declarations** — the census
reads markers from leading comments, so an inline one attaches to the
wrong node and is silently ignored. Census baseline re-recorded in the
same commit (debt did not increase; markers moved 5 sites out of the
guard count).

## Not done

`ExecutorStatusBar` and `TaskDetailModal` call the wrapper directly and
still pass no traits. `ExecutorStatusBar` already receives
`columnFlagsByTaskId` so it is a one-liner; `TaskDetailModal` has no
trait index in scope and needs one threaded. Left out to keep this
reviewable — the ratchet keeps both visible.

## Verification

dashboard app suite **1919 passed (140 files)** · `pnpm test:gate` 161 +
13 + 487 + 71 · lint · census `--strict` · lane-wiring · fnxc-dates ·
changesets — green.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:41:14 -07:00
gsxdsm
bb6c08d9d6 fix(scripts): the blocked-by recovery reported "Repairs: 0" on a board it never examined (#2992)
## A recovery tool that reports "Repairs: 0" without having examined
anything

Every lane test in `recover-stale-blocked-by.mjs` is a legacy id:

```js
function isTerminalColumn(column) { return column === "done" || column === "archived"; }
const isActive = row.column === "in-progress" || (row.column === "in-review" && row.worktree && !row.paused);
if (row.column !== "todo" || !row.blockedBy) continue;          // ← the candidate gate
```

On a board whose lanes are named anything else, that gate matches
**nothing**. The planner returns no findings and the script prints
`Repairs: 0`.

An operator running a recovery reads that as *"the board is fine"* when
the tool never examined a single card. **A silently empty answer from a
recovery tool is the worst shape available** — indistinguishable from
success, and consulted precisely during an incident.

This is not dead code: `docs/soft-delete-verification-matrix.md` cites
it as the GREEN backstop for FN-5528, and it has its own test file.

## Detection only — and why I did not "fix" the classification

Correct classification needs the board's resolved trait vocabulary. This
script holds a **raw backend** (`openBackend` → `asyncLayer` + `sql`),
not a `TaskStore`, so resolving lanes here would mean reimplementing IR
trait resolution inside a `.mjs` script — a worse bug than the one it
fixes, and precisely the kind of second, drifting copy this migration
keeps deleting.

So the assumptions are not repaired; they are made **loud**. That is the
same principle the lane-wiring gate applies to itself:

> a gate whose errors land on "nothing to report" is the one failure
mode a ratchet must not have

The unknown-lane list rides on the returned array as a
**non-enumerable** property rather than widening the return type —
`recoverBlockedBy` is consumed as `findings[]` by the entry point and by
tests, and an operator may be scripting around that shape.

## The first test pins the gap rather than papering over it

```js
assert.deepEqual(unrecognisedLanes(rows), ["backlog", "checking"]);
// The gap this warns about, pinned rather than claimed fixed: the planner still sees nothing.
assert.deepEqual(planRecoverBlockedBy({ rows, tasksDir }), []);
```

I would rather the next reader find that assertion than discover it
themselves during an incident.

## Revert proof

With `unrecognisedLanes` returning `[]` (the pre-fix behaviour):

```
✖ names lanes the planner does not understand, so an empty result cannot read as healthy
✔ stays quiet on a legacy board, so the warning means something when it appears
✖ reports each unknown lane once, ignoring rows with no column at all
ℹ pass 5   ℹ fail 2
```

The legacy-board case passes **both ways by design** — it guards against
the warning firing spuriously, so I am not counting it as coverage of
the defect.

## Verification (measured)

- `node --test` — **7 passed** (4 pre-existing + 3 new), 0 failed
- `node --check`, `eslint` — clean
- `check-sql-column-literals`, `lifecycle-column-census --strict`,
`check-lane-wiring`, `check-fnxc-future-dates` — green

No changeset: root `scripts/` is repo tooling, not part of the published
package.

## How this was found, since the method matters more than the fix

My batch is "cli + plugins + anything left", and I had been reading
*"anything left"* as nothing. Eight packages and all of `scripts/` sit
outside the four named batches. This is the first thing I found there;
sibling one-shot scripts (`reconcile-task-state-consistency.mjs`,
`reconcile-leaked-soft-deletes.mjs` — which contains a raw `UPDATE … SET
"column" = 'archived'`) carry the same hardcoded assumptions and are
**not** addressed here.
2026-07-30 23:41:03 -07:00
gsxdsm
fb53a96eaa test(engine): the lease-seam alarm fired downward — re-point it, and close two ways it could pass without a fix (#2987)
## What happened

Both of my source-level audits went red on the advance that landed
#2975. They are exact counters, not floors, so this is the alarm working
**downward** — the direction it was written for. #2975 converted the two
self-healing `shouldHoldActiveFileScopeLease` call sites and closed the
2-of-4 seam that
`workflow-file-scope-lease-caller-gap-live-e2e.pg.test.ts` was
measuring.

I judged the conversion real before updating anything. Both sites now
derive their answers from `resolveProjectColumnsForRoles(...)` sets the
sweep had already resolved a few lines above (`self-healing.ts:4754`,
`:5825`) — trait membership, not a literal. So the numbers moved on
purpose.

## The part that is not bookkeeping

Re-pointing an audit to whatever the code now says is how a guard goes
dead. Both assertions could have been satisfied by something that is
**not** a fix, so both were tightened:

| Way it could pass without a fix | Old assertion | Now |
|---|---|---|
| `isWipColumn: true` hardcoded at a self-healing site — the original
defect wearing the converted call shape, answering "yes" for a blocker
resting anywhere | only checked the key was *absent* | requires
resolved-set membership: `/isWipColumn:\s*\w+\.has\(\w+\.column\)/` |
| a site answering one of the two independent role questions and not the
other | `includes(a) \|\| includes(b)` counted it as converted | `&&` |

The scheduler's own two sites *do* pass literal `true`, correctly — they
have already filtered to a role-resolved bucket, so there the answer is
a fact about the loop, not about the card. The form check is scoped to
`self-healing.ts` for that reason.

## Mutation evidence

Not reasoned — measured. Each mutant applied to `self-healing.ts`, suite
re-run, file restored:

| Mutant | Result |
|---|---|
| baseline | 8 passed |
| M1 — hardcode `isWipColumn: true` at one site | **1 failed** |
| M2 — drop `isReviewColumn` at one site (half-converted) | **2 failed**
|
| M3 — revert both sites to pre-#2975 | **2 failed** |

M2 is the one that justifies the `&&`: re-running it with the counter
reverted to `||` leaves the file **green (4/4 passing)**. The old
counter provably could not distinguish a closed seam from a half-closed
one — the exact blind spot this file exists to remove.

## Verification

`test:gate` exit 0 · live-PG E2E surface **171/171** · lifecycle-column
census exit 0 · FNXC date ratchet exit 0 · `pnpm lint` clean. Production
files untouched (`git status` clean on `self-healing.ts` after every
mutant).

## Scope

The other measured seam, `evaluateParkedAgentTaskLink`, is **unchanged
at 2-of-6** — four callers still omit the resolved columns, so the class
is not closed, only one of its two instances is. That remains
characterized, not fixed, in the same file; converting those four is the
capacity worker's file, not mine.

The call-site facts are still asserted against source text rather than
driven through the self-healing sweep, which would need the full
dependency-lease reconcile harness. That limit was stated in the
original file and still is.

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

## Summary by CodeRabbit

* **Tests**
* Updated end-to-end workflow checks to verify resolved workflow-role
values at active lease call sites.
  * Strengthened assertions for WIP and review column detection.
* Updated audit coverage to reflect conversion of all active-file-scope
lease callers.
  * Preserved tracking for the remaining parked-link integration points.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 23:38:10 -07:00
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