Commit Graph

2682 Commits

Author SHA1 Message Date
gsxdsm
6a465e1006 docs(workflow-learnings): probe harnesses lie more often than the gates do (#2983)
## What

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

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

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

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

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

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

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

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

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

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

## Why this is docs and not a gate change

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

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:16:26 -07:00
gsxdsm
4f929acc10 fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)
Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

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

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

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

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

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

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

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


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

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

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

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

---------

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

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

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

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

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

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

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

* **New Features**
* Missions can now set an optional per-mission task ID prefix
(overriding the project default).
* Added task prefix support to mission create/edit UI and dashboard
APIs, including normalized uppercase values and validation.
* **Bug Fixes**
* Improved commit hook generation for custom prefixes and special
characters, with safer shell handling to prevent unsafe interpretation.
* **Chores**
* Added PostgreSQL migration and schema-applier support to persist and
propagate mission task prefixes, including upgrade/backfill coverage.
* **Tests**
* Added backend and UI/API test coverage for task-prefix creation,
clearing, and ID minting behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 21:35:23 -07:00
gsxdsm
b1bd571682 batch-sql-ratchet: the census / gate-ratchet family — collection branch, fold here (#2941)
## Family branch for consolidation directive item 4

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

Closing #2924 as superseded by this.

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

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

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

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

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

---------

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

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

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

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

## Scope is provably comment-only

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

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

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

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

## A correction worth recording

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

## Closing the originals

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:12:26 -07:00
gsxdsm
b3b377d367 docs(workflow-learnings): two lane-literal classes no tool of ours can see (#2877)
Docs only. Two findings from this unit that cost real time to derive and
would otherwise be re-derived by whoever reaches these files next.

## 1. `=== "archived"` is usually a SENTINEL

`packages/core/src/task-store/async-comments-attachments.ts` carries
**9** census guards — the second-largest single-file count outside
`self-healing.ts`. Reading all nine: **exactly one** is a board-column
comparison. The other eight compare against a value `getLiveTaskColumn`
*manufactures*:

```ts
if (row.column === "archived" || row.deletedAt != null) return "archived";  // ← fabricated
return row.column;
```

Converting those eight to `isArchivedColumnRole` would keep passing on
the built-in board and start **failing** on a renamed one — a
soft-deleted parent's documents would become readable. **The conversion
makes the renamed board worse**, which is the opposite of what the
census count implies.

The rule that separates them: look at where the compared value *came
from*, not at its type. From `task.column` or a DB field → a board lane.
From a function that *returns* `"archived"` as a documented outcome → a
sentinel.

Consequence worth stating plainly: **a file's census count is an upper
bound on convertible sites, not a work estimate.**

## 2. Lane literals inside raw `sql` are in no total at all

The Reliability panel had three inputs. Two were call arguments and
converted routinely (#2861). The third encoded its lanes in a `sql`
fragment:

```sql
metadata->>'to' = 'in-review' OR (metadata->>'from' = 'in-review' AND metadata->>'to' = 'done')
```

The census scans `===`/`!==` comparisons; the unwired-lane-parameter
guard scans declarations. **Neither can see a string inside a `sql`
template**, so this class is not in the backlog number — a second,
independent reason the total is a floor. Second known instance after the
archived gate in PR #2724, which makes it a pattern rather than an
accident.

Fixed in #2875, and the doc says so rather than leaving it described as
outstanding — a learnings doc that reports a fixed defect as open sends
the next reader to a dead end. `scripts/check-sql-column-literals.mjs`
(#2841) is the detector for the class and freezes the surface at 30
sites; the two are complementary.

## 3. Sibling files

The GitLab importer's `column: "triage"` was fixed in #2843. The Linear
importer — written from the same template, with **two tests pinning the
bug** — still had it, and was found only by re-grepping an area I had
already declared clean (#2860). When a defect is found in a file that
has a sibling, the sibling is the next place to look, and no tool will
tell you that.

## Verification

`pnpm lint` clean. No source change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:05:01 -07:00
gsxdsm
6099f028e4 docs: correct every number in the self-healing sweep doc — all of mine were wrong, three different ways (#2865)
CodeRabbit flagged #2838's doc as saying four sweeps converted when the
PR converted more. It merged before I could answer, so this is the
fix-forward — and re-measuring found the count itself was wrong, along
with **every intermediate number I published**.

## Measured, comments stripped

Literal column queries in `self-healing.ts`: **47 before, 36 now.**
Eight sweeps converted, all eight named in the doc.

## Three distinct errors, each recorded because the next worker re-runs
this

1. **The per-commit "N remaining" counts (44, 43, 42, 41, 40) were
arithmetic on an assumed starting point.** I decremented a number
instead of measuring one — in a program whose central discipline is that
measurement beats assumption, in commit messages that also said
"measured".
2. **A raw `grep -c` counts explanatory comments that quote the old
query form** — including the ones these conversions *add*. So converting
a sweep could leave the count unchanged, which is exactly what it
appeared to do for six of the eight.
3. **The obvious comment filter (`startsWith("//") || startsWith("*")`)
misses block-comment lines beginning with ordinary prose**, which is
most of them here. That is why my first correction said 45 and was still
wrong.

The doc now carries the strip-comments-then-count command, so the number
is **reproducible rather than quoted**.

## Also corrected

The activation-risk list is **2 sweeps, not 4** —
`finalizeNoOpReviewTasks` and `recoverCompletionHandoffLimbo` were
converted in the same PR and are no longer risky. A stale list naming
specific sweeps and line numbers is worse than a stale count: it reads
as a work queue, and I nearly "fixed" a guard I had already wired from
exactly that kind of row.

## Verification

`pnpm lint`, `check:changesets`, census `--strict` — clean. Docs-only;
no code change.
2026-07-30 16:34:45 -07:00
gsxdsm
5792452f0a docs(workflow-learnings): mutation testing has one blind spot — your own imagination (#2858)
The most transferable thing this lane produced, and it is a correction
to advice I wrote earlier in the same document.

## The gap

Every other section here says *"watch the guard go red before you trust
it."* That rule is necessary and **not sufficient**, and the way it
fails cost the most.

Three instruments were written during this program. Each was
mutation-tested in both directions before shipping. Each was green.
Reviewers then found, in those same instruments:

- a **file-level pre-filter** that skipped whole files, so a forbidden
site added to a file with no other SQL was invisible;
- an **anchored pattern** that missed qualified and compound fragments
(`t."column" = 'done'`);
- a scan over **SOURCE text**, where a double-quoted TS string still
spells `\"column\"` with the backslashes in it;
- an operator list of `= != <>` that never considered **`IN (...)`**;
- and worst, a template scan that joined only the **static spans** — so
a Drizzle query, which puts the COLUMN in the interpolation hole and the
legacy id in the static text, matched nothing. **That gate was blind on
the exact files it was built to freeze.** Enabling that one shape took
the population from 14 to 31 and revealed five previously invisible
files.

## Why the mutation tests could not catch any of them

All five are **false negatives**, and the reason is structural rather
than sloppy:

> A mutation you write is a mutation you already imagined, so it lands
inside the space your scanner understands.

Reintroducing a defect the checker was designed around proves the
checker still handles that defect. It says nothing about shapes you
never modelled.

## What does find them

1. **Run the instrument against the code it was written for and read the
hits by hand.** The Drizzle blindness was obvious the moment someone
asked *"why is the merge-queue query — the reason this exists — not in
the output?"*
2. **Prefer one unanchored pattern over a fast pre-filter plus a precise
one.** Every false negative above came from two patterns disagreeing
about whether to run the real check at all. A pre-filter is a second,
weaker specification of the thing you are testing.
3. **Treat a guard's own count as a claim to verify, not a result.** "14
sites" read as coverage for days; it was the subset one scanner happened
to model.

A false positive is loud and gets fixed. **A false negative prints a
baseline and reads as coverage.**

Docs only — no changeset.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:55:00 -07:00
gsxdsm
7784cb1fe8 self-healing: six recovery sweeps that never ran on a renamed board — and the guards widening their queries activates (#2838)
**Six self-healing sweeps did not run at all on a renamed board. Each is
a recovery path — the thing that unsticks a card when something has
already gone wrong.**

#2800 measured this class and could not fix it: a read happens *before*
any task is in hand, so there is nothing to resolve a per-task lane
from. `resolveProjectColumnsForRoles` (landed separately) is the seam
that was missing.

## What was silently dead

| sweep | what stayed broken on a renamed board |
| --- | --- |
| `reconcileDoneTaskIntegrity` | a landed card kept **no commit sha**,
forever |
| `recoverAlreadyMergedReviewTasks` | a card whose merge **succeeded**
stayed parked with `status: "failed"` |
| `recoverStuckMergeDeadlocks` | **doubly blind** — no candidates *and*
no dependents |
| `recoverInterruptedMergingTasks` | a task interrupted mid-merge sat in
`merging` indefinitely |
| `recoverMergeableReviewTasks` | a card ready to merge was never
re-enqueued |
| `recoverReviewTasksWithFailedPreMergeSteps` | a card parked on a
failed review step was never revived |

The census scored the `task.column === "..."` re-assertion *inside* each
loop, never the query above it. Converting those comparisons would have
dropped six counts and changed nothing — the loop bodies were already
unreachable.

## The conversion shape — five parts, three of which review taught me

Documented in `self-healing-sweeps-are-blind-on-a-renamed-board.md`,
because the second sweep **drifted from the first**: I wrote it from the
pre-review version and reproduced a flaw already fixed one commit
earlier.

1. **Read** — project union, query each column, dedupe by id. Legacy ids
unioned so a board mid-rename is not skipped.
2. **Verdict** — per card against **its own** workflow. Widening the
read and widening the verdict are different decisions: *a missed row is
invisible, a wrong row is a write.* Using the project union as a
per-card test claims a card because some **other** board calls its
column that role.
3. **Provenance** — the resolver **substitutes** the built-in IR rather
than failing, so `length > 0` reads as "this card answered" when nobody
did. It does not change the verdict (measured: identical) — it makes the
unrepaired card **reportable**.
4. **The log strings** — widening a query invalidates every message
naming the old literal. One logged `"stale merging task(s) in
in-review"` after its read covered several lanes.
5. **The guards the query ACTIVATES.**

## Part 5 is the one that bites

A guard downstream of a literal query is **unreachable** on a renamed
board — and unreachable is indistinguishable from correct. That is why
these sit unwired indefinitely.

`recoverReviewTasksWithFailedPreMergeSteps` filters on `blocker !==
"task has failed pre-merge workflow steps"` — an **exact string match**.
Unwired, the blocker returns `"task is in 'checking', must be in
'in-review'"`, so widening the query alone would have made the sweep
**find every card and reject every card**.

Measured: **6 sweeps hold both a literal query and an unwired lane
guard**; 30 hold a literal query with no such guard. All six are named
in the doc.

**One of the six was my own already-converted sweep.** I widened
`recoverAlreadyMergedReviewTasks` two commits before noticing its
`getTaskHardMergeBlocker` was unwired — so for two commits it found
renamed-board cards and declined them. The scan must run **before**
widening; I did it after, and only caught it because the next sweep
forced the question. `getTaskHardMergeBlocker` was the blind spot for
four of the six: a wrapper, no lane parameter at all, every caller
behind a literal query.

## Corrections to my own work, kept visible

- The project union used as a **per-card verdict** — the flat-set
mistake `project-lane-vocabulary.ts` warns about in its own header,
which I quoted while writing it.
- A **provenance fix that was a no-op**: measured identical verdicts in
every state, revert passed its own new test, so it was thrown away
rather than shipped with a comment claiming otherwise.
- The second sweep **reproducing the first's pre-fix shape**.
- Three assertions that were **vacuous until the revert exposed them** —
including one where the write needed a real git repo, so `commitSha`
could not distinguish accepted from rejected.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71
- `self-healing.test.ts` 412, query-blindness suite 12
- `tsc` on core and engine; `pnpm lint`; `check:changesets`; census
`--strict` — all clean, each run explicitly
- Every conversion revert-measured, **each direction independently**
where a sweep has two (read and guard)

## Scope

**42 queries remain**, 5 of the 6 activation-risk sweeps among them.
Each is per-sweep work — its own filter semantics, its own downstream
guards, its own log strings — so they land one at a time with the
pattern proven, never swept.


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

## Summary by CodeRabbit

- **Bug Fixes**
- Self-healing workflows now work correctly on boards with renamed
lifecycle columns.
- Improved recovery for completed, in-review, interrupted, stalled, and
failed-merge tasks.
- Prevented tasks from being incorrectly classified using another
workflow’s columns.
  - Added warnings when a task’s workflow lanes cannot be resolved.

- **Documentation**
- Expanded guidance on renamed-board recovery behavior and related
diagnostic limitations.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:38:10 -07:00
gsxdsm
89aaf341d0 the unwired-seam audit: 9 defects the census cannot see, incl. a reviewed card that cannot merge (#2820)
**Nine operator-visible defects in a class the census cannot see, plus
the audit method that found them.**

The census scans for lifecycle-column **comparisons**. This PR is about
guards that have no literal to find: a helper takes an optional
*resolved* lane set, its own test passes it, the census entry is gone —
and the callers pass nothing. **A resolved seam nobody wired is
indistinguishable from no seam at all.**

## What was broken

| defect | operator sees |
| --- | --- |
| `getTaskMergeBlocker` unwired in `mergeTaskImpl` | `Cannot merge FN-1:
task is in 'checking', must be in 'in-review'` — **a reviewed card
cannot merge** |
| …and in the completion move | `Cannot move FN-1 to done: …` — **and
cannot complete** |
| `isParkedTaskColumn` unwired ×2 (`agent-heartbeat`) | a durable agent
keeps claiming a parked card; **Health Check renders it RUNNING** |
| `resolveLinkSyncColumnRoles` first-per-role | link hygiene skips a
**second hold lane** entirely |
| `executor` active-task predicate first-per-role | a card in a **second
wip lane reads as INACTIVE**; its prompt file becomes reclaimable |
| `isPlanningContinuationTaskDispatchable` partially threaded | a board
declaring `done` as *non-terminal* stalls its cards — **stalled by a
lane name** |
| `default-workflow-hooks:72`, `executor:2404` | resolved gate admits
the move, unresolved blocker refuses it |

## The recurring shape, which is sharper than "a caller forgot an
argument"

Four sites resolve the lane and then re-ask with the literal, **a few
lines apart in the same function**:

- `task-artifacts-ops` resolves `completeColumn`, then asks the blocker
with the literal.
- `default-workflow-hooks:72` gates on `lifecycleColumns?.review`, then
the literal.
- `executor:2404` compares `resolveResumeLanes(…).review`, then the
literal.
- `resolvePlanningContinuationCandidate` applies the caller's terminal
set, then delegates without it.

**Grep for the helper, not the literal.** The literal is one function
away, correctly annotated as a fallback — which is exactly why the
census is blind to all of it.

## The arity trap, named and measured (six occurrences, one caught by
review here)

`resolveLifecycleColumns` answers *"which column is **the** hold
lane?"*. A `.includes()`/`.has()` test asks *"is this **any** hold
lane?"*. Nothing distinguishes them — same types, no literal.

**A default-vs-renamed differential cannot catch it**, because the
default board declares one column per role and therefore cannot express
the failing shape. It needs a *structurally* different fixture. That is
a sharper rule than "test both vocabularies", and it would have caught
all six.

Scanned: 12 candidate sites. **4 fixed · 3 blocked (2 on the inert sync
IR reader; `triage:833` also query-shaped) · 1 needs a hook-contract
change · 3 not defects (a returned tuple; an ordering-sensitive
precedence list) · 1 false positive of my own scan.**

A sweep over all twelve would have broken the ordering-sensitive pair,
delivered nothing at the sync-blocked ones, and "fixed" a site that was
already correct.

## Two traps in fixing this class — I hit both here

1. **The legacy id is a FALLBACK, not a member.** Pre-seeding
`"in-review"` admits a board that *declares* `in-review` as its WIP
column — a card mid-implementation merges prematurely. A real resolved
answer must **replace** the default. (Caught by review; it is the same
unscoped-legacy-acceptance the glasses plugin's review caught earlier,
which I had read and reintroduced.)
2. **Two guards, one assertion.** `toContain("must be in")` passed with
`mergeTaskImpl` reverted, because the *completion* guard caught the card
instead. The assertion now names the site (`Cannot merge` vs `Cannot
move … to done`) so the two fail independently.

## Corrections I made to my own work, recorded rather than quietly fixed

- My first PG test was **vacuous three ways**:
`saveWorkflowDefinition?.()`/`setTaskWorkflowSelection?.()` do not exist
(the `?.` swallowed both, so the task kept the builtin workflow),
`updateTask({column})` does not move a card, and a two-node IR made
every setup move illegal. Premise is now **asserted**, not assumed.
- My doc claimed the audit was complete. It enumerated **helpers**, not
every **caller** — `getTaskMergeBlocker` alone has 13 call sites.
Corrected in place, with the still-unwired ones listed by file and line
and a note to distrust any "audit complete" claim including mine.
- A severity correction to another worker's E2E:
`selectActionablePlanningContinuations` has **no production caller**, so
its stated consequence is latent, not live.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71
- `tsc` on core and engine; `pnpm lint`; `check:changesets`; census
`--strict` — all clean, each run explicitly
- Every fix revert-measured; each has a non-vacuous companion. The
two-hold-lane and repurposed-`in-review` cases exist because the default
board cannot express those shapes.

## Deliberately not done, with reasons in
`resolved-seams-nobody-wired.md`

`isTaskReadyForMerge` (dead in production — wiring it would be the
anti-pattern itself); `getTaskHardMergeBlocker` (3 of 4 callers are
query-gated sweeps); `getInReviewStallReason` (needs a **batch
prefetch**, not a per-task resolve — its callers decorate every task on
every list read; the in-review stall badge is wrong on renamed boards
until then); `default-workflow-hooks` planning/live-work sets (needs
`DefaultWorkflowMoveContext` to carry the IR — a shared contract
change).
2026-07-30 15:08:01 -07:00
gsxdsm
7927c7b58a docs(testing): probe the DOM before theorising about a missing element (#2850)
Earned the hard way this week: three separate causes in `App.test.tsx`
and `board-mobile-view-switch.test.tsx` all presented **identically** as
a missing element, each with a DOM that looked healthy.

| what the test said | what was actually wrong |
|---|---|
| `Unable to find "+ New Task"` | `ListView` rendered its workflow
**skeleton**, which carries the same `list-view` class as the real body
— so the preceding `waitFor(".list-view")` passed |
| `Unable to find role="heading" "New Task"` | `NewTaskModal` **threw**
— an incomplete `vi.mock` was missing `isShortViewport` — and an
`ErrorBoundary` swallowed it |
| `Unable to find [data-testid="switch-to-board"]` | an uncaught render
error **unmounted the entire React root**; the DOM was already empty
three lines earlier |

The part worth recording is the hit rate. **Three theories were offered
before any probe — "the board renders nothing", "i18n is returning
keys", "the FloatingWindow rework" — and all three were wrong.** Three
probes each landed the cause on the first try. Those wrong theories cost
days; the probe is four lines.

The doc records the snippet, what each signal means (`error-boundary` in
the DOM = a swallowed throw, and its text names a missing mock export
exactly; empty DOM with no boundary = unmounted root, so trust the
*first* failing assertion not the reported one; container present but
contents absent = a skeleton standing in), and the corollary for writing
assertions:

> Wait on a marker only the real thing has.

A class shared with a loading or empty state turns *"the list rendered"*
into *"something rendered"*, and the test then fails one line later
against a DOM that looks fine. That is why `list-view-body` exists
(#2834).

Placed under the dashboard testing sections in `docs/testing.md`. Docs
only — no changeset.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:07:34 -07:00
gsxdsm
be12ca905b docs(workflow-learnings): the fifth shape — converted consumer, literal-passing producer (#2835)
Found by the operator reviewing **my own** shipped fix (#2823). It
belongs in this document because it is the only shape so far that every
instrument in the program reported as done.

## The defect

`clearNearDuplicateReferencesTo` was converted to resolve the
canonical's column flags, and my test proved that by supplying `column:
"shipped"`. Both production call sites in `moves.ts` gated on the
**resolved** complete lane and then passed the literal `column: "done"`.
The consumer was correct; the producer was never converted; and the
hand-supplied fixture value is exactly what hid it.

Why nothing caught it:

- the **census** counts comparisons — a value passed as an argument is
not a comparison;
- the **seam check** asks whether callers *supply* the argument — these
did, with a literal;
- the **test** supplied the interesting value itself, so it exercised
the consumer and never the producer.

And the part I would have got wrong: driving the flow end to end still
does **not** distinguish them. The consumer looks the passed column up
in the canonical's IR, finds nothing for `done` on a renamed board, and
falls through to the legacy predicate where `done` *is* terminal — right
answer, wrong reason. It only bites on a board that declares a `done`
column **without** the complete trait.

## The rule

> A differential test must vary the value the **production** code
computes, not one the test hands in.

If the fixture passes the lane name, it has tested the consumer. Who
computes that argument in production, and do they compute it or spell
it, is a separate question — and the one that was wrong here.

## Measured, so nobody builds the wrong instrument

An AST probe for call arguments shaped `{ column: "<legacy id>" }` finds
**79 sites** across `packages/`. It correctly flags the two real
`moves.ts` offenders — but most of the rest are legitimate: `set({
column: "archived" })` writing the archive state, `listTasks({ column:
"todo" })` filtering a query.

So a blocking gate on this shape needs a curated list of consumers that
interpret a column as a **role**, as opposed to storing or filtering it
— a per-consumer judgment call, not a mechanical check. **Recorded
rather than built**, and deliberately not attempted on top of five
unmerged PRs in packages I do not own.

I also verified one nearby call site that a naive version of this probe
flags and which is **not** a defect: `archive-lifecycle-2.ts:353` passes
`column: "archived"`, but that path sets `task.column = "archived"`
unconditionally, so it is passing the column the card actually reached.
Exactly what the operator's fix is about.

Docs only — no changeset.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:25:59 -07:00
gsxdsm
ed6d54485b glasses plugin: the review actions could never resolve a review lane (4 guards + 3 invisible destinations) (#2816)
Four agent actions still keyed on literals, with three census-invisible
`moveTask` destinations between them. `agent-actions.ts` already had
`laneContext`/`destination` from an earlier partial conversion — these
were simply never migrated.

## Census

| file | main | here |
| --- | ---: | ---: |
| `plugins/fusion-plugin-even-realities-glasses/src/agent-actions.ts` |
4 | **0** |

Plus 3 hardcoded `moveTask` destinations the census cannot see
(`requestReview` → `in-review`, `returnToAgent` → `todo`, `retryTask` →
`todo`).

## The real finding: this plugin could never resolve a review lane

`resolveLifecycleColumns` keys its `review` role on the
**`mergeOrchestration` trait alone**. A board whose review column
carries only `merge-blocker` and/or `human-review` — the common custom
shape, since `merge` is opt-in — resolves **no review lane at all**.

So every review-gated action here (`requestReview`, `acceptReview`,
`returnToAgent`, `retryTask`) compared against `undefined` and **refused
every card**, and `requestReview` had nowhere to move one. This is not a
regression from converting them; it is why they *could not* be converted
with `lanes.review` as-is.

Converting the four guards without noticing would have shipped four
actions that fail closed on exactly the boards this program exists to
support — a conversion that looks complete, passes its suite, and makes
the plugin useless on a custom board.

**Widened in `laneContext`, not in the shared resolver.**
`resolveLifecycleColumns` is consumed well beyond this plugin, and its
`review` role deliberately means "the merge-orchestration column" for
the merge queue. The gap is already recorded in
`notification-renamed-lifecycle-columns.test.ts` and in #2807 —
reconciling the two definitions is a core-level decision, not one to
take from a plugin. `mergeBlocker` is preferred over `humanReview`
because a card cannot leave a merge-blocking column until the gate
clears, which is the closer analogue of the legacy `in-review`.

## The suite caught an over-reach of mine

My first version put a blanket `if (degraded) conflict(...)` at the top
of `retryTask`, which broke a pinned invariant the test names outright:
**"a degraded workflow does not block retries that move nothing."** The
status-only retry just clears fields; refusing it because the workflow
could not be read breaks a recovery that needs no lane at all.

Degraded now blocks only the branches that actually **move**. Same
reasoning applied to `acceptReview`, which also moves nothing. The
existing `startWork` convention — conflict on degraded — is right
precisely *because* it moves.

## Ordering

`returnToAgent` and `retryTask` now resolve their destination **before**
the field clear. Both cleared first, so a rejected move left the
assignee and status — or the worktree, branch and base refs — nulled
with the card exactly where it was. That is the fifth instance of this
half-applied shape in the audit, and it is rule 3 in the class doc.

## Revert results (measured, each independently)

| conversion | reverted → |
| --- | --- |
| `requestReview` destination | 1 failed — moves to the literal
`in-review`, which this workflow does not declare |
| `returnToAgent` destination | 1 failed — moves to the literal `todo`,
same |

Plus a non-vacuous companion: a renamed card *not* in the wip lane must
still be refused by `requestReview`, so a gate admitting everything
would not pass.

## Verification

- Plugin suite — **186/186**
- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `tsc` on the plugin — clean
- `pnpm lint`, `check:changesets`, census `--strict` — all clean (run
explicitly)
2026-07-30 12:55:38 -07:00
gsxdsm
ba40942a10 batch-dashboard-app: 75 → 2 across packages/dashboard/app — the last two are deliberate, not missed (#2772)
**Batch branch is live: `batch-dashboard-app`.** Push conversions here
as commits rather than opening per-file PRs — that is the CI-run
bottleneck this model removes.

**One-line ownership note for you to arbitrate:** you have addressed me
as U11, U12 and U7 at different points, so the `u12 worker ->
batch-dashboard-app` mapping is ambiguous from my side. I claimed it
because `dashboard/app` is where I have done the most work this session
(TaskContextMenu, Column, TaskCard, TaskDetailModal, columnRoles,
taskActivity) and I know which of its guards are load-bearing fallbacks.
**If another worker is the intended owner, say so and I will hand the
branch over rather than both of us pushing to it** — two workers on one
shared branch is exactly what silently discarded a reviewed fix in #2645
today.

## The work order (measured at branch point, tests excluded)

**75 guards across 32 files.** Largest: `TaskContextMenu.tsx` 9 ·
`Column.tsx` 7 · `ListView.tsx` 6 · `TaskDetailModal.tsx` 4 · then a
long tail of 3s, 2s and 1s. Full per-file list is in the committed work
order so feeders can claim without re-measuring.

## Two rules this surface keeps tripping on

**1. A literal after `??`, or in the `else` of a `flags ?` ternary, is a
DEGRADED-MODE answer — not an unconverted guard.** Two real states reach
it: the **pre-load window** (board renders before the workflows fetch
resolves) and a card stranded on an id its workflow no longer declares.
In both, `columnFlagsById` has no entry at all. Deleting the fallback
does not remove a decision — it substitutes "no role" silently, and
affordances vanish during first paint.

Those sites reach 0 by **marking**, not deleting. Expect
`TaskContextMenu.tsx` and the `utils` files to be **mostly marks**. A "9
→ 0" that deleted 9 fallbacks is a regression wearing a green census.

**2. A marker excuses ONLY the construct it is attached to** — the
statement or function holding the literal, not a sibling declaration.
This has cost three passes, two of them mine; my first attempt on
`reliability-metrics.ts` scored **1 of 6**. **Verify by the count
moving, not by the comment existing.** With the ratchet gate-blocking, a
mis-marked batch either wedges the gate or locks the miss into a
re-recorded baseline.

## Status

Opening commit is the work order only — **0 of 75 converted so far.** I
am near the end of my context, so I am establishing the branch and the
shared list rather than starting conversions I cannot finish cleanly.
Feeders can begin immediately; I will keep the branch rebased.

My other PR **#2762** (`live-agent-count.ts` 6 → 0) is green and
unconflicted — per your rule it should land rather than fold into a
batch, and it is `packages/core` so it belongs to batch-core anyway.

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

* **New Features**
* Task UI now resolves workflow “column roles” per task to drive
diffs/merge details, routing/steering, progress/runtime visibility, and
review badges.
* Right-dock/overflow views and dev-server now use per-task column
traits for “executing” behavior and dependency-based “Up Next”
eligibility.
* **Bug Fixes**
* Fixed bulk action selection/delete/archive eligibility and prevented
cross-workflow role leakage.
* Made in-review/stale-paused-review, stuck, and effective
executor/validator model logic role-aware.
* **Tests**
* Added regression coverage for degraded-flag behavior and ensured
resolved-flag props aren’t ignored.
  * Added a static check to fail builds on inert optional flag seams.
* **Documentation**
  * Updated batch work-order and mega-batch branch guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Late addition: the seam gate was masking a real offender

`scripts/check-inert-flag-seams.mjs` matched call sites by NAME, so two
same-named functions in
different modules were conflated. I had documented that as a known
false-positive source and moved
on — reports mentioning `sortTasksForDisplayColumn` are noise, read past
them.

That annotation was the damage. Core's `sortTasksForDisplayColumn`
genuinely never receives its
`columnFlags` argument outside its own tests. The dashboard's separate
function of the same name
(`app/components/taskSorting.ts`), called with up to five arguments from
`Lane`/`Board`/`ListView`,
was raising the arg-count max and clearing core's seam. The offender was
behind a row everyone had
been told to skip.

The gate now records the module each callee is imported from and matches
it against the seam's
declaring module.

**Measured, by reverting the change:** the scan prints `17 seams, all
supplied` and emits **no row**
for the function. With the change, it is reported. Both directions
watched.

Reported on #2783 rather than fixed from outside — core owns it, and
"wire the flags" vs "drop the
parameter and let the literal stay counted" is their judgment call.
TEMPORARY allow-list entry
carries it meanwhile; the existing staleness check fails the moment the
site becomes supplied, so
the entry cannot outlive the fix.

Two known limits remain, both inherent to name matching and both
documented in the script: the
one-supplier floor, and the `__tests__` exclusion (hence the two
permanent `ALLOWED` entries).


## And the one-supplier floor, closed the same way

I wrote in the section above that the floor "hasn't cost anything yet."
That is verbatim the
reasoning that kept the imported-shadow bug alive, so I closed it
instead of leaving the note.

`best < arity` asked only whether SOME caller supplied the argument. One
correct call site cleared
the seam while every sibling took the legacy fallback — the
`isTaskStuck` defect class, where two of
three sites omitted the flags and the gate stayed green because the
third was right. Review caught
that one. A partially-supplied seam is the harder of the two:
wholly-unsupplied is uniformly wrong,
this works on the board you tested and degrades on the column you did
not.

**Measured:** dropping the flags argument at `Column.tsx`'s supplied
call site produces
`supplied by 5/6 call sites; omitted at
packages/dashboard/app/components/Column.tsx:1 (of 2)`;
restoring returns `all supplied at every call site`. Red and green both
watched.

Two real omissions found, both on `isNearDuplicateCanonicalInactive`:

- **`TaskDetailModal.tsx`** — deliberate, and it **corrects a note I
left at that site**. The old
note said hoisting the flags state was "the actual fix." It is not, for
this call: the flags in
scope describe the *modal's* task, and the canonical is a **different
task** on a column this
component never resolves. Passing them would type-check, read as a
conversion, and answer about
the wrong task — exactly what `column-role-degraded-flags.test.ts`
exists to catch. Supplying it
  correctly needs a fetch, which is a data change and out of scope.
- **`core/task-store/branch-group-ops.ts`** — genuinely wireable (the
impl is async and already
holds `store` and `canonicalId`). Reported on #2783, not edited from
outside.

Exemptions for this class are keyed by **call site**
(`<file>::<function>`), not by function name.
A name-level entry would waive every site of a partially-supplied seam,
which is backwards — its
other sites are correct and are the reason the omission is worth
reporting. Both entries carry the
same staleness check as the name-level list and cannot outlive their
fix.

Remaining known limit, now the only one: the `__tests__` exclusion,
which makes a test-only export
read as having no callers. That is what the two permanent `ALLOWED`
entries are.


## The `__tests__` exclusion, and two allow-list entries built on false
reasons

Named as the "last remaining limit" above, so it got closed too. The
scan now reads test files for
call sites — but counts them **separately**, and a test never clears a
seam. That direction is the
dangerous one: counting test callers as suppliers would have re-hidden
core's
`sortTasksForDisplayColumn`, whose only suppliers are its own tests.
Measured by lifting its
exemption: still reported.

Both permanent allow-list entries claimed the scanner couldn't see their
callers. **Both reasons
were false**, and reading tests is what proved it:

- **`evaluateMergeBlockerGuard`** — zero callers in tests either. Its
only reference in the repo is
its own declaration; never registered as a trait hook; the
`evaluateDefaultWorkflowGuards` reader
its file header credits does not exist. The `lifecycleColumns`
conversion went onto dead code, and
its note describes a crossing the guard cannot make. Reported on #2783,
including the two things I
am explicitly *not* concluding (no `"guard"` hook is registered in
production; whether that is
  residue or a dropped registration needs core's intent).
- **`isRecoverableMissingWorktreeReviewFailure`** — 5 test call sites.
It wraps
`...WithProgress`/`...NoProgress`, the live pair called from
`self-healing.ts`, both supplying
  `reviewColumns`. Entry kept, true reason recorded.

### A wrong turn, recorded because it is the failure mode this PR is
about

I first classified no-production-caller seams as *informational* when
they weren't re-exported from
a package index, reasoning that a public export might be called
externally. That silently downgraded
`sortTasksForDisplayColumn` — a confirmed real offender — from failing
to a footnote. Publication
status has nothing to do with whether there is production behaviour to
be wrong. Reverted to the
simple rule: no production caller means inert, and it fails.

It is worth stating plainly because it is the exact shape of everything
else in this PR: a change
that made the gate read *cleaner* while making it catch *less*, and it
type-checked, passed every
test, and would have reviewed fine.

### Where that leaves the check

Every blind spot named in this PR has now been closed, and **each one
produced a real defect within
minutes of closing it** — imported shadows, the one-supplier floor, the
`__tests__` exclusion. Four
verified findings went to core, one to engine. I would not read the
remaining ~240 guards' green
gates as evidence that they are clean; I would read them as untested.


## Two guards for one question, one of them worse

Having hardened the script, I checked its older twin rather than
assuming it was fine.
`resolved-flags-seams-have-suppliers.test.ts` carried its own copy of
the trailing-flags-parameter
check — written before the script existed — with **all three** holes the
script has since closed.

**Measured on one reintroduced defect** (dropping the flags argument at
`Column.tsx`'s supplied
`isNearDuplicateCanonicalInactive` call):

| | result |
|---|---|
| `scripts/check-inert-flag-seams.mjs` | `supplied by 5/6 call sites;
omitted at .../Column.tsx:1 (of 2)` |
| this test's arity half | **3 passed** |

Deleted the arity half. Redundancy between a strong and a weak check
isn't redundancy — it's a green
result available to whoever runs the weak one, and there was no signal
at the call site telling you
which you were looking at.

The **props-shape half stays**: it has no twin in the script, and I
confirmed it still fires by
reintroducing the original `PrPanel` defect (outer component stops
destructuring `taskColumnFlags`)
— it reports `PrPanel declares taskColumnFlags but never takes it`.

Dashboard app suite: **113 files / 3921 tests** (was 3922 — the deleted
case is the difference).


## The gate started catching defects as they landed

Syncing with main brought in three fresh conversions from other workers.
The hardened check flagged
all three immediately — the first time these guards have fired on
someone else's landed code rather
than on my own.

- **`TaskCard`** — `getRunningOptionalGateBadge(task)` omitted flags
while *both* `ListView` sites
supplied. Fixed, and `taskColumnFlags` added to the `useMemo` deps: no
`exhaustive-deps` rule here,
so a memo that reads flags without listing them keeps the first-paint
`undefined` answer and
  reproduces the bug through staleness instead of omission.
- **`TaskTokenStatsPanel`** — `getTotalAgentActiveMs` omitted while
`TaskCard` supplied, so the same
runtime number came from the real column on a card and from legacy ids
in the detail modal. Now
takes `columnFlags`, supplied from `detailColumnFlags` — correct here
because the panel renders the
  modal's **own** task, unlike the near-duplicate canonical above.
- **`ListView` ×2** — passed `columnFlagsById.get(task.column)`, the
cross-workflow **union**. A task
whose own workflow doesn't declare that column gets a *neighbour
workflow's* traits. The landed
comment justified it as "this list already owns `columnFlagsById`" —
exactly the reasoning
`column-role-degraded-flags.test.ts` exists to reject. It failed on
merge and is how I found this.

Also: the `getTotalAgentActiveMs` exemption I was carrying
**self-retired**. Main wired the seam, the
staleness check failed the entry, and I removed it. That mechanism has
now paid for itself once.

### Pre-existing, NOT from this PR: `App.test.tsx` is red on main

`app/components/__tests__/App.test.tsx` fails **10 of 141** identically
with my changes, with my
changes stashed, and with main's own `App.tsx` restored. Not mine, and
not in the merge gate.

**Bisected on clean `main` checkouts, so this is measured rather than
inferred:**

| commit | date | result |
|---|---|---|
| `main~400` (`41d60f0355`) | 2026-07-25 | **140 passed** (140 tests) |
| `main~275` (`74d6513fae`) | 2026-07-27 | 3 failed / 141 |
| `main~210` (`d2ce1ba8b5`) | 2026-07-29 | 10 failed / 141 |
| `main` (`6fc98fd6c7`) | 2026-07-30 | 10 failed / 141 |

So it is **not one regression** — it degraded in two stages across
2026-07-25 → 07-29, and the test
file itself changed in that window (140 → 141 tests). Three commits
touched it there:
`73b2a32e2b`, `f26cbedf4f`, `f157bf7460`. That window overlaps the
workflow-owned lifecycle
migration, which is suggestive but not something I confirmed.

The failures are render-level, not assertion-level — `Unable to find an
element with the text: + New
Task`, `Unable to find role="dialog"`, `Unable to find ... Back nav
task`. The board appears to
render nothing. That reads like a real regression or a harness mismatch
after the lifecycle
migration, not a flake, so I have deliberately **not** quarantined it —
quarantine is for flakes, and
using it here would hide the signal. Flagging for whoever owns
`App.tsx`.

My suites: `app/__tests__` **113 files / 3921 tests** green, `tsc` 0,
lint 0, census `--strict` 0,
seam gate 0.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:46:12 -07:00
gsxdsm
6fc98fd6c7 the third census-invisible class: 51 hardcoded moveTask destinations, measured — and duplicates never archived on a renamed board (#2808)
A third census-invisible class, measured — plus the two worst instances
fixed.

## The shape

```ts
if (task.column !== "in-review") { … return; }     // the census counts THIS
await this.store.moveTask(taskId, "in-progress");  // and cannot see THIS
```

The census is an AST scan for **comparisons**. A `moveTask` destination
is a **call argument**, so no backlog entry ever points at one.
Converting the guard alone is *worse than converting neither*: the
handler starts admitting work on a renamed board and then tries to move
the card into a lane that board may not declare.

This bit twice in one week — #2797 (`branch-worktree` requeued into a
lane that may not exist) and #2807 (a GitHub "changes requested" review
dropped, then a move to a hardcoded `in-progress`). Both times it was
found only because the guard *next to it* happened to be under
conversion. So I went looking.

## Measured

Across `core`/`engine`/`dashboard`/`cli`/`plugins`, excluding
`__tests__`/`*.test.*` and comment lines:

| | count |
| --- | ---: |
| hardcoded `moveTask` destinations in production | **51** |
| …passing `recoveryRehome: true` — **deliberate**, not defects | 22 |
| …plain, rejected on a board that does not declare the target | **29**
|

**The 22 must not be "fixed".** `moves.ts` exempts them on purpose
(#1411): a card stranded in an undeclared column has to stay rescuable
to a legacy safe-landing column, or it can never be recovered at all. A
sweep that converts them deletes the rescue path. That distinction is
the reason this is 29 and not 51, and it is why I measured before
writing.

## Why this got sharper recently

The `workflowHasColumn(workflowIr, toColumn)` rejection used to sit
inside a block gated on `isWorkflowColumnsCompatibilityFlagEnabled` — a
settings key **nothing in production writes** — so it never executed and
the legacy `VALID_TRANSITIONS` table decided instead. U12 hoisted it out
of that dead branch and it is now live, proven on a real store by
`live-move-path-undeclared-target.test.ts`:

```
moveTask(card in "todo" -> "triage")  now REJECTS: /Unknown column for this workflow/
```

That changed the failure mode of all 29 from *"silently lands the card
in an undeclared column"* to *"throws"*.

**29 is not a crash count.** Whether a throw surfaces or disappears
depends on whether the caller catches, which is per-site and I did
**not** measure it — the doc says so explicitly rather than letting the
number imply severity it hasn't earned.

## Fixed here: 9 of the 29

`duplicate-intake` and `duplicate-guard` both archive a duplicate. On a
renamed archive lane the move is rejected, so **the duplicate is never
archived and keeps sitting on the operator's board as live work** — and
in `duplicate-guard` the row has already been stamped
`deterministicDuplicateOf`, so it is *marked* a duplicate while
occupying an active lane. Half-applied, which is the same trap as
#2797's branch clear.

Both now resolve the `archived`-trait column from the task's own
workflow through one shared helper, unioned with the legacy id.

**`cli/commands/task-lifecycle`** — `finalizePullRequestMerge` and
`finalizeNoOpMergeTask` both move the card to a hardcoded `"done"`, and
both run `updateTask({ status: null, mergeRetries: 0 })` *first*. On a
rejection the merge has already landed and the bookkeeping is already
cleared while the card never reaches its complete lane: the operator
sees a merged branch, a card still sitting in review, and a reset retry
counter. Same half-applied shape as #2797's branch clear. Both now route
through one resolver so they cannot drift.

**`contamination` / `foreign-only-contamination` (×2) /
`restart-recovery-coordinator`** — four recovery requeues to a hardcoded
`"todo"`, none of them a `recoveryRehome` escape. On a board without
that column the move is rejected and **the recovery never completes** —
the card stays contaminated or stranded, which is precisely the state
these paths exist to clear.

**Consolidation.** `resolveReboundTargetForTask` and
`resolveArchiveTargetForTask` now live beside
`resolveTaskLifecycleColumns` in `workflow-lifecycle-traits`, already
the store-dependent resolution seam. My first pass put the archive
helper inside `duplicate-intake` and had `duplicate-guard` import it
from there — wrong home, and it would have grown a copy per caller as
more sites converted. Seven call sites now share two definitions.

**Plain (non-`recoveryRehome`) destinations: 29 → 21.**

**Coverage on the CLI pair is scoped, and I'd rather say so than imply
more:** the test covers the *resolver*, not the two call sites. Both
enclosing functions are private and reachable only through
`processPullRequest`, which needs a live GitHub surface — exporting them
purely to test wiring is a worse trade than stating what is covered.
Three cases: renamed lane resolves, no-workflow falls back to the legacy
id (which also pins that a default board is byte-identical), and a
throwing lookup falls back.

## Revert result (measured)

| conversion | reverted → |
| --- | --- |
| duplicate archive destination | new case fails — `moveTask` called
with `"archived"` on a board whose archive lane is `boxed` |
| CLI complete-lane resolver | replacing the body with a bare `return
"done"` fails the renamed case |
| both move-target resolvers | replacing either body with a bare return
of its legacy id fails 5 cases across the resolver suite and
`duplicate-guard` |

Each resolver has a **non-vacuous companion** asserting it does *not*
return the legacy id on a renamed board — without it, a resolver
returning any string would pass. The fallback cases are load-bearing
rather than padding: `resolveWorkflowIrForTask` degrades to the built-in
IR rather than throwing, and the built-in rebound/archive lanes *are*
`todo`/`archived`, so those cases also pin that a default board is
byte-identical.

The pre-existing case asserting the legacy `"archived"` passes both
ways, which is exactly why it could not detect this and why the new one
supplies a workflow.

## Ownership note

`packages/core` was `batch-core`'s territory and `packages/cli` was
`batch-cli-plugins`'. Both batches have landed, and this is
newly-discovered work in the class documented here rather than leftover
conversion backlog. Four sites, two shared helpers — happy for either
half to move if those owners would rather carry it.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `duplicate-guard` + `duplicate-intake` — 40 passed
- `tsc` on core and engine — clean
- `pnpm lint`, `check:changesets`, census `--strict` — all clean (run
explicitly; a clean `pnpm lint` alone is not evidence the CI Lint check
passes)


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

## Summary by CodeRabbit

- **Bug Fixes**
- Duplicate tasks are now archived to each workflow’s configured archive
lane.
- Completed tasks are moved to the workflow-specific completion lane,
with a safe fallback for older workflows.
- Recovery and requeue actions now use each workflow’s configured
rebound lane instead of assuming a fixed destination.

- **Documentation**
- Added guidance on avoiding failures caused by hardcoded workflow
destinations and incomplete lifecycle conversions.

- **Tests**
- Added coverage for renamed workflow lanes, fallback behavior,
duplicate archiving, and recovery destinations.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:25:00 -07:00
gsxdsm
ed0df8b0e2 evidence: the self-healing sweeps do not RUN on a renamed board — 49 hardcoded column QUERIES, and 17/30 fakes hide it (#2800)
**Evidence only — no conversions, no behaviour change.** One doc, one
test. It changes how the fleet should read the largest remaining file in
the backlog.

## The finding, measured on `origin/main`

`packages/engine/src/self-healing.ts` carries:

- **97** lifecycle-column comparisons the census counts, and
- **49** calls of the shape `this.store.listTasks({ column: "<literal>",
… })`.

`listTasks`' option is `column?: ColumnId` — **one literal column**,
applied as a filter in the store. On a workflow whose lanes are renamed,
every one of those 49 queries returns an **empty array**, so the sweep
it feeds does nothing at all.

**The self-healing sweeps are not
mostly-correct-with-some-unconverted-guards. They never execute.** The
`in-review` family alone is roughly half the calls: merge recovery,
wedged merges, branch rebind, pending-step reconciliation.

## Why this matters to the census specifically

```ts
const tasks = await this.store.listTasks({ column: "done", slim: true });
const candidates = tasks.filter((task) =>
  task.column === "done" &&        // <-- the census counts THIS
  …
);
```

The census scores the **comparison**, not the query. Converting it is a
legal-looking change that drops a count and changes **nothing an
operator can observe** — the loop body still never runs, because the
list was already empty.

Roughly **31** of self-healing's remaining comparisons are this shape.
Driving `self-healing.ts` to 0 would report the subsystem as converted
while it stays inert on custom boards. In this file the census total is
not merely a floor — it is actively misleading, and I'd rather the fleet
know that before someone spends a week on the 97.

## Why the existing suite cannot see it

Measured across `packages/engine/src/__tests__/self-healing*.test.ts`:

- **30** files define a `listTasks` on their store fake.
- **17** ignore the `column` option entirely.

```ts
// representative of the 17
listTasks: vi.fn(async (options?: { limit?: number; offset?: number }) => {
  const all = [...tasksById.values()];   // options.column is never read
  return all.slice(offset, offset + limit);
}),
```

The fake is **more permissive than production**. The sweep receives rows
the real query would have filtered out, so the test proves the sweep's
*logic* while saying nothing about whether the sweep is ever *reached*.
A green self-healing suite is not evidence that self-healing runs.

This is the mirror image of
`store-fake-defects-that-masquerade-as-production-bugs.md`: there a fake
is *missing* something production needs and the code looks broken; here
it supplies *more* and the gap looks fixed.

## About the test

It **pins a known defect** and is labelled as such in the file header —
it asserts what the engine does today, which is the wrong thing.

It asserts the **query argument**, not the outcome. The outcome is `0`
either way, so an outcome assertion cannot distinguish *"nothing to do"*
from *"asked the wrong question"*. Asserting the argument also avoids
standing up the git-evidence path these sweeps enter once they have
candidates.

- **Ratchet proven to fire:** repointing `reconcileDoneTaskIntegrity`'s
query at the renamed lane makes it fail — `1 failed | 2 passed`. A guard
that reports success without checking anything is worse than no guard,
so I ran it.
- **Guard on the guard:** a first case asserts the renamed fixture
really does resolve a complete lane that is not `done`. Without it,
every later assertion could pass vacuously if the fixture ever collapsed
to the default vocabulary.
- **Control case:** shows the ignoring fake hands back a row whose
column is `shipped` from a query that asked for `done` — the mechanism
by which the suite stays green.

When the query layer is fixed this test will fail, forcing an update.
That is the intent.

## What I did NOT do, and why

I did not fix it. `column?: ColumnId` takes one id, and the resolution
is circular at the query layer — you need a task to know its workflow,
and you are querying to find the tasks. A real fix is either a
multi-column query option (`columns?: readonly ColumnId[]`) plus a
resolved union across live workflow definitions, or dropping the filter
and post-filtering by role in the engine.

Either is a **behaviour change to a shared store API across 49 call
sites**. That is a coordinator-level decision, not something a
conversion PR should take unilaterally — the same reasoning that kept
membership predicates out of the census. I'd take it on if you want it;
it needs to be a deliberate call, not a side effect of a conversion
sweep.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
2026-07-30 11:15:43 -07:00
gsxdsm
9b573268a4 docs(u9): audit every non-scheduler execute() call site — exactly ONE ignores pause (#2747)
## What this closes

`executor-prompt.test.ts` has 3 failures asserting that `execute()`
refuses to dispatch a user-paused card during a global pause. The guard
lives in the **scheduler** (`scheduler.ts:1515`, a hard stop that never
reaches `.execute(`), so those tests call a layer that has never
enforced it.

I flagged this in #2719 without being able to say how large the real gap
was. This answers that — and the answer is much narrower than "execute()
is unguarded".

## Every non-scheduler call site

Enclosing method and guard status resolved **programmatically**, not by
reading nearby lines:

| Site | Enclosing method | Guarded? | Reading |
|---|---|---|---|
| `executor.ts:3333` | `dispatchUnpauseResume()` | no | **Correct
as-is** — it *is* the unpause path; a guard here is self-contradictory |
| `executor.ts:3495` | `constructor()` — `task:moved` sub, `to ===
"in-progress"` | **no** | **The one real gap** |
| `executor.ts:5702` | `resumeTaskForAgent()` | yes | `globalPause \|\|
enginePaused` + `!task.paused` |
| `executor.ts:5858` | `resumeOrphaned()` | yes | same guard earlier in
the method |
| `in-process-runtime.ts:2255` | `drainWorkflowContinuations()` |
indirect | gated by `status !== "active"`; engine pause is expected to
leave the runtime non-active |

**Exactly one path can reach `execute()` without consulting pause
state.** So the open question is not *"does `execute()` need a guard"*
but *"can a `task:moved` → `in-progress` event fire while paused"* —
narrow, and answerable by whoever owns the pause contract.

## A measurement correction worth recording

My first pass used a 40-line window above each call and **mis-attributed
two sites**: `:5702` and `:5858` looked unguarded because their guard
sits earlier in the same method, above the window. Resolving the
enclosing method properly flipped both to guarded and cut the apparent
gap from three sites to one.

That is the difference between reporting "3 of 5 paths ignore pause" and
the truth. A proximity heuristic is not an enclosing-scope analysis.

## Still not decided, deliberately

Three options, and they are not equivalent:

1. **Guard the `task:moved` subscription** — narrowest; keeps the
scheduler as the single pause authority. Does *not* make the three tests
pass, since they call `execute()` directly.
2. **Give `execute()` its own guard** — makes the tests pass, but must
not refuse the legitimate internal re-dispatch paths.
`dispatchUnpauseResume()` would break outright: it exists to resume a
card the operator just unpaused.
3. **Retire the three direct-`execute()` assertions**, covering the
invariant at the scheduler layer where it is enforced.

(2) and (3) both touch coverage of **user pause** — a safeguard this
program re-ratified and told workers not to narrow. Choosing either
silently inside a test-repair PR is how a safeguard gets weakened by
accident.

## What is NOT verified

Whether that subscription is actually **reachable** while paused.
Proving it needs a trace of who emits `task:moved` with `to ===
"in-progress"` under a global pause; if every emitter is itself gated,
the gap is theoretical. That trace is the remaining work before
preferring option 1 over the status quo.

Docs-only — no source, no tests. Lint clean.

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

## Summary by CodeRabbit

* **Documentation**
* Added an audit documenting workflow pause behavior and identifying an
event-driven execution path that can create sessions during a global
pause.
* Recorded findings from existing test failures and reviewed available
enforcement points for pause handling.
* Documented trade-offs and the remaining decision on where pause guards
should be applied.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 08:51:32 -07:00
gsxdsm
245086dad6 docs: the census total is a floor — 25 membership predicates it structurally cannot see, one a live defect (#2763)
Docs only, extending the entry #2748 landed. Opening it because the
fleet reads the census total as its completion bar, and that total
excludes a whole predicate class — a measurement that should not live in
a chat reply.

## Measured on `origin/main`

- **47** array/Set literals of two or more lifecycle ids, in 35 files.
- **25 are membership predicates against a task's column** —
`SET.has(task.column)` / `ARRAY.includes(task.column)` — in 19 files.
Two are documented fallbacks behind a resolved primary, so **~23 are
unconverted guards**.
- The census scans `===` / `!==` against a column. **None of these is a
comparison, so none is counted.**

| file | constant |
| --- | --- |
| `cli/src/commands/task.ts` (3) | `retryReviewColumns` |
| `dashboard/app/components/TaskCard.tsx` (2) | `TIME_INDICATOR_COLUMNS`
|
| `engine/src/eval-followups.ts` (2) | `OPEN_COLUMNS` |
| `engine/src/merger.ts` (2) | `sourceTerminal` |
| `engine/src/task-revert.ts` (2) | `REVERTABLE_COLUMNS` |
| `core/src/agent-role-policy.ts` (1) | `IMPLEMENTATION_TASK_COLUMNS` |

## One is a proven live defect

`isImplementationTask` is
`IMPLEMENTATION_TASK_COLUMNS.has(task.column)`, and
`evaluateImplementationTaskBind` short-circuits to `allowed: true` when
it returns false. **On a renamed board every agent is bind-compatible
with every task** — the role check that stops a liaison being handed
implementation work (the NEXT-871 loop FN-7851 fixed) does not apply.

It surfaced only because a reviewer questioned a coverage claim in one
of my dispatch tests (#2739). Passing an agent wasn't proof the
evaluator ran, so I asserted a `custom`-role agent must be *refused* —
and that test failed against production. Flagged at the site in #2739,
not fixed: `isImplementationTask` is a sync pure predicate with no
store, and making the routing policy async is a behaviour change to
agent admission.

## What this does and does not argue

The census is the right instrument — AST-based, honest about what it
measures, and it has caught real drift in both directions (it failed on
me in #2724 when merged conversions moved an inventory *down*). This is
not an argument against it.

It is an argument against reading **"backlog: N" as "N guards remain"**.
The same shape already appeared in the archived gate (#2724), where the
rule is additionally encoded in Drizzle predicates and raw `sql`
templates that no comparison scan can see. Two independent classes now,
found the same way — by looking at what the instrument's definition
excludes.

**Extending the census to count membership predicates is deliberately
left to you, not done here.** It would move every worker's number
mid-fleet, and deciding which sets are lifecycle guards versus
board-config definitions or type unions is exactly the judgement
`DELIBERATE-LITERAL` exists for — 47 collections would each need that
call.

## Verification

`pnpm lint` clean · census `--strict` exits 0 · no code changes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:12:51 -07:00
gsxdsm
1322a1bb11 docs(solutions): the optional-flags seam kept four green suites blind to their own conversion — and why I did not ship a ratchet for it (#2748)
Docs only. No code, no census movement.

## The finding, measured

Four consecutive files in this program had **fully green suites at
conversion time** that could not have detected the conversion — correct
or broken:

| file | pre-existing cases blind to the change |
| --- | --- |
| `github-tracking-reconciler.ts` | **33** (fake store had no workflow
reader) |
| `TaskReviewTab.tsx` | **45** (`columnFlags` omitted everywhere) |
| `plan-approval-hold-invariant` drain | **25** (`opts.lifecycle`
omitted everywhere) |
| `task-age-staleness.ts` | **12** (`context.lifecycle` omitted
everywhere) |

The cause is structural. Every conversion here uses the same seam — the
caller passes resolved flags, the helper falls back to the legacy id
when they are absent — and every pre-existing test omits the flags. So
the suite passes **before** the conversion, **after a correct one**, and
**after a wrong one**, as long as the fallback is intact. "The suite is
green" carries no information about the change.

I reported this observation four times in PR bodies. Restating it a
fifth time is worth less than writing it where the next worker will
actually find it.

## It also corrects the obvious test

The natural property is "hold the traits fixed, change the id, behaviour
is identical". That is only half the invariant. It does not catch:

```ts
// Not a fallback — an OVERRIDE. The id wins even when traits disagree.
return column === "in-review" || flags?.mergeBlocker === true;
```

Renaming `in-review` → `checking` leaves that correct, because the trait
arm answers. The defect appears in the **converse** direction — a column
that still *carries* a lifecycle name while its traits say otherwise,
which is what you get by repurposing a default column rather than
renaming one. That is the direction that found a live **"Merge & Close"
offered on a mid-implementation card** in #2718.

## Why this is not a ratchet — a negative result, recorded

I tried to automate it, and I am shipping the reason it failed rather
than a guard I do not trust.

The **consumer scan is sound**: AST-based, 31 files, 66 role-helper call
sites. The **coverage half is not**. The renamed ids this program uses —
`building`, `checking`, `converted`, `published`, `backlog` — are
ordinary English words that appear in unrelated test prose, and a test
merely *importing* the module under test does not prove it exercises the
role path. My scan reported `TaskCard.tsx` as covered by
`Column.test.tsx` on a **filename coincidence**.

A guard built on that reports coverage that does not exist, which is
worse than no guard, so it is not shipped.

A sound alternative — pin the consumer set and make each new file
declare its status — was also rejected: a 31-entry status inventory
would conflict with every concurrent fleet PR that adds coverage. That
is the same churn already removed from the census baseline by dropping
its derived aggregates.

The attempt is written down so the next person does not repeat it from
scratch, and the requirement lives as a review criterion until someone
finds a sound signal.

## What it asks for

1. **A flags-supplying case** — if every case omits the new parameter,
the conversion is untested in both directions.
2. **Both directions where both are reachable** — renamed lane, and
repurposed column.
3. **A non-vacuous companion** — assert what the widened predicate must
still *exclude*, or a predicate matching every column satisfies your new
cases. (Both `TaskReviewTab` and the dispatch filters needed this.)
4. **Run the revert and record the failure text.** Twice in this program
a new case passed with the change reverted: once because the branch was
gated behind an unwired handler (`refine` needs `onOpenRefine`), once
because the hook was dispatched by trait and the test IR did not declare
that trait, so it never ran at all.

Cross-linked both ways with the adjacent `store-fake-defects` entry,
with the distinction stated so the two are not confused: **there** a
fake is missing a method so a branch never runs and production looks
wrong; **here** the fake is complete and the test is correct, but a
parameter is absent so production takes its documented fallback.

## Verification

`pnpm lint` clean · census `--strict` exits 0 (unmoved — this PR changes
no code).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:11:22 -07:00
gsxdsm
11aba0394e docs(solutions): converting a column literal to a role makes it async — the four forms that ship green (#2710)
Four review rounds across `TaskCard.tsx` and `TaskDetailModal.tsx` each
found a **real defect**. None was in the conversion itself — every one
came from the same property change. The fleet has ~600 guards left to
convert against the same helpers, so this is written down rather than
left in four commit messages.

## The property that changes

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

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

## The four forms

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

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

## Why a doc rather than four commit messages

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

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

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

## Contents

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

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

## Verification

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

Docs-only; no changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:37:19 -07:00
gsxdsm
bb3bdab999 The ratchet follows the count down — a drop tightens instead of reddening the gate (coordinator item 2) (#2679)
Taken after asking twice for reassignment with no reply, and after the
same failure bit a **third** time. No open PR touches the census CLI, so
this is unowned in practice — **U12, say so if you have started and I
will close this in favour of yours.**

## What changed

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

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

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

## The residual, named rather than glossed

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

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

## Exercised end to end

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

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

## Two of my own mistakes, recorded

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

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

## Verification

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

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

---

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:56:08 -07:00
gsxdsm
ae23be79f7 fleet: scheduler.ts 28 → triaged (NOT converted) + repo-wide reachability measurement — the work order sorts on a number that doesn't predict convertibility (#2687)
## Claim

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

## Why not converted

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

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

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

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

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

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

### 2. Flag scope — the binding constraint

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

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

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

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

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

## A guard that must be skipped, not guessed

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

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

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

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

## Suggested census upgrade (not done here)

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

## Method correction worth propagating to every fleet worker

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

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

Docs-only — no source, no test, no census change.
2026-07-30 02:53:18 -07:00
gsxdsm
bb30d37e59 fleet: self-healing.ts 110 → scoped (NOT converted) — sync workflow reads make this cluster unsafe to batch (#2683)
Claiming the largest unclaimed cluster per the work order, then
**handing it back sized rather than half-converted.** Docs only; census
unchanged (722 / triage 0).

## The cluster

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

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

## Why the mechanical conversion is unsafe here

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

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

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

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

## Recommended split, by SWEEP not by column

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

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

## Why I am not doing item 1 myself

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:44:27 -07:00
gsxdsm
13bf7e001d Closing-bar verification pass on origin/main — one tree, one report (+ the E2E red it found) (#2660)
**Closing-bar item 4, run on one clean tree at `origin/main`
(`be63e72f1`).** Nobody was assigned this and my own work is merged, so
I took it.

> **This PR is now REPORT-ONLY — net zero file changes.** I found the
planning-lane E2E red, fixed it, then discovered **#2658 (gsxdsm) makes
byte-for-byte the same change** to the same helper and was opened first.
I reverted mine rather than leave two identical edits to one function to
conflict. **The E2E result below depends on #2658 landing** — on
`origin/main` without it, that suite is 2 failed / 5 passed.
>
> The duplication is worth one note for the fleet: two workers
independently hit the same control-card failure and independently traced
it to FN-7648's unplanned-seed gate plus a fixture that never wrote a
spec. Independent confirmation of the diagnosis, but also ~an hour spent
twice — the census-style work order exists to stop exactly that, and E2E
fixture defects are not on it.

## Report — all four, one tree

| Check | Result |
|---|---|
| `pnpm test:gate` | **PASS** (132 + 10 + 487 + 71 tests) |
| `pnpm verify:fast` | **PASS** — 13 steps green in 89.9s, boot smoke
`GET /api/health 200`, clean shutdown |
| E2E families | **13 files / 109 tests PASS** — *after* the fix below;
**2 failed** before it |
| census | total **787**, triage **10** |

## Two corrections to the bar itself

**1. It is not "all-8 E2E" any more — there are 13 families.** The suite
grew while the bar was being written:

```
agent-count · agent-link · lease-rebound · lifecycle · merge-family · merge-rebound
merge-safeguards · merged-board · planner-lane · planner-lane-resolution
planning-lane · rebound-family · stranded-column
```

A verification pass scoped to 8 would have skipped 5 families —
including the one that was red. Worth fixing the number in the bar so
the final pass globs rather than counts.

**2. `DELIBERATE-LITERAL (reviewed)` reads 3, and I chased it —
RESOLVED, no gap.** I flagged the drop from an earlier "7" as a possible
fleet-safety hole. It is not one. Reconciled against `--json byFile`:

| File | markers | counted `deliberate` | counted `column` |
|---|---|---|---|
| `hold-release.ts` | 2 | **2** | **0** |
| `live-agent-count.ts` | 1 | **1** | 6 |
| `replan-target.ts` | 2 | 0 | 4 |

`deliberate: 3` = hold-release 2 + live-agent-count 1, which is exactly
the set of marker-covered **comparisons**. `replan-target.ts`'s two
markers sit above `return "triage"` **return-value** literals, not
comparisons — the census correctly does not count those as guards at
all, so they are neither `deliberate` nor `column`. The earlier "7" was
simply a different tree state before conversions landed; I was quoting a
stale number.

Worth noting the marker matcher is already hardened for the subtle case:
`hasDeliberateMarker` walks every **ancestor** rather than the enclosing
statement, because the real markers sit above the enclosing *function*
while the comparison is a `return` inside it — a statement-only lookup
"silently reclassified three reviewed literals as backlog". That is the
guard-cannot-fire pattern, already caught and fixed by whoever wrote the
AST version.

**Consequence for the fleet: the census's categories are trustworthy
as-is.** No pre-launch action needed on this.

## The red it found

`workflow-planning-lane-live-e2e.pg.test.ts` — **2 failed / 5 passed**,
including its own **control** case:

```
releases an ordinary held card on a default board (the control)
  → AssertionError: expected [] to include 'FN-OK'
```

`seedHeldTask` never wrote a `PROMPT.md`, so task creation's bootstrap
seed stood, and FN-7648's `isUnplannedForExecution` correctly refused to
release an unspecified card. **The sweep was right; the fixture was
asking it to release a card that had never been specified.**

**This is the second instance of the identical defect** — same cause and
same fix as `workflow-lifecycle-live-e2e`'s `seedTask` in #2634. This
suite was written after that fix and did not inherit it. The graph-entry
contract doc already states the rule: *"Scheduler/release test fixtures
must model a card that cleared the gate ... A held unreviewed card is
the gate working."*

Both failures had one cause — the mid-sweep approval-park case was
downstream of the control never releasing. **5 → 7 passed**, and cards
that are *supposed* to be held still are, held by their own
status/marker, which is what those cases assert.

Given it has now happened twice, a shared `seedPlannedTask` helper in
the E2E fixture module would prevent a third. I did not add one here: it
touches suites owned by U7 and U11 mid-consolidation, and this PR should
stay the verification pass plus its one finding.

## Bar status after this

- **gate / verify:fast / E2E** — green on one tree, with this commit.
- **triage → 0** — still **10**, all in U12's `moves.ts` (4) and
`register-task-workflow-routes.ts` (1) per file scan; flag resolution in
flight.
- **ratchet tightened (item 2)** — not done, U12's.
- Once triage hits 0 and the ratchet lands, re-running this exact pass
is a ~4-minute job and I can produce the final report.

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

---------

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

## What is in here, file by file

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

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

## The three threads it closes

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

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

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

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

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

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

## Behavioural findings, not tidying

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

## Revert proofs, isolated per site

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

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

## Commit discipline

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

## Verification

- `pnpm test:gate` **71/71**
- 162/162 across the glasses plugin's 19 files; 26/26 across the four
new engine suites
- engine + glasses typecheck clean; `pnpm lint` clean

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


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

## Summary by CodeRabbit

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:52:55 -07:00
gsxdsm
9dbc98f1b3 Audit: every sync workflow-IR read answers for the DEFAULT workflow (not a PG-only problem) (#2653)
Docs only. This came out of a #2593 review thread that reported the
problem as PostgreSQL-specific. **It is unconditional**, and it has
consequences well outside the guard I was fixing — including one that
looks like a live production break for custom workflows.

## The chain, each link checkable

1. `TaskStore.getTaskWorkflowSelection(taskId)` delegates straight to
`getTaskWorkflowSelectionImpl` — **no mode branch** (`store.ts:2545`).
2. `getTaskWorkflowSelectionImpl` **returns `undefined`
unconditionally** (`workflow-definitions.ts:505-512`). Its own comment:
*"sync selection reader is incomplete-PG; use
getTaskWorkflowSelectionAsync."* A PG-cutover stub that never got
finished.
3. So `resolveTaskWorkflowIrSyncImpl` always takes its `if
(!workflowId)` branch and returns `resolveDefaultWorkflowIr()`. Its
`isBuiltinWorkflowId` and `SELECT ir FROM workflows` branches are
**unreachable in production**.

`resolveTaskWorkflowIrSync` is typed `WorkflowIr`, non-optional — so
callers cannot detect the substitution. There is no `undefined` to check
and the IR that arrives looks valid.

**Why tests don't catch it:** test stores stub
`getTaskWorkflowSelection` with a real selection, so the reader works
under test and substitutes only in production. Any test written against
a stubbed store proves the caller's logic and never the reader's
behavior.

## Consequences, severity descending

1. **Custom fields appear to be rejected on custom workflows.**
`resolveTaskCustomFieldDefsSyncImpl` returns `ir.fields` — the DEFAULT
workflow's. `task-update.ts:128-136` validates against them, and its own
comment states the outcome: *"a write against a workflow with no fields
(the default) is rejected with a typed CustomFieldRejectionError."*
2. **Per-workflow capacity pools collapse** —
`resolveEffectiveWorkflowIdSyncImpl` reads the same selection, so every
task resolves to `resolveCapacityPoolId(undefined)`.
3. **Plugin transition hooks re-run against the wrong IR**
(`lifecycle-ops.ts:1052`, crash recovery).
4. **Terminal-node detection degrades** to `nodeId === "end"`
(`branch-and-pr-entities.ts:578`).
5. **A U7 guard was inert** — fixed in #2593. Its fail-closed arm was
`workflowIr ? … : true`, dead code against a non-optional return.

**#1 and #2 are REASONED FROM SOURCE, NOT OBSERVED.** I did not execute
those paths, and I am labelling them that way in the doc rather than
reporting them as confirmed. No test in `packages/core` covers
`CustomFieldRejectionError` or `resolveTaskCustomFieldDefsSync` —
consistent with the gap, but absence of a test is not proof of a break.
**Reproduce before fixing.** I would rather hand you a labelled
hypothesis than a confident claim I did not verify.

## Why this matters for the fleet, specifically

The census work replaces column literals with trait lookups. A
conversion that resolves its traits through a **sync** reader produces a
guard that reads the DEFAULT workflow's traits for every task —
plausible, wrong, and invisible. **It converts a visible literal into a
hidden bug**, and the ratchet counts it as progress.

Suggested addition to the fleet brief: conversions must resolve through
`resolveWorkflowIrForTaskWithProvenance` and branch on `source`;
`resolveTaskWorkflowIrSync` is never acceptable in a converted guard.

## Not fixed here

Each consequence needs its sync call path made async — a real slice per
site, not an end-of-turn edit. #2593 fixed only the one that was mine.
Census unchanged (781 / triage 5); this PR adds and converts no guards.

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

## Summary by CodeRabbit

* **Documentation**
* Added an architecture-pattern finding documenting a workflow-reading
limitation that can cause synchronous reads to use the default workflow.
* Described resulting effects on custom workflow updates, crash
recovery, capacity-pool handling, and terminal-node detection.
* Documented testing gaps and guidance to avoid synchronous task
workflow reads.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:37:59 -07:00
gsxdsm
2771408bba ci: enforce the lifecycle-column ratchet — it has never actually run (#2654)
**The ratchet was advisory.** `scripts/lifecycle-column-census.mjs`
existed only as `pnpm census:lifecycle-columns` — without `--strict` —
and **no workflow invoked it**. Nothing has ever compared the tree to
the baseline. Every "the baseline ratchet holds them" assumption in this
program rested on a check that does not run.

That explains both classes of hole:

**1. Three PRs lowered counts without re-recording,** leaving allowances
the deleted guards could return through while every check stayed green.
I've tightened them across #2593 and earlier PRs, but nothing stops the
next one.

**2. #2621 GREW the count while its own title claimed "count 0 → 0".**
It added `column === "triage"` and `column === "todo"` at
`register-task-workflow-routes.ts:2681`, taking that file to **23
against an allowance of 22**. It landed unchallenged. This is the
failure mode the ratchet exists to prevent, and it happened *inside this
program*, in a PR that asserted the opposite.

## The change

Adds `check:lifecycle-columns` (the census with `--strict`) to the
`pr-checks.yml` lint job, next to `check:changesets` and
`check:routes-modular` — the established pattern. **~1.8s over ~1950
files**, so this is not a slow-test addition.

## Proven to fail, in both directions

A guard that reports success without checking anything is worse than no
guard, so:

| injected defect | result |
|---|---|
| `const __probe = (c: string) => c === "triage"` added to `moves.ts` |
`count ROSE — moves.ts: 39 -> 40`, exit 1 |
| run against main's current baseline | exit 1 on
`mission-feature-sync.ts: allows 5, tree has 0` |

Both reverted; exit 0 restored. Note the second row: **this check is RED
on main right now**, which is the point.

## Merge order

**Stacked on #2593**, which carries the `DELIBERATE-LITERAL` marker for
the #2621 site (a v1 IR declares no roles, so no trait can answer that
question) plus the baseline re-record. Standalone on main this PR is red
— correctly. **Merge #2593 first**, then this.

I stacked rather than duplicating those two edits because I already
caused one conflict today by appending related content from two
branches, and #2651 merged a correction ahead of the section it
corrected. Same-content edits in two PRs is the same mistake.

## Census

Unchanged by this PR: **776 total, triage 5, reviewed 16** — it adds no
guards and converts none. It only makes the numbers enforceable.

## For the fleet

This should land before the 776-guard fleet launches. The brief says
"the baseline ratchet must shrink by exactly the converted count" —
until now nothing verified that claim, so a batch worker could report a
shrink that did not happen, or grow the count while converting, and CI
would agree.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:20:31 -07:00
gsxdsm
5481c27729 docs(solutions): finding 6 — read the implementation before claiming its output is wrong (#2649)
Completes `proving-a-code-path-actually-runs.md` (merged as #2642) with
the rule its own author broke three times while writing it. **Docs
only.**

## Why this belongs in that document rather than a new one

Findings 1-5 are about proving **your own** claim: does this path run,
can this test fail, is this negative result observable. Finding 6 is the
mirror image — the claims we make against **other people's** work — and
it is the same underlying error pointed outward. Splitting them would
let a reader take the first five as "be rigorous about my code" and miss
that the identical discipline applies when reviewing someone else's.

## The three cases, all mine, all in one day

| What I claimed | What was actually true |
|---|---|
| The census undercounts triage guards, 13 vs 10 | `summarize()` counts
`byColumnId` only for `kind === "column"`. My patched counter summed
`role`, `status` and `deliberate` too. The three "missing" ones were
exactly the ones it classifies correctly — and I reported this against
the instrument the program had just adopted as authoritative. |
| `resolvePlannerLanesForTask` silently disables two recovery paths for
legacy cards — escalated across four messages | The file's own header
had already reasoned it through and documented why that answer is
correct. And `TaskStore` implements `getTaskWorkflowSelectionAsync`,
which the resolver prefers — so real projects never take the path my `{
getTask }`-only probe forced. |
| `executor.ts` is clean of triage guards | A receiver-specific grep
missed three under `from` and `originColumn`. Same error one step
earlier: trusting a reconstruction of the thing instead of the thing. |

Every one was: reconstruct behaviour from outside → compare to actual
output → find a difference → report a defect, **without reading the
implementation.**

## The rules it adds

- Read the implementation and its header comment before reporting
anything as wrong. On this codebase the reasoning is usually already
written down, and the FNXC note frequently answers the exact objection —
twice today it answered mine verbatim.
- **A fixture is not a measurement of production.** When a probe and the
real system disagree, suspect the probe: ask what it had to stub, and
whether production ever supplies that shape.
- Retract precisely and immediately. A false defect report against
shared infrastructure costs more than the bug would have — it sends
people to verify something already correct, and spends the credibility
needed for the next report that is real.

Also updates the count in the intro (five → six) and adds an
`applies_when` entry so the doc surfaces for "about to report a tool as
defective", which is when it is needed and not when someone is already
debugging.

`pnpm lint` clean. No changeset — internal documentation.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:07:06 -07:00
gsxdsm
0c07584d51 U11 fallout: disprove the coding-ideas column collapse, and correct a U11 note that recorded the merge backwards (#2651)
Two findings, no behavior change. Both are about **recorded reasoning
that was wrong** — the kind that sends the next person the wrong way.

## 1. The coding-ideas column collapse does not work (IR change
reverted)

I implemented it — deleted `ideas`, moved its `intake`/`autoTriage:
false` onto Planning, repointed the `start` anchor, updated the IR
suites to the merged shape (they went green, 44/44). Then the wider
suites failed and showed why it cannot work.

**The manual gate IS the column boundary.** `replan-target.ts` names the
discriminator in its own comment: *"The real discriminator is which lane
the triage service SCANS, which depends on the intake column's
`autoTriage` config."* So `ideas` is unscanned, `todo` is scanned, and
"promote" means moving the card from one into the other. Merge them and
one column must be both:

| if… | consequence |
|---|---|
| `autoTriage: false` wins | never scanned → nothing is ever planned →
the capacity hold releases an **unplanned** card into `in-progress`,
violating FN-7648 |
| scanning wins | `autoTriage: false` is meaningless → the manual gate
is gone → the preset duplicates the default Coding workflow |

**8 tests fail, and they are not fixtures** — they encode the promotion
flow itself, e.g. `store-create-intake-column.test.ts` › *"promotes an
Ideas-parked task to todo without planning it (still bootstrap-stub
PROMPT.md)"*. Rewriting them would have meant inventing what "promote"
means with no destination column, which is how a broken flow gets
blessed by a green suite.

**What it would actually take:** a promoted flag the triage scan reads,
so one column can hold both "not yet promoted" and "being planned". That
is a new lifecycle signal, not a column merge — the same shape as the
deferred `needs-replan` follow-up. Happy to scope it.

**I also corrected my own earlier checklist** in this doc, which said to
delete the now-dead `isUnplannedStartCreate` arm. Wrong: `autoTriage` is
a general trait field (`builtin-traits.ts`), so any custom workflow can
declare a manual intake with `intake !== hold`. The arm is dead only for
this preset.

## 2. `replan-target.ts` recorded the U11 merge backwards

The note claimed U11 deletes `todo` and keeps `triage`. It is the
reverse — Shape B kept the id `todo` and deleted `triage`, precisely so
the ~120 `column === "todo"` guards kept their meaning and no data
migration shipped. The default lineage now declares `todo, in-progress,
in-review, done, archived`.

The lookups are correct today, but **for the opposite reason to the one
recorded**: the default lineage falls *through* the `triage` lookup and
lands on `todo`, its merged planning column. `triage` still matches the
workflows that genuinely declare it (Lead generation, PR review).

Also flagged without changing (it would be a behavior change): the
`return "triage"` fallbacks on the no-match and throw paths name a
column the default lineage no longer declares, so a workflow with
neither `triage` nor `todo` gets a nonexistent target.

## Census

**Unchanged: 781 total, triage 5.** This PR adds no guards and converts
none — `workflowHasColumn(ir, "triage")` is a call argument, not a
comparison, so it is outside what the census counts either way.

## Verification

41/41 engine replan-target suites (including the existing
`replan-target-merged-planning-column` suite that covers the corrected
behavior) · engine typecheck clean · the reverted IR restores the tree
to main's content for those three files, verified by `git checkout --`.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:26:49 -07:00
gsxdsm
7ab6506c0f docs(solutions): proving a code path actually runs — the five ways U8 shipped code that never executed (#2642)
Durable write-up of U8's verification findings. **Docs only — no code
change, no CI risk beyond lint.**

These currently exist only in PR bodies, which nobody greps.
`docs/solutions/` is where this project keeps exactly this kind of
thing, and every one of the five will recur: the handler-pair shape and
the resolved-vs-guessed fork both have more call sites than U8 touched.

## The five

1. **Two prompt-node handlers exist; only one runs.**
`createDefaultNodeHandlers` prefers the primitives handler whenever
`deps.primitives` is set, and `executeWorkflowGraph` always sets it — so
every seam entry in `createAuthoritativeWorkflowSeams` is unreachable
for prompt nodes. A lifecycle announcement sat there through two PRs. It
type-checked and its unit tests passed, because a seam-level test calls
the seam object directly and therefore always can.

2. **A negative instrumentation result is worthless without a control.**
No output from an instrumented seam is only evidence once you have shown
writes from that module are visible under the harness. One
`process.stderr.write` at module load separates "never ran" from "output
swallowed" — opposite conclusions.

3. **Source-string ratchets prove syntax, not behavior.** Three were
torn down in review. The sharpest guarded a never-executed-code bug with
a source search, reproducing the bug one level up; measured, the
behavioural version fails an inverted dispatch and the textual one
passes it. Includes the sub-rules paid for the hard way: use the AST not
regex (a brace in a string truncated an extraction to 13 lines and every
count read a *passing* zero), guard the guard, anchor by index rather
than a character window.

4. **A green test on first try, on a path with no prior coverage, is a
warning.** Two conversions were reverted in one day because their tests
passed with the change reverted. Negative assertions succeed trivially
when the method returns early — `recoverCompletedTask` has seven guards
before the converted line, and the fixture has to satisfy all of them.

5. **A named workflow selection is not a resolved one.** Provenance
cannot be inferred from the returned value, because a fallback IR and a
valid id-less IR are structurally identical — the resolver that knows
has to report it. This is the fork every remaining lifecycle-column
conversion hits.

## Why this rather than another conversion

Everything left in my area is now owned and further along than I could
take it: `executor.ts` → #2628 (which solved the `recoverCompletedTask`
fixture I could not), `self-healing.ts` → #2560 (independently hit all
three traps I catalogued), the dashboard cluster → #2625/#2626/#2636.
Duplicating that would be motion, not progress. Turning findings that
cost real cycles into something greppable is the useful thing I can
still add.

`pnpm lint` clean. No changeset — internal documentation.

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

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

## Summary by CodeRabbit

* **Documentation**
* Added a best-practices guide for verifying that workflow code paths
actually execute.
* Covers reliable behavioral assertions, instrumentation controls,
regression-proof tests, source validation, and detection of fallback
behavior.


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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:59:24 -07:00
gsxdsm
8e211d1870 TAKING scripts/: parse instead of grep — an AST classifier for the lifecycle-column bar, cross-checked by a second implementation (#2633)
The program's completion bar is "`column === "triage"` reaches zero".
This measures what that bar actually covers, and checks the measurement
in so it cannot drift.

## The number, measured by the checked-in tool

```
lifecycle-column-census: scanned 1956 source files

  COLUMN guards (the backlog):   1031
  ROLE comparisons (not guards):   10
  DELIBERATE-LITERAL (reviewed):    4

  by column id:
     313  done
     217  in-review
     201  in-progress
     177  archived
      83  todo
      40  triage

  top files:
     151  packages/engine/src/executor.ts
     136  packages/engine/src/self-healing.ts
      50  packages/dashboard/app/components/TaskCard.tsx
      44  packages/core/src/task-store/moves.ts
      34  packages/dashboard/app/components/TaskDetailModal.tsx
```

**`triage` is under 4% of the class.** Every one of those 1031 sites is
the same defect: a lifecycle decision made by column NAME, which stops
matching the moment a board renames a column. The bar can be met in full
while 991 identical guards remain — and two files hold a quarter of
them.

## The tracked count is wrong in three directions at once

Each of these cost real work this week, which is why this is a PR and
not a comment.

1. **Vocabulary.** It measures one of six legacy ids.
2. **Receiver.** It is anchored on locals named
`column`/`toColumn`/`fromColumn`, so it never saw the three real guards
in `executor.ts` written against `from` and `originColumn`. One of those
meant completed-but-stranded work was never recovered on a renamed
board, with nothing else owning that state (converted in #2628).
3. **Collision.** `role === "triage"`, `agentType === "triage"`,
`entry.agent === "triage"` compare an **AGENT ROLE**. The planner *lane*
is named `triage` and keeps that name — U11 removed the *column*. Ten
such sites were counted as backlog, and the "obvious" fix (renaming the
role) silently empties the planner's prompt template and mis-binds its
model markers.

A count that is too high and too low simultaneously sends work to the
wrong files while hiding the files that need it. So the census reports
**three separate numbers** and never nets them.

## Proven to fail on the original defect

Not asserted — exercised:

```
$ # reintroduce `task.column === "triage" || task.column === "todo"` into live-agent-count.ts
$ node scripts/lifecycle-column-census.mjs --strict; echo "exit=$?"
  packages/core/src/live-agent-count.ts: 10 -> 12
exit=1
$ # restore the file
$ node scripts/lifecycle-column-census.mjs --strict >/dev/null; echo "exit=$?"
exit=0
```

The CLI also exits 1 when its own file list comes back empty — a guard
that reports success without checking anything is worse than no guard.

## 12 regression cases, split by what they defend

Must catch: all six ids; a guard on a local named `from`/`originColumn`
(verbatim the executor.ts shape); single quotes; negation; several
comparisons on one line.

Must **not** catch: role comparisons; comment prose (two tracked
"guards" in `replan-target.ts` were prose about a filter that lives in
another file); a trailing `// … === "triage"` on a code line; sites
carrying a `DELIBERATE-LITERAL` marker.

Plus: **one marker cannot launder a distant guard in the same file** —
that is how allowlists rot.

## Report-only, deliberately

`--strict` compares per-file counts against
`scripts/lib/lifecycle-column-census-baseline.json` and fails when any
file's count **rises**. It is **not** wired into the merge gate: a
thousand-site backlog cannot be a blocking check the day it is first
measured, and a guard nobody can pass is a guard everyone disables.
Owners tightening their own area re-record the baseline in the PR that
lowers it. This is the ratchet shape the `DELIBERATE-LITERAL` markers
scattered through the program already anticipate.

## Stated limitation

Classification is by receiver **name**, so a future field named `agent`
that holds a column would be misclassified as a role comparison.
Recorded at the site, and it is precisely why the two classes are
reported separately instead of netted into one figure.

## Verification

- 12/12 new cases
(`packages/engine/src/__tests__/lifecycle-column-census.test.ts`)
- `pnpm test:gate` **71/71**; `pnpm lint` clean
- `pnpm census:lifecycle-columns`, `--json`, and `--strict` all
exercised end to end
- documented in `docs/testing.md`; no production code touched

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:52:18 -07:00
gsxdsm
6d10683dbd docs(solutions): store fakes that lie — six fixture defects that each looked like a production bug (#2534)
Six consecutive slices of U7 produced **six test-fixture defects, and
every one first presented as a bug in the code under test.** Not one was
real.

Each cost 15–60 minutes debugging the wrong file. **Two would have
shipped a false green** — a test passing while asserting nothing — if
the failure had happened to look plausible rather than implausible.

This is not a story about carelessness. Every one of these fakes was
modelled on an existing fixture in this repo, and the repo's fixtures
are inconsistent about exactly the things that matter.

## The catalogue

| # | Defect | How it presented | Real cause |
|---|---|---|---|
| 1 | `moveTaskIf` ignores its predicate | Test passed; in-txn guard
untested and indistinguishable from absent | Fake never invoked the
callback |
| 2 | `updateTaskAtomic: vi.fn()` never invokes its callback | *Every*
finalize bailed before the branch under test | Success is derived from
whether the callback ran |
| 3 | Harness default parameter swallows the input | "Task vanished"
case became a duplicate of the control | `harness(undefined)` triggers
the default |
| 4 | `logEntry: vi.fn()` returns `undefined` | Sweep appeared to match
only one column | `.catch` on a non-promise throws, aborting the loop
after item one |
| 5 | Harness lets `poll()` reach the real `specifyTask` | **exit 1 with
every test green** | Real agent path threw *asynchronously*, after
assertions passed |
| 6 | `updateTask: vi.fn()` returns `undefined` | Branch "did not run" |
Same as #4 |

**4 and 6 are the same shape, found a week apart, because nothing
prevented the second.** That is the argument for writing this down.

## The three rules

1. **Every store method a fake exposes returns what the real one
returns** — overwhelmingly a promise. Production writes `await
store.m(...).catch(h)` as a fail-soft idiom; `.catch` on `undefined`
throws a `TypeError` that unwinds into a broad *"never let housekeeping
break the poll"* handler and vanishes. Symptom is never "your fake is
wrong" — it is *"the loop only processed the first item"*.
2. **A fake handed a predicate or callback must invoke it.** Ignoring it
makes the guarded and unguarded implementations *indistinguishable*, so
a test named for the guard cannot detect the guard's removal. Includes
the `onLockedRead` hook, without which an in-transaction recheck stays
untestable even once the predicate is invoked.
3. **Stub the agent-dispatch boundary.** `poll()` ends in "start an
agent", which in a unit test throws *after* the test resolved — `17
passed`, exit code 1, which on CI reads as infrastructure noise.

> Never accept a non-zero exit on a green run. It is the only signal
that something escaped your assertions entirely.

## Also covered

- **How to spot a fixture defect fast** — the tell is *failing for the
wrong reason*. Three concrete checks before you open the production
file.
- **Why differential tests earn their keep** even when they feel
redundant: the default-vocabulary half doubles as a fixture self-check,
because it asserts behavior that is by definition already shipping. On
this program, "both halves failed" was the signal that found three of
the six.
- **The connection to guards that cannot fire** — six of those on this
program too, including a ratchet I wrote that matched only a
double-quoted literal (#2527). Same discipline either way: *prove the
check fails on the thing it claims to catch before trusting that it
passes.* Including the warning that one ratchet injection silently
failed to apply, leaving a green run that would have "proven" the
ratchet worked.

## The concrete next step, stated plainly

A shared `createTaskStoreFake({ tasks, workflowIr })` with
promise-resolving, callback-invoking defaults would remove this whole
class in one small PR. **It is not built here** because it is cross-unit
and needs adopters — building it inside U7 and hoping others find it is
how conventions die. The doc says: if you are about to hand-roll a
seventh store fake, build the helper instead and link it.

Docs-only; no changeset (AGENTS.md excludes internal docs).

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


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

## Summary by CodeRabbit

* **Documentation**
* Added guidance on six store-fake defect patterns that can resemble
production bugs during testing.
* Documented best practices for creating reliable store fakes, including
promise handling, callback invocation, and async dispatch isolation.
* Added diagnostic techniques for distinguishing fixture issues from
genuine application defects.
* Included guidance for validating production guards and links to
related documentation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:42:36 -07:00
gsxdsm
73338502e5 fix(test) + E2E: re-green main's lifecycle release leg, and prove the MERGED board + REVISE rework (#2634)
**Second batch.** Three commits, no production code. `pnpm test:gate`
green, `pnpm lint` clean, all three E2E suites together **3 files / 41
tests, exit 0**.

## 1. main's lifecycle E2E is RED right now — this fixes it

Independently of my work, on a detached `origin/main`: **2 failed / 18
passed**. Scenarios 1 and 2 fail with `sweep.released` **empty**.

**Cause:** `seedTask` relied on task creation's PROMPT.md, which is a
bootstrap seed (`"# <id>\n\n<description>"`). FN-7648's
`isUnplannedForExecution` reads that file for any card resting in an
intake- **or** hold-trait column and refuses to move an unplanned card
into a processing column. The sweep reported `held: [{ reason:
"move-rejected-or-no-slot" }]`.

**That is the gate working.** The fixture was asking the scheduler to
release a card that had never been specified. The fix is the one the
graph-entry contract doc already prescribes: *"Scheduler/release test
fixtures must model a card that cleared the gate ... A held unreviewed
card is the gate working."* `seedTask` now writes a planned PROMPT.md.

**Verified it repairs main, not just this branch:** applying only that
file to a detached `origin/main` leaves scenarios 1 and 2 **passing**,
with the 4 residual failures being scenarios 3 and 6 — which need the
fixture-options commit main does not have.

### I was wrong in #2627 and this corrects it

In #2627 I named the in-transaction capacity gate (#2488/#2499) as the
likely cause. **It was not.** Two hypotheses died, both recorded in the
code comment so nobody re-runs them:

| Hypothesis | Result |
|---|---|
| E2E settings lack `maxConcurrent` → capacity gate rejects the move |
added `maxConcurrent`/`maxWorktrees` → **still 2 failed**. Not the
cause. |
| the move itself is refused | a direct `moveTask(id, wip)` →
**succeeded**. Never the blocker. |

Only then did probing the two release gates give
`isTaskBlockedOnApproval=false`, `isUnplannedForExecution=true`, and
dumping the file show the stub. I've flagged the wrong lead on #2627 too
— a plausible-sounding cause pointed at another worker's PR is worse
than no lead.

## 2. E2E evidence: the MERGED intake+hold board

U11's shape — one column carrying intake **and** hold — had no
end-to-end coverage; every prior E2E drove intake and hold as separate
columns.

- shared fixture gains opt-in `mergedIntakeAndHold`, plus `MERGED_VOCAB`
(legacy ids, so a failure is attributable to the **role** merge alone)
and `MERGED_RENAMED_VOCAB` (ids move too).
- lifecycle scenario 3 drives the full spine: planning runs **in place**
on the dual-role column, the real `runHoldReleaseSweep` releases
**from** it, the graph runs to complete.
- 4 merge-safeguard cases on the merged board (finalize, proofless
refusal with the same reason, merged+renamed landing no legacy id,
at-most-once).

## 3. E2E evidence: a REVISE routes back through rework

The plan's `InReview → InProgress: review requests changes` had **no**
live-engine evidence on any board — the fixture's review seam always
succeeded.

Two things the engine taught me, both corrected here:
- the **IR validator refused** my rework edge: it is only legal into a
node with `config.reworkRegion: true`. A real contract, and the
validator catching it is the system working. `exec` now declares it (the
shape the builtin uses on `merge-attempt`).
- my first assertion was wrong. A REVISE does **not** leave the card in
wip — rework re-enters `exec` within the same run, review approves on
its second call, and the card finishes at complete. The evidence is the
**seam sequence**
`["planning","execute","review","execute","review","merge"]`, not an
intermediate column the run has already passed. Asserting the final
column alone would have been satisfied by a graph that ignored the
REVISE entirely.

## Both families are mutation-attributed

| Scenario | Mutation | Result |
|---|---|---|
| 3 — merged intake+hold | `isHeldTask` treats intake/hold as exclusive
| **exactly its 2 tests** fail |
| 6 — REVISE → rework | disable rework re-entry in
`workflow-graph-executor` | **exactly its 2 tests** fail |

Both fixture options are opt-in; the two pre-existing suites are
behaviourally unchanged (27 → 29 → 41 passed across the additions, no
existing assertion touched).

## Still not shipped: safeguard 2's graph E2E

Attempted twice, deleted both times. Attempt 1 passed and then survived
mutating `merge-gate` to ignore `task.autoMerge` — the card parked on
the review column's `merge-blocker` trait, not the gate. Attempt 2
removed that trait to isolate the gate, and the **control** case parked
too. Isolating it needs a merge path mirroring the builtin (`merge-gate
→ merge node → end`) rather than a direct edge to `end` — a real
redesign, not a speculative edit. The enforcement that holds today is
`allowInReviewMergeProcessing` in `project-engine` (unit-mutation
verified, NEW=9; gated via #2526).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:23:32 -07:00
gsxdsm
9a11e0b136 U2b reproduction: the live move path accepts the column U11 deleted (characterized, not patched) (#2601)
Found while proving U11's caveat 2. **Characterization plus guard-rails
— no production change, deliberately.**

## The defect

A default-workflow card in Planning can be moved **into `triage`** — a
column its workflow no longer declares — re-creating exactly the
stranded state `reconcileUndeclaredTaskColumns` exists to repair.

Measured on a fresh store:

```
experimentalFeatures.workflowColumns   null            ← no production writer
createTask(...)                        column = "todo"
moveTask("todo" → "triage")            ACCEPTED
moveTask("todo" → "bogus-column")      REJECTED: "Valid targets: in-progress, triage, archived"
```

The second rejection is the tell. Validation is real — but it is the
**legacy `VALID_TRANSITIONS`** table talking, and that table does not
know the card's workflow. Its `todo` row still lists `triage`.

## Why the workflow-aware check does not run

`moves.ts` gates its adjacency block — including
`workflowHasColumn(workflowIr, toColumn)` — on
`isWorkflowColumnsCompatibilityFlagEnabled`, which reads the raw
`experimentalFeatures.workflowColumns` key. Nothing writes it, so the
block is dead on the path every real project takes.

**Corollary, already reported:** U11's undeclared-source escape hatch in
`resolveAllowedColumns` also does not run in production. It was added
with #2515 so a stranded card would have a legal move instead of `Valid
targets: none`; on the live path that rescue comes from the legacy table
instead. Mutation-verified — stubbing the hatch back to `[]` leaves the
operator-move test green.

## Why I did not fix it

PR #2499 un-gated the capacity check and **explicitly scoped validation
out**:

> SCOPE, deliberately narrow: only the CAPACITY check is un-gated.
`workflowIr` stays flag-gated so transition VALIDATION keeps its current
behavior — the inline path's bare-Error/"Valid targets:" contract is
unchanged, and none of the Phase A2 divergences are flipped here.

That is a considered decision by the owner of this function, and several
suites pin the contract it protects. Overriding it from outside would
flip an error shape I do not own.

**What has changed since that decision is U11:** the legacy table now
offers a target the default workflow does not declare, which it never
did before. That is new input to the scoping call, not licence to ignore
it — so this lands as a reproduction for U2b rather than a patch.

U2b's branch (`feature/workflow-move-path-convergence`) is stale — HEAD
predates several merged PRs, clean tree — so nothing is being raced.

## What ships

The defect is **characterized, not asserted-as-correct**: the test pins
today's behaviour so it is visible and measurable, and an `it.todo`
states the intended behaviour. Writing it as a passing "refuses" test
would have required the fix; writing it as a failing test would redden
CI; asserting the current behaviour as *correct* would be a lie.
Characterization plus `it.todo` is the honest third option.

Four guard-rails pin what a fix must **not** break:

- every declared lifecycle move (`todo → in-progress → in-review →
done`)
- archiving
- a `recoveryRehome` deliberately reaching an undeclared column — the
path that rescues already-stranded cards, and the one a careless fix
would break
- a premise test asserting the compatibility flag really is unset, so
the suite fails loudly if that ever changes rather than silently testing
a different code path

## Exposure

Narrow but real. U10 already fixed the dashboard move menu to offer only
workflow-declared targets, so the board does not present this. The
**write path** does — REST API, CLI, plugins, any stale client — which
is why the guard belongs in `moves.ts` rather than only in the UI.

5 passed + 1 todo; lint clean.

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Added coverage for task moves involving workflow-declared and
undeclared columns.
* Documented a known issue where tasks can currently be moved into the
deleted `triage` column.
  * Preserved valid moves, archiving, and recovery re-homing behavior.

* **Documentation**
* Added reproduction steps, affected move paths, and guardrails for
addressing the issue.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:12:57 -07:00
gsxdsm
30e0a8f291 U11 P0 audit: no hard stall in the recovery block — and one obvious fix is wrong (#2570)
Docs only. Answers the P0 question per site: **does it still fire, what
silently stops happening, is there a backup?**

## Headline: no hard stall

The alarming reading — *"the orphaned-planning-status sweeps stop
finding default cards, so a card whose planner died sits with
`status:"planning"` forever, invisible to discovery"* — **does not
hold.**

`triage.ts`'s `sweepStalePlanningStatuses` is the **periodic primary**
for that repair and already tests `column !== "triage" && column !==
"todo"`. It covers the merged column. The two self-healing sweeps
perform the same repair and are **redundant nets**, not the sole rescue.

That is the difference between a P0 and a cleanup, and it is only
visible by reading the **backup** path rather than the broken guard.
Recorded so nobody re-derives the panic.

## Self-healing block, by blast radius

| site | fires? | what stops | backup | verdict |
|---|---|---|---|---|
| `:12106`, `:12427` | no | clearing a stale `planning` status |
`triage.sweepStalePlanningStatuses` | redundant net lost — **cleanup** |
| `:2961/2981/3016` `recoverAdvancedTriageTasks` | no | re-homing a card
with a worktree + durable IR pin to its **pinned** resume column |
hold-release still releases it on capacity (real spec ⇒
`isUnplannedForExecution` false) | **degraded, not stuck** — fix first |
| `:12254` | no | a bounded priority nudge | none needed; the doc says
nudge, not rescue | **low** |
| `:12151`, `:9151` | **yes** | — | already OR-paired | **safe** |

**Second-order trap at `:3016`.** It skips when `resumeColumn ===
"triage"`, guarding against resuming a card into the column it already
occupies. Post-merge the pinned column is `todo`, which is **not**
skipped — so pairing the literal at `:2961` *without* also pairing
`:3016` produces a `todo → todo` move. **Repair the three together.**

## Two sites in the ownership split are already handled

- **`usage-limit-detector.ts:126`** (assigned to u8) — already fixed in
**PR #2567**. Real breakage: the planning lane stopped being recognised,
so a card being planned was neither parked when its provider hit a usage
limit nor resumed when it recovered.
- **`spec-staleness.ts:95`** (assigned to u7) — already proven safe
as-is, merged with #2515. **Its obvious fix is wrong.** I tried `||
task.column === "todo"` and it turned an existing test red: it breaks
the parked-preserved-progress path.

## The generalisation, which is the most useful thing here

**On the merged column, `todo` answers two different questions.**

After the merge `todo` is both the planner column *and* the
capacity-hold column. So any site that used `triage` to mean *"is being
planned"* **cannot simply be paired with `todo`**, because `todo` also
means *"is parked waiting for capacity"*. Those sites need **status or a
trait**, not a wider literal.

That is precisely the mistake a bulk conversion makes, and
`spec-staleness.ts` is the worked example: the guard was already asking
status, and widening the column would have destroyed the distinction.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:03:23 -07:00
gsxdsm
67904f8a2c U11: merge Todo into Planning on the default lineage (+ the migration mechanism, and a measured safety audit that cuts the work list 32%) (#2515)
**Merges Todo into Planning on the operator's real default workflow.**
Held from merge pending the `triage` literal audit below — see *Gating*.

## The board change

`builtin:coding` → `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR` →
clones `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`. That IR now declares
**five** columns, and `plan`, `plan-review`, `plan-replan` and `start`
all live in the merged Planning column:

```
columns: todo="Planning", in-progress, in-review, done, archived
  start -> todo      plan       -> todo
  plan-review -> todo  plan-replan -> todo
  parse -> in-progress            (first implementation node)
```

The id stays `todo`, the display name becomes "Planning". That is the
cheaper half: `todo` was already the hold column, so every trait lookup,
task row, stored selection and the 121 `column === "todo"` guards keep
their meaning, and **no stored row needs re-homing**. Promoting `triage`
instead would have produced the same board while making those guards
workflow-*dependent* — live for Coding (Ideas), silently dead for
Coding.

`builtin:legacy-coding` keeps its six-column shape, per the operator's
decision. It exists to be the old thing.

## Entry contract, before and after each IR edit

| | result |
|---|---|
| before the default-lineage edit | **15 passed** |
| after the edit | **13 passed, 2 failed** |
| after reading both | **15 passed** |

Neither failure was routed around. One was a genuine expectation change
(two planning entry points became one); the other was my own
`mergeTodoIntoPlanning` helper throwing *"source IR is not the
split-column shape this merge transforms"* — because production **is**
the merged shape now. I **deleted** the helper rather than making it
tolerant: a transform that has silently become a no-op asserts nothing.

## The safety argument, proven not asserted

Entering at `start` is exactly what dragged cards backward in the three
earlier reverted attempts. `merged-planning-start-node-no-move.test.ts`
proves against the **real** boundary controller and **real** default IR
that entering `start` performs no move (`moveTask` is never *called*),
reaches no hold→wip capacity seam, and **still moves on a genuine
crossing** so the no-op is same-column rather than a disabled boundary.
Removing the controller's same-column short-circuit turns exactly the
two no-move tests red.

## The migration mechanism

A card can outlive its column. `resolveAllowedColumns` derives targets
from graph adjacency, and an undeclared source has none — so it returned
`[]` and **every** move was rejected with "Valid targets: none",
including the one that would rescue the card. An undeclared source now
resolves to the workflow's rebound target. Escape hatch, not relaxation:
declared columns are untouched, and it offers the rebound target *only*,
so a stranded card gets back **into** the lifecycle rather than a free
jump past review.

## A real regression this surfaced

`isDefaultWorkflowColumns` matched the legacy **six** ids as a set. The
merged default declares five, so the match stopped firing and the
default board fell through to neighbor-only adjacency, which **drops
legal moves and invents an illegal one**:

| edge | effect |
|---|---|
| `in-progress → done` | **dropped** — the mission-validation cross edge
|
| `in-review → todo` | **dropped** — review work back to planning |
| `todo/done → archived` | **dropped** — the FN-4892 direct-archival
edges |
| `done → in-review` | **invented** — a backward edge no rule allows |

Adjacency now derives from lifecycle **roles**. The load-bearing
assertion: the legacy six still reproduce `VALID_TRANSITIONS`
**verbatim**. Applied only when a workflow declares the full role set,
so custom boards keep neighbor adjacency.

## Failure accounting (core package, vs a 49-failure baseline)

| stage | failed | new |
|---|---:|---:|
| after the merge | 65 | 18 |
| after the escape hatch | 52 | 5 |
| after role-derived adjacency | 53 | 4 |

The 4 remaining are 3 `builtin-workflows` expectations encoding the
pre-merge shape and 1 create-intake expectation naming `triage` on
`builtin:coding`.

Two `schema-applier` and two `workflow-reconciliation-production-shape`
failures appeared in intermediate runs and are **not mine** — both files
pass in isolation (75/75 and 7/7). I re-ran each before attributing
them, which is why the earlier "priority" flag on the reconciliation
pair was withdrawn.

Gate: **309/309**. Lint clean.

## Gating: the `triage` audit
(`docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md`)

Program tracking cited **58** `triage` comparisons. Measured with the
same pattern:

| | count |
|---|---:|
| raw comparisons | 87 |
| inside comments | 1 |
| **not a lifecycle column at all** | **15** |
| column comparisons | 71 |
| OR-paired with `"todo"` in the same expression | 32 |
| **exclusive `triage` — the real work list** | **39** |

**15 do not compare a column.** `role === "triage"`, `surface ===
"triage"`, `sessionPurpose === "triage"`, `entry.agent === "triage"`
name the planning **agent**. Converting them would be actively wrong,
and the failure — a planning agent that can't resolve its prompt
template — would look nothing like a column bug.

**One site changes an operator-visible affordance**, which is why
per-site review beat a sweep:

`TaskCard.tsx:1927` — `taskColumnFlags?.intake === true && task.column
!== "triage"`. The literal is a **narrowing**, not a match. After the
merge a Planning card has `intake === true` and `column === "todo"`, so
the narrowing stops applying and **Start begins rendering on default
Planning cards where it previously did not.** A sweep would have
"converted" the literal and shipped the new affordance silently.

These guards do not go **dead**, they go **workflow-dependent** —
`triage` stays live for legacy-coding, Ideas, every linear built-in and
any user workflow (R11) — which is harder to detect than dead.

Work list and ownership are in the audit doc.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:39:20 -07:00
Phil Larson
b85a5d4531 fix(core): bound compound engineering review remediation (#2532)
## Summary

- cap Compound Engineering Code Review remediation at two Execute→Review
repair passes
- enable no-progress detection for the built-in CE workflow
- preserve explicit project/workflow overrides while making the authored
CE default visible in settings and docs
- update stale IR/changeset language that still described Code Review as
unbounded when unset

## Why

The previous CE default was effectively unbounded. A reviewer that
repeatedly returned `REVISE` could consume thousands of remediation
cycles without terminally parking the task. The built-in workflow should
fail closed after a small, explicit budget while still allowing
operators to author a different numeric cap.

## Verification

- `FUSION_PG_TEST_SKIP=1 corepack pnpm@10.33.0 --filter @fusion/core
exec vitest run src/__tests__/builtin-workflows.test.ts` — 46 passed, 17
skipped
- `corepack pnpm@10.33.0 --filter @fusion/core typecheck`
- `corepack pnpm@10.33.0 --filter @fusion/dashboard exec vitest run
app/components/__tests__/WorkflowSettingsPanel.test.tsx
app/components/__tests__/workflow-setting-display.test.ts` — 33 passed
- `corepack pnpm@10.33.0 --filter @fusion/dashboard typecheck`
- `corepack pnpm@10.33.0 changeset status --since=origin/main`
- `git diff --check origin/main...HEAD`


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

- **Improvements**
- Compound Engineering Code Review now caps remediation attempts at 2;
after two unsuccessful attempts, the process parks instead of retrying
indefinitely.
- Post-restart review recovery now completes in a single maintenance
cycle to reduce delays.
  - Default post-review fix budget increased from 3 to 10.
- Review revision limits now consistently honor workflow-authored
defaults when settings are left empty, and `0` disables automatic
remediation.

- **Documentation**
- Updated the workflow editor, settings reference, workflow steps, and
operator panel text to clarify cap/default/disable semantics (including
CE: 2).

- **Tests**
- Added/updated unit tests to validate the new bounded remediation
behavior and messaging.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 00:05:29 -07:00
gsxdsm
99be8e6153 docs(U9): correct the safeguard baseline — safeguards 1 and 4 are NOT covered (#2520)
**U9, PR4.** Docs-only correction to a document already on `main`
(#2511). No changeset.

## I got #2511 wrong, and it matters

#2511's table claimed all six merge safeguards were verified. **Two of
them were not**, and the error is the same family this program exists to
stamp out: I reported the **absolute** failure count under mutation,
with **no baseline**.

`merge-error-recovery.test.ts` (10 failures) and `self-healing.test.ts`
(1) are **already red on clean `main`**. The "11 failed" I credited to
the row 1 mutation *was that pre-existing red*. The mutation added
nothing. I even flagged the identical `11 failed` on rows 1 and 3 as "a
red flag" in my own notes and then did not chase it.

## Re-measured as deltas

Baseline fail-SET vs mutated fail-SET on the identical selection,
reporting only NEW failures, each named:

| # | Safeguard | Baseline | Mutated | **NEW** | Verdict |
|---|---|---|---|---|---|
| 1 | user pause | 11 | 11 | **0** | **NOT COVERED** |
| 2 | `autoMerge:false` | 0 | 9 | **9** | covered |
| 3 | dependency gating | 0 | 5 | **5** | covered |
| 4 | capacity single-flight | 10 | 10 | **0** | **NOT COVERED** |
| 5a | merge-proof (pre-enqueue) | 0 | 1 | **1** | covered, thin |
| 5b | file-scope | 0 | 6 | **6** | covered |
| 6 | at-most-once | 0 | 3 | **3** | covered |

**Four hold. Two do not.**

- **Safeguard 1** is the pause invariant re-ratified in #2486. Removing
`task.paused || task.userPaused` from the merge admission provider
admits a **user-paused card into the merge pump** — and nothing fails.
- **Safeguard 4** is the single-flight guard that serializes merge.
Removing it permits concurrent `drainMergeQueue` entry — and nothing
fails.

Both guards **work correctly today**. What is missing is any test that
would notice if they stopped. That is exactly the state U9 must not
convert on top of — and #2511 said the opposite.

## Also corrected: nothing here is defended by blocking CI

The one gate-admitted file (`merger-merge-lifecycle.test.ts`) is not the
file that proves any surviving row. Rows 2/5a/6 rest on
`project-engine.test.ts`, row 3 on core's `task-merge.test.ts` — neither
is in the gate (core's gate is two PG tests via `test:pg-gate`).

## Three distinct ways the first pass was wrong

All recorded in the doc, because each produced a confident wrong answer:

1. **Absolute counts with no baseline** — rows 1 and 4. A mutation run
must diff fail-sets and report only new failures.
2. **Too-narrow selection** — an earlier pass measured rows 1 and 3 at
zero and I nearly filed two false gaps. Widening fixed row 3 but is also
how the pre-existing red crept in. Both directions need the baseline
diff.
3. **A harness that silently matched nothing** — the delta harness's
regex required a `|project|` segment in vitest's `FAIL` line.
`@fusion/core` does not emit one, so it parsed **zero** failures at both
baseline and mutation and printed "NOT COVERED" for row 3, which is
covered by 5 tests. A verification tool that reports success without
checking anything is worse than no tool; it must be tested against a
known-failing case first.

The harness now aborts on a no-op patch, asserts a clean restore, and I
validated its parser against a known-failing run before trusting it.

## Next

**PR5 writes the missing tests for safeguards 1 and 4**, then gate
admission. Neither should wait for U8 — they guard code that exists
today, and the conversion needs them in place first. That is the
reversible call I'm making rather than asking.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:19:35 -07:00
gsxdsm
aeef592187 docs(U9): safeguard baseline — six merge safeguards verified by mutation (#2511)
**U9, PR3.** Docs-only, one new file, zero production changes. No
changeset (internal docs). Independent of #2504.

This is the six-row safeguard table required as a U9 artifact —
delivered **verified**, replacing the partial, unearned version I put in
#2494.

## Every row proven by mutation

Break the guard in production, run the cited tests, confirm red,
restore. A cited test that does not fail is not evidence.

| # | Safeguard | Consulted at | Result under mutation |
|---|---|---|---|
| 1 | user pause | `project-engine.ts:645` (merge admission filter) |
**11 failed** / 1865 passed |
| 2 | `autoMerge:false` | `project-engine.ts:2797`
`allowsAutoMergeProcessing` | **9 failed** / 250 passed |
| 3 | dependency gating | `task-merge.ts:402` unresolved-dependency
reason | **5 failed** / 94 passed |
| 4 | capacity | `project-engine.ts:3178` single-flight `mergeRunning` |
**11 failed** / 1445 passed |
| 5a | merge-proof (pre-enqueue) | `project-engine.ts:2609`
`getTaskMergeBlocker` consult | **1 failed** / 272 passed |
| 5b | merge-proof (file scope) | `merger-file-scope.ts:200`
`FileScopeViolationError` | **6 failed** / 172 passed |
| 6 | at-most-once | `project-engine.ts:2730` `mergeActive` dedupe | **3
failed** / 263 passed |

**All six hold. Nothing is currently broken.** Per-row test attribution
is in the doc.

## Finding 1 — one of nine safeguard test files runs in blocking CI

Only `merger-merge-lifecycle.test.ts` is in the `engine-core`
allow-list. The core gate is two PG tests (`test:pg-gate`) and does not
include `task-merge.test.ts`.

AGENTS.md: CI blocks on Lint/Typecheck/Build/Gate, and "a red
non-blocking run is information, not a merge stopper."

So a change breaking **user pause on merge admission, dependency gating,
capacity single-flight, or the file-scope invariant** does not block a
PR today — it goes red in full-suite, after the merge.

Acceptable for a lane nobody is rewriting. Wrong for the lane U9
rewrites next. **Recommendation: admit the highest-value safeguard tests
to the gate before conversion begins, with the budget cost measured** —
engine-core is 5.36s against a ~60s ceiling, so there is room, but I
won't assume it. Proposed as PR4.

## Finding 2 — safeguard 5a rests on a single non-gate test

Removing the pre-enqueue merge-blocker consult fails exactly one test.
Thinnest of the six, on a destructive-risk gate. Its sibling 5b is well
covered (6 tests), so the invariant isn't unguarded — but the consult
that keeps a blocked task out of the queue very nearly is.

## Methodology note, because it cost an hour

**Rows 1 and 3 initially measured ZERO failures and looked like coverage
gaps. Both were wrong** — the test selection was too narrow. Widening
row 1 from three files to
`project-engine|merge|concurrency|self-healing` turned 0 failures into
11. Row 3's real coverage lives in `@fusion/core`'s suite, which `pnpm
--filter @fusion/engine` never runs, even though the engine config
aliases `@fusion/core` to source so the mutation *was* live.

I nearly reported two false gaps. Recorded as two rules: a narrow
mutation run cannot prove absence of coverage, and cross-package guards
need cross-package runs.

## Scope

New file only — deliberately **no** edit to
`docs/workflow-policy-ownership-map.md`, because #2504 already edits
that file at the same anchor and I want both PRs independently
revertable. Cross-link follows once both are on main.

Verified: no production diff, all mutations restored, `pnpm lint` clean.

## Not covered, stated rather than implied

Reviewer-lane safeguards; FN-7720 operator bypass; FN-8492
orphaned-pending-step rewrite; branch-group promotion sequencing. Each
needs its own verified row before the matching conversion.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:15:50 -07:00
gsxdsm
6ee20d9817 docs(U9): correct merge-stack slice statuses to measured wiring state (#2504)
**U9, PR2.** Docs-only, no changeset (AGENTS.md: internal docs).

## Why

All seven slices in `docs/plans/workflow-owned-merge-stack/` were marked
`draft-stack-handoff` — accurate when drafted 2026-06-09, wrong now. **A
worker picking up this stack cold would have re-implemented S04, which
has already landed.** I nearly did.

## Measured, against `main @ 46f35323c`

| Slice | Was | Now | Evidence |
|---|---|---|---|
| S02 projection | draft | `landed-unwired` |
`projectMergeRequestToWorkflowWorkItem` implemented, **0 production
callers** |
| S03 scheduler claim | draft | `landed-unwired` |
`claimDueWorkflowWorkItem` implemented; its only caller is S05's
processor, itself unwired |
| S04 IR regions | draft | **`landed`** | `merge-gate`, `merge-retry`,
`manual-merge-hold`, `merge-attempt`, `recovery-router` present in the
coding IR |
| S05 runtime driver | draft | `landed-unwired` | `runWorkItem` /
`processDueWorkflowWorkItem` implemented, exported from `index.ts`, **0
production callers** |
| S06/S07/S08 | draft | `not-started` | merge still runs through
`merger.ts` + the live `ProjectEngine.mergeQueue` pump |

## The finding that changes U9's sequencing

`WorkflowWorkItemKind` is `task | merge | retry | manual-hold |
recovery`. The only live pump —
`InProcessRuntime.drainWorkflowContinuations` — filters `kinds:
["task"]`. The generic processor that would claim the other four kinds
has **no production caller**.

So the entire merge-lane work-item vocabulary is dormant: **zero
writers, zero readers.**

**I checked whether this is a live bug and it is not.** Nothing in
production writes a non-`task` kind — the only two writers
(`plan-review-continuation.ts`, `workflow-column-boundary-hooks.ts`)
both go through `replaceActiveTaskWorkflowContinuation`. Nothing is
stranded today. I'd rather say that plainly than let a scary-sounding
finding stand unqualified.

But it produces a hard ordering constraint, now recorded in S07:

> **S07 must not land before S03/S05 are actually driven.** S07 is the
slice that starts writing `merge`-kind work items. If it lands first,
those items are created and never claimed — a card that reaches the
merge boundary and silently stops.

This also reframes U9's job on S02/S03/S05: **wire them, don't build
them.**

## Scope discipline

Docs-only — `git diff --stat` is 9 files, all under `docs/`. No
production code, no tests, no behavior. `pnpm lint` clean.

I also fixed the three parent-plan lines asserting the slices are "all
still `draft-stack-handoff`", and the four landed slices' Stack Role
paragraphs that would otherwise contradict their own new Measured State
block. Leaving those stale would recreate exactly the defect this PR
fixes.

Related: #2494 pins the S04 caveat — the IR regions are declared but
their config is read by nothing.

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

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

## Summary by CodeRabbit

- **Documentation**
- Updated workflow-owned merge planning documents with current
implementation and wiring statuses.
- Added measured wiring details showing which workflow capabilities are
active, implemented but unused, or not started.
- Clarified sequencing requirements to ensure merge processing is not
enabled before prerequisite workflow paths are operational.
- Corrected slice metadata and references to reflect the latest measured
state.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:15:16 -07:00
gsxdsm
d873ee8160 docs: correct KTD-6 — the "flag-off" move path is live, the hooks path is dead (#2466)
Docs only. Corrects a factual error in the program plan merged in #2463,
found during Phase A execution.

## What was wrong

KTD-6 listed three deletions as "provably dead." Two are. The third —
the flag-off inline move path in `task-store/moves.ts` — is the **live**
path:

- It is gated on `isWorkflowColumnsCompatibilityFlagEnabled`
(`store.ts:38`), which reads the **raw**
`experimentalFeatures.workflowColumns` key.
- That is a *different* function from the always-true public
`isWorkflowColumnsEnabled`, which is what the plan reasoned from.
- No production code writes that key, so the flag reads false — the
inline branch runs for essentially every project, and
`default-workflow-hooks.ts` is the dead one.
- `moves.ts:637` already said so: *"this 'flag-OFF' branch is the
DEFAULT move path for nearly every project — the strict compat flag
reads false because nothing sets it."*

Deleting it would have swapped every project onto an untravelled code
path and called it a cleanup.

## Changes

- **KTD-6** carries the correction inline, with the two consequences
that bind the rest of the program.
- **U2** rescoped to the two proven-dead deletions; `moves.ts` is out of
scope for it.
- **New U2b** converges the two implementations with a per-behavior
equivalence proof, and **blocks Phase B** — nothing downstream may
assume the trait-hook path runs until it lands.
- **U3's emit point must attach to the live inline path.** Wired into
the dead hooks path, the event seam would never fire — and because
subscribers are non-authoritative by design, *nothing would fail a
test*.
- Risk table gains: *a "dead" branch turns out to be live*.

## How it was caught

The Phase A worker escalated instead of following the instruction,
because U2 carried a delete-only Execution note: *any behavior change
found while removing a branch means the branch was not dead — stop and
treat it as a finding*. I verified the claim independently (flag
function, absent writers, live `settings.json`, source comment) before
accepting it.

That note stays on every deletion unit.

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

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

* **Documentation**
* Clarified terminology in the workflow lifecycle migration plan,
distinguishing the live inline move path from the compat-flag behavior.
* Expanded the convergence phase with explicit behavior-equivalence
requirements, pinned observable expectations, and removal of compat-flag
branching.
* Updated the migration diagram and phase ordering to reflect the new
gating structure, revised scope boundaries, and refreshed risk
mitigations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:28:26 -07:00
gsxdsm
3c5d07ab01 docs: workflow-owned lifecycle program plan + column-placement contract (#2463)
Docs only — no code. Companion to #2462.

##
`docs/solutions/architecture-patterns/workflow-node-column-placement-and-graph-entry-contract.md`

Why a workflow node's `column` is a lifecycle contract rather than a
display choice: it decides **who can drive the node**, **whether the
card holds a WIP slot**, and **whether anything can move it onward**.

Contents:
- The graph **entry contract** (`resolveColumnResumeNode`, shipped in
#2462) with the resume table.
- The **plan-in-place chain** — triage → finalize → continuation seed →
drain → resume → capacity suspend → release — annotated with the check
each link performs. Notably `todo`, not `triage`: an intake column has
no releaser, so a card parked there waits for a human.
- Why the pre-release gate must be narrow (column match **and**
enablement).
- The measured failure table from three reverted placement attempts.
- Why removing a column is a lifecycle-vocabulary refactor, not a
workflow edit: **82 guards** that silently stop matching, **43 writes**
to a column that no longer exists, **59 dashboard literals**. A guard
that never fires doesn't fail a test — it disables a recovery path.

## `docs/plans/2026-07-26-001-refactor-workflow-owned-lifecycle-plan.md`

The program that finishes the job, in four movements:

1. Resolve lifecycle columns from the workflow instead of ~207 string
literals.
2. Move every lane — planning, execution, review, merge — behind graph
nodes; lane services keep substrate only (storage, leases, timers,
supervision, capacity, recovery, audit).
3. A **post-commit event seam**: transitions commit transactionally,
*then* emit; subscribers react and may enqueue durable work items, but
no subscriber performs a transition. Enforced by test — dropping every
subscriber must change no lifecycle outcome.
4. Only then merge Todo into a single Planning column.

Phased so each phase lands green independently, with the IR change
deliberately **last** (KTD-7). Changing the workflow first makes the
suite green over dead guards — that's how the earlier attempts hid their
own breakage.

The merge lane **adopts** the existing design in
`docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`
(slices S02–S08, still `draft-stack-handoff`) rather than authoring a
competing one, with a note to re-validate against current `main` since
it was drafted seven weeks ago.

Scale is stated honestly: ~48k lines across the four lane services, with
the executor unit explicitly landing across several commits rather than
one sweep.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:43:45 -07:00
gsxdsm
0e3d2a2265 refactor: delete meta-task auto-archive and automated recovery follow-ups (#2461)
Deletes two pieces of automated "meta" machinery that filed and
garbage-collected cards restating state already on the task that failed.
Net **-1015 lines**.

## Why

**Automated recovery follow-ups.** `createAutomatedFollowup` and its
dedup engine (289 lines of signature matching, 1h recurrence
rate-limiting, 24h supersedes windows) existed to file recovery cards
for verification-cap and merge-conflict give-ups. In both cases the
parent is *already* parked `failed` with a descriptive `error` and a log
entry carrying the failing command, branch, and output — the card was a
second copy of that.

**Meta-task auto-archive.** The sweeps that garbage-collected those
cards were worse than redundant: the regex classifier matched ordinary
feature work, and its positional fallback bound cards to unrelated
tasks, so **live work could be archived**.

They are removed together, because the auto-archive sweeps only existed
to clean up after the follow-up engine.

## What changed

### Deleted
- `packages/engine/src/verification-followup-dedup.ts` in full —
`createAutomatedFollowup`, `decideAutomatedFollowup`,
`AutomatedFollowupKind`, `computeVerificationFailureSignature`,
`extractFailingTestFiles`.
- `findActiveRecoveryFollowUp` — dead code, defined and never called
(`tsc` independently flagged it `6133 declared but its value is never
read`).
- The meta-task auto-archive sweeps `autoArchiveResolvedMetaTasks` /
`autoArchiveStalledMetaTasks` and helpers `classifyMetaTask` /
`resolveMetaTargetTaskId` / `computeMetaChainDepth` / `archiveMetaTask`
/ `evaluateMetaAutoArchiveGuards`, plus settings
`metaTaskStallAutoCloseMs` and `metaTaskActiveExecutionGraceMs`.
- Run-audit types `task:auto-archived-meta-resolved`,
`task:auto-archived-meta-stalled`,
`task:auto-archive-meta-resolved-skipped`,
`task:auto-archive-meta-stalled-skipped`,
`verification:followup-created`, `verification:followup-deduped`.

The two signature helpers were **deleted rather than relocated** — once
the three call sites went they were provably unreachable:
`buildVerificationFailureSignature` had exactly one caller, and it was
the only caller of `extractFailingTestFiles`.

### Call sites 1 and 2 — park kept, card dropped
Verification-cap and merge-conflict give-ups keep their park, audit
event, operator comment, and log entry. Site 1's `error` string was
reworded off `"See follow-up task for investigation."` (no follow-up
will exist) to carry the guidance itself. `autoResolveDisabled` was
**kept** — it still drives the outer park guard and the `reason` string;
only the inner branch that guarded card creation is gone.

### Call site 3 — autostash orphan, replaced not deleted
This one is a genuine data-loss guard, so it keeps a durable trail. A
`live`-classified orphan is a merger stash holding **real uncommitted
work**, and unlike sites 1–2 there is no parked parent — the parent may
already be `done` and merged, so nothing else on the board would ever
mention the stash.

The card is replaced by a `logEntry` **and** an `addTaskComment` on the
parent, preserving every fact the old description carried: the sha,
`record.label` (the handle `git stash` recovery needs),
`record.detectedByTaskId`, and `sourcePhase`. New truthful run-audit
event `task:autostash-orphan-live-detected` replaces the borrowed
`verification:followup-*` name, with ids/outcomes-only metadata per
AGENTS.md.

### Kept unchanged: the two real product features
Eval follow-ups (`eval-followups.ts`) and PR-comment follow-ups
(`pr-comment-handler.ts`) only borrowed the shared engine for its dedup
pass. Both keep their exact behavior, column, priority, `sourceType`,
and log lines, with dedup inlined as a `listTasks` scan on
`suggestionId` / `prNumber` respectively. Both fail open (create) if the
listing throws, matching the old engine.

## Test changes — read this one

Two tests asserted the *deleted* engine's rate-limited `"[verification
recurrence]"` logEntry. Those assertions were removed, **not loosened**:
both tests still assert no duplicate card is created, and the eval test
still asserts the existing id is reported back. No coverage of surviving
behavior was weakened. The three `meta-*` test files were deleted along
with the sweeps they covered.

## Verification

```
$ pnpm test:gate
 Test Files  2 passed (2)     Tests   10 passed (10)    # core
 Test Files  16 passed (16)   Tests  299 passed (299)   # engine-core
 Test Files  1 passed (1)     Tests   70 passed (70)    # ci-shape
GATE_EXIT=0

$ pnpm --filter @fusion/engine --filter @fusion/core exec tsc --noEmit -p tsconfig.json
TSC_EXIT=0   (no output)
```

Plus a file-scoped run over the touched surfaces (`eval-followups`,
`pr-comment-handler`, `merger-autostash-orphan-surface`,
`merger-autostash-cleanup`, `run-audit`, `run-audit-secret-taxonomy`,
`project-engine`, `project-engine-manager`): **213/213 passed**.

A repo-wide grep confirms no surviving references to any deleted symbol,
module, or audit event.

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


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

## Summary by CodeRabbit

* **Bug Fixes**
* Failed tasks now retain recovery and verification details directly on
the original task instead of generating separate follow-up cards.
* Live autostash issues now preserve stash information in task comments
and activity logs.
* Existing evaluation and pull-request follow-ups continue to be reused
when appropriate.

* **Changes**
  * Removed automatic archival of meta-tasks.
  * Removed obsolete meta-task timing settings.

* **Documentation**
* Updated architecture and settings documentation to reflect these
workflow changes.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:38:58 -07:00
Victor Canô
d6c917d726 feat(dashboard): add view and settings-section enumeration API (#2453)
## Summary

Gives external integrations (command palettes, plugin launchers,
alternate dashboard shells) a supported way to **discover the host UI**
— instead of hardcoding the dashboard's view ids, labels and settings
search terms and hand-syncing them on every release. This is the
read-only metadata slice of the "constrained by a stable host context
and API client" idea in
`docs/proposals/2026-07-01-dashboard-theme-plugin-system.md`, and the
follow-on to #2415 (theme tokens + overlay layering).

Two additions, both inert unless called:

| Endpoint | Returns |
|---|---|
| `GET /api/views` | Every registered built-in view id, in dashboard
order — `id`, English `label`, plus optional i18n `labelKey`, legacy
`aliases` and `internal` flag. |
| `GET /api/settings/sections` | Selectable Settings sections — `id`,
`label`, `labelKey`, `scope`, `group`, `keywords`, `searchableKeys`,
`advanced`. |

Both are read-only, return static project-independent metadata, take no
project id, and are mounted inside `createApiRoutes` so they sit behind
exactly the same `/api` authentication as every other dashboard route —
no more, no less.

## What actually changed — one source of truth

The endpoints are the small part. The core of the diff is **collapsing
duplicated UI metadata into two shared registries that now drive both
the dashboard UI and the API**:

- `packages/dashboard/src/shared/dashboard-views.ts` — canonical view
ids + English labels + i18n keys + legacy aliases.
- `packages/dashboard/src/shared/settings-sections.ts` — canonical
settings sections + scope/group/search metadata, with `group` and
`advanced` derived from the list's own structure.

`LeftSidebarNav`, `SettingsModal` and `useViewState` were rewritten to
consume those registries instead of carrying their own copies (net
**−230 lines** in `SettingsModal` alone). Edit the registry and the
rendered UI and the API move together.

## Drift protection

Being precise about what each test can and cannot catch, because
"no-drift" claims are easy to overstate:

- `left-sidebar-nav-registry-parity.test.tsx` — the one test that
catches drift the registry does not already determine. It **renders**
the sidebar with a recording `t()` spy and pins each entry's translation
key and English fallback to the registry (the sidebar still hardcodes
its keys). It also asserts the rendered destination count equals the
enrolled id list, so a newly added sidebar view fails until it is
enrolled.
- `ui-metadata-sync.test.ts` — pins the Settings navigation list,
advanced-visibility set, persisted view list, reset-key registry and
both endpoint payloads to the registries. Since those consumers are now
*derived* from the registries, these assertions mainly guard against a
future consumer **re-hardcoding** its own copy. Two of them do stand on
their own: each section's served `group` is pinned to the group header
it actually renders under, and no published `labelKey` may resolve to a
non-leaf i18n node.
- `register-ui-metadata-routes.test.ts` — drives the real Express router
and asserts each endpoint serves the registry payload verbatim, with no
filtering or reshaping.
- Exactly two **existing** tests are updated, both for the same reason:
they asserted that `SettingsModal.tsx`'s *source text* contains a
section literal that now lives in the registry.
`VoiceInputSection.modal-visibility.test.tsx` now asserts Voice Input's
Basic-mode contract against `SETTINGS_SECTION_METADATA`, and
`mcp-documentation.test.ts` reads the registry for the two MCP section
ids. No other existing test in the package changes.

## Design notes / decisions for review

- **`GET /api/views` returns the full registry, not the live menu.** It
includes flag-gated / experimental ids and `internal` (non-navigable)
destinations; reachability depends on flags and plugins this endpoint
does not evaluate. Documented as "known view ids", not "visible nav
entries".
- **`labelKey` is optional and best-effort; `label` is the guarantee.**
A `labelKey` is published only where the dashboard itself renders that
view's title through it. `graph` (labelled from a plugin manifest) and
the internal `task-detail` carry none rather than advertise a key that
resolves to nothing — and `task-detail` in particular must not point at
`taskDetail.title`, which is an occupied i18n *namespace* whose lookup
returns an object rather than falling through to a default. A guard test
now enforces that. Separately, a few published keys (`nav.ideation`,
`nav.importTasks`, `nav.automations`, `pr.view.title`) are the
dashboard's real keys but aren't in the shipped catalogs yet because the
host supplies their English inline; the docs say plainly that consumers
must fall back to `label`.
- **`keywords` / `searchableKeys` are explicitly non-contractual.**
`searchableKeys` exposes the raw i18n translation-key strings backing a
section's searchable copy; values, ordering and presence may change
between releases. Documented as best-effort search hints, never stable
identifiers.
- **Migration is deliberately partial.** The desktop sidebar, Settings
navigation and persisted view list now come from the registries;
`Header.tsx` and the mobile More sheet still hardcode a few of the same
labels. They can still drift from what `GET /api/views` reports;
converting them is left to a follow-up so this diff stays reviewable.
- **No project scoping, deliberately.** The proposal doc rightly pushes
plugin traffic through a project-scoped client — these two endpoints are
the exception that proves the rule: they return static registry metadata
that is identical for every project, so threading a `projectId` would
imply a scoping guarantee that does not exist here. They never touch
`getScopedStore` / `TaskStore`.
- **Two endpoints rather than one `/api/ui-metadata` envelope.** Views
and Settings sections are independent registries with different
consumers, and `/settings/sections` sits naturally beside the existing
`/settings/*` routes. A consumer that only needs navigation doesn't pay
for settings metadata.
- **The registry extraction ships with the endpoints rather than as a
separate PR.** The registries *are* the mechanism that keeps the API
honest — split apart, the first half is a refactor with no observable
effect and the second can't land without it.
- **Placement:** `packages/dashboard/src/shared/` is a new directory,
and these are the first *production* `app/ → src/` imports in the
package (today the only one is in `ProviderIcon.test.tsx`). They sit
under `src/` because `src/`'s tsconfig cannot import `app/`, so a module
both sides consume has nowhere else to go; both registries are
dependency-free data leaves, and `vite build` plus
`check-no-node-only-core-imports-in-dashboard` confirm the client bundle
is unaffected. The considered alternative was `packages/core/src` behind
the `dashboard-browser-safe-core-modules.json` allowlist, where
`mobile-nav-primary-items.ts` keeps a destination→labelKey table — these
stayed out of `core` because they are dashboard-owned UI ids, and
because the two tables describe different surfaces (core mirrors the
mobile nav's `nav.skills`/`nav.settings`; this registry mirrors the
desktop sidebar's `header.skillsView`/`header.settings`).
- Ships a `@runfusion/fusion` **minor** changeset (`category: feature`).

Happy to adjust any of the above — shape, placement, or dropping
`searchableKeys` — if you'd rather it landed differently.

## Verification

- Rebased onto `main@26dcccb7c`. Two conflicts, both resolved by
absorbing upstream's work rather than reverting it:
- `SettingsModal.tsx` — upstream's `voice-input` section (and the
`FNXC:VoiceInput` decision comment explaining it stays out of the
advanced-only set) moved into the registry. The registry's section list
is byte-identical to `main`'s `SETTINGS_SECTIONS` (45/45 entries, all
fields), and the registry-derived `ADVANCED_SETTINGS_SECTION_IDS` is
identical to `main`'s hardcoded set (19/19, same order) — both verified
mechanically, not by eye. Upstream's `RUNTIME_*`
hide-uninstalled-runtimes sets are untouched.
- `routes/README.md` — the `mount-sequence` list regenerated from
`CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE`, so `registerVoiceRoutes`
and `registerUiMetadataRoutes` are both in place and the contract test
passes.
- `DASHBOARD_VIEWS` covers exactly `main`'s `BuiltInTaskView` union,
aliases included, and `BUILT_IN_TASK_VIEWS` reproduces `main`'s 27-entry
array in order (`devserver` still preceding `dev-server` for the
migration path).
- Every one of the 20 sidebar labels the refactor rewrote was checked to
be byte-identical to `main`'s hardcoded fallback, and every `FNXC:`
decision comment displaced by the move was accounted for — all 75 in
`SettingsModal.tsx` and all 11 in `useViewState.ts` survive, relocated
onto the registry entries they document.
- The full `dashboard-app` + `dashboard-api` suites were run at this
commit (**20,706 passing**) and again on unmodified `main@26dcccb7c`,
and the failing-file sets compared: **every file that fails here also
fails on `main`** — nothing regresses. The overlap is environment-driven
(Postgres-backed `*.pg.test.ts`, tests needing built `dist` artifacts,
and `SettingsModalNodeRouting.test.tsx`'s `No "fetchSystemInfo" export
is defined on the "../../api" mock`), none of it touched by this change.
- `tsc --noEmit` clean for both dashboard projects, `eslint` clean on
every changed file, and `vite build` of the client bundle succeeds (the
two pre-existing `@fusion-plugin-examples/claude-runtime` /
`playwright-core` module-resolution errors reproduce on unmodified
`main`).
- Repo gate scripts pass: `check-changeset-format`,
`check-routes-modular`, `check-no-node-only-core-imports-in-dashboard`,
`check-no-cwd-relative-dashboard-test-reads`, `check-mock-completeness`.
- The three new assertions were mutation-tested rather than assumed
load-bearing: breaking the registry's `group` derivation, dropping an
enrolled sidebar id, and re-pointing `task-detail` at the
`taskDetail.title` namespace each make their test fail.
- Local CodeRabbit review over two passes: 3 minor findings, all
addressed (parity projection missing `group`; route tests asserting
partial instead of exact payloads; the `labelKey` guard not covering the
settings registry).


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

## Summary by CodeRabbit

* **New Features**
* Added authenticated, read-only APIs for discovering dashboard views
and selectable Settings sections.
* Added dashboard view metadata, including labels, aliases, internal
status, and translation keys.
* Added Settings metadata with grouping, scope, advanced status, and
search-related information.
  * Updated navigation and Settings UI labels to use shared metadata.

* **Documentation**
  * Documented the new metadata endpoints and integration guidance.

* **Bug Fixes**
* Added safeguards and automated checks to keep UI navigation and API
metadata synchronized.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 22:34:18 -07:00
gsxdsm
e4cb957b57 FN-8619: migrate modal geometry to FloatingWindow
Unify migrated modal geometry and responsive behavior under FloatingWindow.

- Cover Agent Detail and GitHub Import floating-window interaction and persistence contracts.
- Preserve unique Agent Detail labels, mouse-only backdrop dismissal, and tablet touch layouts.
- Update the dashboard modal migration inventory.

Files changed:
 docs/dashboard-modal-inventory.md                  |   6 +-
 .../dashboard/app/components/AgentDetailView.css   |  49 +++++-----
 .../dashboard/app/components/AgentDetailView.tsx   |   6 +-
 .../dashboard/app/components/FloatingWindow.css    |  10 +-
 .../dashboard/app/components/TaskDetailModal.css   |  15 +++
 .../AgentDetailView.floating-window.test.tsx       | 102 +++++++++++++++++++++
 .../__tests__/GitHubImportModal.test.tsx           |  58 ++++++++++++
 ...etailModal.responsive-and-dependencies.test.tsx |  24 +++++
 8 files changed, 241 insertions(+), 29 deletions(-)

Fusion-Task-Id: FN-8619

Fusion-Task-Lineage: efa95e41-b12e-4e5d-aeb2-65b43c2dd8e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 20:04:33 -07:00
gsxdsm
43160a7aae FN-8621: migrate complex modals to FloatingWindow
Unify complex dashboard modal presentation under persisted FloatingWindow geometry.

- Migrate Create Room, Task Detail, Agent Detail, and GitHub Import modal presentations.
- Preserve documented embedded and docked exceptions, dismissal behavior, and nested scrolling.
- Add presentation-contract coverage and publish dashboard guidance and changesets.

Files changed:
 ...n-8619-resize-persist-modals-floating-window.md |   7 ++
 .changeset/fn-8621-create-room-floating-window.md  |   7 ++
 docs/dashboard-guide.md                            |  16 +++-
 docs/dashboard-modal-inventory.md                  |  12 +--
 .../dashboard/app/components/AgentDetailView.css   |  16 +---
 .../dashboard/app/components/AgentDetailView.tsx   | 102 ++++++++++++++++-----
 .../dashboard/app/components/CreateRoomModal.css   |  19 +++-
 .../dashboard/app/components/CreateRoomModal.tsx   |  57 ++++++++----
 .../dashboard/app/components/FloatingWindow.css    |  13 ++-
 .../dashboard/app/components/FloatingWindow.tsx    |  15 +++
 .../dashboard/app/components/GitHubImportModal.css |  11 +--
 .../dashboard/app/components/GitHubImportModal.tsx |  40 ++++++--
 .../dashboard/app/components/TaskDetailModal.css   |  52 +----------
 .../dashboard/app/components/TaskDetailModal.tsx   |  58 ++++++------
 .../__tests__/AgentDetailView.core.test.tsx        |   2 +-
 .../AgentDetailView.mobile-scroll.test.tsx         |   6 +-
 .../components/__tests__/CreateRoomModal.test.tsx  |  62 +++++++++++--
 .../components/__tests__/FloatingWindow.test.tsx   |   1 +
 .../__tests__/GitHubImportModal.test.tsx           |   8 +-
 ...etailModal.responsive-and-dependencies.test.tsx |  77 +++++++---------
 .../__tests__/modal-presentation-contract.test.tsx |  74 +++++++++++++++
 .../dashboard/app/hooks/useEmbeddedPresentation.ts |   2 +-
 .../dashboard/app/hooks/useModalResizePersist.ts   |   5 +
 23 files changed, 441 insertions(+), 221 deletions(-)

Fusion-Task-Id: FN-8621

Fusion-Task-Lineage: 04b6f3fe-d527-4a21-a0cb-489eb20f5e91

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 19:52:32 -07:00
gsxdsm
89b4621a93 FN-8607: migrate shared modals to FloatingWindow
Standardize agent, onboarding, and utility modal behavior on the shared FloatingWindow contract.

- Migrate modal hosts to shared geometry, sheet, dismissal, and focus behavior.
- Make FloatingWindow the sole owner of modal ARIA semantics and protect portal-safe dismissal surfaces.
- Cover migrated modal contracts and document the responsive sheet requirements.

Files changed:
 docs/dashboard-guide.md                            |   6 +-
 packages/dashboard/app/components/AgentGenerationModal.tsx        |   8 +-
 packages/dashboard/app/components/AgentImportModal.tsx  |   4 +-
 packages/dashboard/app/components/AgentListModal.tsx    |   4 +-
 packages/dashboard/app/components/AgentOnboardingModal.tsx        |   4 +-
 packages/dashboard/app/components/DockerNodeOnboardingModal.tsx   |   4 +-
 packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx |   4 +-
 packages/dashboard/app/components/FloatingWindow.css    |  36 ++++--
 packages/dashboard/app/components/FloatingWindow.tsx    |  12 ++
 packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx |   2 +-
 packages/dashboard/app/components/NativeShellOnboardingModal.tsx  |   2 +-
 packages/dashboard/app/components/SetupWizardModal.tsx  |   3 +-
 packages/dashboard/app/components/SubtaskBreakdownModal.tsx       |   2 +-
 packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx | 134 ++++++++++++++++++---
 packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx | 128 +++++++++++++++++---
 packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx | 124 +++++++++++++++++--
 packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx |  59 ++++++---
 packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx |  20 ++-
 18 files changed, 458 insertions(+), 98 deletions(-)

Fusion-Task-Id: FN-8607

Fusion-Task-Lineage: 11ba6a9c-ce89-4358-83c4-a15ef15b6128

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 18:57:02 -07:00
gsxdsm
ba24a530ef FN-8620: migrate dashboard modals to FloatingWindow
Standardize dashboard modal geometry and interaction behavior through the shared FloatingWindow contract.

- Migrate New Task, right-dock expansion, terminal, onboarding, and utility modals to FloatingWindow.
- Expand shared window resizing, positioning, accessibility, and contract coverage.
- Document the modal migration inventory and publish changesets.

Files changed:
 .changeset/fn-8607-floating-modal-contract.md      |   7 +
 .changeset/fn-8620-bespoke-geometry-modals.md      |   7 +
 docs/dashboard-guide.md                            |  47 ++
 docs/dashboard-modal-inventory.md                  |   6 +-
 .../app/components/AgentGenerationModal.tsx        |  14 +-
 .../dashboard/app/components/AgentImportModal.tsx  |   7 +-
 .../dashboard/app/components/AgentListModal.tsx    |   8 +-
 .../app/components/AgentOnboardingModal.tsx        |   6 +-
 .../app/components/DockerNodeOnboardingModal.tsx   |  14 +-
 .../ExperimentalAgentOnboardingModal.tsx           |   6 +-
 .../dashboard/app/components/FloatingWindow.css    |  21 +
 .../dashboard/app/components/FloatingWindow.tsx    |  62 ++-
 packages/dashboard/app/components/MailboxModal.tsx |  14 +-
 .../components/MilestoneSliceInterviewModal.tsx    |  12 +-
 .../app/components/NativeShellOnboardingModal.tsx  |   6 +-
 packages/dashboard/app/components/NewTaskModal.css | 196 +------
 packages/dashboard/app/components/NewTaskModal.tsx | 593 +++++++--------------
 packages/dashboard/app/components/RightDock.css    |  84 +--
 .../app/components/RightDockExpandModal.tsx        | 333 ++----------
 .../dashboard/app/components/SetupWizardModal.tsx  |  20 +-
 .../app/components/SubtaskBreakdownModal.tsx       |   6 +-
 .../dashboard/app/components/TerminalModal.css     | 120 +----
 .../dashboard/app/components/TerminalModal.tsx     | 321 ++---------
 .../__tests__/AgentModals.floatingWindow.test.tsx  |  26 +
 .../components/__tests__/FloatingWindow.test.tsx   |  27 +-
 .../app/components/__tests__/NewTaskModal.test.tsx |  66 +--
 .../OnboardingModals.floatingWindow.test.tsx       |  20 +
 .../app/components/__tests__/RightDock.test.tsx    |  18 +-
 .../components/__tests__/TerminalModal.test.tsx    |  61 +--
 .../UtilityModals.floatingWindow.test.tsx          |  18 +
 .../components/__tests__/migratedModalFixtures.tsx |  25 +
 .../__tests__/modalFloatingWindowContract.test.tsx |  30 ++
 32 files changed, 726 insertions(+), 1475 deletions(-)

Fusion-Task-Id: FN-8620
Fusion-Task-Lineage: 2a912260-3292-4b13-bcf0-9de5a3df8ccd
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 18:35:10 -07:00
gsxdsm
034827f251 FN-8623: restore CDP touch geometry test lane
Restore a dedicated Chromium CDP lane for dashboard touch-geometry coverage.

- Add an opt-in touch-geometry test command and isolated Vitest project.
- Keep the browser-dependent spec out of deep and quality backfill collection.
- Document browser discovery, port, and single-collection requirements.

Files changed:
 docs/testing.md                                    | 10 ++-
 packages/dashboard/package.json                    |  1 +
 .../__tests__/dashboard-test-config-guard.test.ts  | 71 +++++++++++++++++++++-
 .../task-modal-touch-resize-browser.test.ts        |  5 ++
 packages/dashboard/vitest.config.ts                | 28 ++++++++-
 scripts/lib/test-inventory-spec.json               |  3 +-
 6 files changed, 114 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-8623

Fusion-Task-Lineage: eafd7497-9302-49a4-8e9e-aa93c9f56a6f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 18:04:28 -07:00
gsxdsm
62e13f484b docs: index dashboard-modal-inventory.md in Audit Reports
FN-8617 committed docs/dashboard-modal-inventory.md as the canonical
classification of all 45 dashboard modal surfaces (classes A-D) with
file:line evidence and FloatingWindow migration targets, but the docs
index was not updated. Add the Audit Reports entry so the doc is
discoverable from the docs hub.

Verified: orphan scan clean (only known intentional orphans remain);
pnpm --filter @runfusion/fusion test:docs-index passes (2/2).
2026-07-26 17:20:56 -07:00