511f5b7e2bc091261e7d61aaf67ae644750d72ea
12796 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 --> |
||
|
|
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>
|
||
|
|
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. |
||
|
|
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 -->
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 -->
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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>
|
||
|
|
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.
|
||
|
|
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 --> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |