The program's completion bar is "`column === "triage"` reaches zero".
This measures what that bar actually covers, and checks the measurement
in so it cannot drift.
## The number, measured by the checked-in tool
```
lifecycle-column-census: scanned 1956 source files
COLUMN guards (the backlog): 1031
ROLE comparisons (not guards): 10
DELIBERATE-LITERAL (reviewed): 4
by column id:
313 done
217 in-review
201 in-progress
177 archived
83 todo
40 triage
top files:
151 packages/engine/src/executor.ts
136 packages/engine/src/self-healing.ts
50 packages/dashboard/app/components/TaskCard.tsx
44 packages/core/src/task-store/moves.ts
34 packages/dashboard/app/components/TaskDetailModal.tsx
```
**`triage` is under 4% of the class.** Every one of those 1031 sites is
the same defect: a lifecycle decision made by column NAME, which stops
matching the moment a board renames a column. The bar can be met in full
while 991 identical guards remain — and two files hold a quarter of
them.
## The tracked count is wrong in three directions at once
Each of these cost real work this week, which is why this is a PR and
not a comment.
1. **Vocabulary.** It measures one of six legacy ids.
2. **Receiver.** It is anchored on locals named
`column`/`toColumn`/`fromColumn`, so it never saw the three real guards
in `executor.ts` written against `from` and `originColumn`. One of those
meant completed-but-stranded work was never recovered on a renamed
board, with nothing else owning that state (converted in #2628).
3. **Collision.** `role === "triage"`, `agentType === "triage"`,
`entry.agent === "triage"` compare an **AGENT ROLE**. The planner *lane*
is named `triage` and keeps that name — U11 removed the *column*. Ten
such sites were counted as backlog, and the "obvious" fix (renaming the
role) silently empties the planner's prompt template and mis-binds its
model markers.
A count that is too high and too low simultaneously sends work to the
wrong files while hiding the files that need it. So the census reports
**three separate numbers** and never nets them.
## Proven to fail on the original defect
Not asserted — exercised:
```
$ # reintroduce `task.column === "triage" || task.column === "todo"` into live-agent-count.ts
$ node scripts/lifecycle-column-census.mjs --strict; echo "exit=$?"
packages/core/src/live-agent-count.ts: 10 -> 12
exit=1
$ # restore the file
$ node scripts/lifecycle-column-census.mjs --strict >/dev/null; echo "exit=$?"
exit=0
```
The CLI also exits 1 when its own file list comes back empty — a guard
that reports success without checking anything is worse than no guard.
## 12 regression cases, split by what they defend
Must catch: all six ids; a guard on a local named `from`/`originColumn`
(verbatim the executor.ts shape); single quotes; negation; several
comparisons on one line.
Must **not** catch: role comparisons; comment prose (two tracked
"guards" in `replan-target.ts` were prose about a filter that lives in
another file); a trailing `// … === "triage"` on a code line; sites
carrying a `DELIBERATE-LITERAL` marker.
Plus: **one marker cannot launder a distant guard in the same file** —
that is how allowlists rot.
## Report-only, deliberately
`--strict` compares per-file counts against
`scripts/lib/lifecycle-column-census-baseline.json` and fails when any
file's count **rises**. It is **not** wired into the merge gate: a
thousand-site backlog cannot be a blocking check the day it is first
measured, and a guard nobody can pass is a guard everyone disables.
Owners tightening their own area re-record the baseline in the PR that
lowers it. This is the ratchet shape the `DELIBERATE-LITERAL` markers
scattered through the program already anticipate.
## Stated limitation
Classification is by receiver **name**, so a future field named `agent`
that holds a column would be misclassified as a role comparison.
Recorded at the site, and it is precisely why the two classes are
reported separately instead of netted into one figure.
## Verification
- 12/12 new cases
(`packages/engine/src/__tests__/lifecycle-column-census.test.ts`)
- `pnpm test:gate` **71/71**; `pnpm lint` clean
- `pnpm census:lifecycle-columns`, `--json`, and `--strict` all
exercised end to end
- documented in `docs/testing.md`; no production code touched
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
8.6 KiB
JavaScript
194 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-14:20 (Phase C convergence):
|
|
CLI wrapper. The rules, the measured numbers that motivated them, and the reason the three
|
|
classes are reported separately live in `scripts/lib/lifecycle-column-census.mjs`; the
|
|
regression suite that pins each form this census must catch lives in
|
|
`packages/engine/src/__tests__/lifecycle-column-census.test.ts`.
|
|
|
|
Report-only by default:
|
|
node scripts/lifecycle-column-census.mjs # human table
|
|
node scripts/lifecycle-column-census.mjs --json # machine-readable
|
|
node scripts/lifecycle-column-census.mjs --compare # cross-check AST vs text classifier
|
|
node scripts/lifecycle-column-census.mjs --strict # fail if any file DIVERGES from baseline
|
|
node scripts/lifecycle-column-census.mjs --strict --update-baseline # re-record after lowering it
|
|
|
|
`--strict` fails on a RISE (a reintroduced guard) and equally on a DROP that was not recorded: a
|
|
stale allowance is a hole through which the same guards can return while the check stays green.
|
|
|
|
NOT wired into the merge gate. A thousand-site backlog cannot be a blocking check on the day it
|
|
is first measured; `--strict` exists so it can become one incrementally, per-file, once owners have
|
|
converted their areas.
|
|
*/
|
|
import { execSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-22:50: the AST classifier is the instrument. Three people
|
|
measured this backlog with three greps and got three answers, so the number is taken from a parse.
|
|
The text classifier stays beside it as an independent second implementation — `--compare` runs both
|
|
and fails if they disagree, which is the only evidence available that either is right.
|
|
*/
|
|
import { censusFiles, summarize } from "./lib/lifecycle-column-census-ast.mjs";
|
|
import {
|
|
censusFiles as censusFilesText,
|
|
summarize as summarizeText,
|
|
} from "./lib/lifecycle-column-census.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const BASELINE_PATH = join(HERE, "lib", "lifecycle-column-census-baseline.json");
|
|
|
|
let files;
|
|
try {
|
|
files = execSync(
|
|
"git ls-files 'packages/*/src/**/*.ts' 'packages/*/src/*.ts' 'packages/*/src/**/*.tsx' 'packages/*/app/**/*.ts' 'packages/*/app/**/*.tsx' 'plugins/*/src/**/*.ts' 'plugins/*/src/**/*.tsx'",
|
|
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
|
)
|
|
.split("\n")
|
|
.map((f) => f.trim())
|
|
.filter(Boolean)
|
|
.filter((f) => !f.includes("__tests__") && !/\.(test|spec)\.tsx?$/.test(f));
|
|
} catch (err) {
|
|
// FAIL CLOSED: if the file list cannot be produced, nothing has been checked.
|
|
console.error(`lifecycle-column-census: could not list files — ${err?.message ?? err}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
console.error("lifecycle-column-census: file list is EMPTY — refusing to report on zero files.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const findings = censusFiles(files);
|
|
const summary = summarize(findings);
|
|
const json = process.argv.includes("--json");
|
|
const strict = process.argv.includes("--strict");
|
|
const compare = process.argv.includes("--compare");
|
|
const updateBaseline = process.argv.includes("--update-baseline");
|
|
|
|
if (json) {
|
|
console.log(JSON.stringify({ scannedFiles: files.length, ...summary, byFile: summary.byFile }, null, 2));
|
|
} else {
|
|
console.log(`lifecycle-column-census: scanned ${files.length} source files\n`);
|
|
console.log(` COLUMN guards (the backlog): ${summary.totals.column}`);
|
|
console.log(` ROLE comparisons (not guards): ${summary.totals.role}`);
|
|
console.log(` STATUS comparisons (not guards): ${summary.totals.status}`);
|
|
console.log(` DELIBERATE-LITERAL (reviewed): ${summary.totals.deliberate}\n`);
|
|
console.log(" by column id:");
|
|
for (const [id, count] of Object.entries(summary.byColumnId).sort((a, b) => b[1] - a[1])) {
|
|
console.log(` ${String(count).padStart(4)} ${id}`);
|
|
}
|
|
console.log("\n top files:");
|
|
for (const [file, count] of summary.byFile.slice(0, 20)) {
|
|
console.log(` ${String(count).padStart(4)} ${file}`);
|
|
}
|
|
if (summary.byFile.length > 20) {
|
|
// Never let a truncated list read as "that is all of it".
|
|
console.log(` … and ${summary.byFile.length - 20} more files`);
|
|
}
|
|
}
|
|
|
|
if (compare) {
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-23:05:
|
|
THE CONTRACT IS SUPERSET, NOT EQUALITY. The text classifier is knowingly weaker — it matches per
|
|
line, `===`/`!==` only, and literal-on-the-right only — so the parser legitimately finds MORE
|
|
(measured: 6 more, all real; `data.to !== "archived"` and multi-line `||` chains in scheduler.ts).
|
|
Demanding equality would just force the parser down to the regex's blind spots.
|
|
|
|
What must NEVER happen is the other direction: a site the REGEX found and the parser missed means
|
|
the parser has a hole, and then its number cannot be the bar. That is the failure this checks.
|
|
*/
|
|
const text = summarizeText(censusFilesText(files));
|
|
console.log(`\n text classifier: ${JSON.stringify(text.totals)}`);
|
|
console.log(` AST classifier: ${JSON.stringify(summary.totals)}`);
|
|
const regressions = ["column", "role", "status", "deliberate"].filter(
|
|
(kind) => text.totals[kind] > summary.totals[kind],
|
|
);
|
|
if (regressions.length > 0) {
|
|
console.error(
|
|
`\nlifecycle-column-census --compare: the regex found MORE than the parser for ${regressions.join(", ")}.\n` +
|
|
"The parser has a blind spot; its count cannot be the bar until this is closed.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const extra = summary.totals.column - text.totals.column;
|
|
console.log(` parser is a superset (+${extra} column guards the regex cannot see).`);
|
|
}
|
|
|
|
if (!strict) process.exit(0);
|
|
|
|
if (!existsSync(BASELINE_PATH)) {
|
|
console.error(`lifecycle-column-census --strict: no baseline at ${BASELINE_PATH}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const baseline = JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
|
const baselineByFile = new Map(Object.entries(baseline.byFile ?? {}));
|
|
const currentByFile = new Map(summary.byFile);
|
|
const regressions = [];
|
|
const stale = [];
|
|
|
|
for (const [file, count] of currentByFile) {
|
|
const allowed = baselineByFile.get(file) ?? 0;
|
|
if (count > allowed) regressions.push({ file, count, allowed });
|
|
else if (count < allowed) stale.push({ file, count, allowed });
|
|
}
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-17:55 (PR #2633 review, greptile P1):
|
|
A file that has DROPPED below its baseline is also a failure, and this is the difference between
|
|
a ratchet and a high-water mark. Left alone, a conversion that takes a file from 10 guards to 3
|
|
leaves a stale allowance of 10 — so seven guards can be reintroduced later and `--strict` stays
|
|
green. That is exactly the rot this tool exists to prevent, wearing a passing check.
|
|
|
|
Files that disappear entirely are also stale entries; they are reported the same way, because a
|
|
deleted or renamed file leaving its allowance behind is the same hole.
|
|
*/
|
|
for (const [file, allowed] of baselineByFile) {
|
|
if (!currentByFile.has(file) && allowed > 0) stale.push({ file, count: 0, allowed });
|
|
}
|
|
|
|
if (regressions.length > 0) {
|
|
console.error("\nlifecycle-column-census --strict: column-guard count ROSE\n");
|
|
for (const r of regressions) {
|
|
console.error(` ${r.file}: ${r.allowed} -> ${r.count}`);
|
|
}
|
|
console.error(
|
|
"\nResolve a lifecycle column from the task's own workflow (resolveLifecycleColumns /\n" +
|
|
"resolveTaskLifecycleColumns) instead of comparing its name. If the literal is genuinely\n" +
|
|
`correct, record why at the site with a ${"DELIBERATE-LITERAL"} marker.\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (stale.length > 0) {
|
|
if (updateBaseline) {
|
|
writeFileSync(
|
|
BASELINE_PATH,
|
|
`${JSON.stringify({
|
|
generatedFrom: "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
|
totals: summary.totals,
|
|
byColumnId: summary.byColumnId,
|
|
byFile: Object.fromEntries(summary.byFile),
|
|
}, null, 2)}\n`,
|
|
);
|
|
console.log(`\nlifecycle-column-census --strict: baseline TIGHTENED for ${stale.length} file(s).`);
|
|
process.exit(0);
|
|
}
|
|
console.error("\nlifecycle-column-census --strict: baseline is STALE — it allows more than the tree has\n");
|
|
for (const s of stale) {
|
|
console.error(` ${s.file}: allows ${s.allowed}, tree has ${s.count}`);
|
|
}
|
|
console.error(
|
|
"\nA stale allowance is a hole: those guards can be reintroduced later and this check stays\n" +
|
|
"green. Re-record the baseline in the SAME PR that lowered the count:\n\n" +
|
|
" node scripts/lifecycle-column-census.mjs --strict --update-baseline\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("\nlifecycle-column-census --strict: every file matches its baseline exactly.");
|
|
process.exit(0);
|