U12: the LAST triage guard — Plan was offered on executing cards named triage (#2664)

The final `column === "triage"` in production source, and it was a live
defect rather than dead vocabulary.

## The defect

`isPreExecutionHoldColumn` ORed the legacy id with the traits
**unconditionally**:

```ts
return column === "triage" || flags?.intake === true || flags?.hold === true;
```

That is not a fallback. A resolved column merely *named* `triage`
answered true even when its own traits said work was underway — so the
context menu offered **Plan**, which re-plans, on a card that is already
executing.

Now flags-first, with the id as the documented no-metadata answer.

## Why the file's earlier conversion missed it

Every existing case in `TaskContextMenu.test.tsx` passes a column with
**no flags**, or with `hold`/`intake` set. All of them agree under both
forms, so the suite could not distinguish them. Nothing exercised a
column whose **name and traits disagree**, which is the only shape that
separates an OR from a fallback.

Three new cases cover it. Revert check: restoring the OR form fails the
first one — Plan reappears on a mid-flight card.

## The asymmetry is preserved, and now tested

The degraded set stays `{triage}` **alone**, deliberately not the
`{todo, triage}` used by `isPreImplementationColumnRole`. That helper
drives the preserve-progress prompt, where a flagless `todo` *should*
prompt because losing steps is unrecoverable. This drives Plan, where a
flagless `todo` must **not** offer to re-plan a card that may already be
planned. The file documented that difference; nothing asserted it. Now a
test does.

## On reaching zero honestly

The surviving literal is marked `DELIBERATE-LITERAL`. It is the degraded
answer, not an unconverted guard — there is no trait to read when
`flags` is `undefined`, which happens during first paint and for a card
in a column its workflow no longer declares. Deleting it would silently
withdraw Plan from exactly the stranded cards that most need
re-planning.

So **`triage → 0` means "no unconverted guards remain", not "the string
is gone"**, and I would rather say that than move a number by deleting a
fallback.

| branch | triage |
|---|---:|
| `origin/main` | 5 |
| this PR | **4** |
| #2655 (flag resolution, removes 4 in `moves.ts`) | 1 → **0** combined
|

I found it with the census's own AST classifier rather than grep — my
grep of the same tree returned only comment prose and would have had me
report the bar as met while a real defect sat in
`TaskContextMenu.tsx:179`.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded in this
PR (column 769 → 768, deliberate 12 → 13). `tsc -p tsconfig.app.json`
clean. `TaskContextMenu.test.tsx` 18/18.

Depends on nothing; stacks cleanly with #2655 and #2661.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 00:37:08 -07:00
committed by GitHub
parent 3bf9bf5f74
commit efbbc45eb0
4 changed files with 87 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The Plan action no longer appears on cards that are already executing.
category: fix
dev: isPreExecutionHoldColumn ORed the legacy `triage` id with the column's traits unconditionally, so a resolved column merely named `triage` was treated as a planning target even when its traits said work was underway. Now flags-first with the id as the documented no-metadata fallback.

View File

@@ -176,7 +176,31 @@ export function isPreExecutionHoldColumn(column: string, flags?: TaskContextMenu
Same shape, different degraded answer: the trait path is identical and the fallbacks are not
interchangeable. Kept separate with the difference recorded, rather than made to look shared.
*/
return column === "triage" || flags?.intake === true || flags?.hold === true;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-08:00 (U12 — the LAST `triage` column guard):
FLAGS-FIRST, id only as the degraded answer. It used to OR the legacy id with the traits
UNCONDITIONALLY, which is not a fallback: a resolved column that happens to be named `triage` but
whose traits say it is mid-flight answered true, offering Plan on a card that is already executing.
The degraded set stays {triage} ALONE — deliberately not the {todo, triage} used by
`isPreImplementationColumnRole`, for the reason recorded above: that helper drives the
preserve-progress prompt where a flagless `todo` should prompt, while this drives the Plan
affordance where a flagless `todo` must not offer to re-plan a possibly-planned card.
Behaviour delta is exactly the inversion. Flags absent: unchanged (`column === "triage"`). Flags
present and intake/hold: unchanged (true). Flags present, name `triage`, traits mid-flight: was
true, now false — which is the defect.
DELIBERATE-LITERAL: the surviving `triage` is the DEGRADED answer, not an unconverted guard, and it
is the last `triage` comparison in production source. Converting it is not available — there is no
trait to read when `flags` is undefined, which happens during first paint and for a card in a column
its workflow no longer declares. Deleting it would silently withdraw Plan from exactly the stranded
cards that need re-planning most.
So the census reaching zero for `triage` means "no unconverted guards remain", not "the string is
gone". Recorded here rather than achieved by deleting a fallback to move a number.
*/
return flags ? (flags.intake === true || flags.hold === true) : column === "triage";
}

View File

@@ -376,3 +376,54 @@ describe("shouldShowActionsMenu by workflow shape (not by column id)", () => {
expect(model("backlog", { intake: true })).toBe(false);
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-08:00 (U12 — the last `triage` column guard):
THE INVERSION. `isPreExecutionHoldColumn` ORed the legacy id with the traits unconditionally, so a
resolved column merely NAMED `triage` answered true even when its own traits said work was underway
— offering Plan, which re-plans, on a card that is already executing.
The existing cases here all pass a column with no flags or with hold/intake set, so every one of them
agrees under both the old and new form. That is why this defect survived the file's earlier
conversion: nothing exercised a resolved column whose name and traits disagree.
REVERT CHECK: restore the `column === "triage" ||` prefix and the first case fails — Plan reappears
on a mid-flight card.
*/
describe("pre-execution hold resolves traits, not the column's name", () => {
it("does NOT treat a mid-flight column NAMED `triage` as a planning target", () => {
const model = buildTaskActionMenuModel({
task: makeTask({ column: "triage" }),
t,
columnLabel: columnLabel as any,
currentColumnFlags: { intake: false, hold: false, countsTowardWip: true } as any,
onPlan: vi.fn(),
} as never);
expect(model.actions.map((a: { id: string }) => a.id)).not.toContain("plan");
});
it("still offers Plan on a RENAMED hold column", () => {
// The narrowing guard: traits decide, so a board that never uses the legacy name still works.
const model = buildTaskActionMenuModel({
task: makeTask({ column: "backlog" as never }),
t,
columnLabel: columnLabel as any,
currentColumnFlags: { intake: true, hold: true } as any,
onPlan: vi.fn(),
} as never);
expect(model.actions.map((a: { id: string }) => a.id)).toContain("plan");
});
it("keeps the flagless degraded answer for `triage` and withholds it for flagless `todo`", () => {
/*
The asymmetry the file documents: with no flags, `triage` is the only pre-execution hold. A
flagless `todo` must NOT offer Plan, because re-planning an already-planned card is not
recoverable by the operator.
*/
const forColumn = (column: string) =>
buildTaskActionMenuModel({ task: makeTask({ column: column as never }), t, columnLabel: columnLabel as any, onPlan: vi.fn() } as never)
.actions.map((a: { id: string }) => a.id);
expect(forColumn("triage")).toContain("plan");
expect(forColumn("todo")).not.toContain("plan");
});
});

View File

@@ -1,10 +1,10 @@
{
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"totals": {
"column": 769,
"column": 768,
"role": 5,
"status": 187,
"deliberate": 12
"deliberate": 13
},
"byColumnId": {
"done": 201,
@@ -12,7 +12,7 @@
"in-review": 209,
"archived": 148,
"todo": 60,
"triage": 5
"triage": 4
},
"byFile": {
"packages/engine/src/self-healing.ts": 110,
@@ -24,9 +24,9 @@
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 20,
"packages/core/src/store.ts": 12,
"packages/engine/src/project-engine.ts": 12,
"packages/dashboard/app/components/TaskContextMenu.tsx": 10,
"packages/engine/src/mission-execution-loop.ts": 10,
"packages/core/src/task-store/async-comments-attachments.ts": 9,
"packages/dashboard/app/components/TaskContextMenu.tsx": 9,
"packages/dashboard/src/github-tracking-comments.ts": 9,
"packages/dashboard/src/github-tracking-reconciler.ts": 9,
"packages/engine/src/notification/notification-service.ts": 9,