diff --git a/packages/core/src/eval-signal-collector.ts b/packages/core/src/eval-signal-collector.ts index 1c3034c75b..bfb8c269c1 100644 --- a/packages/core/src/eval-signal-collector.ts +++ b/packages/core/src/eval-signal-collector.ts @@ -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", diff --git a/packages/core/src/task-store/async-comments-attachments.ts b/packages/core/src/task-store/async-comments-attachments.ts index ced82e8b74..8038aa7736 100644 --- a/packages/core/src/task-store/async-comments-attachments.ts +++ b/packages/core/src/task-store/async-comments-attachments.ts @@ -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; diff --git a/packages/engine/src/__tests__/lifecycle-column-census.test.ts b/packages/engine/src/__tests__/lifecycle-column-census.test.ts index 9dcd03d741..781c31b9d1 100644 --- a/packages/engine/src/__tests__/lifecycle-column-census.test.ts +++ b/packages/engine/src/__tests__/lifecycle-column-census.test.ts @@ -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", () => { diff --git a/packages/engine/src/auto-merge-finalization.ts b/packages/engine/src/auto-merge-finalization.ts index 557538cb53..4ed9048e26 100644 --- a/packages/engine/src/auto-merge-finalization.ts +++ b/packages/engine/src/auto-merge-finalization.ts @@ -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)) { diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index db177d922c..6e4430919a 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -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, diff --git a/scripts/lifecycle-column-census.mjs b/scripts/lifecycle-column-census.mjs index f5ca72cf0a..5341b9a678 100644 --- a/scripts/lifecycle-column-census.mjs +++ b/scripts/lifecycle-column-census.mjs @@ -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):