Files
fusion/packages/dashboard/app/utils/columnRoles.ts
gsxdsm cef1b08af3 U12: the census baseline follows the count down — and goes in the merge gate (#2661)
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>
2026-07-30 00:47:31 -07:00

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;
}