diff --git a/packages/core/src/__tests__/column-roles.test.ts b/packages/core/src/__tests__/column-roles.test.ts new file mode 100644 index 0000000000..3d1bfe59ce --- /dev/null +++ b/packages/core/src/__tests__/column-roles.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + isArchivedColumnRole, + isCompleteColumnRole, + isHoldColumnRole, + isIntakeColumnRole, + isPreImplementationColumnRole, + isReviewColumnRole, + isTerminalColumnRole, + isWipColumnRole, + type ColumnRoleTraitFlags, +} from "../column-roles.js"; + +/* +FNXC:WorkflowResolvedColumns 2026-07-30-15:20: +Covers BOTH modes of every predicate — the flags path and the degraded no-flags fallback. The +fallback is the half that had no test when these lived only in the dashboard app, and it is the half +that matters: it runs for any caller holding a task row without a resolved IR, and for a card resting +in a column its workflow no longer declares. + +The renamed-column cases are the point of the whole conversion: a column carrying the right trait +under a NON-legacy id must answer yes, and a column carrying the legacy id but the WRONG trait must +answer no. A predicate that only ever saw default boards would pass while doing nothing. +*/ + +const F = (f: ColumnRoleTraitFlags): ColumnRoleTraitFlags => f; + +describe("column-role predicates — flags decide when present", () => { + it("reads each role off its own trait, under a renamed column id", () => { + expect(isIntakeColumnRole(F({ intake: true }), "Inbox")).toBe(true); + expect(isHoldColumnRole(F({ hold: true }), "Parking")).toBe(true); + expect(isWipColumnRole(F({ countsTowardWip: true }), "Building")).toBe(true); + expect(isReviewColumnRole(F({ mergeBlocker: true }), "Checking")).toBe(true); + expect(isCompleteColumnRole(F({ complete: true }), "Shipped")).toBe(true); + expect(isArchivedColumnRole(F({ archived: true }), "Attic")).toBe(true); + }); + + it("a legacy id with the WRONG traits answers no — the id must not win over resolved flags", () => { + // The conversion's whole claim: once flags resolve, the id is not consulted. + expect(isCompleteColumnRole(F({ archived: true }), "done")).toBe(false); + expect(isWipColumnRole(F({ hold: true }), "in-progress")).toBe(false); + expect(isIntakeColumnRole(F({ hold: true }), "triage")).toBe(false); + expect(isReviewColumnRole(F({ complete: true }), "in-review")).toBe(false); + }); + + it("pre-implementation is the union of intake and hold", () => { + expect(isPreImplementationColumnRole(F({ intake: true }), "x")).toBe(true); + expect(isPreImplementationColumnRole(F({ hold: true }), "x")).toBe(true); + expect(isPreImplementationColumnRole(F({ countsTowardWip: true }), "x")).toBe(false); + }); + + it("review accepts either separable trait", () => { + expect(isReviewColumnRole(F({ humanReview: true }), "x")).toBe(true); + expect(isReviewColumnRole(F({ mergeBlocker: true }), "x")).toBe(true); + expect(isReviewColumnRole(F({}), "x")).toBe(false); + }); + + /* + The distinction #2685 established, asserted rather than only documented: an archived card is + finished but NOT completed, so a throughput surface counting `complete` must not see it. + */ + it("complete EXCLUDES archived, while terminal includes both", () => { + expect(isCompleteColumnRole(F({ archived: true }), "x")).toBe(false); + expect(isArchivedColumnRole(F({ complete: true }), "x")).toBe(false); + + expect(isTerminalColumnRole(F({ complete: true }), "x")).toBe(true); + expect(isTerminalColumnRole(F({ archived: true }), "x")).toBe(true); + expect(isTerminalColumnRole(F({ countsTowardWip: true }), "x")).toBe(false); + }); + + it("an empty resolved-flags object is authoritative — it does NOT fall back to the id", () => { + // `{}` means "traits resolved, this column has none", which is different from "unresolved". + expect(isCompleteColumnRole(F({}), "done")).toBe(false); + expect(isWipColumnRole(F({}), "in-progress")).toBe(false); + expect(isTerminalColumnRole(F({}), "archived")).toBe(false); + }); +}); + +describe("column-role predicates — degraded fallback when flags are absent", () => { + it("falls back to the legacy id for each role", () => { + expect(isIntakeColumnRole(undefined, "triage")).toBe(true); + expect(isHoldColumnRole(undefined, "todo")).toBe(true); + expect(isWipColumnRole(undefined, "in-progress")).toBe(true); + expect(isReviewColumnRole(undefined, "in-review")).toBe(true); + expect(isCompleteColumnRole(undefined, "done")).toBe(true); + expect(isArchivedColumnRole(undefined, "archived")).toBe(true); + }); + + it("pre-implementation falls back to BOTH planning ids, merged and pre-merge", () => { + // `todo` is the post-U11 merged Planning column; `triage` its pre-merge predecessor, retained + // for projects upgraded mid-flight that still hold cards there. + expect(isPreImplementationColumnRole(undefined, "todo")).toBe(true); + expect(isPreImplementationColumnRole(undefined, "triage")).toBe(true); + expect(isPreImplementationColumnRole(undefined, "in-progress")).toBe(false); + }); + + it("terminal falls back to done OR archived", () => { + expect(isTerminalColumnRole(undefined, "done")).toBe(true); + expect(isTerminalColumnRole(undefined, "archived")).toBe(true); + expect(isTerminalColumnRole(undefined, "in-review")).toBe(false); + }); + + it("a RENAMED column with no resolved flags answers no — the fallback cannot invent a role", () => { + // The honest limit of the degraded mode, asserted so nobody mistakes it for trait resolution. + expect(isCompleteColumnRole(undefined, "Shipped")).toBe(false); + expect(isWipColumnRole(undefined, "Building")).toBe(false); + expect(isIntakeColumnRole(undefined, "Inbox")).toBe(false); + }); +}); diff --git a/packages/core/src/column-roles.ts b/packages/core/src/column-roles.ts new file mode 100644 index 0000000000..8df57b9483 --- /dev/null +++ b/packages/core/src/column-roles.ts @@ -0,0 +1,123 @@ +import type { TraitFlags } from "./trait-types.js"; + +/* +FNXC:WorkflowResolvedColumns 2026-07-30-15:05: +Column-ROLE predicates reachable from every package. + +WHY THIS EXISTS AND WHY IT IS NOT A SECOND ABSTRACTION. The role helpers were introduced in +`packages/dashboard/app/utils/columnRoles.ts` — a dashboard-APP module. Measured against the census, +only 150 of 722 lifecycle guards (20%) sit where that module can be imported; engine (316), core +(148), the dashboard SERVER (78) and the CLI (24) cannot reach it at all. This is the SAME +flags-first / legacy-id-fallback predicate placed where the other 80% can call it, and core already +exports `resolveColumnFlags`, so no new resolution machinery comes with it. See +`docs/plans/workflow-owned-merge-stack/fleet-conversion-reachability.md` for the measurement. + +SEMANTICS ARE MIRRORED, NOT INVENTED. The three distinctions below are the ones #2685 established +for the dashboard-side set, restated here deliberately so the two cannot answer the same question +differently: + + - `isCompleteColumnRole` does NOT count `archived`. An archived card is finished but not + *completed*; a throughput surface counting both double-counts 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 `mergeBlocker` OR `humanReview`. They are separable traits, but + every caller converted so far asks "is this card in review", for which both qualify. A caller + that needs exactly one should read the flag directly rather than widen this. + +WHY THE LEGACY FALLBACK IS NOT DEAD CODE. Flags are absent in two real states: a card resting in a +column its workflow no longer declares (mid-flight upgrade), and any caller holding a task row +without a resolved IR. A bare `flags.complete === true` returns false in both — silent degradation, +not a visible failure. The fallback is the degraded mode, named once and covered by tests, rather +than an inline id comparison repeated per call site. +*/ + +/** + * Legacy column ids, used ONLY when a column has no resolved trait flags. + * + * NOT lifecycle rules. `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 the column. + */ +const LEGACY_INTAKE_COLUMN_ID = "triage"; +const LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS: ReadonlySet = new Set(["todo", "triage"]); +const LEGACY_HOLD_COLUMN_ID = "todo"; +const LEGACY_WIP_COLUMN_ID = "in-progress"; +const LEGACY_REVIEW_COLUMN_ID = "in-review"; +const LEGACY_COMPLETE_COLUMN_ID = "done"; +const LEGACY_ARCHIVED_COLUMN_ID = "archived"; + +/** The subset of resolved trait flags these role questions read. */ +export type ColumnRoleTraitFlags = Pick< + TraitFlags, + "intake" | "hold" | "countsTowardWip" | "mergeBlocker" | "humanReview" | "complete" | "archived" +>; + +/** Does this column play the INTAKE role — the lane a card enters before implementation? */ +export function isIntakeColumnRole(flags: ColumnRoleTraitFlags | 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? + * + * Either trait qualifies: both mean work has not started there, so moving a part-done card in + * risks discarding steps. + */ +export function isPreImplementationColumnRole( + flags: ColumnRoleTraitFlags | 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? */ +export function isHoldColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return flags ? flags.hold === true : columnId === LEGACY_HOLD_COLUMN_ID; +} + +/** + * Does this column count as WORK IN PROGRESS? + * + * Keyed on `countsTowardWip` so this predicate and capacity arithmetic cannot disagree. + */ +export function isWipColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return flags ? flags.countsTowardWip === true : columnId === LEGACY_WIP_COLUMN_ID; +} + +/** + * Is a card here awaiting REVIEW — a merge-blocking gate or an explicit human approval? + * + * Either trait qualifies; see the header note on why this is deliberately the union. + */ +export function isReviewColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return flags + ? Boolean(flags.mergeBlocker || flags.humanReview) + : columnId === LEGACY_REVIEW_COLUMN_ID; +} + +/** + * Is this a terminal-SUCCESS column — work completed, dependencies satisfied? + * + * Excludes archived: see the header note. Use `isTerminalColumnRole` for "finished either way". + */ +export function isCompleteColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return flags ? flags.complete === true : columnId === LEGACY_COMPLETE_COLUMN_ID; +} + +/** Is this column ARCHIVED — globally hidden and out of the lifecycle? */ +export function isArchivedColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return flags ? flags.archived === true : columnId === LEGACY_ARCHIVED_COLUMN_ID; +} + +/** + * Is a card here FINISHED either way — completed or archived? + * + * Exists because the pattern `column !== "done" && column !== "archived"` is the single most + * repeated shape in the backlog (e.g. `task-merge.ts` dependency/blocker checks). Naming the union + * keeps callers from re-deriving it and from accidentally dropping one half. + */ +export function isTerminalColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean { + return isCompleteColumnRole(flags, columnId) || isArchivedColumnRole(flags, columnId); +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 16eb97ccdd..7c6fc8594d 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -350,6 +350,20 @@ export type { TraitViolationSeverity, TraitAuditWarning, } from "./trait-registry.js"; +/* FNXC:WorkflowResolvedColumns 2026-07-30-15:05: column-ROLE predicates must be in the GATE barrel + too — the engine-core gate project resolves @fusion/core to the bundle built from THIS file, so an + export present only in index.ts is undefined at runtime under the gate. */ +export { + isIntakeColumnRole, + isPreImplementationColumnRole, + isHoldColumnRole, + isWipColumnRole, + isReviewColumnRole, + isCompleteColumnRole, + isArchivedColumnRole, + isTerminalColumnRole, +} from "./column-roles.js"; +export type { ColumnRoleTraitFlags } from "./column-roles.js"; export { BUILTIN_TRAIT_IDS, BUILTIN_TRAIT_DEFINITIONS, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9936b192a5..732e66da1e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -381,6 +381,19 @@ export type { TraitViolationSeverity, TraitAuditWarning, } from "./trait-registry.js"; +/* FNXC:WorkflowResolvedColumns 2026-07-30-15:05: column-ROLE predicates, reachable from every + package (the dashboard-app helper set is not importable from engine/core/cli). */ +export { + isIntakeColumnRole, + isPreImplementationColumnRole, + isHoldColumnRole, + isWipColumnRole, + isReviewColumnRole, + isCompleteColumnRole, + isArchivedColumnRole, + isTerminalColumnRole, +} from "./column-roles.js"; +export type { ColumnRoleTraitFlags } from "./column-roles.js"; export { BUILTIN_TRAIT_IDS, BUILTIN_TRAIT_DEFINITIONS, diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index b758538916..de5c87c4e4 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -40,7 +40,8 @@ import { StaleTaskReporter } from "./stale-task-reporter.js"; import { BacklogPressureReporter } from "./backlog-pressure-reporter.js"; import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; -import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns } from "@fusion/core"; +import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole } from "@fusion/core"; +import type { ColumnRoleTraitFlags } from "@fusion/core"; import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core"; import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js"; import { moveTaskToReplanColumn } from "./replan-target.js"; @@ -1668,27 +1669,36 @@ export class Scheduler { workflowIdByTaskId.set(task.id, "builtin:coding"); } })); - // Per distinct workflow: columnId → countsTowardWip flag; null when the IR - // failed to resolve or has no v2 columns (forces the literal fallback). - const wipFlagsByWorkflowId = new Map | null>(); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-15:40 (fleet conversion, scheduler.ts): + Per distinct workflow: columnId → resolved trait flags; null when the IR failed to resolve or + has no v2 columns, which leaves every lookup undefined and defers to the helper's documented + degraded mode. + + Was a hand-rolled copy of `isWipColumnRole`: it stored only `countsTowardWip` as a boolean and + re-implemented flags-first-then-legacy-id inline. Storing the flags object instead lets the + shared predicate decide, so this scheduler and the role helpers cannot drift on what "counts as + WIP" means. Behaviour is identical in all four states — column present with the flag true or + false (flags win), column absent from a resolved IR, and IR resolution failed (both fall back + to the legacy id). + */ + const columnFlagsByWorkflowId = new Map | null>(); for (const workflowId of new Set(workflowIdByTaskId.values())) { try { const ir = await resolveWorkflowIrById(this.store, workflowId, wipIrCache); const columns = (ir as WorkflowIrV2).columns; - wipFlagsByWorkflowId.set( + columnFlagsByWorkflowId.set( workflowId, - columns ? new Map(columns.map((c) => [c.id, resolveColumnFlags(c).countsTowardWip === true])) : null, + columns ? new Map(columns.map((c) => [c.id, resolveColumnFlags(c)])) : null, ); } catch { - wipFlagsByWorkflowId.set(workflowId, null); + columnFlagsByWorkflowId.set(workflowId, null); } } - const isWipColumnTask = (task: Task): boolean => { - const flags = wipFlagsByWorkflowId.get(workflowIdByTaskId.get(task.id) ?? "builtin:coding"); - const wip = flags?.get(task.column); - if (wip !== undefined) return wip; - return task.column === "in-progress"; - }; + const columnFlagsForTask = (task: Task): ColumnRoleTraitFlags | undefined => + columnFlagsByWorkflowId.get(workflowIdByTaskId.get(task.id) ?? "builtin:coding")?.get(task.column); + const isWipColumnTask = (task: Task): boolean => + isWipColumnRole(columnFlagsForTask(task), task.column); const wipTaskIds = tasks.filter(isWipColumnTask).map((task) => task.id); let reservedWorktreeSlots = wipTaskIds.length; let reservedConcurrentSlots = reservedWorktreeSlots; diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 0ad04c794f..09c9e59269 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,14 +1,14 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "totals": { - "column": 722, + "column": 721, "role": 5, "status": 186, "deliberate": 17 }, "byColumnId": { "done": 195, - "in-progress": 138, + "in-progress": 137, "in-review": 200, "archived": 147, "todo": 42 @@ -18,7 +18,7 @@ "packages/engine/src/executor.ts": 85, "packages/dashboard/app/components/TaskCard.tsx": 42, "packages/dashboard/app/components/TaskDetailModal.tsx": 30, - "packages/engine/src/scheduler.ts": 28, + "packages/engine/src/scheduler.ts": 27, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 20, "packages/core/src/task-store/moves.ts": 15, "packages/core/src/store.ts": 12,