**Fleet blocker, measured before claiming a file — this unblocks 94% of the work order.** ## The gap The work order says *"conversion pattern: the existing role helpers ONLY; no new abstractions"*. Measured against the census, those helpers cover **42 of 722** backlog guards: | role | guards | helper? | |---|---:|---| | `intake` / `hold` → `todo` | 42 | ✅ `isIntakeColumnRole`, `isPreImplementationColumnRole`, `isHoldColumnRole` | | `in-review` | 200 | ❌ | | `done` | 195 | ❌ | | `archived` | 147 | ❌ | | `in-progress` | 138 | ❌ | **680 guards — 94% — had no helper to convert to.** Every fleet worker hits this on their first file. I hit it claiming `TaskCard.tsx`, whose 42 guards are `done` 13, `archived` 12, `in-progress` 9, `in-review` 7, `todo` 1. ## Why this is not "a new abstraction" It is the **same** abstraction — flags-first, legacy id only as the documented no-metadata fallback — applied to the roles it did not yet cover. The alternative is inlining a flags-plus-fallback expression at 680 sites, which recreates exactly the copy-paste drift these helpers exist to remove: **three inline copies in `ListView` are what started this file.** Widening `ColumnRoleFlags` threads nothing new through any call site. Callers already pass these flags — `TaskContextMenuColumnFlags` declares all of them — the interface had only *declared* the two the earlier helpers needed, so the type was dropping the rest on the floor. ## A correction I made mid-change My first draft of that comment claimed the flags were "already carried on `ColumnRoleFlags`". `tsc` disproved it immediately — `complete`, `archived` and `countsTowardWip` did not exist on the type. Corrected rather than quietly patched, because that claim *was* the justification for calling this a completion rather than an addition. ## Each distinction is asserted, not just documented - **`isCompleteColumnRole` does not count `archived`** — an archived card is finished but not *completed*; surfaces counting throughput would double-count it. - **`isWipColumnRole` keys on `countsTowardWip`**, the same flag capacity arithmetic uses, so a board cannot have a column that counts toward WIP for capacity but not for this predicate. - **`isReviewColumnRole` accepts either `mergeBlocker` or `humanReview`** — separable traits, but every converted caller asks "is this card in review", for which both qualify. A caller needing one and not the other should read the flag directly rather than widen this. Both directions are asserted in every case, so a helper returning `false` unconditionally cannot pass. ## Verification `columnRoles.test.ts` **10 → 14**. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p tsconfig.app.json` clean. `pnpm lint` clean. No census movement — this adds capability, converts nothing. My `TaskCard.tsx` conversion (42 → 0) follows on top of it. No changeset: internal helpers, no user-facing change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
11 KiB
TypeScript
223 lines
11 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;
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-30-19:00 (fleet phase):
|
|
The terminal and mid-flight traits, added for the four role helpers at the bottom of this file.
|
|
Every one of these is already produced by the trait registry and already carried on the flag
|
|
objects callers pass in (`TaskContextMenuColumnFlags` declares all of them) — this interface had
|
|
simply only declared the two the earlier helpers needed, so callers were passing them and the type
|
|
was discarding them.
|
|
*/
|
|
readonly complete?: boolean;
|
|
readonly archived?: boolean;
|
|
readonly countsTowardWip?: boolean;
|
|
readonly mergeBlocker?: boolean;
|
|
readonly humanReview?: 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-30-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-30-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;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Terminal / mid-flight roles */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-30-19:00 (fleet phase — completing the pattern, not adding one):
|
|
THE FOUR ROLES THE HELPERS WERE MISSING.
|
|
|
|
The fleet work order says "existing role helpers ONLY; no new abstractions". Measured against the
|
|
census, the existing helpers cover `intake` and `hold` — which is 42 of the 722 backlog guards. The
|
|
other 680 (94%) are `done` 195, `in-review` 200, `archived` 147, `in-progress` 138, and there was no
|
|
helper for any of them. Every fleet worker hits that on their first file.
|
|
|
|
These are not a new abstraction. They are the SAME one — flags-first, legacy id only as the
|
|
documented no-metadata fallback — applied to the roles it did not yet cover. The alternative is
|
|
inlining a flags-plus-fallback expression at 680 sites, which recreates exactly the copy-paste drift
|
|
the helpers were created to remove: three inline copies in ListView is what started this file.
|
|
|
|
Every flag used here is already produced by the trait registry and already passed by callers —
|
|
`TaskContextMenuColumnFlags` declares all of them. `ColumnRoleFlags` had only DECLARED the two the
|
|
earlier helpers needed, so callers were handing them over and the type was dropping them on the
|
|
floor. Widening the interface threads nothing new through any call site.
|
|
|
|
WHY EACH KEEPS AN ID FALLBACK. `columnFlags` is legitimately absent during first paint and for a card
|
|
in a column its workflow no longer declares. A bare trait read returns false there, which is silent
|
|
degradation — a Done card stops rendering as complete, an archived card stops being filtered out.
|
|
Same reasoning recorded at the top of this file, unchanged.
|
|
*/
|
|
|
|
/** Terminal-success id, used only when a column has no resolved traits. */
|
|
const LEGACY_COMPLETE_COLUMN_ID = "done";
|
|
/** Archived id, used only when a column has no resolved traits. */
|
|
const LEGACY_ARCHIVED_COLUMN_ID = "archived";
|
|
/** Implementation-lane id, used only when a column has no resolved traits. */
|
|
const LEGACY_WIP_COLUMN_ID = "in-progress";
|
|
/** Review-lane id, used only when a column has no resolved traits. */
|
|
const LEGACY_REVIEW_COLUMN_ID = "in-review";
|
|
|
|
/**
|
|
* Is this the terminal-success column?
|
|
*
|
|
* `archived` is deliberately NOT included: an archived card is finished but not *completed*, and
|
|
* surfaces that count throughput would double-count it.
|
|
*/
|
|
export function isCompleteColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags ? flags.complete === true : columnId === LEGACY_COMPLETE_COLUMN_ID;
|
|
}
|
|
|
|
/** Is this the archived column — globally hidden, excluded from board and capacity? */
|
|
export function isArchivedColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags ? flags.archived === true : columnId === LEGACY_ARCHIVED_COLUMN_ID;
|
|
}
|
|
|
|
/**
|
|
* Does this column occupy an implementation/WIP slot?
|
|
*
|
|
* Keyed on `countsTowardWip` rather than a `wip` trait name because that is the flag the trait
|
|
* registry exposes, and it is the one capacity arithmetic already uses — so a board cannot have a
|
|
* column that counts toward WIP for capacity but not for this predicate.
|
|
*/
|
|
export function isWipColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags ? flags.countsTowardWip === true : columnId === LEGACY_WIP_COLUMN_ID;
|
|
}
|
|
|
|
/**
|
|
* Is this the review / merge-orchestration lane?
|
|
*
|
|
* EITHER trait qualifies. `mergeBlocker` and `humanReview` are separable — a lane can block merges
|
|
* without a human in it, and vice versa — but every caller converted so far asks "is this card in
|
|
* review", for which both are true. A caller that genuinely needs one and not the other should read
|
|
* the flag directly rather than widen this.
|
|
*/
|
|
export function isReviewColumnRole(flags: ColumnRoleFlags | undefined, columnId: string): boolean {
|
|
return flags
|
|
? flags.mergeBlocker === true || flags.humanReview === true
|
|
: columnId === LEGACY_REVIEW_COLUMN_ID;
|
|
}
|