From a57f6699b381bf91dd77c2e644b05d93425dc9ca Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 23:29:56 -0700 Subject: [PATCH] fix(cli): `fn task list` never printed cards in renamed columns (#2986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## `fn task list` never printed cards in renamed columns ```ts for (const col of COLUMNS) { // the legacy six ids const colTasks = tasks.filter((t) => t.column === col); ``` A task in a workflow-defined column matches **no iteration**, so it is not printed. This is not a wrong label or a wrong glyph — **the card is absent**, and the output reads as a shorter, healthy board rather than as a bug. On a fully renamed board the command prints nothing but the header. `COLUMNS` also still contains `triage`, which U11 (#2515) deleted. ## Found where the previous author left it The `DELIBERATE-LITERAL` note directly above this loop is correct about its own glyph, and it named the deeper bug rather than hiding it: > NOT claimed as trait-resolved, and the deeper bug is left alone: because the loop iterates the legacy enum, a card in a workflow-renamed column is not rendered AT ALL. That is the R8/U10 surface change […] and a far bigger fix than this glyph. It also predicted the coupling: *"If this ever iterates workflow-resolved columns, that difference becomes live and the right answer is a trait lookup, not this."* So both move together — once the loop can yield a custom id, the terminal test **must** stop being an id comparison. Fixing only the loop would leave a renamed done-lane rendering as active work. ## Two deliberate choices **Lanes come from the tasks, not from a resolved IR.** A board can span several workflows and therefore has no single column list, and a card must never depend on a resolution succeeding in order to be *visible*. Legacy ids keep their familiar order and labels; anything else follows alphabetically, so output is deterministic. **Terminal lanes are resolved**, via `resolveProjectColumnsForRoles(TERMINAL_ROLES)` — that is a display question with a real answer, and this function is async with a store in hand. Best-effort: a failed resolve falls back to the legacy pair rather than failing the command, and an unresolved custom lane renders as *active*. Showing a finished card with the wrong glyph is a far smaller error than the blank board this replaces. ## Coverage, and its limit stated plainly The lane-selection decision is extracted to an exported seam and tested there. It is **not** end-to-end: `runTaskList` resolves a real project context and ends in `process.exit`, so driving it would need the mock-the-world shell `docs/testing.md` tells us to avoid when a narrower seam exists. The call site is held by the compiler instead — the loop's only source of lanes is that function. I have written this in the test file rather than leaving it implied, because "5 passed" on a helper could otherwise read as proof of the command's behaviour. Reverted — the seam returning `[...COLUMNS]`, which is exactly what the loop did — **all 5 cases fail**: ``` AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'backlog', 'building', 'checking' ] AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'todo', 'shipped' ] Tests 5 failed (5) ``` ## Verification (measured) - **86 passed / 3 files** — new suite plus `bin.test.ts` and `pr-merge-review-lane.test.ts` - `tsc --noEmit`, `eslint` — clean - `lifecycle-column-census --strict`, `check-lane-wiring` (26, none added), `check-fnxc-future-dates` — green - `pnpm check:changesets` — clean; changeset included (`patch`), since `packages/cli` is the published `@runfusion/fusion` and this is user-facing --- .changeset/cli-task-list-renamed-columns.md | 7 ++ .../__tests__/task-list-board-columns.test.ts | 53 ++++++++++++++ packages/cli/src/commands/task.ts | 73 +++++++++++++++++-- 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 .changeset/cli-task-list-renamed-columns.md create mode 100644 packages/cli/src/__tests__/task-list-board-columns.test.ts diff --git a/.changeset/cli-task-list-renamed-columns.md b/.changeset/cli-task-list-renamed-columns.md new file mode 100644 index 0000000000..2e2ac3b91b --- /dev/null +++ b/.changeset/cli-task-list-renamed-columns.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix `fn task list` silently omitting cards in renamed or custom workflow columns. +category: fix +dev: `runTaskList` iterated the legacy six-id `COLUMNS` constant and filtered `t.column === col`, so a card in a workflow-defined column matched no iteration and was never printed. Lanes now come from the tasks themselves via the exported `boardColumnsForDisplay`, and the terminal glyph resolves via `resolveProjectColumnsForRoles(TERMINAL_ROLES)` with the legacy pair as a fail-soft fallback. diff --git a/packages/cli/src/__tests__/task-list-board-columns.test.ts b/packages/cli/src/__tests__/task-list-board-columns.test.ts new file mode 100644 index 0000000000..7cd12f1a39 --- /dev/null +++ b/packages/cli/src/__tests__/task-list-board-columns.test.ts @@ -0,0 +1,53 @@ +/* +FNXC:CliBoardVocabulary 2026-07-30-24:40: +THE INVARIANT: `fn task list` prints every card, whatever its board calls the lane. + +`runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a card in a +workflow-defined column matched no iteration and was never printed — the board looked shorter and +healthy rather than broken, and a fully renamed board printed nothing but the header. + +SCOPE OF THIS COVERAGE, stated rather than implied: these cases pin the lane-selection decision, +which is the entire content of the fix. They do NOT prove `runTaskList` calls it — that function +resolves a real project context and ends in `process.exit`, so driving it needs a mock-the-world +shell, the shape `docs/testing.md` says to avoid when a narrower seam exists. The call site is held +by the compiler instead: the loop's only source of lanes is this function. + +Reverted — this function returning `[...COLUMNS]`, which is what the loop did — the first two cases +fail: renamed lanes vanish entirely, and `shipped` never appears. +*/ +import { describe, expect, it } from "vitest"; +import { boardColumnsForDisplay } from "../commands/task.js"; + +const at = (...columns: string[]) => columns.map((column) => ({ column })); + +describe("boardColumnsForDisplay", () => { + it("includes workflow-defined lanes the legacy enum has never heard of", () => { + expect(boardColumnsForDisplay(at("backlog", "building", "checking"))).toEqual([ + "backlog", + "building", + "checking", + ]); + }); + + it("keeps a renamed terminal lane, which the legacy filter dropped silently", () => { + expect(boardColumnsForDisplay(at("todo", "shipped"))).toEqual(["todo", "shipped"]); + }); + + it("orders legacy lanes in their familiar board order, whatever order the cards arrive in", () => { + expect(boardColumnsForDisplay(at("done", "todo", "in-review", "in-progress"))).toEqual([ + "todo", + "in-progress", + "in-review", + "done", + ]); + }); + + it("puts custom lanes after legacy ones and sorts them deterministically", () => { + expect(boardColumnsForDisplay(at("zeta", "todo", "alpha"))).toEqual(["todo", "alpha", "zeta"]); + }); + + it("emits each lane once however many cards sit in it, and nothing for an empty board", () => { + expect(boardColumnsForDisplay(at("building", "building", "building"))).toEqual(["building"]); + expect(boardColumnsForDisplay([])).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 6a001a2b19..0910150670 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, resolveProjectColumnsForRoles, TERMINAL_ROLES, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -25,6 +25,34 @@ function columnLabel(column: ColumnId): string { return (COLUMN_LABELS as Record)[column] ?? column; } +/* +FNXC:CliBoardVocabulary 2026-07-30-24:40: +The lanes `fn task list` prints, derived from the CARDS rather than from the legacy enum. + +`runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a task in a +workflow-defined column matched no iteration and was NOT PRINTED. Not a wrong label — the card is +absent, and the output reads as a shorter, healthy board rather than as a bug. A fully renamed board +prints nothing but the header. + +Derived from the tasks, not from a resolved IR, deliberately: a board can span several workflows and +therefore has no single column list, and a card must never depend on a resolution succeeding in order +to be VISIBLE. Legacy ids keep their familiar order; anything else follows alphabetically, so output +is deterministic. + +Exported for test: `runTaskList` itself resolves a real project context and ends in `process.exit`, +so covering it end-to-end would need a mock-the-world shell — the shape `docs/testing.md` tells us to +avoid when a narrower seam exists. This IS the seam: it is the whole decision about which lanes appear. +*/ +export function boardColumnsForDisplay(tasks: ReadonlyArray<{ column: ColumnId }>): ColumnId[] { + const legacyOrder = (id: string) => { + const index = (COLUMNS as readonly string[]).indexOf(id); + return index === -1 ? COLUMNS.length : index; + }; + return [...new Set(tasks.map((t) => t.column))].sort((a, b) => + legacyOrder(a) === legacyOrder(b) ? String(a).localeCompare(String(b)) : legacyOrder(a) - legacyOrder(b), + ); +} + // Register GitHub tracking hook so CLI task creation paths (add, duplicate, // refine, import, delegate) trigger tracking issue creation. try { @@ -604,11 +632,41 @@ export async function runTaskList(projectName?: string) { console.log(); } - for (const col of COLUMNS) { + /* + FNXC:CliBoardVocabulary 2026-07-30-24:40: + Iterate the columns the BOARD has, not the legacy six — a renamed card was not printed AT ALL. + + This loop ran `for (const col of COLUMNS)` and filtered `t.column === col`, so any task in a + workflow-defined column matched no iteration and `fn task list` silently omitted it. Not a wrong + label or a wrong glyph: the card is absent, and the output looks like a shorter, healthy board. + On a fully renamed board the command prints nothing but the header. + + The glyph note directly above predicted exactly this ("If this ever iterates workflow-resolved + columns, that difference becomes live and the right answer is a trait lookup, not this") and left + the deeper bug named as R8/U10 surface work. It is fixed here because the two cannot be separated: + once the loop can yield a custom id, the terminal test below MUST stop being an id comparison. + + Columns come from the TASKS rather than from a resolved IR, so every card is rendered whatever its + workflow — a board spanning several workflows has no single column list, and a card must never + depend on resolution succeeding to be visible. Legacy ids keep their familiar order and labels; + anything else follows, alphabetically, so the output is deterministic. + + Terminal lanes ARE resolved, because that is a display question with a real answer and this + function is async with a store in hand. Best-effort: a failed resolve falls back to the legacy + pair rather than failing the command, and an unresolved custom lane renders as active — the same + fail-open direction used elsewhere, since showing a finished card with the wrong glyph is a far + smaller error than the blank board this replaces. + */ + const terminalColumns = await resolveProjectColumnsForRoles( + context.store as Parameters[0], + TERMINAL_ROLES, + ).catch(() => undefined); + + for (const col of boardColumnsForDisplay(tasks)) { const colTasks = tasks.filter((t) => t.column === col); if (colTasks.length === 0) continue; - const label = COLUMN_LABELS[col]; + const label = columnLabel(col); /* FNXC:CliBoardGlyph 2026-07-29-22:40 (lifecycle-column vocabulary): All four non-terminal columns rendered the SAME glyph, so the four id comparisons @@ -630,11 +688,10 @@ export async function runTaskList(projectName?: string) { ALL. That is the R8/U10 surface change (no surface derives its column set from the legacy enum) and a far bigger fix than this glyph. */ - /* DELIBERATE-LITERAL: `col` comes from the legacy `COLUMNS` enum this loop iterates, so the literal - matches its own receiver by construction. The real defect is named in the comment above — a card in a - workflow-renamed column is not rendered at all — and converting this glyph would hide that behind a - trait lookup while the loop still cannot see the card. Retires with the loop. */ - const dot = col === "done" || col === "archived" ? "○" : "●"; + /* The "retires with the loop" condition above is now met: `col` can be a custom id, so the terminal + test is a resolved-lane membership check. DELIBERATE-LITERAL only as the degraded fallback when the + resolve failed, which is the documented unconverted-caller default. */ + const dot = (terminalColumns ? terminalColumns.has(col) : col === "done" || col === "archived") ? "○" : "●"; console.log(` ${dot} ${label} (${colTasks.length})`); for (const t of colTasks) {