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>
This commit is contained in:
gsxdsm
2026-07-31 05:50:13 -07:00
committed by GitHub
parent d09a856941
commit ada62a7c4a
6 changed files with 328 additions and 10 deletions

View File

@@ -131,6 +131,16 @@ export function collectDeterministicSignals(
return {
taskId: task.id,
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:51 (DELIBERATE-LITERAL — this is a FALLBACK ARM, not
pending work): the resolved path is `options.archivedColumns`, and the literal is only reached when
a caller supplies no resolved set. Marked rather than converted because there is nothing left to
convert here: rewriting the fallback to resolve on its own would need a workflow read inside a
collector that takes none, and would move this file's TypeScript tally in
`archived-column-gate-parity.test.ts`, whose argument is that the archived gate's three encodings
must move together. The marker exempts it from the census; the comparison itself is unchanged, and
that guard's scan is marker-blind, so its inventory is untouched.
*/
column: (options?.archivedColumns ? options.archivedColumns.has(task.column) : task.column === "archived")
? "archived"
: "done",

View File

@@ -155,6 +155,15 @@ export async function getLiveTaskColumn(
.limit(1);
const row = rows[0];
if (!row) return null;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:51 (DELIBERATE-LITERAL — FALLBACK ARM, not pending work):
`archivedColumns` is the resolved path; the literal is reached only when a caller supplies no
resolved set. `archived-column-gate-parity.test.ts` opens by naming THIS FILE as the worked example
of why the archived gate cannot be converted one encoding at a time — the SQL halves in this same
module still compare the raw string, so converting the TypeScript arm alone is the split brain it
describes. Marked so the census stops offering it as available; the comparison is unchanged and that
guard's scan is marker-blind, so its audited inventory is untouched.
*/
const isArchivedLane = archivedColumns ? archivedColumns.has(row.column) : row.column === "archived";
if (isArchivedLane || row.deletedAt != null) return "archived";
return row.column;

View File

@@ -468,10 +468,35 @@ describe("the baseline can always be re-recorded", () => {
}
}
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:59 (the fixture rotted, and it took main red with it):
These two cases pinned the FILE `self-healing.ts` and the NUMBER 1 — "stale baseline says 1, tree
has more". Conversions took that file to exactly 1, so `toBeGreaterThan(1)` failed and `main` went
red on a test whose subject (does `--update-baseline` write on a rise?) had not changed at all.
Measured on a clean detached `origin/main`: 1 failed | 39 passed, with nothing from this branch
applied. The backlog shrinking is the POINT of this program, so any fixture keyed to a specific
file's count is guaranteed to expire — the only question is which cycle.
So the target and the number are now DERIVED: ask the census which file currently holds guards,
then construct a baseline one below that file's real count. The rise is manufactured rather than
assumed, and the assertion is exact (`toBe(real)`) instead of an open inequality that was only ever
a proxy for it. Same discipline as the self-syncing fixture below — a test about control flow must
not depend on how much work the fleet has finished.
*/
function fileWithGuards(): { file: string; count: number } {
const out = execFileSync("node", [cliPath, "--json"], { encoding: "utf8", cwd: repoRoot }) as string;
const parsed = JSON.parse(out) as { byFile: [string, number][] };
const entry = parsed.byFile.find(([, count]) => count > 0);
if (!entry) throw new Error("census reports no file with guards — fixture cannot manufacture a rise");
return { file: entry[0], count: entry[1] };
}
it("exits 0 and REWRITES the baseline under --update-baseline, even when the count rose", () => {
/* The case the ordering bug broke: a rise used to exit before the write, so the
one command whose whole job is re-recording could not re-record. */
const stale = { totals: { column: 1, role: 0, status: 0, deliberate: 0 }, byFile: { "packages/engine/src/self-healing.ts": 1 }, byColumnId: {}, queryByFile: {} };
const { file, count } = fileWithGuards();
const stale = { totals: { column: count - 1, role: 0, status: 0, deliberate: 0 }, byFile: { [file]: count - 1 }, byColumnId: {}, queryByFile: {} };
const r = runCli(["--strict", "--update-baseline"], stale) as unknown as { status: number; stdout: string; baselinePath: string };
expect(r.status).toBe(0);
const written = JSON.parse(fs.readFileSync(r.baselinePath, "utf8"));
@@ -479,20 +504,109 @@ describe("the baseline can always be re-recorded", () => {
FNXC:LifecycleColumnCensus 2026-07-30-19:10:
Asserted on the per-file entry rather than `totals`, which the pin no longer stores — the
derived aggregates were the only lines every conversion PR rewrote, and so the sole cause of
fleet-wide conflicts in this file. The claim is unchanged and still specific: the stale pin
said 1, and the rewritten pin must carry the tree's real (higher) count for that same file.
fleet-wide conflicts in this file. The claim is unchanged and still specific: the stale pin was
one BELOW the tree, and the rewritten pin must carry the tree's real count for that same file.
*/
expect(written.byFile["packages/engine/src/self-healing.ts"]).toBeGreaterThan(1);
expect(written.byFile[file]).toBe(count);
expect(r.stdout).toContain("ACCEPTED RISES");
});
it("exits 1 and LEAVES the baseline alone on a rise without --update-baseline", () => {
const stale = { totals: { column: 1, role: 0, status: 0, deliberate: 0 }, byFile: { "packages/engine/src/self-healing.ts": 1 }, byColumnId: {}, queryByFile: {} };
const { file, count } = fileWithGuards();
const stale = { totals: { column: 1, role: 0, status: 0, deliberate: 0 }, byFile: { [file]: count - 1 }, byColumnId: {}, queryByFile: {} };
const r = runCli(["--strict"], stale) as unknown as { status: number; stdout: string; baselinePath: string };
expect(r.status).toBe(1);
const after = JSON.parse(fs.readFileSync(r.baselinePath, "utf8"));
expect(after.totals.column).toBe(1);
});
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:58:
`--claims` reports which remaining files an open PR already touches. Both cases here run against a
STUBBED `gh` on PATH, so the suite makes no network call and does not depend on the repo's live PR
list — a test that asserted real PR numbers would go red every time one merged.
THE FAIL-SOFT CASE IS THE IMPORTANT ONE. When `gh` cannot answer, the degraded report must say so
loudly. A claim report that silently renders "nothing is claimed" is worse than no report at all:
it actively tells the reader to start work another lane already holds, which is the exact failure
this flag exists to prevent (three overlapping conversions on self-healing.ts, two on executor.ts).
*/
function runWithStubbedGh(stub: string, extraArgs: string[] = []): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fusion-census-gh-"));
const ghPath = path.join(dir, "gh");
fs.writeFileSync(ghPath, stub);
fs.chmodSync(ghPath, 0o755);
try {
return execFileSync("node", [cliPath, "--claims", ...extraArgs], {
encoding: "utf8",
cwd: repoRoot,
env: { ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}` },
}) as string;
} catch (err) {
return (err as { stdout?: string }).stdout ?? "";
}
}
/** The census's own current top file, so the fixture cannot rot as the backlog shrinks. */
function topRemainingFile(): string {
const plain = execFileSync("node", [cliPath], { encoding: "utf8", cwd: repoRoot }) as string;
const match = plain.match(/top files:\n\s+\d+\s+(\S+)/);
if (!match) throw new Error("could not read a remaining file from the census output");
return match[1];
}
it("attributes a remaining file to the open PR that touches it", () => {
const target = topRemainingFile();
const payload = JSON.stringify([{ number: 9999, title: "stub pr", files: [{ path: target }] }]);
const out = runWithStubbedGh(`#!/bin/sh\ncat <<'JSON'\n${payload}\nJSON\n`);
const claimed = out.slice(out.indexOf("CLAIMED by an open PR"), out.indexOf("UNCLAIMED:"));
expect(claimed).toContain(target);
expect(claimed).toContain("#9999");
/* And it must LEAVE that file out of the start-here list, which is the half that matters. */
expect(out.slice(out.indexOf("UNCLAIMED:"))).not.toContain(` ${target}\n`);
});
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:50:
"Unclaimed" is not "available", and the first version of --claims conflated them — it put
`taskRevert.ts` (two guards with a written blocker) and `scheduler.ts` (the canonical INERT
sync-resolver file) at the top of "start here". Both would have been active mistakes to convert.
Asserted as an INVARIANT over the report's own sections rather than against a file list, so it
cannot rot as files move between categories: whatever the report calls deferred or inert-risk must
not also appear under start-here. A hardcoded expectation here would go stale the first time one of
those six files is converted.
*/
it("never lists a deferral-noted or sync-resolver file under start-here", () => {
const payload = JSON.stringify([]);
const out = runWithStubbedGh(`#!/bin/sh\ncat <<'JSON'\n${payload}\nJSON\n`);
const section = (start: string, end?: string) => {
const from = out.indexOf(start);
if (from < 0) return "";
const to = end ? out.indexOf(end, from) : -1;
return out.slice(from, to < 0 ? undefined : to);
};
const startHere = section("no deferral note, no sync resolver", "unclaimed but");
const excluded = [
...section("converting here may be INERT", "unclaimed but every guard").matchAll(/\s{4}\d+\s+(\S+)/g),
...section("every guard carries a deferral note", "A touched file").matchAll(/\s{4}\d+\s+(\S+)/g),
].map((m) => m[1]);
/* Anti-vacuity: an empty exclusion list would make the assertion below trivially true. */
expect(excluded.length).toBeGreaterThan(0);
expect(startHere).not.toBe("");
for (const file of excluded) expect(startHere).not.toContain(file);
});
it("says so loudly when gh cannot answer, instead of reporting everything as unclaimed", () => {
const out = runWithStubbedGh("#!/bin/sh\nexit 1\n");
expect(out).toContain("CLAIMS: unavailable");
expect(out).toContain("POSSIBLY CLAIMED");
/* The dangerous output is the one that invites a duplicate claim. */
expect(out).not.toContain("UNCLAIMED:");
});
});
it("names what it accepted instead of swallowing it", () => {

View File

@@ -24,6 +24,14 @@ async function resolveFinalizationColumns(
isCompleteColumn: (columnId: string) => columnHasFlag(ir, columnId, "complete"),
};
} catch {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:51 (DELIBERATE-LITERAL — the FAIL-SOFT arm of an
already-converted resolver): the resolved path is the `try` above. This block runs only when the
workflow IR cannot be read at all, and its whole job is to answer with the built-in vocabulary so
finalization keeps working rather than throwing. Resolving here is impossible by construction —
the resolver is what just failed — so this is not pending conversion work and is marked instead of
being left to re-offer itself as available on every census.
*/
return {
completeColumn: "done",
mergeColumn: "in-review",
@@ -114,6 +122,8 @@ export async function validateWorkflowDoneMergeProof(
being re-asked with an id — which is the half-conversion shape this program keeps finding, here
within one file.
*/
/* DELIBERATE-LITERAL: the fallback arm of the conversion described directly above — reached only
when a caller passes no resolved predicate. The resolved path is `options.isCompleteColumn`. */
const isCompleteLane = options.isCompleteColumn ? options.isCompleteColumn(task.column) : task.column === "done";
if (!hasProof) return { ok: false, reason: isCompleteLane ? "done-without-merge-confirmation" : "missing-merge-confirmation" };
if (options.checkWorkflowSteps !== false && hasIncompleteWorkflowSteps(task)) {

View File

@@ -3,9 +3,7 @@
"byFile": {
"packages/dashboard/app/utils/taskRevert.ts": 2,
"packages/engine/src/scheduler.ts": 2,
"packages/core/src/eval-signal-collector.ts": 1,
"packages/core/src/mission-store.ts": 1,
"packages/core/src/task-store/async-comments-attachments.ts": 1,
"packages/core/src/task-store/audit-ops.ts": 1,
"packages/core/src/task-store/lifecycle-ops.ts": 1,
"packages/core/src/task-store/merge-queue-ops-2.ts": 1,
@@ -19,7 +17,7 @@
"packages/engine/src/triage.ts": 1
},
"deliberateByFile": {
"packages/core/src/task-store/async-comments-attachments.ts\u0000archived": 5,
"packages/core/src/task-store/async-comments-attachments.ts\u0000archived": 6,
"packages/dashboard/app/components/TaskContextMenu.tsx\u0000in-review": 3,
"packages/engine/src/self-healing.ts\u0000in-review": 3,
"packages/core/src/live-agent-count.ts\u0000in-progress": 2,
@@ -37,6 +35,7 @@
"packages/dashboard/app/components/TaskDetailModal.tsx\u0000triage": 2,
"packages/dashboard/app/hooks/useTaskDiffStats.ts\u0000done": 2,
"packages/dashboard/src/reliability-metrics.ts\u0000in-review": 2,
"packages/engine/src/auto-merge-finalization.ts\u0000done": 2,
"packages/engine/src/cli-agent/state-machine.ts\u0000done": 2,
"packages/engine/src/scheduler.ts\u0000archived": 2,
"packages/engine/src/scheduler.ts\u0000done": 2,
@@ -54,6 +53,7 @@
"packages/core/src/agent-store.ts\u0000done": 1,
"packages/core/src/async-mission-store-queries.ts\u0000archived": 1,
"packages/core/src/async-mission-store-queries.ts\u0000done": 1,
"packages/core/src/eval-signal-collector.ts\u0000archived": 1,
"packages/core/src/live-agent-count.ts\u0000archived": 1,
"packages/core/src/live-agent-count.ts\u0000done": 1,
"packages/core/src/plugin-store.ts\u0000done": 1,

View File

@@ -28,7 +28,7 @@ Consequence for conversion PRs, stated because it is a real cost: lowering a cou
re-recording the baseline in the same PR (`--strict --update-baseline`). That is deliberate — it puts
the new number in the diff, where a reviewer sees it, instead of in a hand-written claim.
*/
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -96,7 +96,34 @@ const updateBaseline = process.argv.includes("--update-baseline");
/* `--exact` keeps hard failure on a DROP, for the end state where the count is pinned. */
const exact = process.argv.includes("--exact");
const triage = process.argv.includes("--triage");
const claims = process.argv.includes("--claims");
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:58 (the census says WHERE the work is but not WHO HAS IT):
`--claims` maps each remaining file to the OPEN PRs already touching it, so "claim the largest
cluster" can be answered without discovering the collision at merge time.
WHY. Duplicate claims are now the dominant coordination cost of the fleet phase, and they are
measured, not suspected. `self-healing.ts` took THREE overlapping conversions from different lanes
while one branch was open (#3049, #3075, #3078) — every one forced a full rebuild of #3094, and each
conflict was the same shape: same guard, two spellings, different variable names. On 2026-07-31 the
executor listener took TWO independent conversions in one afternoon (#3112, #3118), reached by two
workers who each read the census, saw the top cluster, and started. Neither could see the other.
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` above already measured the underlying fact — 53 of 88 guards
were inside an open PR — which is the same observation one step short of being actionable.
REPORT-ONLY AND FAIL-SOFT, on the same terms as `--triage`: opt-in, prints 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 rather than failing the census. A gate must
not depend on network state, and this is a work-selection aid, not a gate.
HEURISTIC, AND SAID SO. A PR touching a file is not proof it converts THAT file's guards — it may
edit an unrelated function. It over-reports (a claim that is only adjacent) rather than under-reports,
which is the safe direction for "check before you start": the cost of a false claim is one comment
asking, and the cost of a missed one is a rebuilt branch.
*/
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:30 (the headline number stopped tracking work):
`--triage` splits the backlog into sites that carry a DOCUMENTED reason for staying a literal and
@@ -116,7 +143,29 @@ guard whose text marks a deliberate deferral. It cannot tell a good reason from
far above its guard reads as unflagged. It is a triage aid for choosing work, never a gate — which is
why it is opt-in and why nothing downstream consumes it.
*/
const FLAG_MARKERS = /FLAGGED|LEFT COUNTED|left counted|deliberately NOT converted|Recorded instead|Left as a literal|DELIBERATE-LITERAL|accurate debt|blocked on/;
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:51 (the marker list was a guess, and it under-matched):
MEASURED by working the start-here list this flag produces: ALL SIX files it offered carry an explicit
deferral note, in phrasing none of the original markers matched.
- `moves.ts` "THIS ARM STAYS INLINE, deliberately"
- `mission-store.ts` "audited — DEAD SYNC PATH, do not convert"
- `ResearchTaskActionModal.tsx` "SIZED, NOT CONVERTED" / "STILL A LITERAL"
- `audit-ops.ts`, `async-comments-attachments.ts`, `eval-signal-collector.ts`
defer to `archived-column-gate-parity.test.ts` by name
The cost of missing them is not cosmetic. Five of the six are `packages/core` `archived` sites inside
that parity guard's three-encoding inventory, where converting the TypeScript half ALONE is the
documented split brain. So the start-here list was most confidently offering the one class of
conversion this repo maintains a dedicated ratchet to prevent — an under-matching marker list does not
merely overstate the backlog, it aims a worker at the trap.
WHY PHRASES AND NOT A CASE-INSENSITIVE CATCH-ALL. Adding `i` would let "flagged" match casual prose
anywhere in a 40-line window and quietly reclassify live guards as reviewed, which is the same failure
in the opposite direction. These are the literal phrasings present in the tree, added as evidence
rather than as a net.
*/
const FLAG_MARKERS = /FLAGGED|LEFT COUNTED|left counted|deliberately NOT converted|Recorded instead|Left as a literal|DELIBERATE-LITERAL|accurate debt|blocked on|NOT CONVERTED|not converted|do not convert|do NOT convert|STAYS INLINE|STILL A LITERAL|archived-column-gate-parity|non-renameable system column|SIZED, NOT/;
/** Split the column guards into documented-deferral vs unexamined, by comment proximity. */
function triageFindings() {
@@ -238,6 +287,132 @@ if (!json) {
}
}
/** Guard total across a [file, count] list. */
function unclaimedGuardTotal(entries) {
return entries.reduce((sum, [, count]) => sum + count, 0);
}
/** Open PRs keyed by the census-relevant files they touch. Returns null when `gh` cannot answer. */
function openPrClaims(files) {
const wanted = new Set(files);
let raw;
try {
/* One bulk call — per-PR `gh pr view` would be a request per PR and is what made this too slow
to be habitual. --limit is generous because a partial list reads as "unclaimed". */
raw = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", "200", "--json", "number,title,files"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 30_000,
});
} catch {
return null;
}
let prs;
try {
prs = JSON.parse(raw);
} catch {
return null;
}
const byFile = new Map();
for (const pr of prs) {
for (const entry of pr.files ?? []) {
const path = entry.path ?? entry;
if (!wanted.has(path)) continue;
if (!byFile.has(path)) byFile.set(path, []);
byFile.get(path).push({ number: pr.number, title: pr.title });
}
}
return byFile;
}
if (claims && !json) {
const remaining = summary.byFile.map(([file]) => file);
const byFile = openPrClaims(remaining);
if (byFile === null) {
/* Loud rather than silent: a claim report that quietly degrades to "nothing is claimed" is worse
than no report, because it actively tells the reader to start work someone else holds. */
console.log("\n CLAIMS: unavailable — `gh` did not answer (offline, no token, or not installed).");
console.log(" Treat every file below as POSSIBLY CLAIMED and check before starting.");
} else {
const claimed = summary.byFile.filter(([file]) => byFile.has(file));
const unclaimed = summary.byFile.filter(([file]) => !byFile.has(file));
const claimedGuards = claimed.reduce((sum, [, count]) => sum + count, 0);
console.log(`\n CLAIMED by an open PR: ${claimed.length} files holding ${claimedGuards} guards`);
for (const [file, count] of claimed.slice(0, 12)) {
const prs = byFile.get(file).map((p) => `#${p.number}`).join(" ");
console.log(` ${String(count).padStart(4)} ${file} ← ${prs}`);
}
if (claimed.length > 12) console.log(` … and ${claimed.length - 12} more claimed files`);
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:50 (UNCLAIMED IS NOT THE SAME AS AVAILABLE):
The first version of this flag printed the unclaimed list under "start here". That is wrong, and it
misled ME within minutes of shipping it: the top unclaimed file was `taskRevert.ts`, whose two
guards carry a written blocker — they classify a NEIGHBOUR row, and supplying the modal's own flags
would answer "is this neighbour finished?" with the wrong task's traits. Converting it would be a
correctness regression, not progress.
So the start-here list is crossed with `--triage`'s classification: a file is available only when no
open PR holds it AND at least one of its guards lacks a documented deferral note. The two signals
answer different questions ("has someone taken it?" vs "is it takeable?") and only their
intersection is a work queue. Files that are unclaimed but fully flagged are shown separately, so
they stay visible as debt without reading as an invitation.
*/
const { flagged } = triageFindings();
const fullyFlagged = new Set();
for (const [file, count] of unclaimed) {
if (flagged.filter((f) => f.file === file).length >= count) fullyFlagged.add(file);
}
const available = unclaimed.filter(([file]) => !fullyFlagged.has(file));
const deferred = unclaimed.filter(([file]) => fullyFlagged.has(file));
/*
FNXC:LifecycleColumnCensus 2026-07-31-23:50 (the third filter, and the one with teeth):
A file can be unclaimed and unflagged and STILL be the wrong place to start, because converting a
guard through `resolveTaskWorkflowIrSync` is INERT — that resolver answers with the default
workflow in production, so the converted guard behaves exactly as the literal did while leaving the
census. Three PRs already did this (#3051, refuted live in #3058; #3062/#3068/#3079 now fail the
build on it), which is more damage than any missing conversion in the remaining backlog.
Caught by dogfooding this flag: with only the two filters above, `scheduler.ts` sat at the TOP of
"start here" — and it is the canonical inert file. The report would have walked the next worker
straight into the trap the ratchets exist to catch.
Same warning-not-subtraction stance as the SYNC-RESOLVED section below: attributing individual
guards to the resolver needs dataflow this parser does not do, so these are separated and labelled
rather than hidden.
*/
const syncCallRe = /resolveTaskWorkflowIrSync\s*\??\.?\s*\(/;
const isSyncResolved = (file) => {
try {
return syncCallRe.test(readFileSync(join(REPO_ROOT, file), "utf8"));
} catch {
return false;
}
};
const clean = available.filter(([file]) => !isSyncResolved(file));
const inertRisk = available.filter(([file]) => isSyncResolved(file));
const availableGuards = clean.reduce((sum, [, count]) => sum + count, 0);
console.log(`\n UNCLAIMED: ${unclaimed.length} files holding ${unclaimedGuardTotal(unclaimed)} guards`);
console.log(` of those, AVAILABLE (no open PR, no deferral note, no sync resolver): ${clean.length} files / ${availableGuards} guards — start here`);
for (const [file, count] of clean.slice(0, 12)) {
console.log(` ${String(count).padStart(4)} ${file}`);
}
if (clean.length > 12) console.log(` … and ${clean.length - 12} more available files`);
if (inertRisk.length > 0) {
console.log(` unclaimed but the file calls the SYNC resolver — converting here may be INERT: ${inertRisk.length} files`);
for (const [file, count] of inertRisk.slice(0, 6)) console.log(` ${String(count).padStart(4)} ${file}`);
}
if (deferred.length > 0) {
console.log(` unclaimed but every guard carries a deferral note (debt, NOT a work queue): ${deferred.length} files`);
for (const [file, count] of deferred.slice(0, 6)) console.log(` ${String(count).padStart(4)} ${file}`);
if (deferred.length > 6) console.log(` … and ${deferred.length - 6} more`);
}
console.log(" A touched file is not proof the PR converts ITS guards — over-reports rather than misses.");
}
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-20:10 (fleet — the census could not see an INERT conversion):