Coordinator item 2. The census had the right mechanism and no teeth. ## The gap `--strict` already fails on a rise **and** on an unrecorded drop — that logic was correct. But nothing blocking ran it, so the baseline drifted to **854 while the tree held 787**. That is **67 guards of regression that would have merged silently**: a high-water mark wearing a ratchet's name. This is the same shape as the ceilings I tightened in #2647, one level up. Worth saying plainly: I fixed the vitest ratchet's slack by hand and did not check whether the *authoritative* instrument had the same problem. It did, and by a much larger margin. ## Three changes 1. **`--strict` runs in `test:gate`.** The baseline cannot go stale again without a red gate. 2. **Baseline re-recorded: 854 → 785** across 14 files (`triage` 38 → 9). 3. The single RISE is resolved honestly rather than absorbed. ## The +3 investigation One file rose: `register-task-workflow-routes.ts` **22 → 23**. #2621 replaced one `task.column === "todo"` with `task.column === "triage" || task.column === "todo"` — a net **+1** that also reintroduced a `triage` literal, while the PR title reported *"count 0 → 0"*. Not an accusation. There was no gate for the author to check against, and a hand-counted claim in a PR title is exactly the thing that goes wrong without one. Change 1 is the fix. **The literal is justified and stays**, marked `DELIBERATE-LITERAL` rather than converted. It is the **v1-IR arm**: a v1 workflow yields no role assignments, so `resolveLifecycleColumns` returns nothing and the legacy pre-implementation ids are the only pre-WIP signal available. The `else` branch directly below already resolves intake/hold for every v2 workflow. Converting this arm would not finish anything — it would delete the only answer v1 boards have and admit `in-progress`/`in-review` cards into a rebound that clears worktree, branch and retry counters, which is the regression #2621 was fixing. ## Both directions proven | direction | probe | result | |---|---|---| | rise | add `t.column === 'in-review'` | `live-agent-count.ts: 6 -> 7`, exit 1 | | drop | convert one guard | `self-healing.ts: allows 111, tree has 110`, exit 1 | **The drop probe took three attempts to test honestly, and the first two "passed" while proving nothing:** 1. I renamed a receiver (`task.column` → `Probe`) — the classifier is **fail-closed**, so an unknown receiver is still counted and the number never moved. 2. I targeted a site in `hold-release.ts` that carries a `DELIBERATE-LITERAL` marker — not counted as a column guard at all, so removing it changed nothing. Only removing a counted comparison outright moved the number. Both false negatives came from me assuming the probe worked because the command exited the way I expected. ## On auto-rewrite vs fail-and-instruct You offered either. The script already does **fail-and-instruct**, with `--update-baseline` as the explicit re-record, and I kept it that way rather than making the test rewrite the baseline during a run. Reason: a silent downward rewrite means a conversion PR's own diff never shows the number moving, so "census before/after in the PR body" becomes unverifiable — the reviewer would have to re-derive it. Failing with the new number in the message puts it in the diff where a human sees it, and it costs one command. ## Verification `pnpm lint` clean. `pnpm test:gate` green with the census in it — `every file matches its baseline exactly` (10 / 132 / 487 / 71). Note for the fleet launch: with `--strict` gating, **every** conversion PR must now re-record the baseline in the same PR. That is the intended cost, and it makes the fleet's "baseline must shrink by exactly the converted count" rule mechanically enforced instead of a review instruction. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
6.5 KiB
TypeScript
132 lines
6.5 KiB
TypeScript
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
ONE place that answers "what ROLE does this column play?" when trait metadata may be
|
|
missing, replacing three copy-pasted id fallbacks in ListView.
|
|
|
|
WHY A HELPER AND NOT A BARE TRAIT READ. Trait flags come from the resolved workflow, so
|
|
`columnFlagsById` legitimately has no entry in two states:
|
|
|
|
1. the pre-load window — the board renders before the workflows fetch resolves;
|
|
2. a stranded card resting in a column its workflow no longer declares.
|
|
|
|
A bare `flags.intake === true` returns false in both, which is silent degradation rather
|
|
than a visible failure: the Planning badge just stops appearing, and a move back into a
|
|
pre-implementation lane stops asking whether to preserve step progress — so an operator
|
|
loses completed steps with no prompt and no error. That is why the fallback exists and
|
|
why deleting it is not the cleanup it looks like.
|
|
|
|
What was wrong was having the fallback THREE TIMES, inline, as `column === "todo" ||
|
|
column === "triage"`. Copies drift, none of them were reachable from a test, and each
|
|
read like a lifecycle rule rather than the degraded mode it is. Here it is named, has one
|
|
definition, and is covered — including the degraded path itself, which is the part that
|
|
never had a test.
|
|
*/
|
|
|
|
/** The subset of a column's resolved trait flags these role questions need. */
|
|
export interface ColumnRoleFlags {
|
|
readonly intake?: boolean;
|
|
readonly hold?: boolean;
|
|
}
|
|
|
|
/**
|
|
* The pre-graph column ids that behaved as pre-implementation lanes.
|
|
*
|
|
* NOT a lifecycle rule — a last-resort guess used only when a column has no resolved
|
|
* traits. `todo` is the post-U11 merged planning column; `triage` is its pre-merge
|
|
* predecessor, retained because a project upgraded mid-flight can still hold cards there
|
|
* while its workflow no longer declares it.
|
|
*/
|
|
const LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS: ReadonlySet<string> = new Set(["todo", "triage"]);
|
|
|
|
/** Pre-merge intake id, used only when a column has no resolved traits. */
|
|
const LEGACY_INTAKE_COLUMN_ID = "triage";
|
|
|
|
/**
|
|
* Does this column play the INTAKE role — the lane a card enters before implementation?
|
|
*
|
|
* Drives the transient Planning badge. Traits when resolved; the legacy intake id only
|
|
* when they are absent, so the badge does not vanish during first paint.
|
|
*/
|
|
export function isIntakeColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags ? flags.intake === true : columnId === LEGACY_INTAKE_COLUMN_ID;
|
|
}
|
|
|
|
/**
|
|
* Is this column a PRE-IMPLEMENTATION lane — intake or a hold?
|
|
*
|
|
* Drives the "preserve progress?" prompt when a card with completed steps is moved
|
|
* backwards. Either trait qualifies: both mean work has not started there, so moving a
|
|
* part-done card in risks discarding steps.
|
|
*/
|
|
export function isPreImplementationColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags
|
|
? Boolean(flags.intake || flags.hold)
|
|
: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS.has(columnId);
|
|
}
|
|
|
|
/**
|
|
* Does this column play the HOLD role — a lane a card WAITS in rather than works in?
|
|
*
|
|
* FNXC:WorkflowResolvedColumns 2026-07-30-23:30 (U12 — worktree upcoming-work list):
|
|
* Distinct from {@link isPreImplementationColumnRole}, whose degraded id set is `{todo, triage}`.
|
|
* This one degrades to `todo` ALONE, because `triage` was the intake lane and never the
|
|
* wait-for-capacity lane — listing an intake card as "upcoming work" in the worktree view would
|
|
* report a card that has no plan yet as ready to run.
|
|
*
|
|
* Same shape, different degraded answer — the asymmetry U11 verified for
|
|
* `isPreExecutionHoldColumn` and this file already documents elsewhere.
|
|
*/
|
|
export function isHoldColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-31-07:25 (U12 — census gate repair) DELIBERATE-LITERAL:
|
|
This IS the no-metadata fallback, so the literal is the answer rather than an unconverted guard —
|
|
the same status as `LEGACY_INTAKE_COLUMN_ID` on the line above, which reads as a named constant and
|
|
so was never counted while this inline twin was.
|
|
|
|
Deleting it is not available: `columnFlags` is legitimately absent during first paint and for a card
|
|
in a column its workflow no longer declares, and a bare trait read there silently drops the
|
|
affordance this drives. The reason is recorded in full at the top of this file.
|
|
|
|
`todo` ALONE, deliberately — not the `{todo, triage}` pair used by the pre-implementation helper.
|
|
`triage` was the intake lane and never the wait-for-capacity lane, so listing an intake card as
|
|
upcoming work would report a card with no plan as ready to run.
|
|
*/
|
|
return flags ? flags.hold === true : columnId === "todo";
|
|
}
|
|
|
|
/** Legacy pre-implementation id pair, used only when a column has no resolved traits. */
|
|
const LEGACY_FIELD_EDITABLE_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
|
|
|
|
/**
|
|
* May a card's title/description be edited in this column?
|
|
*
|
|
* FNXC:WorkflowResolvedColumns 2026-07-31-00:15 (U12 — one affordance, two components):
|
|
* Editing belongs to PRE-IMPLEMENTATION lanes: the card has no session, no worktree, and no plan
|
|
* being executed against the text. Any terminal, executing or review trait VETOES it even when
|
|
* `intake`/`hold` is also present — a column carrying both is still one where work is underway, and
|
|
* rewriting a description out from under a running plan is the failure being prevented.
|
|
*
|
|
* Extracted because `TaskDetailModal` resolved this from traits (U10/R8) while `TaskCard` kept a
|
|
* hardcoded `{triage, todo}` set with NO trait path at all, even though `taskColumnFlags` was already
|
|
* in scope there. On a renamed board the operator could edit a task's title in the detail modal but
|
|
* the pencil button was absent from its card. One affordance living in two components with one of
|
|
* them converted is the FN-6115 -> FN-6118 -> FN-6123 shape; a shared definition is what stops it
|
|
* recurring, not a second conversion.
|
|
*/
|
|
export function isFieldEditableColumnRole(
|
|
flags: (ColumnRoleFlags & {
|
|
readonly complete?: boolean;
|
|
readonly archived?: boolean;
|
|
readonly countsTowardWip?: boolean;
|
|
readonly mergeBlocker?: boolean;
|
|
readonly humanReview?: boolean;
|
|
}) | undefined,
|
|
columnId: string,
|
|
): boolean {
|
|
if (!flags) return LEGACY_FIELD_EDITABLE_COLUMN_IDS.has(columnId);
|
|
if (flags.complete || flags.archived || flags.countsTowardWip || flags.mergeBlocker || flags.humanReview) {
|
|
return false;
|
|
}
|
|
return flags.intake === true || flags.hold === true;
|
|
}
|