Commit Graph

627 Commits

Author SHA1 Message Date
gsxdsm
208c32c970 chore(fnxc): re-record the future-dates baseline after the UTC rollover (92 → 64 files) (#3278)
## What

Re-records the future-dates baseline after the UTC rollover. **92 → 64
file entries.** Tooling only, no source changes.

The gate's own instruction is *"If a count went DOWN, re-record the
baseline in the same commit."* UTC is now `2026-08-01`, so every stamp
dated `2026-08-01` is dated **today** rather than after it, and **28
files no longer carry any future-dated stamp at all.**

## Why pruning matters more than the number

Those 28 files kept a non-zero allowance they no longer need, and **an
allowance is a hole a new violation can hide in.** Four separate commits
landed a future-dated stamp last evening — in that environment, a stale
allowance on a file is exactly where the fifth would go unnoticed. With
the entries pruned, the next one in any of those files is caught on the
first run instead of being absorbed silently.

This is the same direction as #3168 (*"tightens the allowance 1 → 0"*)
and #3211, just triggered by the clock rather than by a fix.

## What this is not

- **Not a correction to anyone's stamp** — no source file is touched.
- **Not a loosening** — no entry increases and no file is added.
- The **123 stamps still dated beyond today** (e.g. `2026-08-06`) keep
their existing allowances untouched.

```
baseline file entries   92 -> 64
check-fnxc-future-dates rc=0
lint                    clean
```

## Related, deliberately not included

`scheduler.ts:2258` carries `FNXC:WorkflowScheduling 2026-08-01-01:05` —
dated today, one hour ahead of the current clock. I repointed it while
preparing this change and then reverted: after the rollover it is no
longer a gate violation, and mixing a cosmetic timestamp edit into a
baseline re-record would make both harder to review. Noting it so the
residual I flagged when closing #3270 does not get lost — it is now an
accuracy nit rather than a gate concern.
2026-07-31 17:14:53 -07:00
gsxdsm
79b08a2e99 gate(census): say WHY role and status are not backlog, where the numbers print (#3275)
## The hazard

The column backlog is **0**. The two largest numbers the census prints
are now `ROLE (12)` and `STATUS (185)`, sitting directly beneath it,
labelled only `(not guards)`.

That is a verdict with no reason. For a worker under a directive to
drive a census down — finding the backlog line already at zero and two
bigger numbers underneath — "(not guards)" is thin protection. This PR
puts the reason where the numbers are.

## Why they are genuinely not backlog

Both classify by **receiver**, not by the literal
(`ROLE_RECEIVER_TOKENS` = `role, agentType, agent, lane, capability,
sessionPurpose, surface, purpose, agentRole`; status matches
`/status/i`). A legacy column id next to one of those is a different
domain that happens to share vocabulary with the old board. Sampled from
the current tree, not reasoned:

| site | receiver | what it actually is |
| --- | --- | --- |
| `packages/cli/src/commands/task.ts:529` | `outcome === "archived"` | a
task **outcome** |
| `.../routes/register-chat-routes.ts:894` | `type === "done"` | a chat
**message type** |
| `.../cli-agent/telemetry-hub.ts:304` | `kind === "done"` | a telemetry
**kind** |
| `packages/cli/src/commands/goals.ts:178` | `status === "archived"` | a
**goal's** status |
| `packages/cli/src/commands/mission.ts:145` | `status ===
"in-progress"` | a **mission's** status |

None is a task column, so none has a workflow lane to resolve against.
Converting a goal's `status === "archived"` to a column trait would not
remove a legacy id — **it asks the wrong object for a lane it does not
have**, and the resulting bug would be invisible on the default board
for precisely the reason every inert conversion is. That is 185
opportunities to inject a real defect while a number goes down.

## Output-only, verified

Counts, JSON, baseline comparison and exit codes are untouched:

```
--strict exit=0
json totals: {"column": 0, "role": 12, "status": 185, "deliberate": 150}   # byte-identical
```

60 census tests pass (`lifecycle-column-census.test.ts`,
`census-reclassification-message.test.ts`). `lifecycle-columns`,
`move-target-literals`, `inert-sync-lanes`, `quarantine-ledger` all exit
0.

## Provenance

I raised "role/status have no inertness proof behind them" several times
as a reason not to touch them, which was too weak — it implied the work
might be valid pending proof. Rather than leave that hanging I went and
looked. They are not unproven conversions; they are **not conversions at
all**. Correcting my own earlier framing, and putting the finding where
the next person will hit it instead of in a report they will not read.

No changeset — internal tooling.
2026-07-31 17:12:10 -07:00
gsxdsm
78d411cfe2 fix: main is RED on two gates — record the new fallback, repoint six future-dated stamps (#3261)
`9094d1640e` (globalPause gates every graph node entry) reddened **two**
lifecycle gates on main. Both are fixed here, in separate commits.

## 1. The census ratchet went 0 → 2

`isTerminalColumnTask` in `scheduler.ts`:

```ts
const flags = columnFlagsForTask(task);
if (flags) return flags.complete === true || flags.archived === true;
return task.column === "done" || task.column === "archived";   // ← counted
```

**The code is correct.** It resolves traits first and falls back only
when the workflow is unreadable. The census counts fallback literals on
purpose — *"a fallback literal is still a literal and should go when the
trait path becomes unconditional"* — and reports them beside the backlog
as already-converted. Its own remedy for a legitimate one is a
`DELIBERATE-LITERAL` marker at the site.

Recorded rather than converted because **there is nothing to convert
to**: a task whose workflow cannot be read has no resolved lane, and
treating it as non-terminal would count a finished card's retained
worktree against live capacity — the opposite of what the surrounding
fix does.

Marker sits in the declaration's **leading** comments; an inline one
attaches to the wrong node and is silently ignored, which cost a
miscount once before. Baseline re-recorded in the same commit, since the
census tracks deliberate counts and reports a marker addition as
`RECLASSIFIED`.

## 2. The stamp gate was red as well

Six files stamped `2026-08-01-00:2x` while UTC was `2026-07-31`:

```
workflow-column-boundary.ts             2      workflow-graph-task-runner.ts   1
workflow-column-boundary-hooks.ts       1      in-process-runtime.ts           5 (allows 4)
workflow-column-boundary-capacity.test  1
```

This checkout is UTC-7, so "just after midnight local" is tomorrow in
UTC — the case AGENTS.md documents, which passes `pnpm lint` locally
*because* the local clock agrees with what was written. Second
occurrence today; I fixed the same shape on #3208 for another worker.

Repointed to `2026-07-31-22:2x`, preserving relative order. **Zero
non-comment lines changed** — 8 lines across 6 files, verified by
diffing out FNXC lines.

## Measured

| check | before | after |
|---|---|---|
| `census --strict` | **1** | **0** |
| backlog | **2** | **0** (DELIBERATE-LITERAL 148 → 150) |
| `check-fnxc-future-dates` | **1** | **0** |
| `pnpm test:gate` | 0 | 0 |
| `census-reclassification-message` | 2 failed | **1 failed** |

That last row is deliberate: the remaining failure is the
expired-premise case #3260 fixes, and I have not touched it. The
capacity test from `9094d1640e` still passes 9/9.

## Why this landed at all

Both gates run in `pr-checks.yml`, so a PR carrying either would have
gone red. Worth someone checking how it merged — a stale merge base
would explain it, and if so the same hole is open for the next merge.
2026-07-31 16:29:37 -07:00
gsxdsm
1660b136d7 fix(gate): the move-target ratchet could not see a file until it was committed (#3256)
## What

**#3254 fixed this blind spot in the census. It was still present
here.** Found by re-running that probe against the other four lifecycle
gates. No product change.

`git ls-files` lists **tracked** files only, so a brand-new file
containing `moveTask(id, "done")` scored **0** locally and flipped the
ratchet the moment it was staged. The author sees a green gate, commits,
and CI disagrees — the worst possible feedback order.

It is also the exact shape that made my own first census probe measure
nothing while reading as "no gap", which is how the class was found in
the first place.

Fixed the way #3254 did — `--cached --others --exclude-standard` — plus
a dedupe, because a path can appear under **both** flags in some index
states and would otherwise count twice against a baseline expecting one.

## Measured

```
untracked probe:            0 detected before  ->  1 after
--strict on a clean tree:   green before and after   (no false positives)
lint clean; fnxc-future-dates: none added
```

## The other gates, measured in the same pass

| gate | sees untracked files? |
|---|---|
| `lifecycle-column-census` | ✅ since #3254 |
| `check-sql-column-literals` | ✅ already |
| `check-inert-sync-lane-conversions` | ✅ already — walks the filesystem
with `readdirSync` |
| `check-lane-wiring` | n/a — does not use `ls-files` |
| `check-move-target-literals` | ❌ → **fixed here** |

This was the last gate with the gap. All five now agree about what a
file is.

## Correction to #3250

I wrote there that this script *"has no export seam and runs at
import"*, and used that to justify shipping without a unit test. **It
does have a seam** — an `isEntryPoint` guard — so a test could import it
without triggering the scan.

That does not change #3250's conclusion (its revert-proof measurement
stands on its own), but the stated reason was wrong, and it was wrong in
the direction that excused less testing. Correcting it here rather than
leaving it as precedent.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Local source-file checks now include newly created and untracked
files.
* Duplicate file entries are removed when files appear in multiple Git
states.
  * Existing tracked-file and CI scanning behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 15:45:36 -07:00
gsxdsm
b7c7977c09 fix(gate): catch cast move-targets, keep ?? fallbacks unflagged, and pin all of it (#3253)
Follow-up to #3250. Two changes, and **one of them is a decision not to
widen** — which is the part I would review first.

## Caught now: the cast form

```ts
moveTask(id, "done" as ColumnId)   // was invisible
```

Columns are typed `ColumnId`, so a cast is the **natural** spelling
wherever the parameter is nominally typed. The gate was weakest exactly
where this codebase is most likely to write a literal.
Cast-wrapping-a-ternary is caught too.

## Deliberately NOT caught: `??` / `||` / `&&`

I recommended these arms on #3250. **I was wrong, and the tree proved
it.** Adding them flagged:

```ts
moveTask(id, (await resolveTaskLifecycleColumns(store, id))?.complete ?? "done", ...)
```

That is the fail-soft idiom this entire programme rests on — resolve,
fall back to the legacy id when the workflow is unreadable, exactly as
the role helpers degrade. It is the **correct** pattern.

A gate that demands a `DELIBERATE-LITERAL` marker on every safe fallback
teaches people to add markers by habit, and a habitual marker is how the
next real literal walks straight through. So: a legacy id **after** `??`
is the safe shape and stays unflagged; a legacy id as the **whole**
destination is caught. Backed out, with the reasoning at the site so
nobody re-adds it.

If you disagree, the counter-argument is that a fallback could mask a
lane that should have resolved — but that wants its own report, not this
ratchet's exit code.

## Pinned — the gate had no tests at all

Fifth spelling missed across three rounds, and every earlier probe was
run by hand and thrown away, because the scanner executed on import.
Commit 1 makes it importable (behaviour-preserving, `--strict`
identical); commit 2 adds **12 tests in both directions**:

| catches | does not catch |
|---|---|
| direct, backtick, ternary, nested ternary, parenthesised, cast,
cast-over-ternary | `??` fallback, `\|\|` fallback, resolved
destination, substituted template |

The negatives are load-bearing, not padding — four of them encode false
positives that either shipped or arrived while widening.

## The root cause, recorded in the test header

**The destination is a POSITION; every fix so far has enumerated NODE
KINDS.** A kind list is something the language extends faster than we
guess — I started that pattern myself in #3246 by requiring
`arguments[1]` to *be* a literal. The durable defence is that each shape
someone finds stays found.

## Measured

| check | result |
|---|---|
| real tree | **0** targets, `--strict` exit 0 (the `??` false positive
is gone) |
| gate tests | **12 pass / 0 fail** |
| anti-vacuity | removing the cast arm **fails** the suite; restoring
passes |
| eslint / `check-fnxc-future-dates` | clean / 0 |

One test corrected itself during writing: I asserted `"drafting"`
extracts to `[]`, and it returns `["drafting"]` — legacy filtering is
the caller's job. Kept as a test of that split, since folding the
vocabulary into the extractor would force every future shape to thread
the legacy list.
2026-07-31 15:35:08 -07:00
gsxdsm
78d5efbcaf gate: the census could not see a file until it was committed (#3254)
## Why I went looking

The fleet directive is to claim the largest census file cluster. There
is no cluster — the backlog is **0 guards / 0 files**. So the useful
question is whether that 0 is *true*, since the whole phase steers by
it. I had just found a blind spot in my own ratchet (#3252), so I probed
this one the same way.

## What the probes found

A plain, unremarkable guard in a new file scored **zero**:

```ts
// packages/engine/src/probe-helper.ts
export function g(task: { column: string }): boolean {
  return task.column === "in-review";
}
```

Not a cast, not an obfuscation — the exact canonical shape the census
exists to count. It scored 0 in six different directories, and it scored
0 with every cast variant too, which is what initially made this look
like a repeat of #3252.

It is not. The same guard pasted into `scheduler.ts` counted immediately
(0 → 2 with two probes, casts included). The census walks expressions
fine. The miss was **file discovery**: `git ls-files` lists **tracked
files only**, so the file did not exist as far as the census was
concerned. `git add` it and `--strict` goes to exit 1 on the spot.

## What this does and does not mean

**It does not mean the backlog number is wrong.** Everything on `main`
is committed, so CI has always seen the whole tree, and I re-confirmed
the committed totals are unchanged by this PR: `{"column": 0, "role":
12, "status": 185, "deliberate": 148}`. **Backlog 0 is real.** I want
that stated plainly rather than buried, because "ratchet has a hole"
invites the opposite reading.

**What it does mean** is that the census was blind at the one moment
anyone actually consults it. A worker adds a helper, runs the census
against their own work, reads 0, commits — and the guard lands,
attributed to a push rather than to the edit that introduced it. The
instrument was answering about the last commit while being asked about
the working tree.

## The fix

`--cached --others --exclude-standard`, plus a dedupe (a path can appear
under both flags in some index states, which would double every guard in
that file).

| case | before | after |
| --- | --- | --- |
| untracked new file with a guard | 0 | **1** |
| same file, staged | 1 | 1 (dedupe holds — not 2) |
| ignored path (`dist/`) | 0 | 0 (build output still excluded) |
| committed tree | 0 | 0 (backlog unchanged) |

## The part worth keeping

This also **aligns the scope with `check-inert-sync-lane-conversions`**,
which walks the filesystem via `readdirSync` and so always saw untracked
files.

That mismatch is not cosmetic — it is what made #3252 expensive. The
same probe was *caught* by one instrument and *missed* by the other, and
I spent a full investigation treating that as a claim about expression
walking when part of it was two tools disagreeing about which files
exist. When instruments in one program disagree on their own domain,
every differential between them is unreadable until you notice.

## Verification

- Mutation-verified in both directions on all four cases above.
- 53 `lifecycle-column-census.test.ts` tests pass.
- All eight ratchets exit 0; `pnpm test:gate` exit 0.
- Working tree confirmed clean after every probe.

## What I did not do

I did not touch `role: 12` or `status: 185`. Those are different metrics
with no inertness proof behind them, and driving them down is a separate
unit that needs saying explicitly — a conversion there could be cosmetic
and nothing currently would catch it.
2026-07-31 15:34:57 -07:00
gsxdsm
cf4418e3db gate: a type assertion hid the sync source — seventh shape of one pattern (#3252)
## What this is

#3251 audits the five lifecycle ratchets with staged probes and claims a
gap in mine:

> `check-inert-sync-lane-conversions` — does NOT catch: a DIRECT
`store.resolveTaskWorkflowIrSync(...)` read feeding
`resolveLifecycleColumns`

I tested it rather than accepting it, and got a **split result**: a
probe inserted into the existing `executor.ts` was **caught** (19 → 20,
exit 1), refuting the row; a standalone probe file was **missed**
(stayed 19, exit 0), confirming it. Two probes of nominally the same
thing disagreeing means one of them is describing something else.

## The actual mechanism

Instrumenting a copy of the script ruled out the file-discovery
explanations: `scanned files: 1850 | probe in list: true`, and the
probe's function `isReview` was collected into the sources list. So the
file is scanned, the function is tracked, and the guard is still not
counted — the loss is downstream, in expression walking.

The one syntactic difference between the two probes was a cast.
Measured, holding everything else fixed:

| argument to `resolveLifecycleColumns(...)` | before | after |
| --- | --- | --- |
| `store.resolveTaskWorkflowIrSync(id)` | caught (20) | caught (20) |
| `store.resolveTaskWorkflowIrSync(id) as never` | **MISSED (19)** |
caught (20) |
| `store.resolveTaskWorkflowIrSync(id)!` | **MISSED (19)** | caught (20)
|
| `(store.resolveTaskWorkflowIrSync(id) as any)!` | **MISSED (19)** |
caught (20) |

So: the direct read **is** tracked. The **cast around it** was not.
`unwrapForSyncCall` unwrapped `await`, parentheses, conditionals,
binaries and (since #3181) call arguments — but stopped at `as`,
`satisfies`, `!` and angle-bracket assertions.

**Correction to #3251's row, not a rejection of it.** The gap is real
and reproducible; the stated cause ("a direct read is untracked") is not
the one operating. That distinction matters for anyone acting on the
table: fixing "track direct reads" would have changed nothing.

## The fix

One walker clause, alongside the existing `await`/parenthesized unwrap.
Real tree unchanged at **19 guards / 3 files, exit 0** — this adds no
backlog, it closes a blind spot.

## Why it is the same story a seventh time

Inline → membership → cross-module → wrapper argument → census
switch/includes → ternary destination → **type assertion**. Across three
different tools, the rewrite that hides a guard is the one that changes
its *syntactic category* without changing its meaning.

Type assertions are the purest case yet: `as`, `satisfies` and `!` are
**erased at runtime**. They cannot alter behaviour at all — they can
only alter visibility. A guard wearing one is byte-identical in outcome
to the same guard bare, and scores as absent.

## Verification

- Mutation-verified in both directions: with the fix reverted all three
cast forms read 19; with it applied all read 20.
- All eight ratchets exit 0: `inert-sync-lanes`, `lifecycle-columns`,
`fnxc-future-dates`, `quarantine-ledger`, `move-target-literals`,
`inert-flag-seams`, `lane-wiring`, `sql-column-literals`.
- `pnpm test:gate` exit 0 (which runs this script since #3136).
- Every probe removed; `git status` clean before each measurement.

## What I did not do

I did not re-audit the other four ratchets against cast-wrapped probes.
#3251's staged-probe method is the right instrument for that and it is
that author's file; if the same blind spot exists in the census or the
flag-seam checker, it will show up as a cast form scoring zero. Worth
one pass by whoever owns those.
2026-07-31 15:29:41 -07:00
gsxdsm
0b30eb4146 fix(gate): detect ternary move-target literals, which #3246's ratchet could not see (#3250)
## What

#3246 landed a gate holding `moveTask` legacy-literal destinations at
zero, printing **"POPULATION EMPTY … keep it empty."** I probed that
claim the way #3247 probed the census. It held for two spellings and not
a third.

| form | before | after |
|---|---|---|
| `moveTask(id, "done")` | ✅ | ✅ |
| `` moveTask(id, `todo`) `` | ✅ | ✅ |
| `moveTask(id, ok ? "done" : "in-review")` | ❌ **invisible** | ✅ |

The check required `arguments[1]` to *be* a literal. A ternary over two
lanes is a natural way to write exactly the destination this gate exists
to prevent — and per the gate's own header, a wrong target **throws** at
runtime rather than no-opping.

## Measured

```
real repo, before and after:  0 targets, --strict passes   (no false positives)
ternary probe:                0 on HEAD~1  ->  1 after
direct + backtick forms:      unchanged
DELIBERATE-LITERAL marker:    still suppresses (canonical placement)
lint clean; fnxc-future-dates: none added
```

## Two scoping decisions, both probed rather than assumed

**Not descending into `??` / `||`.** `moveTask(id, lanes.complete ??
"done")` is the documented degraded arm this program writes deliberately
— the shape the lifecycle census classifies as `traitFallback` rather
than backlog. Counting it would report correct code as debt. Measured at
0 both before and after.

**`const t = "archived"; moveTask(id, t)` is still undetected**, and the
comment says so at the site. Resolving it needs symbol/dataflow analysis
rather than a shape test, which is a different tool than this file is.
Flagged so the next person extends deliberately instead of assuming
coverage.

## No unit test, and why

The script has no export seam and executes at import, so testing it
means extracting one — a refactor of a one-commit-old file, which
belongs in its own change rather than folded into a behaviour fix. The
revert-proof is the measurement above: the ternary probe reads 0 against
`HEAD~1` and 1 against this commit.

## Note to #3246's author

I raised these gaps on your PR first and offered to send this rather
than assume. Two traps that cost me time on the census extension, in
case you take it further:

- **A count-unless-excluded rule backfires on this vocabulary.** My
first census extension counted `switch (x)` unless the receiver looked
like a role/status and reported 7 guards — 6 were `switch (eventName)` /
`switch (state)` / `switch (event)`, since event enums routinely carry
`case "done"`. Requiring a *positive* column signal was the fix.
- **Verify the probe file is git-tracked.** My first probe measured
nothing because the scanner enumerates tracked files; the scanned-file
count stayed flat and I nearly read that as "no gap."
2026-07-31 15:19:11 -07:00
gsxdsm
4e2f52ce8f feat(gate): ratchet move-target literals at zero — #3150's population had nothing holding it (#3246)
Closes the gap I flagged when re-measuring #3150: that population is at
**0**, and nothing was holding it there.

## Why this surface has no gate today

The lifecycle census parses **comparisons**. A move destination is a
call **argument**:

```ts
await store.moveTask(id, "in-review");   // never counted by anything
```

#3150 measured 31 of these across four files. They are now 0 — I
verified that on current main before writing this — but the comparison
backlog drifted **787 → 854** during the window its own ratchet was
unwired, and this population never had one.

## The failure mode is louder than the guards'

A wrong lane **guard** silently answers "no". A wrong move **target** is
rejected by `moveTaskInternal` with `TransitionRejectionError:
unknown-column` — so on a board that renamed its review lane, every task
finishing implementation **threw** instead of reaching review. Loud at
runtime, invisible to any test on the default board.

## AST, not grep — and that is measured, not stylistic

| scan | result |
|---|---|
| comment-naive grep of `self-healing.ts` | 1 hit — **JSDoc prose**: `*
could call moveTask("in-review")` |
| #3150's own SQL survey by grep | 37 hits against **12** real sites (25
comments) |

Comments are not AST nodes, so that false-positive class cannot occur
here in either direction.

## Controls — all four run, because a gate that only reports 0 proves
nothing

| probe | expected | got |
|---|---|---|
| real `moveTask(id, "in-review")` injected | fail | **exit 1**, names
the file |
| identical call as JSDoc prose | pass | **exit 0** (AST ignores
comments) |
| legacy target + leading `DELIBERATE-LITERAL` | pass | **exit 0**
(marker honored) |
| probe removed | pass | **exit 0** |

The third is the #1411 `recoveryRehome` safe-landing path, where the
legacy id genuinely *is* the target. Marker must be **leading** — the
census already learned that an inline marker attaches to the wrong node
and is silently ignored.

## Ratchet semantics match the census

Fails on a **drop** as well as a rise. A stale allowance is a hole a
re-added target can return through while the gate stays green — exactly
what let the comparison baseline drift.

## Measured

| check | result |
|---|---|
| this gate | scans **1816** files, reports **0** |
| `check:lifecycle-columns` / `check:sql-column-literals` | 0 / 0 |
| `check:fnxc-future-dates` / `check:lane-wiring` | 0 / 0 |
| eslint / `pnpm test:gate` | clean / exit 0 |

Wired into `pr-checks.yml` beside the sibling ratchets, named to match
("Move-target ratchet").

## Scope

Gate only — **no production code touched**, and no conversions in this
PR. The population was already empty; this makes "31 → 0" an invariant
instead of a snapshot, which is the caveat I attached when recommending
#3150 for closure.

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

* **Quality Improvements**
  * Added automated validation for task-movement configuration values.
  * Pull request checks now detect unexpected changes in tracked values.
* Added baseline tracking with strict validation to identify both
additions and removals.
  * Added support for explicitly documenting intentional exceptions.
  * Improved reporting for file-discovery and source-reading failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:56:07 -07:00
gsxdsm
301bd8ed1e fix(census): detect membership and switch column guards, which could land silently (#3247)
## What

The census prints **"a new guard cannot land silently"** next to a zero.
That claim was true only for the guard form it happened to parse. This
closes the two it could not see. No product change.

The comparison walk visits `BinaryExpression` only, so neither of these
was visible:

```ts
["done", "archived"].includes(task.column)
switch (task.column) { case "todo": ... }
```

Both are lifecycle-column guards by any reading.

## How I found it

By applying this program's own rule — **break the guard on purpose** —
to the guard itself. I staged a probe file with five guard forms and
measured which moved the count:

| form | counted before |
|---|---|
| `t.column === "todo"` | ✅ |
| `t.column !== "in-review"` | ✅ |
| `["done","archived"].includes(t.column)` | ❌ |
| `switch (t.column) { case "triage": }` | ❌ |
| SQL string `"column" = 'done'` | ❌ (separate gate owns this) |

A worker converting a `===` chain into an array membership would have
scored the conversion **and kept the guard**.

*(The first probe run was itself invalid — the file was untracked and
the census enumerates git-tracked files, so the scanned count stayed at
1961 and nothing was measured. Staging it moved the scan to 1962.
Checking the scanned count is what caught that.)*

## The near-miss worth reading

My first implementation counted **unless** the receiver looked like a
role or status — mirroring the `===` walk. On the real tree it reported
**7 column guards**, and I nearly published that as a hidden backlog.

Six were false: `switch (eventName)`, `switch (state)`, `switch (event)`
— event and state enums routinely carry `case "done"` / `case
"archived"`. Landing it would have injected six phantom guards into a
backlog the ratchet treats as zero, and `--strict` would then have
**failed every other worker's PR**.

So the new walks require a **positive** column signal instead. That
regression is pinned by a test asserting all three receivers stay
uncounted.

## Measured

```
real repo, before and after:  COLUMN guards 0, STATUS 185   (no false positives)
staged probe:                 2 detected before -> 4 after
new tests:                    6/6 pass; 3 FAIL with the extension reverted
existing lifecycle-census test: 9/9 still green
lint clean; census --strict passes; fnxc-future-dates: none added
```

## Known limit, stated rather than left to be discovered

The positive signal is the receiver **name**, so `switch (column.id)` —
a `Column` object rather than a task's column — is **not** counted. That
is a real guard shape and it is deliberately out of scope: widening to
reach it is exactly what produced the six false positives, so it needs
its own discrimination rather than a looser regex. Flagged here so the
next person extends it deliberately instead of assuming coverage.

## Why this and not another conversion PR

The conversion queue has been genuinely empty for several cycles —
census 0, 116 resolver sites unchanged across four commits, every site
blinded and pinned. The remaining risk in this program was never another
literal; it was that **the instrument defining "done" could not see two
of the shapes it claims to protect against**. A zero from a detector
with blind spots is the exact failure this phase has spent its time
documenting.


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

## Summary by CodeRabbit

* **New Features**
* Added lifecycle-column guard detection for array membership checks and
`switch` cases.
* Recognizes supported column receiver names and classifies findings
consistently with existing guards.
* Ignores status, event, and state receivers, and avoids duplicate trait
fallback findings.

* **Tests**
* Added coverage for membership checks, `indexOf`, `switch` guards, and
deliberate-literal suppression.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 14:48:20 -07:00
gsxdsm
5f6f39e115 fix(census): the scan root and the READ root could disagree, so an injected file list ENOENTs (#3230)
Picks up the bug **@#3228's author diagnosed and deliberately left
documented** rather than guessing at it mid-revision. Their diagnosis
was correct; the bug is mine, from extracting `triageFindings` in #3207
without considering an injected file list.

## The defect

`REPO_ROOT` came from the **script's** location; the file list comes
from `git ls-files` in the **CWD**. Identical in production and nowhere
else. Override the list — which a synthetic-tree fixture must do — and
every path is *listed* against the fixture but *read* against the repo:
`ENOENT` on every read.

## There were THREE read roots, not one

That is why a partial fix still ENOENTs, and I hit it myself: I fixed
`REPO_ROOT`, re-ran, and still got `ENOENT: open 'pkg/src/a.ts'`. The
scanners read the path **as given**:

| consumer | read root before |
|---|---|
| `triageFindings` | `join(REPO_ROOT, …)` |
| sync-resolver probe | `join(REPO_ROOT, …)` |
| `censusFiles` (AST) | path as given → CWD |
| `censusFilesText` | path as given → CWD |

All four now go through a single `readCensusFile`, so a listed path and
a read path cannot diverge again. **No lib change needed** — both
scanners already accept an injectable reader, which is the seam that
made this a small fix.

## What it unblocks

The two ratchet cases #3228 records as *permanently* vacuous at zero
backlog. On a three-file synthetic tree the full cycle is constructible
again:

```
inflated baseline  -> exit 0   TIGHTENED
deflated baseline  -> exit 1   ROSE
```

Neither is constructible against a real tree with nothing left to count
— which is exactly why that coverage was lost when the backlog hit zero,
and why `-1` (my #3218) and skip-at-zero (#3226) were both workarounds
for a missing seam rather than fixes.

## Measured

| | result |
|---|---|
| production scan | **unchanged** — 1961 files, 0 guards, `BACKLOG ZERO`
|
| `--strict` / `--json` / `--compare` | 0 / 0 / **0** (AST and text
classifiers still agree) |
| synthetic fixture | 3 files, **1 backlog / 1 deliberate** — the
numbers #3228 predicted |
| census suite | 53 passed |
| eslint / `check-fnxc-future-dates` / `pnpm test:gate` | clean / 0 / 0
(744 tests) |

`--compare` is the one I would look at first as a reviewer: it runs both
classifiers over the same list and fails if they disagree, so it catches
a reader change that silently alters what either one sees.

## Scope

Seams only — `FUSION_CENSUS_FILE_ROOT` and `FUSION_CENSUS_FILE_LIST`,
both required together (a root with no list still scans the real tree; a
list with no root still reads from it). Production sets neither.

I have **not** rewritten the two vacuous cases. That is #3228's work,
they already have two of six green, and duplicating it is how this fleet
loses PRs to collisions. This just removes the blocker.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved repository analysis reliability when run against configured
file sets or alternate repository locations.
* Prevented analysis from unintentionally reading unrelated files
outside the selected repository context.
  * Existing production behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 12:49:54 -07:00
gsxdsm
cfcbba6f81 fix(census): 4 RED ratchet tests on main, and the report said nothing at zero (#3218)
Two problems, both caused by the backlog actually shrinking.

## 1. Four failing tests on main

**Pre-existing, not introduced here** — running this file on clean
`origin/main` gives `49 passed / 4 failed` with identical messages. I
checked that before touching anything, because the failures surfaced
while I was editing the same file.

The ratchet cases build their fixture like this:

```ts
Object.entries(baseline.byFile).find(([, c]) => c > 1)   // needs a file with MORE THAN ONE guard
```

After the tail reclassification no such entry exists. `find` returns
undefined → `byFile[undefined] = NaN` → the baseline is corrupt → every
case fails with `expected … to contain 'TIGHTENED'`, a message that
points squarely at the CLI when the **fixture** is at fault. That
misdirection is why this sat red.

The ratchet doesn't care *which* file it tightens, only that an
allowance exceeds the measured count. So `inflate` now takes any entry,
and synthesises one against a real scanned file when the backlog is
empty.

`deflate` is the harder half: a RISE needs an allowance **below** the
real count, and once every measured count is 0 the only value below is
negative. The empty case uses `-1`. That is not a realistic baseline
value and the comment says so — it is the sole way to exercise the
`measured > allowed` comparison against a tree with nothing left to
count, which is the tree this suite now runs on.

Same class as the unbounded-slice rot in #3207: **census self-tests
coupled to the size of a shrinking backlog.** That is now twice, so it
is a pattern rather than an accident.

## 2. The report went silent at the finish line

The verdict was two inline branches and neither fired at zero —
`CONVERSION QUEUE EMPTY` required `totals.column > 0`. So the one state
the entire fleet phase was working toward printed **nothing**, which
reads as a broken scan rather than the protected end state.

Extracted to a pure `describeBacklogState({ columnGuards,
unexaminedGuards })` returning lines, so the caller stays a dumb
printer:

```
BACKLOG ZERO: no lifecycle-column guard remains.
This is the protected end state, not an empty scan — `--strict` fails on any RISE, so a new
guard cannot land silently. Use the role helpers (resolveLifecycleColumns / columnHasRole).
```

Pure **specifically** so the zero state is testable before the tree
reaches zero. While it was inline, only the *current* backlog state was
observable — and a message nobody can test before they need it is the
one that is wrong when they do.

## Evidence

| check | result |
|---|---|
| census test file | **53 passed** (was 49 passed / 4 failed) |
| behaviour on today's tree | **unchanged** — identical `CONVERSION
QUEUE EMPTY` block |
| empty-baseline probe | exits 1, `column-guard count ROSE` |
| forced zero verdict | prints `BACKLOG ZERO … not an empty scan` |
| `--strict` / `check-fnxc-future-dates` / eslint | 0 / 0 / clean |
| `pnpm test:gate` | exit 0 (744 tests) |

Four new tests pin all three states, including that the unexamined
branch must **not** claim the queue is empty while real work is
outstanding.

## Census

No guard converted — this is tooling and test repair. Backlog unchanged
at 1, which #3215 takes to 0.
2026-07-31 11:50:14 -07:00
gsxdsm
0bdc9bf4fb fix(dashboard): archived tasks stayed in the research picker on a renamed board (#3215)
## The defect

The enrich-mode task picker filtered with `task.column !== "archived"`.
On a board whose archive lane is renamed, that matched nothing — so
filed-away tasks stayed in the picker and an operator could attach
research findings to work they had deliberately archived.

## Census before / after

| | before | after |
|---|---|---|
| COLUMN guards (backlog) | 10 | **9** |
| `ResearchTaskActionModal.tsx` | 1 | **0 — converted** |

Baseline re-recorded in the same commit; `--strict` green.

## This site was declined twice, and I wrote the second wrong estimate

#3213 left it counted, correctly, on the note that was here — which was
mine. Both prior cost estimates were wrong, so this corrects my own
work:

1. **"Needs a data-fetch change"** — reasoned about
`columnFlagsByTaskId`, a per-**task** map built from board-resident
rows. Right that such a map can't help (archived rows are exactly what a
board map omits), but this guard asks a per-**column** question, so it
never needed one.
2. **"Needs prop threading, MainContent → ResearchView → here"** — right
that the answer is column-keyed, wrong about where it lives. `ListView`
builds `columnFlagsById` *inline*, which made it look like the owner.
The data is `useBoardWorkflows`, a hook already called from `App`,
`Board`, and `HeaderWorkflowSwitcherSlot`.

**Measured cost: one file.** The modal already takes `projectId`, and
`ResearchView` renders it only when a finding is open (`open` hardcoded
beside `if (!finding) return null`) — so the hook cannot fetch for a
closed modal, which was the one real objection to calling it here.

Union across workflows keyed by column id, first declaration wins — the
same convention `ListView` uses, so the two cannot disagree about a
shared id. `isArchivedColumnRole` fail-softs to the legacy id when a
column has no flags, so an unresolved workflow behaves exactly as the
literal did.

## Tests — the invariant, not the repro

Per the surface-enumeration rule, four cases: renamed archive lane,
legacy id, unresolved workflow (fail-soft), and a second workflow's
archive lane through the cross-workflow union. A repro-only test would
pass on the legacy board and prove nothing about the case the guard
exists for.

**Anti-vacuity control:**

| | renamed lane | union | legacy id | fail-soft |
|---|---|---|---|---|
| pre-fix literal | **FAIL** | **FAIL** | pass | pass |
| converted | pass | pass | pass | pass |

The legacy and fail-soft cases hold in both directions **on purpose** —
they pin that this conversion did not change the pre-resolution answer.
Flagging that so 4/4 isn't read as four independent proofs.

## Measured

| check | result |
|---|---|
| `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 |
| `eslint` | clean |
| `tsc -p tsconfig.app.json` (the config that actually covers `app/`) |
exit 0 |
| new tests | 4/4 |
| `pnpm test:gate` | exit 0 (744 tests) |

## Note on process

My first attempt at the control silently did nothing — the revert script
threw a `SyntaxError`, so the "pre-fix" run was the fixed code and
reported 4/4. Caught it because the error printed. The table above is
from the re-run.
2026-07-31 11:42:15 -07:00
gsxdsm
c66b434b7b fix(self-healing): a renamed hold lane re-logged the same overlap blocker on every sweep (#3216)
## The defect

`clearStaleBlockedBy` keeps a per-task memo of which overlap blocker it
already logged, so a sweep running every few seconds doesn't repeat the
same line forever. The memo was retained only while the card sat in a
column matching the literal `todo` — so on a renamed board it was
dropped on **every** sweep and `still blocked by file scope overlap with
<id>` was re-logged each time.

## Census before / after

| | before | after |
|---|---|---|
| COLUMN guards (backlog) | 9 | **8** |
| `packages/engine/src/self-healing.ts` | 1 | **0 — converted** |

Baseline re-recorded in the same commit; `--strict` green. (Counts
follow #3215, which took 10 → 9.)

## The stated blocker was not real

The note here declined the conversion because the lane prefetch is keyed
on `candidates`, *"which this closure helps build"*. Measured — it does
not:

```
6033|  for (const task of blockedTasks) candidates.set(task.id, task);
6034|  for (const task of queuedDependencyTasks) candidates.set(task.id, task);
6036|  for (const [taskId, lastLoggedBlockerId] of this.preservedQueuedOverlapLogged) {   <- only CLEARS memos
```

`candidates` is fully populated two statements earlier, and this loop
only clears memo entries. So the prefetch was hoistable; it now sits
above the loop. That is a pure move of a read-only computation with no
conditional between the two positions.

Reaching the lane clause already proves the id is a candidate —
`!candidates.has(taskId)` is the first arm of the same `||` chain, so
short-circuit means the lane question is only asked for ids the prefetch
covered (`referencedIds.add(task.id)` runs for every candidate).
`lanesOf` still falls back to the legacy set, so an unresolvable
workflow answers exactly as the literal did.

This is the second inherited "too expensive" estimate to fail on
inspection this session (see #3215). Both were written in good faith and
both were checkable in a few minutes.

## One thing typecheck caught that review would not have

`memoTask?.column !== "todo"` was **also** the undefined check, and tsc
narrowed the later clauses on it. Replacing it without that arm compiled
clean to the eye but broke narrowing — `TS18048: 'memoTask' is possibly
'undefined'` on the next line. `|| !memoTask` is now explicit rather
than implied.

## Evidence

The test drives the sweep **twice**, because a single pass cannot
observe a dedup memo at all.

| | pre-fix literal | converted |
|---|---|---|
| `still blocked by file scope overlap` log lines | **2 — FAILS** | **1
— passes** |

Failure message against the pre-fix code: `expected [ [ 'FN-DEPENDENT',
…(1) ], …(1) ] to have a length of 1 but got 2`.

Worth correcting the record: the note called the cost *"a duplicate log
line, not a wrong lifecycle decision"*. The lifecycle half is right —
but it is a duplicate on **every sweep**, so it is recurring log spam,
not a one-off. That is a bigger cost than the note implies, though still
not a correctness bug.

| check | result |
|---|---|
| `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 |
| `eslint` / engine `tsc --noEmit` | clean / exit 0 |
| self-healing + overlap suites | 15 / 21 / 6 passed |
| `pnpm test:gate` | exit 0 (744 tests) |

Reused the existing `RENAMED_BOARD_IR` harness in that file rather than
building a new one.

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

## Summary by CodeRabbit

- **Bug Fixes**
- Improved cleanup of stale workflow blockers, including renamed
workflow lanes.
  - Prevented duplicate overlap warnings during repeated cleanup.
- More reliably preserves valid queued overlaps while ignoring missing
or inactive tasks.

- **Tests**
- Added regression coverage for repeated stale-blocker cleanup and
duplicate warning prevention.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 11:29:25 -07:00
gsxdsm
aa1655ccd9 fleet: reclassify the census tail — 10 → 2 guards, all reasoning already in the code (#3213)
## Census before / after

```
                        before   after
COLUMN guards (backlog)     10       2
DELIBERATE-LITERAL         138     148
```

Baseline re-recorded in the same commit; `--strict` green.

## This converts nothing — the tail was never backlog

All ten remaining guards already carried an explicit in-code decision.
**None carried the `DELIBERATE-LITERAL` marker the census reads**, so
each re-appeared to every fleet pass as if unexamined. That is the whole
defect this fixes.

| site | the reasoning already at the site |
| --- | --- |
| `audit-ops.ts`, `moves.ts` | the degraded fallback arm of an
**already-converted** site; the live arm uses the resolved lane set |
| `scheduler.ts` ×2 | *"LEFT COUNTED"* — an await behind the
`tracked.has` re-entrance guard lets two updates double-start a monitor;
the sibling is the measured-expensive `task:updated` emit path (26 sites
against 7) |
| `notification-service.ts` | this method and its only caller are
**sync**, reached from a listener the store invokes as `(task: Task):
void`; resolving makes the chain async and reorders notification
classification against every other `task:updated` handler |
| `lifecycle-ops.ts` | *"Recorded rather than converted"* — dead code |
| `task-id-integrity.ts` | sync, no store-scoped read; converting alone
would disagree with `getLiveTaskColumn` |
| `triage.ts` | *"LEFT COUNTED until then"* — wants a non-sync-resolved
lane answer |

## Marker placement is load-bearing, and I got it wrong twice

The census reads a node's **leading** comments. A marker in a nearby
block comment attaches to the wrong node and is **silently ignored** —
it reads as reviewed while the count still lists the site.

- `task-id-integrity.ts` — my first marker went into the block comment
above the `const`; the literal is in the `return`. Count stayed at 1
until I moved it.
- `ResearchTaskActionModal.tsx` — marker added, **measured that it did
not register**, reverted.

Every edit was verified by re-running the census, not assumed. That is
the only reason the count actually moved.

## Two sites deliberately left counted

- **`ResearchTaskActionModal.tsx`** — the literal sits mid-expression
inside a `.then()` chain, so no marker can attach. The census's own
guidance is to hoist it into a named helper; the site's note asks for
that to be someone's deliberate change rather than a drive-by, so it
stays counted and honest.
- **`self-healing.ts`** — the memo closure I converted and reverted in
#3049. Its note: a renamed board costs a duplicate log line, not a wrong
lifecycle decision.

## Correction I owe on the measurement itself

For many turns I reported "zero unclaimed guards". That came from a bug
in **my own** query — `byFile` is an array of `[file, count]` pairs and
I had switched to `Object.entries()`, which yields `[index, pair]`, so
`n > 0` was always false and the filter returned zero regardless of
state. It agreed with reality while open PRs held every file, which is
why it went unnoticed; it was still wrong, and a constant zero against a
falling backlog should have prompted me to check it sooner.

## Verification (measured)

- engine `self-healing` + `scheduler` suites — **1003 passed / 56
files**
- core `task-id` / `moves` suites — green
- `tsc --noEmit` clean in core, engine and dashboard; `eslint` clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-fnxc-future-dates` — green

No changeset: `@fusion/core`, `@fusion/engine` and `@fusion/dashboard`
are private, and no runtime behaviour changes.


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

## Summary by CodeRabbit

* **Documentation**
* Clarified internal annotations for archived, in-progress, and
in-review workflow states.
* Documented fallback behavior and timing safeguards across lifecycle,
scheduling, notification, and triage flows.

* **Chores**
* Updated internal lifecycle tracking baselines to reflect current
annotations and state coverage.

* **Bug Fixes**
  * No user-visible behavior changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 11:15:40 -07:00
gsxdsm
8d393422ac chore(fnxc): tighten the future-dates baseline — merge-queue-ops-2 4 -> 3 (#3211)
One-line baseline tightening, produced by the gate's own auto-tighten
path.

`check-fnxc-future-dates` deliberately auto-tightens rather than failing
on a drop, because its population moves with the calendar and a drop has
**no author** — the counterpart asymmetry to
`check-inert-sync-lane-conversions`, where a drop *does* have an author
and must fail. Any gate run regenerates this; `main`'s committed
baseline had simply not caught up.

**Why this isn't churn:** left loose, the baseline permits 4 future
stamps in a file that now has 3. That slack silently absorbs one genuine
future-dated stamp — precisely the failure this gate exists to catch,
and one the fleet hit four times in a single day (`scheduler.ts`, a
scheduler PG test, `task-update.ts` twice by different authors), each a
real time on the wrong day that passed locally and reddened `main` for
everyone else.

Verified: both `check-fnxc-future-dates` and
`check-inert-sync-lane-conversions` green on the tightened baseline.

No changeset: tooling baseline, no published-package surface.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:47:03 -07:00
gsxdsm
215f09d88f fix(census): the bare command could not say the conversion queue is EMPTY — and a test fix for main (#3207)
## Why this exists

The fleet instruction is *"claim the largest unclaimed census file
cluster (`node scripts/lifecycle-column-census.mjs`)"*. That command
cannot answer it. The availability verdict lived **only** behind
`--claims`, which shells to `gh`:

```
line 342:  if (claims && !json) {
```

So a worker following the instruction literally sees per-file counts,
reads a nonzero backlog as a work queue, and picks a file whose guard is
already documented as deferred. Counts alone cannot separate *work left*
from *debt left*.

**Measured cost:** the queue reached **zero unexamined guards** while
dispatch continued. I re-audited the last three candidates —
`merge-queue-ops-2`, `lifecycle-ops`, `notification-service` — and all
three were already documented. Only one was reclassifiable, and by
**deletion** rather than conversion (#3205).

## What the bare command prints now

```
  COLUMN guards (the backlog):   11

  CONVERSION QUEUE EMPTY: all 11 remaining column guard(s) carry a documented deferral note.
  There is no unexamined guard to claim. A nonzero backlog above is DEBT, not a work queue.
  Re-read the note at a site before converting it; run --claims to also check open-PR ownership.
```

Or, when work does exist: `N unexamined guard(s) remain (no deferral
note) — run --triage to list them by file.`

**Local signals only**, so it is honest offline. It reports what it can
prove — no *unexamined* guard remains — and explicitly does **not**
claim the files are unclaimed, because only `--claims` sees open PRs. No
count, no exit code, `--strict`/`--json` untouched.

## Three commits, deliberately separated

1. **`refactor`** — move `FLAG_MARKERS` + the 40-line window into the
lib as `hasDeferralNote()`, verbatim. It was a private const plus an
inline `.slice()` in the CLI, so the rule deciding where the fleet is
sent had **no test in either direction**. Proven identical on the real
tree: `11 documented / 0 unexamined` before and after.
2. **`feat`** — the verdict + 6 tests.
3. **`fix`** — an unrelated pre-existing failure (below).

## The test fix — this one is turning main red

`attributes a remaining file to the open PR that touches it` asserted
over `out.slice(out.indexOf("UNCLAIMED:"))`, which runs to **end of
output** and so also covers the `SYNC-RESOLVED` section printed
afterward. That section legitimately lists `scheduler.ts`.

Latent until `topRemainingFile()` returned `scheduler.ts` — which
happened as the backlog shrank, **a state every conversion moves
toward**. Confirmed pre-existing: clean `origin/main` runs `42 passed /
1 failed` with the identical message.

## Evidence

| check | result |
|---|---|
| `hasDeferralNote` tests | both directions, boundary exact at 40 above
/ not below, 5 real phrasings |
| verdict control (by hand) | one tracked undocumented guard → **11 →
12**, verdict flips to `1 unexamined`; removed → restored |
| test-fix anti-vacuity | claim split broken → **FAILS**; restored →
passes |
| census file | **49 passed** (was 42 passed / 1 failed) |
| `census --strict` / `check:fnxc-future-dates` | exit 0 / exit 0 |
| `pnpm test:gate` | **exit 0** (732 tests) |

The verdict control was **invalid on the first attempt** — my probe file
was untracked and `git ls-files` never scanned it, so the verdict did
not flip and nothing was proven. Recording that because a control that
silently proves nothing is the exact failure this PR is about.

## Census before / after

No guard converted here; this is tooling. Backlog unchanged at 11, all
deferred.
2026-07-31 10:38:48 -07:00
gsxdsm
230be28576 fix(core): the merge-queue enqueue guard was not debt — the code it guarded had no callers (#3205)
## The deferral note was right about the mechanism and wrong about the
remedy

`merge-queue-ops-2.ts` sat in the census as deferred debt behind this
note:

> Converting it properly means either making this path async or pushing
the trait read into SQL, both of which are store-architecture changes
rather than call-site conversions.

That is correct as far as it goes — the guard runs inside
`store.db.transactionImmediate`, so the only synchronous resolver
available (`resolveTaskWorkflowIrSync`) returns the DEFAULT workflow
under PostgreSQL and a "conversion" would be inert.

But it assumed the code needed converting. Measured across the tree:

```
=== every call site of .enqueueMergeQueueSyncInternal( ===
packages/core/src/store.ts:1775:  public enqueueMergeQueueSyncInternal(...)   <- the declaration itself
```

**Zero callers.** Every other occurrence of the name is a comment. The
live path is `enqueueMergeQueueAsync` (`task-artifacts-ops.ts:117`), and
that file already documented the deletion:

> Merge-queue enqueue is PostgreSQL-only via enqueueMergeQueueAsync …
The SQLite `enqueueMergeQueueSyncInternal` arm is deleted.

The arm was deleted; its declaration was not. The guard was unreachable
on the shipped backend.

## Change

- Deleted `enqueueMergeQueueSyncInternalImpl` (-85 lines) and its
`store.enqueueMergeQueueSyncInternal` entry point.
- Dropped the six imports that became unused
(`MergeQueueTaskNotFoundError`, `MergeQueueInvalidColumnError`,
`MergeQueueEntry`, `MergeQueueEnqueueOptions`, `normalizeTaskPriority`,
`MergeQueueRow`).
- Refreshed the three comments naming the removed symbol, so none points
at a deleted identifier. The
`handoffMergeQueueFailureInjectorForTesting` hook those comments sit on
is a **different** member and is untouched — it only mentioned the sync
arm as context.

## Census before / after

| | before | after |
|---|---|---|
| `packages/core/src/task-store/merge-queue-ops-2.ts` | 1 | **0 (entry
removed)** |

Baseline tightened by exactly one entry. **The 0 here is a deletion, not
a conversion** — recorded in the file's own FNXC note so the next worker
does not read it as a converted seam. This is the failure mode the
census warns about ("a count of 0 is the WORST case, not the best"), so
it is stated at the site rather than left to inference.

## Measured

| check | result |
|---|---|
| `census --strict` | exit 0 |
| `@fusion/core tsc --noEmit` | exit 0 |
| `eslint` (4 changed files) | clean |
| core merge-queue tests | **110 passed / 6 files**, incl.
`postgres/merge-queue-renamed-review-column.pg.test.ts` |
| `pnpm test:gate` | exit 0 (**732 tests**) |

No changeset: `@fusion/core` is private and this removes unreachable
code with no user-visible behavior.

## Flagged, not guessed

The other four deferral-note files remain deferred. I only reclassified
this one because its call-site count is a fact I could measure, not a
judgement. Whether `lifecycle-ops.ts:667` is likewise dead (it sits in
the legacy-SQLite polling-replica path) is a separate question I have
not measured, so I have not touched it.
2026-07-31 10:30:16 -07:00
gsxdsm
a319e35a67 fix(dashboard): the card's completion timestamp reads the resolved complete lane (census 13 → 12) (#3146)
`TaskCard.tsx` 1 → 0. **Census 13 → 12**, baseline re-recorded
in-commit.

## The defect

`getInReviewCompletionMs` gated on `task.column === "done"`, so on a
board whose completion lane is renamed, a finished card rendered its
execution time **without the completion half** — the `Completed <when>`
part of the indicator's `title` / `aria-label` never appeared.

Nobody reported it because the card does not look broken. It looks like
a card whose completion time was never recorded.

## The recorded blocker had expired, and I trusted it twice

The note on that helper read:

> Module-scope, takes only a `Task`, and has no flags to consult.
Converting it means either threading resolved flags through a pure
duration helper or resolving a workflow inside it.

True when written (2026-07-30). False within a day, and the evidence is
in the same file:

- `taskColumnFlags` is a **prop of this component**, destructured and
already consumed by `isWipColumnRole` / `isReviewColumnRole`.
- The **sibling duration helpers were threaded for exactly this
purpose** — `getTotalAgentActiveMs` carries the note *"THREADED SO THE
CONVERSION IS NOT INERT"*.
- This helper has **one caller**, inside the component, where the flags
are in scope.

The threading the note called prohibitive was already done; only this
helper was left behind. I read that note twice this week and took it at
face value both times — and what finally prompted the check was main
landing `taskRevert 2 → 0 — **the recorded blocker named the wrong
variable**` (#3129), someone else finding the same class of decay in a
note I had also accepted.

This program's own learnings say a deferral's stated blocker is a claim
that ages like any measurement. I had applied every other entry in that
document this week except that one.

## A dependency-array bug the conversion would have introduced

The memo now reads `taskColumnFlags`, so it joins the dependency array.
Flags arrive **asynchronously** — the board resolves workflows after
first paint — so a card rendered before they load and re-rendered after
would otherwise keep the pre-flag answer, since none of the memo's other
inputs changed. This repo has **no `react-hooks/exhaustive-deps` rule**,
so nothing would have flagged the omission.

## Two wrong probes before a correct one, both caught by controls and
mutation

Recording these because the fix was right from the start and my
instruments were not:

1. **`textContent` matched nothing.** The completion time lands in
`title`/`aria-label`, never in visible text. The **control failed too**
— the signature of a broken probe rather than a broken fix.
2. **`innerHTML` on the whole card matched always.** The lifecycle-dates
footer renders its own `Completed <date>` line, and *that* path already
resolves the complete lane correctly. The probe was reading a different,
already-converted feature. **Mutation exposed it: reverting the fix left
all six green.**

The final assertion queries `.card-time-indicator` and reads its
`title`, which is the only form that can tell the two apart.

## Verification

| | result |
|---|---|
| suite | **6 passed** |
| mutation (restore `=== "done"`) | **1 failed \| 5 passed** — the
renamed case only, control still green |
| dashboard `tsc -p tsconfig.app.json` | **0 errors** |
| census `--strict` | exit 0, baseline re-recorded in-commit |

Flags stay optional with the legacy id as fallback
(`isCompleteColumnRole`), so any caller without resolved flags behaves
exactly as before.

## Note on `check-fnxc-future-dates`

It fails on this branch, but **not because of it** — `scheduler.ts` and
one PG test carry future stamps on `main` itself. #3139 fixes that. None
of my files appear in the report.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:18:18 -07:00
gsxdsm
a6d67844b8 fix(triage): the "unconvertible" site was convertible — the blocker was two test harnesses (#3191)
#3141 measured this site as unconvertible, and I twice reported the
cause as a production constraint. It was not. This is the instrumented
answer to the probe I recommended there and then ran myself.

## The isolation

| configuration | result |
|---|---|
| flag only, no conversion | **8 passed** → the orphan arm is *not* the
cause |
| flag + conversion | **5 failed** → the conversion is |
| same, with a realistic mock store | **8 passed** → the mock was the
cause |

`triage-stuck-requeue-preserve-draft.test.ts` defined neither
`getTaskWorkflowSelection` nor its async twin — exactly like
`triage.test.ts` did before #3189. Both made
`resolveWorkflowIrForTaskWithProvenance` **throw** and take its catch
branch: the *"could not ask"* shape, which a production store never
presents.

So the 5 failures I deferred as a possible semantics change were the
same harness gap in a second file — confirmed, not argued.

## What changes

**`selectionAbsent`** marks the determinate case: the store *answered*
"no selection", so the workflow is the default and its IR is in hand.
Added as a **separate field, not a third `source` value** — `source ===
"default"` is compared in **31 places** in `self-healing.ts` meaning "be
conservative", and a new enum value would silently stop matching every
one of them while still compiling and still passing on a default board.

**`recoverApprovedTask`** now accepts a legacy `triage` row *explicitly*
(its workflow does not declare that column) instead of depending on
`resolvePlannerLanes` **failing** and falling back to legacy ids.
Correctness resting on a resolver's failure mode is what this removes.

## Measured

| | result |
|---|---|
| broad suite (triage / self-healing / recovery / planning) | **77
files, 1302 tests passed** |
| the three directly affected suites, post-rebase | **245 passed** |
| the flag is load-bearing | conversion **without** it: **18 failed \|
221 passed** |
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**The inert-sync-lane count is unchanged at 7 for `triage.ts`.** This
site was never among the counted guards, so this is **not** a ratchet
reduction — stating that rather than letting a conversion imply one. It
removes a real inert dependency the ratchet cannot see, which is the
blind-spot class this phase has been mapping.

## Why this took four attempts

I described this blocker at four levels: merged intake/hold, orphan-arm
scoping, identity verification (filed as **#3187**, closed as wrong),
and finally the harness. **The two I instrumented held; the two I
reasoned to did not.** The fix here is the probe I wrote down for
someone else — which is where it should have started.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:36:03 -07:00
gsxdsm
f703b499d3 census: --triage's pick-work list was 100% false positives (#3194)
## Every entry on the pick-work list was already decided

`--triage` prints a list headed *"unexamined, by file — this is the list
to pick work from"*. On `main` it held 2 sites. **Both carry a full
deferral note:**

| site | what its note says |
|---|---|
| `triage.ts:793` | *"the arm goes back to the literal, which is
**honest about being one**"* — restored by #3126 after #3114 converted
it inertly |
| `scheduler.ts:1323` | *"the second of the two **honest literals** …
converting it here **would be inert**"* |

Neither phrasing was in the marker set. So the pick-list was **100%
false positives**.

## Why this direction of error is the expensive one

Under-reporting a deferral sends a worker at a site whose owner already
wrote down why it must not move. That is not a hypothetical failure — it
is the sequence that cost three PRs: **#3108** flagged a site with both
blockers named and a test behind it, **#3114** converted it anyway hours
later, **#3126** reverted it. A pick-list that nominates decided sites
reproduces exactly that.

Over-reporting has the opposite failure — it hides real work — so the
added phrases are specific to *declining a conversion* (`honest
literal`, `would be inert`), not generic words that appear in ordinary
notes.

## Verified in both directions

Not asserted. I resolved **each of the 12** remaining guards to its
individual marker:

```
 1. scheduler.ts:1238            FLAGGED
 2. scheduler.ts:1323            honest literal
 3. audit-ops.ts:231             Not converted
 4. lifecycle-ops.ts:667         do not convert
 5. merge-queue-ops-2.ts:53      FLAGGED
 6. moves.ts:346                 STAYS INLINE
 7. task-id-integrity.ts:445     Left counted
 8. ResearchTaskActionModal:66   SIZED, NOT
 9. TaskCard.tsx:406             FLAGGED
10. notification-service:1245    FLAGGED
11. self-healing.ts:6054         FLAGGED
12. triage.ts:793                honest about being one
```

**Unexamined is 0.** That is a meaningful state, not just a small
number: the conversion backlog is fully *triaged*, every remaining
literal has a recorded reason, and the next person to touch one is
reading an argument rather than guessing.

## Census before / after

```
before:  COLUMN guards (the backlog):   12
after:   COLUMN guards (the backlog):   12
```

Unchanged, as required — `--triage` is opt-in and moves no count and no
exit code. `--json` and `--strict` verified unaffected.

## Verification

`test:gate` exit 0 · `--strict` exit 0 · `--json` exit 0 · plus
`fnxc-future-dates`, `lifecycle-columns`, `inert-sync-lanes`,
`quarantine-ledger`, `inert-flag-seams`, `lane-wiring`,
`sql-column-literals` — all exit 0. One script; no production file
touched.

*Process note: my first draft of this PR carried a future-dated FNXC
stamp — the third time I have done that. I have switched to taking the
stamp from `date -u` per #3174 rather than typing it, and my pre-push
run of the full `pr-checks` ratchet set (not `test:gate` alone) caught
it before it left the branch, which is what that habit is for.*
2026-07-31 09:35:51 -07:00
gsxdsm
24c565540e gate: a sync lane handed to a wrapper is still inert — 13 scheduler guards were invisible (#3181)
## The fourth shape: a sync lane handed to a wrapper

#3169 taught `unwrapForSyncCall` to walk await, parenthesized,
conditional and binary expressions. It still stops at the **call
boundary**, so a source call sitting in an *argument* position stays
invisible:

```ts
const parked = mergeParkedColumns(resolveTaskParkedColumnsSync(store, id), lanes);
```

That prefers the event payload and falls back to the sync answer
whenever `lanes` is absent. The callee is `mergeParkedColumns`, not a
source — so the walker never looked inside, and **the entire
`scheduler.ts` file read as clean**.

```
main today:   9   (triage 7, executor 2, scheduler 0)
this PR:     22   (scheduler 13, triage 7, executor 2)
```

Thirteen guards. And `check:inert-sync-lanes` has run in `test:gate`
since #3136, so CI is currently enforcing a ratchet that reports a file
it cannot see into as fully converted. The green is official, which
makes it worse than the version nobody ran.

## Is the fallback still reachable?

Yes, which is why these are not retired. #3135 attached lanes at every
*live* emitter, but absence remains reachable three ways: the two
`lifecycle-ops.ts` emitters on the SQLite-only polling path, any future
emitter added without lanes, and the three forwarders
(`project-manager.ts`, `remote-node-runtime.ts`,
`child-process-runtime.ts`) that reconstruct the event object
field-by-field rather than forwarding it.

A rarely-exercised fallback is still a fallback. Counting it as clean is
how the ledger stops meaning anything.

## The change

One line inside your walker, plus its note:

```js
if (ts.isCallExpression(n)) { for (const a of n.arguments) walk(a); }
```

Every shape #3169 added is preserved. Still a name match, not dataflow —
the limits section still applies.

## Mutation evidence — all three shapes, one tree

| Mutant | Result |
|---|---|
| baseline (22) | exit 0 |
| **argument position** (this PR) | **exit 1**, 13 → 14 |
| conditional (#3169's) | exit 1, 13 → 14 |
| inline (#3062's) | exit 1, 13 → 14 |

`scheduler.ts` restored clean after each run.

## Baseline 9 → 22

**Detection, not regression.** No production file changes in this PR. 22
is the exact union I measured before #3169 merged (13 + 7 + 2) and
posted on both PRs at the time — it landing unchanged is the
confirmation that the two fixes were additive rather than overlapping.

## Census before / after

```
before:  COLUMN guards (the backlog):   12
after:   COLUMN guards (the backlog):   12
```

Unchanged — this converts nothing. It restores 13 guards to a ledger
that had silently dropped them.

## Supersedes #3122

#3122 carried this fix as a standalone rewrite of `syncLaneLocals` and
conflicted with #3169 the moment it landed. This is the six-line version
I offered there; #3122 is closed.

## Verification

`test:gate` exit 0 · `check:inert-sync-lanes` exit 0 at the re-recorded
baseline · plus `fnxc-future-dates`, `lifecycle-columns`,
`quarantine-ledger`, `inert-flag-seams`, `lane-wiring`,
`sql-column-literals` — all exit 0. Gate script + baseline only.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved detection of synchronous operations nested within wrapper
arguments.
* Updated synchronization checks to report all currently identified
findings, including additional scheduler-related cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 08:56:32 -07:00
gsxdsm
fa62c951cf fix(gate): the inert ratchet went quiet exactly when the code improved (conditional initializer) (#3169)
Found by dogfooding my own change: I wrote `executor.ts` in the
payload-first/sync-fallback shape while adopting #3140's better
fallback, **predicted in a comment that the guards would stay counted**,
and the gate reported **zero**. The prediction was wrong in the
direction that matters — the gate under-reports.

## The gap

`syncLaneLocals` registered a local only when its initializer **was** a
call expression:

```ts
const sync = payload ? undefined : localSync(store, id);
return column === sync?.hold;          // inert, and counted as nothing
```

Conditionals and `??`/`||` chains are now unwrapped, so a sync call in
any branch registers the local. Still a **name** match, not dataflow —
the file's LIMITS section still applies.

## Why this shape matters more than the inline one already guarded

**The missed shape is the one authors are steered toward.** Falling back
to the sync resolver is *better* than falling back to legacy literals —
it is best-effort under legacy SQLite, whereas a literal can never be
right on a renamed board. So writing the guard well is what made it
invisible.

A ratchet that goes quiet exactly when the code improves is worse than
none: it rewards the worse degraded path with a tidier number.

## Known remaining gap, stated in the test rather than implied

Only **one hop** is followed. The two-hop form is still uncounted:

```ts
const sync  = payload ? undefined : localSync(store, id);
const lanes = { hold: payload?.hold ?? sync?.hold ?? "todo" };
if (from !== lanes.hold) …            // still invisible
```

`executor.ts` is written that way today, which is why it reads 0 while
the sync call is still present. Closing it needs propagation through
object-literal construction — a larger change than this one, and I would
rather ship the one-hop fix with the gap documented than imply full
coverage.

## Verification

| | result |
|---|---|
| gate on `main` | **exit 0**, output unchanged (11 = triage 7 +
executor 4) |
| test suite | **5 pass** |
| new case against the **unfixed** gate | **fails** — `the
conditional-initializer shape must be counted` |

The regression case drives a real file through the scanned tree rather
than calling a helper, because the bug was in which nodes the scan
**visits**. A helper-level assertion would have been written against the
same wrong mental model that produced the gap — which is how the
inline-spelling hole in this same file survived its first draft.

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

* **Bug Fixes**
* Improved detection of sync-lane conversions in conditional
expressions, fallback logic, awaited and parenthesized values, and
object-literal relays.
  * Corrected matching for identifiers containing special characters.
* Updated validation results to include two additional findings that
were previously missed.

* **Tests**
* Added integration coverage for conditional initializers, chained
object-literal conversions, and special-character identifiers.
  * Ensured temporary test files are cleaned up automatically.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:20:06 -07:00
gsxdsm
2cb5cab595 chore(core): mark the mission-store dead-sync-path literal DELIBERATE (census 13→12) (#3179)
Comment-only. `tsc` 0 errors, `census --strict` and
`check-fnxc-future-dates` exit 0.

## Claimed with the new tool

First use of `scripts/check-file-claimed.mjs` (#3175) to pick work
instead of guessing:

```
CLAIMED    packages/engine/src/scheduler.ts        #3177, #3142
CLAIMED    packages/core/src/task-store/audit-ops.ts   #3165
UNCLAIMED  packages/core/src/mission-store.ts
UNCLAIMED  packages/core/src/task-store/task-id-integrity.ts
```

Two of the four files I would have reached for were already taken — by
PRs whose branch names give no hint they touch those paths. That is the
collision this phase paid for five times, answered in one command. I
took `mission-store.ts`; `task-id-integrity.ts` is still free.

## Census 13 → 12

Reclassification, not conversion — the line is unchanged.

## Verified the blocker rather than deferring to it

The site carries an audited note: the sync `MissionStore` reaches
`this.db.prepare`, and `getMissionStoreImpl` returns the
`AsyncDataLayer`-backed `AsyncMissionStore` under PostgreSQL, so the
class is unreachable in the shipped backend.

I checked that independently instead of accepting it —
`async-mission-store.ts:168` states the same routing from the other
side. **That check exists because of #3129**, where a note I had
accepted as settled ("blocked on a per-neighbour flag map that does not
exist") turned out to name the wrong variable, and the file was
convertible all along. I had publicly argued it should stay counted.

So the rule I am applying: a documented blocker gets marked only after
its named obstacle is confirmed from a second source. Here it held; on
`taskRevert.ts` it did not.

## Related, and still open

`merge-queue-ops-2.ts` carries a note of the same shape that does
**not** survive this check — it names two ways to convert (make the path
async, push the trait read down) and misses the one that worked twice
this phase: thread the resolved lanes in from a caller that already
awaited them, as #3112 and #3118 did for `executor.ts`. Its sibling
`taskStillInReview(projectId, reviewColumns)` already takes lanes from
its caller. Worth a real look rather than a marker.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:45 -07:00
gsxdsm
b8bfcd031b tooling: answer "is this file claimed?" in one command (#3175)
Addresses the root cause of a pattern I have now measured four times.

## The finding

**Every fleet worker pushes as the same GitHub account.** `gh pr list
--author "@me"` returns **all 17 open PRs** — mine and teammates' are
indistinguishable. So "is this file already being converted?" can only
be answered by fetching every open PR's file list by hand: 25+ API calls
that no worker makes before starting. I didn't either.

## The measured cost

| PR | Outcome | Landed instead as |
|---|---|---|
| #3096 | shrank to a test | teammate's serialisation + union |
| #3116 | shrank to a test | `preExecLiveColumns`,
`starvedWaitingColumns`, … |
| #3140 | shrank to a test | #3137 |
| #3125 | **shrank to nothing — closed** | #3135 |

Plus #3118, a teammate independently writing the same coverage I wrote
for #3112.

**In every case both implementations were correct and independently
reached the same design** — #3137 chose payload-first-with-sync-fallback
for the same reason I did. This is not carelessness; the fleet is doing
correct work twice and discovering coverage gaps by accident, when
rebases collide.

## What this adds

```
$ node scripts/check-file-claimed.mjs packages/engine/src/self-healing.ts
CLAIMED    packages/engine/src/self-healing.ts
             #3152  fix(self-healing): 18 recovery rebounds hardcoded `todo` …
```

**On its first run it reported `self-healing.ts` claimed by #3152 —
which I had no way to know a moment earlier.** Exits non-zero when
claimed, so it can gate work: `node scripts/check-file-claimed.mjs
<path> && start-work`.

## Deliberate limits

- **It cannot see unpushed work**, so it narrows the collision window
rather than closing it. Two workers starting the same file minutes apart
still collide. Closing that needs **distinguishable authorship** — a
per-worker `Co-Authored-By` or a title prefix — which is a coordination
decision, not a script.
- **A `gh` failure exits 2 and says UNKNOWN, not unclaimed.** A claim
check that fails open is worse than none, which is the same false-green
shape this program keeps finding elsewhere.

Also adds a short AGENTS.md pointer next to the other standing rules.

## Verification

Run against a claimed and an unclaimed path; both answers correct, exit
codes as documented.

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

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

## Summary by CodeRabbit

* **New Features**
* Added a command-line check for open pull requests that may already be
modifying specified files.
* Reports matching pull request details and clearly indicates whether
each file is claimed.
* Returns distinct statuses for claimed files, unclaimed files, and
unavailable GitHub checks.

* **Documentation**
* Added guidance for checking file ownership before beginning conversion
work.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:10:29 -07:00
gsxdsm
2a57820dd2 chore(gate): normalize the last future-dated stamp in task-update.ts (tightens the allowance 1 → 0) (#3168)
**Main is red on the FNXC gate again** — third occurrence of this class
today, third different file.

```
packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1
```

Stamps dated **2026-08-01** while UTC is **2026-07-31-14:24**. Every
open PR inherits the failure; #3164 merged carrying it.

## The fix

Date only, to today. Clock times preserved exactly — they were real
times on the wrong day — and no comment text touched, so the record
reads identically, just in order:

```
-FNXC:StateMachine 2026-08-01-10:20 (PR #2793's finding — the INNER half, merged with #2821):
+FNXC:StateMachine 2026-07-31-10:20 (PR #2793's finding — the INNER half, merged with #2821):
```

Baseline **tightened** as a side effect (`1 → 0`): one future stamp was
grandfathered, normalizing the file cleared it too, and the gate refuses
a stale allowance on the way down. Re-recorded in the same commit.

## The recurrence is the point, not this fix

Three separate files have tripped this in one day — `scheduler.ts`, the
scheduler PG test, and now `task-update.ts` — plus the midnight-rollover
variant this morning that reddened everyone's baseline.

**Stamps are written from a local clock and validated against UTC.** A
worker behind UTC writes what is genuinely "today" for them and produces
a future stamp the moment UTC has already rolled. Nothing in the local
loop catches it: `pnpm lint` passes locally because the local date
agrees.

The durable fix is to generate the stamp from `date -u` rather than a
wall clock — one line in whatever produces these, and the class
disappears. I have patched the symptom three times today; someone should
take the cause. I have not done it myself because the stamps are
authored by hand across every worker's flow, so the change belongs
wherever that convention is documented, not in a file I happen to be
touching.

## Verification

`check-fnxc-future-dates` green (TZ=UTC CI=true) · `pnpm test:gate` 13 +
161 + 487 + 71 · lint · core typecheck clean · diff is date
substitutions only.
2026-07-31 07:56:09 -07:00
gsxdsm
d7324c1a20 fix(gate): main is RED on check-inert-sync-lanes — my #3137 dropped the count without re-recording (#3172)
`check-inert-sync-lane-conversions` is inside `test:gate`, so **the
merge gate itself is red on `main` right now**.

```
inert-sync-lane: total fell 11 -> 7.
  Good news — but re-record the baseline in the SAME commit, or the allowance stays
  high and the gate silently accepts that many NEW inert conversions
exit 1
```

## Cause: mine

#3137 converted `executor.ts`'s planner-evacuation guards (4 → 0 under
the current scan) and **did not re-record the baseline in the same
commit**. The gate fails an unrecorded drop by design — a stale-high
allowance is four free slots for new inert conversions — and that
requirement is stated both in its failure text and in its own test suite
(*"an unrecorded DROP fails, so the allowance cannot stay stale-high"*).
I knew the rule and still shipped without it; the drop only became
visible once the PR merged.

This PR is the baseline only: **11 → 7**, `executor.ts` 4 → 0,
`triage.ts` unchanged at 7.

## Ordering note for my two open gate PRs

#3169 and #3170 make the scan follow a sync lane through a **conditional
initializer** and through an **object literal**. `executor.ts` is
written in exactly that shape after #3137, so with those fixes the count
**rises 7 → 9** — a legitimate rise from better detection, not a
regression. That re-record belongs in #3170's own commit, which is where
the gate asks for it, and I will put it there rather than pre-baking it
here.

So the expected sequence is: **7 now**, **9 when #3170 lands**.

## Process notes

- I checked for an existing fix PR before writing this one.
- I also caught this only because I re-measured the exit code **without
a pipe**. `node gate.mjs | head` then `echo $?` reads `head`'s status
and reported 0 — the exact harness trap recorded in this program's
learnings doc, which I walked into while checking whether main was
healthy.

## Verification

- gate **exit 1 on `origin/main`**, **exit 0** here
- diff is the baseline file only

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:44:42 -07:00
Phil Larson
920d68e10f fix(dashboard): expose column roles to browser bundle (#3151)
## Summary
- export the browser-safe `@fusion/core/column-roles` subpath
- keep Vite/Vitest aliases ahead of broad `@fusion/core` aliases
- restore production dashboard builds after task undo classification
adopted shared column-role helpers

## Test plan
- `node scripts/check-no-node-only-core-imports-in-dashboard.mjs`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run app/utils/__tests__/taskRevert.test.ts --pool=threads
--maxWorkers=1`
- `pnpm --filter @fusion/core typecheck`
- `pnpm --filter @fusion/dashboard typecheck`
- `CI=true pnpm check:changesets`
- `pnpm build`


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

* **Bug Fixes**
  * Fixed dashboard build compatibility for browser-based environments.
* Improved reliability when importing column role functionality across
supported application components.

* **Refactor**
* Made column role utilities available through a dedicated browser-safe
entry point.

* **Chores**
* Updated development and test configurations to consistently resolve
the new entry point.
* Documented the browser-safe module classification and recorded the
release patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 06:46:19 -07:00
gsxdsm
a8dae03fdb fleet(dashboard): taskRevert 2 → 0 — the recorded blocker named the wrong variable (#3129)
The largest remaining census cluster. Deferred twice, with a blocker
that turns out to be false **in the same component where its
counter-example already lives**.

## What the earlier notes got right

`detailColumnFlags` describes the **modal's own task**, and the column
classified here belongs to a **neighbour**. Supplying it would answer
*"is this neighbour finished?"* with a different row's traits — wrong on
data, not merely stale on vocabulary. That reasoning stands and I kept
it.

An earlier pass also converted this, left the parameter unsupplied, and
**reverted it** — correctly. An unsupplied optional parameter is
strictly worse than the literal: the guard is gone, the census counts a
conversion, and the behaviour is the legacy fallback forever. That rule
is why the wiring ships in this same commit.

## What the conclusion got wrong

> "A correct conversion needs per-**neighbour** flags — which the modal
does not have and should not fetch mid-render."

`columnFlagsByTaskId` is a per-task map. It is **already a prop** of
`TaskDetailModal` (declared :367, destructured :727), and the call site
at :992 sits **below** that destructure.

And `TaskDetailModal` already uses it exactly this way, for the
near-duplicate canonical:

```ts
columnFlagsByTaskId?.get(nearDuplicateCanonical.id)
```

…under a note observing that *its* blocker had been *"asserted from the
shape of the problem rather than tested against what was in scope."*
Same assertion, one function over. So the supplier the earlier note went
looking for exists, is per-neighbour, and needs no fetch.

## What it fixes

This lookup skips **finished** candidates so a done/archived prior undo
attempt never renders as an active "Undo task" link. On a board that
renames those lanes it matched neither — a finished undo task kept
rendering as open, which is precisely the stale affordance the
function's own header says it exists to prevent.

## Census

| | before | after |
|---|---|---|
| `taskRevert.ts` | 2 | **0** |
| repo backlog | 17 | **15** |

## Measured

- 4 new cases; `taskRevert.test.ts` **11/11 pass**.
- **MUTATION**: restoring the literal pair fails the renamed case.
- **The negative is load-bearing.** The map is fail-soft, so a candidate
it does not cover must still be treated as **open**, not skipped. A
conversion that skipped unknown candidates would *hide live undo links*
— failing in the direction nobody reports.
- A **control** pins that an unwired caller (no flags at all) still
skips the legacy ids, so the optional parameter cannot regress default
boards.
- `TaskDetailModal` suites — **31 files / 664 tests pass**.
- `tsc --noEmit -p tsconfig.app.json` clean; census `--strict`,
`check-lane-wiring`, `check-fnxc-future-dates` clean.

## Pattern worth noting

This is the fourth deferral this session whose stated blocker had
dissolved or misidentified itself, and the second where the
counter-example was already in the same file. The common shape: a note
records *why* something is blocked, is accurate when written, and is
never re-checked — so the block outlives its cause. Re-reading them cost
minutes each and returned two real conversions.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:58:44 -07:00
gsxdsm
ada62a7c4a census: --claims shows which remaining files an open PR already holds (two duplicate claims today) (#3124)
The census says **where** the work is but not **who has it**, and
duplicate claims are now the dominant coordination cost of this phase.
This adds an opt-in `--claims` report mapping each remaining file to the
open PRs already touching it.

## The problem is measured, not suspected

- **`self-healing.ts` took three overlapping conversions** from
different lanes while one branch was open (#3049, #3075, #3078). Each
forced a full rebuild of #3094, and every conflict was the same shape:
*same guard, two spellings, different variable names*. That PR's body
asks, in as many words, for one lane to own the file.
- **`executor.ts` took two independent conversions today** — #3112 and
#3118 — same four literals, same payload-lanes fix, two branches. Two
workers each read the census, saw the top cluster, and started. Neither
could see the other; I only caught it because both appeared in one `gh
pr list`.

The census is what sends everyone to the same file, so the claim signal
belongs here rather than in a side channel nobody reads. `--triage`
(#3097) already measured the underlying fact — 53 of 88 guards sat
inside an open PR — one step short of being actionable.

## Measured on current main (29 guards)

```
  CLAIMED by an open PR: 6 files holding 15 guards
       6  packages/engine/src/self-healing.ts  ← #3121 #3116
       4  packages/engine/src/executor.ts  ← #3118 #3112
       2  packages/engine/src/auto-merge-finalization.ts  ← #3107
       1  packages/core/src/task-store/task-artifacts-ops.ts  ← #3120 #3119 #3091
       …
  UNCLAIMED: 12 files holding 14 guards — start here
       2  packages/dashboard/app/utils/taskRevert.ts
       2  packages/engine/src/scheduler.ts
       …
```

It independently reproduces **both** collisions I found by hand today,
which is the strongest evidence I can offer that it works: `executor.ts
← #3118 #3112` and `self-healing.ts ← #3121 #3116`.

It also answers the standing fleet instruction empirically. "Claim the
largest unclaimed cluster" currently resolves to **12 files holding 14
guards, none larger than 2** — and one of those two (`scheduler.ts`) is
in the SYNC-RESOLVED list, where conversion is inert. That is a
materially different picture from the headline `29`.

## Design decisions

**Report-only and fail-soft**, on the same terms as `--triage`: opt-in,
printed beside the totals, changes no count and no exit code. It shells
to `gh`, so it is unavailable offline, in CI without a token, and in
sandboxes — all of which print a notice and continue. A gate must not
depend on network state; this is a work-selection aid, not a gate.

**The fail-soft path is loud on purpose**, and it is the case I care
most about. A claim report that silently degrades to "nothing is
claimed" is *worse than no report*, because it actively sends the reader
into work another lane holds — the exact failure the flag exists to
prevent. So when `gh` cannot answer it prints `POSSIBLY CLAIMED` and
suppresses the start-here list entirely rather than rendering it empty.

**Heuristic, and says so.** A PR touching a file is not proof it
converts *that file's* guards — it may edit an unrelated function. It
over-reports rather than misses, which is the safe direction: a false
claim costs one comment asking, a missed one costs a rebuilt branch.

**One bulk `gh pr list` call**, not a request per PR — the per-PR shape
was too slow to become habitual, and a report nobody runs is not a fix.

## Verification

- `lifecycle-column-census.test.ts` — **42 passed** (was 40)
- Differential: disabling the flag gives **2 failed | 40 passed**. Both
new tests fail on the defect they were written for.
- `--strict` and `check-fnxc-future-dates` — exit 0
- Tests stub `gh` on PATH, so no network call and no dependency on the
live PR list. The fixture reads the census's **own current top file**
rather than a hardcoded path, so it cannot rot as the backlog shrinks
(same self-maintaining discipline as #3106).

## What this does not do

It does not reserve anything — there is no lock, and two workers who
both run it can still collide if they start simultaneously. It reports
what is already visible in the PR list, which is enough to catch the
every-case-so-far pattern of *starting work on a file someone has held
for hours*. A real reservation would need shared mutable state, and I
would not add that without an owner asking for it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:50:13 -07:00
gsxdsm
4db4052451 fix(gate): the inert-sync-lane ratchet had two free slots — my own drop went unrecorded (#3117)
Found by merging all five of my open branches together and running the
gates — a check none of them gets individually. The finding turned out
not to be about those branches at all: **`main` itself carries a
baseline of 20 against a real count of 18.**

## It is mine

#3065 replaced three `to === parked.complete || to === parked.archived`
guards with `parked.terminal.has(to)` and took the count **20 → 18**.
The gate *warned* and exited **0**, so nothing failed, I did not
re-record, and the allowance stayed high.

Bisected to be sure rather than inferred:

| commit | count |
|---|---|
| #3051 (`scheduler.ts 12 → 2`) | 20 |
| **#3065 (mine)** | **18** |
| #3100 (mine, comment-only) | 18 |

## The consequence is concrete

The gate whose entire purpose is to stop the inert-conversion class
growing **would have accepted two new inert conversions.**

Verified, not reasoned: with the baseline at 20, adding one new
`parked.wip` comparison to `scheduler.ts` still passed. With the
baseline corrected to 18, the same edit fails.

This is the exact failure mode I called out earlier in this program — a
fix landing without its ledger update — committed by me. The lenient
exit code is why it stayed invisible for a day.

## Two changes

1. **Re-record the baseline 20 → 18.** Restores the ratchet today.
2. **An unrecorded drop now exits 1**, matching the sibling
`check-lane-wiring.mjs`. Restores it tomorrow.

A ratchet that only tightens on request does not ratchet. Two gates
guarding the same program should not disagree about how seriously they
take their own ledger — `check-lane-wiring` already fails here and
explains why; this is the same rule for the same reason.

## Tests, because the exit code *is* the contract

Nothing covered this script's exit codes. The new cases drive it by
**running the script** against a temporarily swapped baseline rather
than importing a helper — a version that printed exactly the right
warning and still exited 0 would satisfy any assertion about its output.

- rise → exits 1, names the file
- unrecorded drop → exits 1, **and the message names
`--update-baseline`**, because a failure that does not name its fix is
noise to whoever hits it
- committed baseline matches the tree → exits 0
- **anti-vacuity**: the scan still finds real guards, so the two
mutated-baseline cases cannot pass against a gate that stopped reading
source and merely compares a number to itself

The baseline file is restored in a `finally`, so a failing test cannot
leave the repo's real ledger modified.

## Measured

- 4 new cases pass (`node --test`).
- Gate exits **1** before the re-record, **0** after, and **1** again
when a synthetic new inert conversion is added.
- `check-lane-wiring`, census `--strict`, `check-fnxc-future-dates` all
clean.

## Census

No movement — this is a gate fix, not a conversion.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:47:09 -07:00
gsxdsm
6483f9ce2b fix(scheduler): resolve task:updated / task:deleted lanes asynchronously (scheduler inert 5 → 0) (#3128)
The last inert guards in `scheduler.ts`. Independent of my other
branches.

## Inert-guard ratchet

| Scope | Before | After |
|---|---:|---:|
| `scheduler.ts` | 5 | **0** |
| total | 12 | **7** (triage.ts 8 → other worker; executor.ts 4 → #3112)
|

## The live bug

These read `resolveTaskParkedColumnsSync`, which answers with the
**default** workflow in production. On a renamed board the scheduler
**never woke** on unpause or planning-finish, and a **deleted blocker
never unblocked its dependents** — the card sat behind a task that no
longer existed.

## The criterion, restated because I got it wrong before

**What blocks a guard is whether its answer is consumed synchronously —
not whether the enclosing listener is declared sync.** I assumed the
latter earlier in this program and reverted for it.

All three fail that test: two only gate `schedule()`, which is itself
`async`, fire-and-forget and re-entrance-guarded; the third already sits
below an `await getSettings()`. The edge-trigger bookkeeping
(`planningTaskIds.delete`) **stays synchronous** on purpose — deferring
*that* would let a second update re-enter the branch.

## The union is load-bearing, not defensive

Post-U11 the default lineage has no `triage` column, so a **resolved**
answer returns `intake: "todo"` where the inert path fell back to
`"triage"`. Converting without unioning the legacy ids silently
**narrowed** the wake set and stopped waking cards in a legacy-named
lane — caught by *"schedules when planning clears in triage"*.

**A resolved conversion must be a superset of what it replaces, or it is
a behaviour change wearing a vocabulary change's clothes.** That's the
reusable lesson here.

## Tests

- Drained with the repo's existing **`flushAsyncHandlers`** helper —
written for exactly this fire-and-forget shape — rather than loosening
any assertion.
- **The characterization test flipped, as designed.**
`workflow-scheduler-parked-columns-live-e2e.pg.test.ts` asserted *"a
dependent in a RENAMED hold column is NEVER unblocked"*, with its author
noting: *"expected to flip to null the moment the resolver is fixed —
and that flip is the whole point of writing it down."* It flipped.
Inverted to a REGRESSION case so the assertion holds the fix rather than
the defect; it now matches its own CONTROL arm, which still guards
against a vacuous pass.

## Verification

- 21 scheduler suites — **361 green**, including the live PostgreSQL e2e
- **`pnpm test:gate` green**; eslint and `tsc` clean
- Changeset added; `check:changesets` passes

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:41:16 -07:00
gsxdsm
ad5172afd5 fix(engine): main is red on check:inert-sync-lanes — #3114's triage conversion is inert, revert the arm (#3126)
## `main` is red on `check:inert-sync-lanes` right now

```
inert-sync-lane: NEW inert conversions — a lane guard now reads a sync resolver
that always answers with the DEFAULT board.
  packages/engine/src/triage.ts: 7 -> 8
```

Verified on a clean `origin/main` checkout, not on my branch. #3114
converted this guard's third arm to `disposeLanes.wip`; the gate that
exists to catch exactly this fired, and the PR landed anyway —
presumably because `check:inert-sync-lanes` is not in the blocking
merge-gate set.

## The change did not change behaviour

`disposeLanes` comes from `resolvePlannerLanes`, which resolves through
`resolveTaskWorkflowIrSync` — inert under PostgreSQL for two independent
reasons (#3103). So `disposeLanes.wip` evaluates to `in-progress`: **the
same value as the literal it replaced.**

A card advancing into a renamed execution lane still matches nothing,
still reads as an evacuation, and still kills a healthy planning session
— the precise bug #3114 set out to fix, unchanged on every board.

So the arm goes back to the literal. The gate's own failure text rules
out the alternative:

> Do NOT re-record the baseline to clear this — that is the same false
green one layer up.

## #3114's analysis is kept — only the code reverts

Its behavioural description is **correct** and is the clearest statement
of this bug anywhere in the file. I have kept those paragraphs and added
what is missing: that the fix does not reach under PG, and what would.

Whoever supplies a lane answer that is not sync-resolved should make
this line read `disposeLanes.wip` and delete the note. The specification
is sitting right there for them.

## It also reconciles two contradictory notes, one of them mine

My #3108 flag said converting the third arm this way adds an inert
comparison and removes a census entry that is telling the truth. #3114
then converted it and added a note saying it fixes the bug. **Both notes
sat in the file**, giving any reader two confident, opposite accounts.
They are now one account with the evidence attached.

## Read this file's census count carefully

#3114 took it to **0** while the inert count went to **8**. The census's
own `--triage` output warns about exactly this shape:

> for a sync-resolved file, a count of 0 is the WORST case, not the best
— the file reads as fully converted

Reverting restores it to 1, which is the honest signal.

## Census

| | before | after |
|---|---|---|
| `triage.ts` | 0 | **1** |
| repo backlog | 26 | **27** |

**The number going up is the point.** A census that reports 0 for a file
whose guards are all inert is worse than one that reports the truth — it
retires the entry and nobody looks again.

## Measured

- `check-inert-sync-lane-conversions`: **exits 1 on `main`, 0 here** (8
→ 7).
- `src/__tests__/triage*` — **25 files / 374 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-fnxc-future-dates` clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:35:28 -07:00
gsxdsm
218086bea2 fleet(engine): self-healing 6 → 1 — the board-stall counter, the last guard that needed a sync answer (#3121)
The last fan-out guard, and the one I explicitly said needed a
synchronous answer. #3109 made that answer available without an await,
so the flag comes off.

## Why this one was last

The other two guards in this listener gated work the listener **already
`void`s**, so they moved onto the async resolver in #3094. This one
increments in-memory state **in the handler's own tick**, so it
genuinely needed a synchronous answer.

The sync IR path was never that answer: `resolveTaskWorkflowIrSync`
cannot resolve a **custom** workflow at all — two independent blockers,
#3103 — which is why I wrote that conversion, measured it, and withdrew
it.

#3109's emitter-carried `lanes` removes the dilemma rather than trading
one horn for the other: reading them needs **no await**, so the
increment stays in the same tick *and* the guard becomes correct.

## What it fixes

On a renamed board this counter read **zero**. The board-stall watchdog
was blind to a board whose cards were moving out of implementation the
whole time — the signal it exists to raise was never raised.

## Census

| | before | after |
|---|---|---|
| `self-healing.ts` | 6 | **1** |
| repo backlog | 29 | **24** |

The remaining 1 is the log-dedup closure — a pre-existing flag whose
degraded answer costs a duplicate log line, not a lifecycle decision.

## Measured

- 3 new cases; `self-healing-completion-fanout.test.ts` **13/13 pass**.
- **MUTATION**: restoring the literal pair fails the renamed case.
- **The paired negative is the load-bearing one.** The guard means
*"left implementation for somewhere that is not implementation"*, so a
move **between two non-wip lanes** must not count. Without that case, a
conversion that counted every move would pass the positive and inflate
the watchdog's denominator — breaking it in the opposite direction,
which is harder to notice than a zero.
- A **fail-soft** case pins that an emit carrying no `lanes` still
counts on the legacy ids.
- **Asserted through the counter itself**, not a downstream alert. The
increment *is* what this guard decides; routing the assertion through
the watchdog would let an unrelated threshold change mask a regression
here.
- `src/__tests__/self-healing*` + `task-agent*` — **42 files / 848 tests
pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## On the withdrawal this reverses

#3094 withdrew a sync-IR conversion of this listener and recorded why,
precisely. That record is what made this cheap: I could tell in one read
that #3109 addressed the *specific* obstacle rather than a general
"async is hard". A flag that names its blocker exactly is a flag that
can be retired the day the blocker goes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:15:14 -07:00
gsxdsm
6050d6eb83 chore(engine): mark the auto-merge-finalization reviewed literals DELIBERATE (census 47→45) (#3107)
Fleet phase. `packages/engine/src/auto-merge-finalization.ts` was the
last census file with no branch, worktree, or open PR against it. Claim
published by pushing the branch before starting.

## Census before / after

| | total | this file |
|---|---|---|
| before | **47** | 2 |
| after | **45** | 0 |

`--strict` exits 0, baseline re-recorded. **Reclassification, not
conversion** — both lines are unchanged.

## Both sites were already reasoned, in a note that calls them
non-defects

- **Line 30** is the resolver's **degraded fallback arm**, inside
`catch`. The live arm two lines up calls `columnHasFlag(ir, columnId,
"complete")`. The literal is reached only when IR resolution throws,
where the legacy id is the only answer left — removing it would make a
failed resolve return nothing.
- **Line 99** picks an **error string**. The note above it works through
threading `isCompleteColumn` in and concludes the signature widening
costs more than the sharper diagnostic buys.

I did not revisit either judgement. The gap was mechanical: prose the
census cannot read, so both stayed in `byFile` as apparent debt for the
next pass to re-derive.

## This is the fourth, and it closes the set

With #3056, #3060, and #3063, **every census file that was unclaimed
during this phase has now been examined, and not one needed a
conversion.** Each site was a three-state fallback arm, or a site a
prior pass had already reviewed and kept.

The corollary is the finding I would most want carried forward: the
remaining count is not a work queue. A worker told to "claim the largest
cluster" reads the number, finds most of it already reasoned, and
reaches for whatever moves it — which is how three PRs converted guards
to a synchronous resolver that is inert under PostgreSQL.

One exception worth preserving: **`taskRevert.ts` should stay counted.**
I claimed, inspected, and released it without marking. Converting it
would classify a *neighbour* row using the modal task's flags — wrong on
data, not merely stale on vocabulary — and its note correctly calls the
entry **accurate debt** blocked on a per-neighbour flag map. Fallback
arms and dead paths → mark. Placeholders awaiting a capability → leave
counted.

## Verification

- `census --strict` exit 0; `tsc --noEmit` (engine) **0 errors**
- No dedicated test file for this module (`vitest` reports none), so no
suite to run — comment-only diff, no behaviour change

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:09:23 -07:00
gsxdsm
82329819f7 fleet: resolve the pre-archive unarchive target (census 69 → 68) (#3091)
## Census

| | column guards |
|---|---|
| before | **69** |
| after | **68** |

## How this was found

By finishing a triage I'd left incomplete. Of the 19 single-guard files,
I had actually examined six and flagged the rest partly on assumption —
so I went back and read them.

Five of the remaining ones turned out to be `archived` comparisons
**pinned by `archived-column-gate-parity.test.ts`** (`audit-ops`,
`task-id-integrity`, `mission-store`, `async-comments-attachments`, plus
`merge-queue-ops-2` for review). Converting any of those moves one of
three encodings that must move together.

**This one isn't pinned**, and that difference is the whole PR.

## What changed

```ts
if (!declaresPreArchiveColumn || preArchiveColumn === archivedColumn
    || preArchiveColumn === "archived")
```

Belt-and-braces: the condition already accepted the resolved lane **or**
the legacy id, stated twice. A set says it once, so the two halves can't
drift apart — the real risk with a duplicated condition, rather than the
census count.

## Why this isn't the split brain

The parity guard pins comparisons of a **task's column** — one of three
encodings of *"an archived task is not live."* This compares a **stored
`preArchiveColumn` value** against the board's archive lane: a different
question, about where to send a card on unarchive.

Verified rather than argued — that suite runs **green** here, and it
went **red** the last time I touched a pinned site (#3076, where I named
an arm and immediately reverted). It's a live check, not an assumption.

## Measured

| check | result |
|---|---|
| archive / artifact / unarchive suites | 7 files, **27 tests green** |
| `archived-column-gate-parity` | **2 passed** |
| five gates + strict census | green |
| core `tsc` | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:06:22 -07:00
gsxdsm
89b21e2906 fleet: triage's planning-evacuation check uses the resolved wip lane (census 45 → 44) (#3114)
## Census

| | column guards |
|---|---|
| before | **45** |
| after | **44** |

## What changed

```ts
if (task.column === disposeLanes.hold || task.column === disposeLanes.intake
    || task.column === "in-progress") return;
```

Two role questions and one id question on the same line.
`resolvePlannerLanes` is **already called immediately above**, and its
result carries `wip` — so this needs no new resolution and no new await.
The literal just stops being the odd one out among its neighbours.

## What it cost on a renamed board

This handler aborts a planning session when a card leaves the planner
lanes. `in-progress` is excluded because *a card advancing into
execution is not an evacuation* — that's stated in the note directly
above it.

Against the literal, that exclusion **never matched** on a board whose
execution lane is renamed. So a legitimate advance into execution read
as an evacuation and **killed a healthy planning session** — precisely
the case the comment says must not abort.

`wip` is optional by design (PR #2628: a missing role stays `undefined`
so callers refuse rather than invent a column). Undefined here means the
board declares no execution lane, so there's no advance-into-execution
to exclude and the comparison is correctly false.

## Not addressed, and pre-existing

This line resolves through `resolvePlannerLanes` — the **sync** twin,
which returns the default workflow's lanes under PostgreSQL. That
affects all three lanes on the line equally and predates this change:
the handler is `(task: Task) => {}` with no await available, so fixing
it needs the same emitter-side change as #3082.

Making the third lane consistent with the other two doesn't deepen that,
and it leaves **one** shape to fix there rather than two.

## Measured

| check | result |
|---|---|
| triage / evacuation / planner-lane suites | **453 tests green** |
| four gates + strict census | green |
| engine `tsc` | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:06:08 -07:00
gsxdsm
7d75633daa gate: follow sync-lane sources across module boundaries (11 inert guards were invisible) (#3079)
## Eleven inert guards were invisible to the check that exists to find
them

#3062 shipped this check collecting sync-lane sources **per file**, and
recorded the module-boundary gap as a known limit. That limit has
stopped being theoretical.

`resolvePlannerLanes` is defined in `replan-target.ts` and consumed in
`triage.ts` and `executor.ts`. `triage.ts:723` already reads:

```ts
if (task.column === disposeLanes.hold || task.column === disposeLanes.intake || task.column === "in-progress") return;
```

A sync-resolved pair sitting directly beside a literal. That is
precisely the site a fleet worker reaches for next — the "conversion" is
a one-word edit, it drops the census by one, and it changes nothing,
because `resolvePlannerLanes` resolves through
`store.resolveTaskWorkflowIrSync` and always describes the default
board. **The shipped check could not have seen it.**

## What changed

Sources are collected in a **first pass over the whole tree**;
consumption is counted in a **second pass** against that repo-wide set.

| file | guards now visible |
|---|---|
| `packages/engine/src/scheduler.ts` | 18 |
| `packages/engine/src/triage.ts` | **7 — previously invisible** |
| `packages/engine/src/executor.ts` | **4 — previously invisible** |

**Baseline 20 → 29. The rise is detection, not regression.** Those
eleven guards were already inert; nothing in the tree got worse and no
production file is touched by this PR. Flagging that explicitly because
the check's own failure text says *"do NOT re-record the baseline to
clear this"* — that rule is about a code change, and this is a detector
widening. The distinction matters and I would rather state it than have
it inferred.

## Mutation evidence, on the motivating site

Converting `triage.ts`'s literal to `disposeLanes.wip` — the exact inert
edit this widening exists to prevent:

| | result |
|---|---|
| check as shipped on main | **exit 0** — missed entirely |
| this PR | **exit 1**, `triage.ts: 7 → 8` |

`scheduler.ts` restored clean after every run.

## Third widening — and the honest read on that

This is the third hole found in this check, each discovered the same
way:

1. **#3062**: matched only the local-variable spelling; the inline
`resolveX(...).review` walked past.
2. **#3068**: matched only comparisons; `parked.terminal.has(to)` walked
past — and worse, made the count *fall*, inviting a re-record that would
have retired live sites.
3. **here**: matched only same-file sources; a cross-module helper
walked past.

Every time, the check was correct about the shape in front of it and
blind to a trivially different spelling of the same defect. I said in
#3068 that the durable fix is to key on **the source** rather than
enumerate consuming syntax; this closes the module-boundary half of
that. The remaining half is value-level dataflow (a sync-resolved id
assigned through an intermediate, passed as an argument, or returned),
which needs a type-aware pass and is a larger change than a gate script
should carry casually.

I am not claiming this version is complete. It is measurably better than
the shipped one on a site that exists in the tree today.

## Census before / after

```
before:  COLUMN guards (the backlog):   84
after:   COLUMN guards (the backlog):   84
```

Unchanged — this converts nothing. It makes eleven already-inert guards
countable so the next conversion of them fails loudly instead of reading
as progress.

## Verification

`test:gate` exit 0 · `pnpm check:inert-sync-lanes` exit 0 ·
lifecycle-column census exit 0 · `pnpm lint` clean. Gate script +
baseline only.

Still open and unrelated: **#3073** re-greens the archived-gate parity
ratchet, which is red on `main` right now and sits outside the merge
gate.
2026-07-31 05:03:11 -07:00
gsxdsm
b633faacab census: --triage splits the backlog into documented deferrals vs unexamined (#3097)
## The headline number stopped tracking work

I have hand-computed this breakdown every round of the phase to decide
what to claim. Making it a first-class mode so nobody else has to, and
so "census before/after" in a PR body means something.

On current `main`:

```
COLUMN guards (the backlog):   51

TRIAGE (heuristic, opt-in; changes no count and no exit code)
  documented deferral (flag note within 40 lines): 13
  unexamined:                                      38

  unexamined, by file — this is the list to pick work from:
     21  packages/engine/src/self-healing.ts
      4  packages/engine/src/executor.ts
      2  packages/engine/src/auto-merge-finalization.ts
      2  packages/engine/src/scheduler.ts
      1  packages/core/src/eval-signal-collector.ts
      ...
```

**51 reads as a lot of available work. It is not.** 13 carry an explicit
reason for staying a literal, and 21 of the remaining 38 are
`self-healing.ts` — which has **eight** open PRs on it. What is actually
loose is roughly a dozen scattered singletons, most of them in
synchronous listeners where the only available resolver is inert.

That gap is not cosmetic; it is causal. A worker told to "claim the
largest cluster" reads 51, finds little that is both unclaimed and
convertible, and reaches for whatever moves the number. That is exactly
how #3051 converted ten `scheduler.ts` guards to
`resolveTaskWorkflowIrSync` — inert under PostgreSQL, refuted end-to-end
in #3058 — and how five open PRs came to share the same helper.

## Design constraints I held to

- **Opt-in.** No flag, no change. Verified: default output is
**byte-identical** to `main` (`diff` clean), and `--json`, `--strict`
and `--compare` all still exit 0.
- **Beside the totals, never inside them.** It changes no count and no
exit code — the same discipline `traitFallbackCount` already documents
two lines above, and for the same reason: a deferred guard is still a
guard.
- **Nothing downstream consumes it.** It is a triage aid for choosing
work, not a gate.

## Stated limits

Classification is **comment proximity**: an FNXC note within 40 lines
above the guard whose text marks a deliberate deferral. It cannot tell a
good reason from a bad one, and a note that sits far above its guard
reads as unexamined. That is why it is opt-in and why no gate reads it.

## The bug I shipped into my own draft, and what it cost

The first version reported **`documented deferral: 0`** — for a tree I
knew had them, because I had counted them by hand that morning. Cause:
it referenced an undefined path constant, and my `try/catch` turned the
`ReferenceError` into an empty file list, so every proximity window was
the empty string and nothing ever matched.

That is the same silent-catch shape I have flagged in review twice this
phase. The catch is now gone: **a triage aid that fails to zero is worse
than one that throws**, because zero reads as a clean answer rather than
a broken instrument. Post-fix it reports 13/38, which matches the hand
counts I have been posting all phase.

## Census before / after

```
before:  COLUMN guards (the backlog):   51
after:   COLUMN guards (the backlog):   51
```

Unchanged by construction — this converts nothing. It makes the number
interpretable.

## Verification

`test:gate` exit 0 · default census output byte-identical to main ·
`--json` / `--strict` / `--compare` exit 0 · `pnpm lint` clean. One
script; no production file touched.

Related and still open: **#3079** (makes the five inert
`resolveMoveFanoutColumnsSync` guards fail the build instead of
registering as a census win) and **#3095**.


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

## Summary by CodeRabbit

- **New Features**
- Added an optional triage mode to classify column findings as
documented deferrals or items requiring review.
- Added aggregate and top-file triage results to make findings easier to
assess.
  - Added guidance for the new command-line option.

- **Bug Fixes**
- Improved source-reading error handling so failures are reported
clearly instead of being silently ignored.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 05:02:57 -07:00
gsxdsm
ec2921b958 fleet(engine): self-healing 23 → 6 — async-reachable guards, plus 3 of 4 fan-out guards the sync path could not serve (#3094)
**Replaces #3093, which I am closing.** Third rebuild of this work.

## A coordination note first, because it is costing more than the code

`self-healing.ts` has had **three** overlapping conversions land from
other lanes while my branch was open — #3049, #3075, #3078. Every time,
replaying my commits produced conflicts that were all the same shape:
*same guard, two spellings, different variable names*. Each rebuild is a
full cycle spent on merge mechanics rather than on lanes.

I have rebuilt against `main`'s own census each time rather than argue
about whose spelling wins, and this PR contains only what `main` (23)
does not have. But if this file is going to keep receiving concurrent
fleet passes, one lane should own it — otherwise the next PR pays the
same tax again.

## Converted

| site | note |
|---|---|
| `isPhantomExecutorBinding` | caller resolved
`lanesOfReclaim(task.id).wip` **three lines above the call**, then
passed a task whose column the predicate compared against `in-progress`
|
| `isWorkspaceOwnerLive` | required `completeColumns` |
| `recoverPausedAbortFailures` **body** | #3075 converted this sweep's
*router* and left three body guards comparing ids |
| `reconcilePreExecutionWorktrees` | a four-id literal in a sweep that
**removes worktrees** |
| `recoverStarvedRefinementTriageTasks` | 2 peer counts that read zero,
so escalation never fired |
| `evaluateParkedAgentTaskLink` | the omitted `parkedColumns` argument |

All through the **async** `resolveProjectColumnsForRoles`, whose only
store read is `listWorkflowDefinitions()` — answerable under PostgreSQL.
That is what separates these from the inert kind.

**The half-converted sweep is the important one.** A router that
resolves correctly feeding a body that compares ids is worse than
converting neither: the route now fires on a renamed board and the body
then acts on the wrong lane. The `moveTask` **target** is the sharp end
— an undeclared target is rejected *except* under `recoveryRehome` with
a legacy id (`moves.ts:570`, the #1411 escape hatch), so a converted
route feeding the literal `"todo"` rehomes the card into a column its
workflow does not declare, which is the state other reconcilers exist to
repair.

**A real dropped-behaviour bug**: `evaluateParkedAgentTaskLink` was
called without `parkedColumns`, falling back to `LEGACY_PARKED_COLUMNS`.
A live durable agent linked to a card resting in a renamed hold lane
read as not-parked, so the safeguard preserving its task link never
applied.

## Withdrawn: the `task:moved` fan-out

I wrote the sync-IR conversion, measured it, removed it.
`getTaskWorkflowSelectionImpl` returns `undefined` **unconditionally**
under PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the
default builtin IR and `columnsWithFlag` on it yields exactly the legacy
ids — inert on every board.

Worse than the literal, because **the literal is counted**. My own test
passed only because its store mock supplied a renamed IR: it pinned the
helper's shape, not production behaviour. The refutation is recorded in
place, and `check-inert-sync-lane-conversions` exits 0 on this branch.

## One question, one answer

An earlier pass of this work mapped the notification-attach guard onto a
wider `activeWork` set, and `self-healing-paused-abort-recovery >
"rehomes an in-progress pause-abort park back to todo"` caught it — an
in-progress park attached a transition notification it should not have.

The fix is not a narrower set. Both guards ask **one** question — *"is
the card already at the requeue target?"* — which the literal happened
to spell twice as `=== "todo"`. The target now resolves once, before the
write, and both read it. Deriving one question two ways is exactly how a
converted guard and an unconverted target drift apart.

## Census

| | before | after |
|---|---|---|
| `self-healing.ts` | 23 | **11** |
| repo backlog | 53 | **41** |

## Measured

- `src/__tests__/self-healing*` + `task-agent*` — **42 files / 836 tests
pass**
- `tsc --noEmit -p packages/engine` clean;
**`check-inert-sync-lane-conversions` exits 0**; census `--strict`,
`check-lane-wiring`, `check-fnxc-future-dates` clean

## The remaining 11, flagged not guessed

- **4** — the fan-out, withdrawn above; blocked on a sync-capable
selection reader.
- **1** — the log-dedup closure: pre-existing flag; it sits before the
lane prefetch it needs, and the degraded answer costs a duplicate log
line, not a lifecycle decision.
- **1** — the synthetic `{ column: "todo" }` for a *missing* task:
deliberate, and now correct rather than unconverted, because
`parkedColumns` is legacy-seeded.
- The rest are status/deliberate classifications the census counts but
that are not lane guards.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved workflow automation for boards using renamed or customized
workflow lanes.
* Fixed task completion fan-out, branch rebinding, recovery, and
stalled-task detection across custom lifecycle columns.
* Prevented completion actions from triggering when tasks move back to
the work-in-progress lane.
* Improved cleanup and pause recovery behavior for customized workflows.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:56:49 -07:00
gsxdsm
e43650416d fix(census): surface files where a conversion may be INERT (#3105)
Tooling fix for a measurement gap **I created and then found.**
Independent of my other branches.

## The gap

`resolveTaskWorkflowIrSync` returns the **default** workflow IR for
every task under PostgreSQL — the shipped backend — because the sync
selection reader answers `undefined` unconditionally. A guard resolved
through it behaves **exactly as the literal it replaced**, yet the
census scored it as converted. The backlog number fell; production did
not change.

I did this twice. One was caught by the new call-site ratchet
(`self-healing.ts`, since reverted to an honest literal). The other —
`scheduler.ts` in my merged #3051 — was not, because it has an
allow-list entry.

**The allow-list stops the class growing. It does not stop it
counting.** `scheduler.ts` alone holds **10 guards** fed by its
allow-listed sync resolver, all already subtracted from the backlog by
earlier PRs.

## What this adds

```
  SYNC-RESOLVED files (conversions here may be INERT): 5
  `resolveTaskWorkflowIrSync` answers with the DEFAULT workflow in production, so a guard
  resolved through it behaves exactly as the literal did. Counts are REMAINING literals;
  a count of 0 is the WORST case, not the best — the file reads as fully converted.
       2  packages/engine/src/scheduler.ts
       0  packages/core/src/store.ts
       0  packages/core/src/task-store/task-store-helpers.ts
       0  packages/core/src/task-store/workflow-task-create-ops.ts
       0  packages/engine/src/replan-target.ts
```

Two deliberate choices:

- **Scans every censused file, not just those with remaining literals.**
A file converted *entirely* through the sync resolver has zero remaining
and would be invisible — which is exactly the case worth surfacing,
because it reads as 100% done. Four of the five are at zero, including
`replan-target.ts`, which the ratchet's own notes record as found only
by the ratchet.
- **A warning, not a subtraction.** Attributing individual guards to the
resolver needs dataflow this parser doesn't do, so the honest output is
"this file contains a sync call site, conversions in it may be inert"
rather than a precise number wrong in the other direction.

## Verification

- Totals and `--json` **byte-identical** before/after (stash-compare: 47
guards, 132 deliberate both ways) — the section is purely additive
- Census suites green: 53 tests + the node-test fallback suite
- Regex requires a *call*, not a mention, so the many files discussing
this in prose don't trip it

## Why it matters to the program

You steer by the backlog number. Right now a real conversion and an
inert one are indistinguishable in it, and my own reports of "20 / 45 /
74 sites resolved" were computed that way. This makes the ambiguity
visible at the point of measurement instead of relying on a reviewer
remembering the PG caveat.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:33:49 -07:00
gsxdsm
39e6891c93 chore(core): mark the dead sync-path lane literal DELIBERATE-LITERAL (census 104→102) (#3060)
Fleet phase. Claimed `packages/core/src/task-store/project-store-ops.ts`
— the largest census file with no branch, worktree, or open PR against
it. Claim published by pushing the branch **before** starting work.

## Census before / after

| | total | this file | deliberate |
|---|---|---|---|
| before | **104** | 2 | 130 |
| after | **102** | 0 | 130 |

`--strict` exits 0, baseline re-recorded in the same commit.
**Reclassification, not conversion** — the line is unchanged.

## The site was already audited today, in prose the tool cannot read

```
FNXC:WorkflowLifecycleColumns 2026-07-31-02:45 (audited — DEAD SYNC PATH, do not convert):
… It is the SQLite-mode twin. The live path is `dequeueMergeQueueOnColumnExitInTransaction`
… and it is ALREADY converted … This body reaches for `store.db.prepare`, which throws in
   PostgreSQL backend mode …
```

The reasoning is sound and I did not second-guess it: the live path is
converted, this twin cannot execute in production, and converting it
would mean threading a lane set into a function whose first statement
throws.

The problem is purely mechanical — **the note is prose, and the census
reads markers.** So the site stayed in `byFile` looking like unconverted
debt, and each fleet pass pays to re-derive the same conclusion. Adding
`DELIBERATE-LITERAL` moves it to `deliberateByFile`, where a
reviewed-and-kept literal belongs.

## This is the second one, which makes it a pattern

Same shape as #3056 (`async-mission-store-queries.ts`, fallback arms).
Across the files I have checked this phase — `agent-store`,
`github-tracking-state`, `planner-overseer`, `auto-merge-finalization`,
`async-mission-store-queries`, and this one — **every site was either a
fallback arm or an already-documented deliberate leave**, and
`agent-store.ts:236` carries its own "FLAGGED AND LEFT COUNTED" note
from today.

So the count is not a work queue, and the gap is not judgement —
previous passes reached the right answer. They recorded it where only a
human reader would find it. Two lines of marker per site closes that,
and the number then means "conversions owed", which is how every worker
reads it when picking a cluster.

## Verification

- `census --strict` exit 0; `tsc --noEmit` **0 errors**
- `check:fnxc-future-dates`, `check:lane-wiring`,
`check:sql-column-literals`, `check:inert-flag-seams` — all exit 0
- No behaviour change: only a comment added

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:30:00 -07:00
gsxdsm
dca67a79a3 fix(census): a DELIBERATE-LITERAL marker on a ternary arm was invisible (#3099)
## The gap

The marker works above a statement or a function. It does **not** work
in the position people actually use it — on the fallback arm itself,
right beside the literal it excuses:

```ts
flags
  ? flags.hold === true
  /* DELIBERATE-LITERAL — the no-metadata fallback. */
  : column === "in-progress";
```

That comment sits **before the `:` token**, so it's the colon's leading
trivia rather than the arm expression's — `getLeadingCommentRanges` at
the arm's full start never sees it. The ancestor walk doesn't rescue it
either: the next ancestor is the `ConditionalExpression`, whose own
leading comments are somewhere else entirely.

## Measured, not hypothesised

`in-review-stall.ts` carried a marker in exactly this position **and
stayed on the backlog**. The only way I could clear it was to
restructure the code into a named set (#3064).

That's the tool dictating shape rather than reading intent — and a
marker that silently does nothing trains people to stop marking. Given
this fleet phase has had several workers reach for `DELIBERATE-LITERAL`
(#3056 used it successfully at statement level), the failure mode is one
worker's marker working and another's not, for reasons neither can see.

## Scope and controls

Scoped to the span between the previous arm (or the condition) and this
one, so it can't pick up a comment belonging to anything else. Verified
both directions with a probe:

| case | before | after |
|---|---|---|
| marker above the statement | `deliberate` | `deliberate` |
| marker on the ternary arm | **`column`** | `deliberate` |
| unrelated marker on a neighbouring statement | `column` | `column`
(unchanged) |

**The real tree's count is unchanged at 51** — nothing is silently
reclassified, because the one site that had an arm marker was already
converted away. This is forward-looking.

## Not fixed here — pre-existing red on `main`

`lifecycle-column-census.test.ts` has two failures (`expected 22 to be
26`, `expected +0 to be 1`) whose fixture arithmetic drifts with real
tree counts as fleet conversions land. **Verified identical on
`origin/main`** before and after this change, so it isn't mine — but
someone should decide whether that fixture ought to be derived rather
than pinned, since every fleet merge moves it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:19:11 -07:00
gsxdsm
a7b2a757fa fix(engine): serialise wedge handling per task, then convert the lane guards it was blocking (5 → 1) (#3087)
The largest unclaimed census cluster, and the one two earlier fleet
passes explicitly declined.

## The standing blocker, taken on

Both passes converted these four ids and reverted, each time after the
same test went red:

```
task-wedge-notification.test.ts > sends one actionable push and mailbox message per active terminal episode
  expected 2 calls, got 1
```

Their diagnosis was right and I have kept it: this branch **resolves** a
wedge episode, `handleTaskUpdated` starts it fire-and-forget from a
synchronous `(task) => void` listener, and **any** await introduced
before the resolve lets a re-wedge arriving close behind reach `claim`
while the previous episode is still active — `claimed: false`, second
operator notification silently dropped. Column resolution needs an
await, so the conversion could not be made safe from inside the branch.

Both notes named the fix and left it for "whoever owns the wedge episode
contract": *serialise wedge handling per task*. This PR does that, then
takes the conversion.

## 1. Serialisation

`enqueueWedgeHandling` chains handling per task id, so
resolve-then-claim keeps its order however many awaits either branch
acquires. Details that matter:

- **Keyed by task, not global** — different tasks stay concurrent, so
this is not a throughput regression on a busy board.
- **The map entry is dropped when its chain drains**, and only if no
later link was appended while it ran, so it does not grow with the task
table.
- **Links never reject.** `maybeNotifyTaskWedge` already owns its error
handling; a rejected link would poison every later notification for that
task.

## 2. The conversion it was blocking

The four ids are an enumeration of *"every lane except review"* — the
lanes whose occupancy proves a wedged card's lifecycle has visibly
resumed. On a renamed board none of them matched, so a recovered card's
episode never resolved. Two consequences, and the second is worse than
the first:

1. the operator keeps an open "needs operator action" alert for work
that has moved on;
2. an active episode **suppresses re-claim**, so the *next* genuine
wedge on that task is never delivered.

Membership over the four roles, legacy-seeded, so an unconverted board
resolves exactly the four ids it used to compare.

## Measured

**The acceptance test the earlier notes named is the gate on both
halves.** With the conversion and *without* the serialisation, "sends
one actionable push and mailbox message per active terminal episode"
fails exactly as they reported. With the serialisation, green. I
reproduced their finding rather than taking it on trust — it is the
evidence that the serialisation is load-bearing and not incidental
refactoring.

| | result |
|---|---|
| `task-wedge-notification.test.ts` | **15/15** (2 new) |
| notification suites | **11 files / 234 tests pass** |
| `tsc --noEmit -p packages/engine` | clean |
| census `--strict`, `check-lane-wiring`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` | clean |

**MUTATION**: restoring the four literals fails the renamed-recovery
case and leaves its paired negative green.

**A vacuity I caught and fixed, worth stating plainly.** My first
version of the renamed case recovered the card with `status: "queued"`.
`hasProgressed` is an OR whose other arm is *"status is a non-failed
string"* — so that arm answered true and the column comparison never
ran. The mutation did not fail it. The case now clears `status` and
`error` together, which makes column membership the only thing that can
resolve the episode, and the paired negative uses the identical shape so
only the lane differs.

## Census

| | before | after |
|---|---|---|
| `notification-service.ts` | 5 | **1** |
| repo backlog | 71 | **67** |

## The remaining 1, flagged not guessed

`isManualMergeHold` (`task.column !== "in-review"`) is sync, and so is
its only caller `classifyWorkflowTransitionNotification`, reached from
the same `handleTaskUpdated` listener. Converting it means making that
whole chain async — a change to notification *classification ordering*
against every other `task:updated` handler, which is a different
contract from the episode one this PR owns. The serialisation added here
does not cover it: it wraps wedge handling, not transition
classification. Threading a pre-resolved `LifecycleColumns` in as a
parameter is the likely fix, and it wants the same gate-placement
judgement applied deliberately rather than swept in behind this.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:10:03 -07:00
gsxdsm
6949f22ef8 fleet: resolve the same-column handoff review target (census 84 → 83) (#3076)
## Census

| | column guards |
|---|---|
| before | **84** |
| after | **83** |

`moves.ts`: 2 → 1. One of its two sites converts; the other **must
not**, and that difference is the useful part of this PR.

## Converted — the move target at the same-column handoff

```ts
if (internal.fromHandoff && toColumn === "in-review")
```

Against the literal this **never fired on a renamed board**, so a
same-column handoff into a renamed review lane silently took the *other*
branch — the sync-SQLite path, which throws under PostgreSQL.

It now asks `moveReviewColumns`: the broad membership set
(`mergeOrchestration ∪ mergeBlocker ∪ humanReview`) already resolved
**three lines above** for the merge-queue pair. Same value, so this
branch cannot disagree with the enqueue/dequeue calls that receive it.

## Not converted — the archived fallback arm

I named it, and `archived-column-gate-parity.test.ts` went red on
**`TypeScript encoding changed`**.

That guard's argument holds: the archived gate is enforced in three
encodings, the SQL halves still compare the raw string, and moving the
TypeScript half alone is the split brain it exists to prevent. Restored
inline **with a note recording the measurement**, so the next person
doesn't retry it and rediscover the same red.

This is the second time that guard has stopped me this session. It's
doing exactly what it was built for.

## On the pre-existing red

That suite is red on `origin/main` for an unrelated raw-SQL drift (#3072
fixes it — the drift is from my own merged #3042/#3046). I verified this
branch produces the **identical** failure and no other, so it doesn't
compound it.

## Measured

| check | result |
|---|---|
| moves / handoff / merge-queue suites | green |
| four gates + strict census | green |
| core `tsc` | clean |
| parity suite | same single raw-SQL failure as `origin/main`, nothing
added |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:57:51 -07:00
gsxdsm
9e242ea294 fix(engine): backlog pressure called every dependency unfinished on a renamed board (#3081)
## The third lane question

This reporter had **three** lane questions. Two were resolved when the
file's query-blindness was fixed — hold and wip, both through
`resolveProjectColumnsForRoles`. The third sat one method down and was
never touched:

```ts
if (dependency.column !== "done") return false;
```

One board, two lane answers.

## What it cost

On a renamed board every dependency reads unfinished, so
`isRunnableCandidate` rejects every card that has one. The
backlog-pressure alert then names **only dependency-free cards** as the
runnable ones.

The failure mode is the quiet kind: the report still renders, the counts
are right, and the candidate list looks plausible. The operator is told
the queue is blocked on nothing in particular. No default-board test can
see it — which is exactly why the earlier conversion of this same file,
which fixed its reads, left this behind.

## Fix

`finishedColumns` (complete ∪ archived) resolved once by the async
caller alongside hold and wip, then passed into the sync predicate.

- **Required parameter, not optional-with-a-literal-default.** An
optional parameter leaves `done` in the file as a silent fallback and
the next caller gets pre-conversion behaviour by writing nothing.
- **Archived is included** because a dependency that has been archived
is finished too — and this reporter already reads with `includeArchived:
true` precisely so archived blockers resolve.
- **Async resolution.** `resolveProjectColumnsForRoles`' only store read
is `listWorkflowDefinitions()`, a project-wide async read that works
under PostgreSQL. That is the line between a real conversion and the
inert sync-IR kind (#3058), and the new test supplies its board through
that same reader so it exercises the production path.

## Census

| | before | after |
|---|---|---|
| `backlog-pressure-reporter.ts` | 1 | **0** |

## Measured

- One new case; file **11/11 pass**.
- **MUTATION**: restoring `dependency.column !== "done"` fails it.
- The case asserts **both directions in one test** — a dependency
resting in the board's own complete lane makes its card runnable, *and*
a dependency still in the hold lane still blocks it. Asserting only the
first would pass against a predicate that had simply stopped checking
dependencies.
- The file already had a `RENAMED_IR` scoped to its second describe;
mine is a distinct `RENAMED_DEPENDENCY_IR` with different lane names. I
hit the shadowing first and the test failed as `under-threshold` — worth
noting because a same-named fixture that silently resolves to the
*other* board is precisely how a renamed-lane test goes vacuous.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Flagged, not guessed

Adjacent census entries I looked at and deliberately left:

- **`executor.ts` (4)** — all inside a sync `task:moved` listener.
Converting via `resolveTaskWorkflowIrSync` would be inert for #3058's
reason, and making the listener async reorders it against every other
subscriber. Correctly out of scope, as #3048 judged.
- **`triage.ts:724`** — half-converted in the same shape:
`disposeLanes.hold`/`.intake` come from a sync resolver, so the resolved
arms are themselves inert and "finishing" the guard would add a third
inert comparison.
- **`auto-merge-finalization.ts` (2)** — one is the resolver's
documented degraded fallback (the live arm calls `columnHasFlag`), the
other is already recorded as a deferred signature-widening whose cost
exceeds the error string it sharpens.
- **`in-review-stall.ts:196`** — an explicitly marked DELIBERATE-LITERAL
no-metadata fallback.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:51:43 -07:00
gsxdsm
3c531d984c fix(engine): self-healing lane cluster round 2 — 38 → 26 (two sweeps could disturb live work) (#3078)
The largest census cluster became **unclaimed again** when #3055 closed
conflicting. I had closed my own #3050 an hour earlier expecting #3055
to land, so this re-applies the conversions #3047 and #3049 did not
cover.

**Re-applied from current main rather than rebasing the closed branch.**
The conversions are small; the conflict archaeology is what went wrong
last time — nine conflicts against #3049, several on variable names
identical to mine, and my mechanical fixup corrupted the file badly
enough that I aborted. Starting from main cost less than resolving that
and carries no risk of resurrecting a stale line.

## Census

| | before | after |
|---|---|---|
| `packages/engine/src/self-healing.ts` | **38** | **26** |
| repo-wide column guards | 84 | **72** |

## Three sweeps, existing role helpers only

| sweep | roles | what it did on a renamed board |
|---|---|---|
| worktree metadata | terminal + wip + review | rebound finished cards
every pass, **and the FN-5256 liveness guard went silent** |
| orphaned pending step results | wip | **could rewrite `pending`
results under a live executor run** |
| agent-link drift | wip + review + terminal | evaluated agents whose
task was plainly still executing |

Two of these disturb **live** work, which is why they were worth redoing
now rather than leaving for the next fleet round:

- The worktree-metadata sweep clears `worktree`/`branch` metadata. Its
liveness guard is the thing standing between that and a running shell
(FN-5256). Keyed on ids, it matched nothing on a renamed board. The
scope-override safety condition beside it now reads the **same resolved
sets**, so the two cannot disagree about which lanes are live —
previously they were two independent literal lists.
- The orphaned-step-results sweep's own header says it must never touch
an executor-owned row. The id-keyed skip made it do exactly that.
Resolved once per sweep, outside the paging loop, so a large board still
pays one resolve.

## Flagged, not guessed — the 26 that remain

Unchanged from my earlier audit and re-verified on this base:

- **Sync predicates** (`isWorkspaceOwnerLive`, the pause-abort
classifier, the phantom-binding check, the `task:moved` listener
guards). No store handle; converting means a signature change or making
a synchronous event listener async, which reorders handlers against a
synchronous emitter.
- **Already-converted fallbacks** — `own.length > 0 ? own.includes(...)
: task.column === "in-review"`. The resolved answer wins; the literal is
the documented no-metadata path.
- **The notification-route `fresh.column === "todo"` sites** — measured
previously: any `await` before the wedge resolve drops an operator
notification. Needs the wedge-episode contract, not a column pass.

## Verification

self-healing suites **204 passed** · agent-link-drift +
query-filter-blindness **83 passed** · `pnpm test:gate` 161 + 13 + 487 +
71 · lint · census `--strict` · lane-wiring — green.
2026-07-31 03:48:44 -07:00
gsxdsm
98aac40ca8 fleet: reads.ts 2 → 0 lifecycle-column guards (#3057)
> **Rebased.** Main landed another worker's conversion of the review
gate while this was open — the overlap was a whole rewritten function,
so I reset to main and rebuilt only my remaining delta on top of their
work rather than resolving hunks. Their conversion is kept as-is.

## Census

| | column guards |
|---|---|
| before | **104** |
| after | **102** |

`reads.ts`: **2 → 0**.

## Two changes

**1. `includeColdStorage`** asks whether the *caller* is filtering to
the archive lane. Against the literal, a caller filtering to a renamed
archive lane took the false branch — cold storage was skipped and the
filtered view returned only whatever archived rows still sat in
`project.tasks`, **a short list presented as the whole archive**. Still
literal on main; converted here.

**2. Both fallbacks become named sets** instead of inline arms —
including the one on the just-landed review gate.

## The second point is the one worth the fleet's attention

This is bookkeeping correctness, not style. The census counts an inline
comparison **whether or not it sits in a fallback branch**, because its
`traitFallback` hint is advisory and never changes `kind`.

So a correctly-converted guard with an inline legacy arm **stays on the
backlog permanently**, and the number stops distinguishing real debt
from documented degraded answers.

Concretely: converting with an inline fallback is correct work that
scores **zero**. My own first pass at this file did exactly that. There
are roughly **12 such sites** across the tree — `github-tracking-state`,
`planner-overseer`, `async-mission-store-queries`,
`register-task-workflow-routes`, `restart-recovery-coordinator` — and I
have that cluster converted and ready to open next.

## Measured

| check | result |
|---|---|
| reads / get-task / stall suites | 5 files, **87 tests green** |
| renamed-archive PG suite | green |
| strict census | green; `tsc` clean |
| unconverted boards | byte-identical — the named sets hold the previous
ids |

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

## Summary by CodeRabbit

* **Bug Fixes**
* Review and archive checks now work correctly with resolved workflow
columns while retaining legacy compatibility.
  * Fresh agent activity is detected in resolved review lanes.
* Lists filtered by a resolved archive lane now include archived items
stored in cold storage.

* **Chores**
  * Updated internal lifecycle tracking baselines.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:43:03 -07:00
gsxdsm
3c12a51627 fix(core): the merge result reported a column the finaliser did not write (merge-queue-ops 3 → 0) (#3071)
Largest unclaimed census cluster in `packages/core` — three `done`
literals in `mergeTaskImpl`. Two of them produced **wrong state**, not
merely a guard that stopped firing.

## 1. The result overrode the writer

`moveToDoneImpl` resolves the board's completion lane and writes it onto
the task object:

```ts
task.column = completeColumn;   // task-artifacts-ops.ts
```

Both merge call sites then did:

```ts
result.task = { ...task, column: "done" };
```

putting the literal back over what the writer had just set. Every
`task:merged` listener — GitHub tracking, the auto-merge handoff — was
told the card landed in `done` while the persisted row said `shipped`.

The row was right and the event was wrong, which is the worse direction:
the listeners act on the event, not the row.

Fixed by reading back what the writer set (`{ ...task }`). Deliberately
**not** a second resolution — that would only be a second chance to
disagree with the finaliser.

## 2. The guard disagreed with the writer

The already-complete short-circuit asked `task.column === "done"`, while
the finaliser it guards short-circuits on the resolved `task.column ===
completeColumn`. On a renamed board those two answers differ, so a card
already resting in the board's completion lane fell through and the
merge ran again against a branch that was already landed and deleted.

Converted with the **same resolution and the same shape** — a single
first-match column, not membership — because the whole point is that
these two answers cannot differ. A workflow declaring no complete lane
resolves to `undefined`, which matches no column; the finaliser refuses
such a board explicitly one function later.

## Census

| | before | after |
|---|---|---|
| `merge-queue-ops.ts` | 3 | **0** |

## Measured

- Two new cases added to `merge-blocker-renamed-review-lane.test.ts`
(same renamed-board fixture, same PG harness) — file **5/5 pass**.
- **MUTATION**: restoring either literal fails **both** new cases and
leaves the three pre-existing ones green.
- Reached with **no git fixture**: with no branch present, `git
rev-parse --verify` fails and the function takes its own documented
*"branch not found — moving to done without merge"* path — which is
exactly the path that calls `moveToDone` and then builds the result. No
repo setup, no flake surface.
- `packages/core` targeted run: **38 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-lane-wiring` ("none added"), `check-fnxc-future-dates` clean.

## Not done here (flagged, not guessed)

The other `done`/`archived` literals still in the core census are each
blocked for a *different* documented reason, so sweeping them into this
PR would have meant guessing:

- `agent-store.ts:236` — a pure formatter over `Pick<Task,"column">`
that prints the column for a human; degrades gracefully and has no store
to resolve from.
- `async-mission-store-queries.ts` — already converted with
caller-threaded lane sets.
- `taskRevert.ts:119` — classifies a **neighbour** task; the only flags
in scope describe the modal's own task, so wiring them would answer the
question for the wrong row. Needs per-neighbour flags.
- `moves.ts:310` — a **refusal**, where a legacy-seeded superset is the
documented hazard rather than the safe direction. Wants its own change
with its own test.
- `mission-store.ts:2332` — a sync SQLite path with no async seam.

Each is real debt; none is a mechanical conversion.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 03:40:11 -07:00
gsxdsm
8eef8852a0 fleet: 4 long-tail fallback arms become named sets (census 101 → 97) (#3064)
## Census

| | column guards |
|---|---|
| before | **101** |
| after | **97** |

The single-guard long tail is **19 files**. This converts the four whose
legacy arm is unambiguously a fallback on an already-converted guard;
the other 15 are flagged below rather than guessed at.

## Two shapes

**`in-review-stall.ts`, `stalled-review-detector.ts`** — the resolved
answer with an inline legacy arm:

```ts
reviewColumns ? reviewColumns.has(col) : col === "in-review"
→ (reviewColumns ?? LEGACY_REVIEW_LANES).has(col)
```

**`merger.ts`, `in-process-runtime.ts`** — belt-and-braces:

```ts
col !== (lifecycle?.complete ?? "done") && col !== "done"
```

That accepted the resolved lane **or** the legacy id, stated twice. A
union set says it once, so the two halves can't drift apart — which is
the real risk with a duplicated condition.

## A finding for anyone else marking fallbacks

`in-review-stall.ts` **already carried a `DELIBERATE-LITERAL` marker**
on that arm and was counted anyway. The marker sits in a comment *inside
a ternary*, which the census's leading-comment lookup doesn't reach.

So: **naming the set works, marking it does not.** Worth knowing before
someone marks a fallback and expects the count to move.

## No behaviour change

`new Set(["in-review"]).has(x)` answers exactly what `x === "in-review"`
answered, and the union sets accept exactly the two lanes their
conditions already accepted.

## Flagged, not converted

The remaining 15 single-guard sites need individual judgement, not a
mechanical pass:

- **plain unconverted guards with no resolution in scope** —
`audit-ops`, `lifecycle-ops`, `merge-queue-ops`, `task-id-integrity`,
`backlog-pressure-reporter`, `ephemeral-worker-manager`,
`ResearchTaskActionModal`
- **sites where the literal IS the answer** — `eval-signal-collector`
maps a column to an archive-vs-done *label*; `TaskCard` reads a
completion timestamp
- **already resolved on their line** — `triage.ts`,
`restart-recovery-coordinator.ts`, both covered by open PRs

## Measured

| check | result |
|---|---|
| core stall suites | 4 files, **85 tests green** |
| engine merger/runtime suites | **1044 tests green** |
| five gates + strict census | green |
| `tsc` (core, engine) | clean |

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