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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user