diff --git a/.changeset/cutover-delete-fn-review-step.md b/.changeset/cutover-delete-fn-review-step.md
new file mode 100644
index 0000000000..48093f40ce
--- /dev/null
+++ b/.changeset/cutover-delete-fn-review-step.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Review gates now run only as workflow nodes — the in-session step reviewer is gone.
+category: internal
+dev: U10/R9 of the IR-driven lifecycle cutover deletes the `fn_review_step` executor tool, its RETHINK git-reset/session-rewind path, the per-step conversation checkpoint map, the deferred reviewer provider-error re-raise channel, and the review-level prompt scaffolding that told the model to call it. Plan/code/browser review are owned exclusively by workflow graph nodes.
diff --git a/.changeset/dashboard-custom-columns.md b/.changeset/dashboard-custom-columns.md
new file mode 100644
index 0000000000..6e31b29bfb
--- /dev/null
+++ b/.changeset/dashboard-custom-columns.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Boards built on custom workflows now show and move cards in their own columns.
+category: fix
+dev: Operator surfaces closed the column set in four places. The dashboard ran every ingested task through `normalizeColumn`, which keeps only the six legacy ids and rewrote everything else to `triage`, so a card in a user-authored column rendered in Triage (new `normalizeColumnId` sanitizes structurally instead). `POST /tasks/:id/move` validated against the `COLUMNS` enum and answered 400 for any workflow-defined column; it now validates against the task's resolved IR and keys worktree allocation on the `wip` trait. Retry / reset / re-engage / unassign / spec-revise moved cards with hardcoded `"todo"`/`"in-progress"`/`"triage"` targets and gated spec revision on `VALID_TRANSITIONS`, all now derived from the task's workflow by trait. GitHub issue open/closed mapping keys on the `complete`/`archived` traits via an injected classifier whose default reproduces the legacy literal mapping. Status badges prefer the running workflow step's IR-declared name over raw engine status tokens.
diff --git a/.changeset/fix-builtin-workflow-lifecycle.md b/.changeset/fix-builtin-workflow-lifecycle.md
new file mode 100644
index 0000000000..98fd64d184
--- /dev/null
+++ b/.changeset/fix-builtin-workflow-lifecycle.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix built-in workflows sending cards backward to Todo and stalling the PR workflow.
+category: fix
+dev: Unseamed nodes in linear built-ins (`security` in Review-heavy, `design-review` in Design, `review-handoff`/`document` in Compound Engineering) defaulted to the capacity-hold column, so the graph moved live cards back into Todo mid-run; they now inherit the preceding node's column. Separately, the `hold` node kind had no default handler, so every hold node threw "No handler registered" — Pull Request workflow cards died at `await-review`; holds now park in place like `manual-merge-hold`.
diff --git a/.changeset/fix-no-merge-workflow-completion.md b/.changeset/fix-no-merge-workflow-completion.md
new file mode 100644
index 0000000000..a2cfc2e582
--- /dev/null
+++ b/.changeset/fix-no-merge-workflow-completion.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Workflows without a merge step now finish in their completion column instead of stalling one column short.
+category: fix
+dev: `end` is a graph terminal and never a column destination (KTD-1), so a card only entered the `complete`-trait column when a real node lived there — true for merge-bearing workflows via `post-merge-verification`, false for any no-merge workflow (e.g. Lead Generation stranded in `outreach`, never `converted`, which also blocked its dependents). Adds `advanceNoMergeWorkflowToCompleteColumn` on the executor's completed-disposition branch, keyed on the absence of a merge-orchestration column so merge-bearing workflows are untouched and `done` still requires a confirmed merge.
diff --git a/.changeset/fix-no-selection-default-workflow-drift.md b/.changeset/fix-no-selection-default-workflow-drift.md
new file mode 100644
index 0000000000..98616d206d
--- /dev/null
+++ b/.changeset/fix-no-selection-default-workflow-drift.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix tasks with no saved workflow selection being unable to move between columns.
+category: fix
+dev: Two resolvers disagreed on the no-selection default IR (catalog `builtin:coding` vs the legacy `BUILTIN_CODING_WORKFLOW_IR` constant), so the move-policy preflight signature never matched and the move threw "workflow move policy preflight is stale". Both sides plus `resolveTaskWorkflowIrSync` now share `resolveDefaultWorkflowIr()`.
diff --git a/.changeset/ir-driven-lifecycle-cutover.md b/.changeset/ir-driven-lifecycle-cutover.md
new file mode 100644
index 0000000000..c21acf99b6
--- /dev/null
+++ b/.changeset/ir-driven-lifecycle-cutover.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Your workflow now drives the board — cards move through the columns you defined, not a fixed six.
+category: feature
+dev: The IR-driven lifecycle cutover makes the workflow IR the single authority over task lifecycle. Node column assignments move cards at runtime through the store's trait-hook `moveTask` path (attributed `workflowMoveSource: "workflow-graph"`, emitting `task:column-transition`), replacing the executor's hardcoded `moveTask(id, "in-review")` merge boundary and its handoff-invariant allowlist. Scheduler, hold/release, self-healing, merger and finalization re-key on column traits (`intake`/`hold`/`wip`/`merge-blocker`/`human-review`/`merge`/`complete`/`archived`/`timing`/`abort-on-exit`/`reset-on-entry`/`stall-detection`) instead of literal column ids, with rebound targets resolved per KTD-10. The graph exclusively owns Plan Review — triage's out-of-graph gate is deleted and `pending` step results are CAS-claimed leases (KTD-4), so duplicate reviewers can no longer race. `reviewLevel` becomes a creation-time preset writing `enabledWorkflowSteps` with zero runtime reads. Graph ownership is unconditional: the legacy execute fallback is deleted, `graphCompletion` is a required parameter, and a store that cannot resolve a workflow fails closed rather than silently running nothing. `builtin:coding` keeps its column ids and observable behavior byte-compatible, pinned by a characterization oracle plus a 6-column benchmark acceptance suite that drives a user-authored workflow Ideas -> Todo -> In-progress -> In-review -> Merging -> Done.
diff --git a/.changeset/legacy-adoption-and-ir-pin.md b/.changeset/legacy-adoption-and-ir-pin.md
new file mode 100644
index 0000000000..ad7fdc17d7
--- /dev/null
+++ b/.changeset/legacy-adoption-and-ir-pin.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Tasks left mid-flight by an older Fusion are now adopted on upgrade instead of sitting stuck.
+category: feature
+dev: Migration 0026 adds `workflow_ir_pin`/`workflow_ir_pin_node_id`/`workflow_ir_pin_column_id` (KTD-3 durable per-node-entry IR pin) and `legacy_adopted_at` (KTD-8 one-time adoption stamp); SCHEMA_BASELINE_VERSION 0025 -> 0026. `planLegacyAdoption` is the shared decision run by both new consumers — the `adopt-legacy-task-rows` startup step in self-healing (ordered first, emits `task:reconcile-legacy-adoption` / `-unmappable`) and `adoptLegacyTaskRowsOnOpen` in the backend-mode store open path. Adoption stamps only rows it mutates, never disturbs user pauses or preserve gates, and parks unknown statuses `paused` with the status left visible. `assertBinaryNotOlderThanDatabase` refuses to open a database migrated by a newer binary (numeric comparison, unparseable markers ignored).
diff --git a/.changeset/six-column-merge-boundary.md b/.changeset/six-column-merge-boundary.md
new file mode 100644
index 0000000000..da86bc17c0
--- /dev/null
+++ b/.changeset/six-column-merge-boundary.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: A custom Merging column now receives the card at merge instead of being sent to In-review.
+category: fix
+dev: The workflow graph collapses the merge region into one seam recorded as node `merge`, but that synthetic node hardcoded `column: "in-review"`, so a user-authored workflow placing its merge nodes in a different column (e.g. `Merging`) had the card moved to `in-review` — a column such a workflow need not even declare. The column now derives from the merge-region node actually being entered, falling back to `in-review` so `builtin:coding` stays byte-identical. Caught by the new 6-column benchmark acceptance test.
diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx
index fbe1da8b12..7184df0884 100644
--- a/packages/dashboard/app/components/ListView.tsx
+++ b/packages/dashboard/app/components/ListView.tsx
@@ -22,7 +22,7 @@ import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
-import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
+import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
import { isTaskAgentActive } from "../utils/taskActivity";
import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel";
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
@@ -69,9 +69,9 @@ type SortField = "title" | "status" | "column" | "retries";
FNXC:MergeQueue 2026-07-15-10:45:
List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge.
*/
-function getTaskStatusLabel(status: string, t: TFunction<"app">): string {
+function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string {
if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval");
- return getTaskStatusBadgeLabel(status, t);
+ return getTaskStatusBadgeLabel(status, t, workflowStepLabel);
}
type SortDirection = "asc" | "desc";
@@ -2743,7 +2743,7 @@ export function ListView({
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
- : getTaskStatusLabel(visualStatus ?? "", t)}
+ : getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))}
) : null}
{planReviewRunning && isAgentActive && (
@@ -2976,7 +2976,7 @@ export function ListView({
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
- : getTaskStatusLabel(visualStatus ?? "", t)}
+ : getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))}
) : (
-
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 1d870703a4..b58700f7e3 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -39,7 +39,7 @@ import { getStalledReviewSignal } from "../utils/taskStalledReview";
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
-import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
+import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
@@ -300,8 +300,8 @@ const TIME_INDICATOR_COLUMNS = new Set([
]);
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
-function getTaskStatusLabel(status: string, t: TFunction<"app">): string {
- return getTaskStatusBadgeLabel(status, t);
+function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string {
+ return getTaskStatusBadgeLabel(status, t, workflowStepLabel);
}
function getDoneCompletionMs(task: Task): number | null {
@@ -3082,7 +3082,7 @@ function TaskCardComponent({
? t("tasks.statusPlanning", "Planning")
: visualStatus === "merging-fix"
? t("tasks.statusMergingFix", "Merging fixes…")
- : getTaskStatusLabel(visualStatus!, t)}
+ : getTaskStatusLabel(visualStatus!, t, getRunningWorkflowStepLabel(task))}
)}
{planReviewRunning && isAgentActive && (
diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx
index ece02b2089..a1a5267461 100644
--- a/packages/dashboard/app/components/TaskForm.tsx
+++ b/packages/dashboard/app/components/TaskForm.tsx
@@ -1604,6 +1604,14 @@ export function TaskForm({
)}
{onReviewLevelChange && (
+ /*
+ FNXC:ReviewLevelPreset 2026-07-19-10:50 (U8 / R6):
+ Review is now a CREATION-TIME preset over the workflow's optional review
+ steps (level -> enabledWorkflowSteps at create), not a runtime signal
+ triage decides. The default (unset) leaves the workflow's own default-on
+ steps in place; an explicit optional-step selection always wins over the
+ level preset (server-side applyReviewLevelPreset).
+ */
)}
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 6dc42fe9ed..c4630178cd 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -2390,7 +2390,10 @@ describe("TaskCard", () => {
expect(Boolean(badge)).toBe(shouldRender);
if (shouldRender) {
expect(badge).toHaveTextContent("Reviewing");
- expect(screen.getByText("planning")).toBeDefined();
+ // FNXC:StatusBadge 2026-07-19-04:30: U12 — the status badge prefers the running
+ // workflow step's IR-declared name ("Plan Review") over the raw engine token
+ // ("planning"); this expectation tracks that intentional cutover behavior.
+ expect(screen.getByText("Plan Review")).toBeDefined();
}
});
diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts
index 5f1a8e4276..a7c93eb306 100644
--- a/packages/dashboard/app/hooks/useTasks.ts
+++ b/packages/dashboard/app/hooks/useTasks.ts
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
-import { normalizeColumn } from "@fusion/core";
+import { normalizeColumnId } from "@fusion/core";
import * as api from "../api";
import { subscribeSse } from "../sse-bus";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
@@ -10,10 +10,21 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
const loggedTaskCacheHitProjects = new Set();
const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS;
+/*
+FNXC:WorkflowColumns 2026-07-19-2b:05 (U12 / R2 / R11):
+Every task the dashboard ingests — initial list, SWR revalidation, and each SSE event — passes
+through here, so this one line decided whether custom columns exist in the UI at all. It used
+`normalizeColumn`, which keeps only the six legacy ids and rewrites everything else to `triage`:
+a card sitting in a user-authored `Merging` column rendered in Triage, and dragging it appeared to
+do nothing. The move handler below already worked around this for its own `to` id ("normalizeColumn
+alone would drop custom ids"), which fixed the symptom for one event and left the ingest path lossy.
+`normalizeColumnId` sanitizes structurally (non-string/empty -> fallback) and passes real ids
+through; membership belongs to the task's resolved workflow, not to a client-side enum.
+*/
function normalizeTask(task: Task): Task {
return {
...task,
- column: normalizeColumn((task as Task & { column?: unknown }).column),
+ column: normalizeColumnId((task as Task & { column?: unknown }).column),
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
steps: Array.isArray(task.steps) ? task.steps : [],
log: Array.isArray((task as Task & { log?: unknown }).log)
diff --git a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts
index d31d51d76c..7b9c115f6c 100644
--- a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts
+++ b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts
@@ -34,6 +34,23 @@ describe("getTaskStatusBadgeLabel", () => {
expect(getTaskStatusBadgeLabel("merging-fix", t)).toBe("Merging fixes…");
});
+ it("keeps merging-fix over a still-running workflow-step label", () => {
+ // A pre-merge step's running state can survive into a merge-fix retry; the badge must not regress to the step name.
+ expect(getTaskStatusBadgeLabel("merging-fix", t, "Plan Review")).toBe("Merging fixes…");
+ });
+
+ it("keeps every active-merge status over a still-running workflow-step label", () => {
+ // The same stale startedAt-without-completedAt step state can survive into the whole merge pipeline.
+ for (const status of ["merging", "merging-pr", "reviewing", "landing"]) {
+ expect(getTaskStatusBadgeLabel(status, t, "Code Review")).toBe("Merging…");
+ }
+ });
+
+ it("lets a running workflow-step label override other statuses", () => {
+ expect(getTaskStatusBadgeLabel("planning", t, "Plan Review")).toBe("Plan Review");
+ expect(getTaskStatusBadgeLabel("needs-replan", t, "Plan Review")).toBe("Plan Review");
+ });
+
it("maps needs-replan to the operator-facing Replan label", () => {
expect(getTaskStatusBadgeLabel("needs-replan", t)).toBe("Replan");
});
diff --git a/packages/dashboard/app/utils/capture-screenshot.ts b/packages/dashboard/app/utils/capture-screenshot.ts
deleted file mode 100644
index c15be6fa1c..0000000000
--- a/packages/dashboard/app/utils/capture-screenshot.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import html2canvas from "html2canvas";
-
-const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
-
-export interface CapturedScreenshot {
- blob: Blob;
- previewUrl: string;
-}
-
-/**
- * FNXC:ReportPipeline 2026-07-16-10:00:
- * Screenshot capture deliberately snapshots only Fusion's app root, never the
- * display. This avoids collecting other tabs or operating-system windows.
- */
-export async function captureAppScreenshot(): Promise {
- const root = document.querySelector("#root");
- if (!root) throw new Error("The dashboard view is not available for capture.");
- const canvas = await html2canvas(root as HTMLElement, { scale: 0.75, useCORS: true, logging: false });
- const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8));
- if (!blob || blob.size > MAX_SCREENSHOT_BYTES) throw new Error("The screenshot is too large. Try a smaller dashboard view.");
- return { blob, previewUrl: URL.createObjectURL(blob) };
-}
diff --git a/packages/dashboard/app/utils/taskProgress.ts b/packages/dashboard/app/utils/taskProgress.ts
index 7008052593..eb6c987760 100644
--- a/packages/dashboard/app/utils/taskProgress.ts
+++ b/packages/dashboard/app/utils/taskProgress.ts
@@ -140,6 +140,27 @@ export function getUnifiedTaskProgress(
return { total, completed, items };
}
+/*
+FNXC:TaskStatusBadge 2026-07-19-2b:55 (U12 / R2 / R11):
+The workflow-step-derived badge label. Operator surfaces used to render raw engine status tokens
+("planning", "needs-replan"), which name ENGINE bookkeeping rather than the stage the card is
+actually in — and which no user-authored workflow has any reason to recognize. When a workflow step
+is running, its own IR-declared name ("Plan Review", "Code Review") is both truer and workflow-owned,
+so it takes precedence over the status vocabulary.
+
+Returns undefined when nothing is running, leaving the status mapping as the fallback. The engine
+statuses themselves are unchanged — `needs-replan` remains the graph's durable replan signal; this
+only decides what the operator READS.
+*/
+export function getRunningWorkflowStepLabel(
+ task: Pick,
+): string | undefined {
+ const running = getUnifiedTaskProgress(task).items.find(
+ (item) => item.source === "workflow" && item.status === "running",
+ );
+ return running?.name;
+}
+
/*
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:00:
FN-7831 requires task cards and list rows to show a distinct "Reviewing" badge only while the optional `plan-review` workflow step is actively running. Reuse the unified progress item status so every board surface follows the same startedAt-without-completedAt semantics as the progress list.
diff --git a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
index 95c809ddc3..dc77ec9f8e 100644
--- a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
+++ b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
@@ -22,14 +22,30 @@ export function shouldSuppressPlanningStatusBadge({
export function getTaskStatusBadgeLabel(
status: string | null | undefined,
t: TFunction<"app">,
+ /*
+ FNXC:TaskStatusBadge 2026-07-19-02:55 (U12 / R2 / R11):
+ Workflow-step state wins over the raw status vocabulary. A card whose Plan Review is running
+ reads "Plan Review" — the step's own IR-declared name — instead of the engine token "planning"
+ or "needs-replan". Pass `getRunningWorkflowStepLabel(task)` here; omit it and the legacy status
+ mapping below is unchanged, so every existing caller keeps its behavior.
+ */
+ workflowStepLabel?: string,
): string {
- if (!status) return "";
- if (status === "merging-fix") {
- return t("tasks.statusMergingFix", "Merging fixes…");
- }
+ /*
+ FNXC:TaskStatusBadge 2026-07-19-09:40:
+ Every active-merge status ("merging", "merging-pr", "merging-fix", "reviewing", "landing") must
+ win over a still-running workflow-step label (a pre-merge step's startedAt-without-completedAt
+ state can survive into the merge pipeline). Checking the status before the workflow-step override
+ enforces this for every caller (TaskCard, ListView grouped rows, ListView table rows) instead of
+ relying on per-call-site pre-checks. "merging-fix" keeps its distinct "Merging fixes…" label.
+ */
if (isActiveMergeStatus(status)) {
- return t("tasks.statusMerging", "Merging…");
+ return status === "merging-fix"
+ ? t("tasks.statusMergingFix", "Merging fixes…")
+ : t("tasks.statusMerging", "Merging…");
}
+ if (workflowStepLabel) return workflowStepLabel;
+ if (!status) return "";
/*
FNXC:TaskStatusBadge 2026-07-28-00:00:
FN-8195 requires the raw engine status "needs-replan" to appear as "Replan" on board cards
diff --git a/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts b/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts
new file mode 100644
index 0000000000..c8f5beb99f
--- /dev/null
+++ b/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts
@@ -0,0 +1,238 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+import { parseWorkflowIr, resolveReboundTarget, columnsWithFlag, normalizeColumnId } from "@fusion/core";
+import type { WorkflowIr } from "@fusion/core";
+import "@fusion/core"; // registers the built-in traits the trait resolvers read
+
+import { decideIssueAction, legacyColumnLifecycleClass } from "../github-tracking-state.js";
+import { sixColumnWorkflowIr } from "../../../engine/src/__tests__/fixtures/six-column-workflow-ir.js";
+
+/*
+FNXC:WorkflowColumns 2026-07-19-2c:10 (U12 / R2 / R11):
+The operator-surface half of the cutover: every dashboard/API decision that used to key on the
+closed six-id column enum must key on the TASK'S WORKFLOW instead.
+
+These are deliberately pure-function tests over the seams U12 re-keyed, not route integration
+tests. The value is in the DECISION (which column, which GitHub state, which label), and pinning it
+directly is both faster and harder to fool than asserting through an Express stack whose store stub
+would have to fake IR resolution anyway. Route wiring is exercised by the existing
+`routes-task-*.test.ts` suites.
+
+The fixture is U11's benchmark IR — the same object its acceptance runner drives — so "the editor
+can build it" and "the runner can run it" are statements about ONE artifact, which is what R11 asks
+for.
+*/
+
+const BENCHMARK_IR = sixColumnWorkflowIr();
+
+describe("U12: column ids stay open across operator surfaces", () => {
+ /*
+ The defect this pins: the dashboard ran every ingested task through `normalizeColumn`, which
+ keeps only the six legacy ids and rewrites everything else to `triage`. A card in a custom
+ `merging` column rendered in Triage and dragging it appeared to do nothing.
+ */
+ it("preserves a novel column id instead of coercing it to a legacy one", () => {
+ expect(normalizeColumnId("merging")).toBe("merging");
+ expect(normalizeColumnId("ideas")).toBe("ideas");
+ // Structurally unusable values still fall back — that is the only coercion left.
+ expect(normalizeColumnId("")).toBe("triage");
+ expect(normalizeColumnId(undefined)).toBe("triage");
+ expect(normalizeColumnId(null, "todo")).toBe("todo");
+ });
+
+ it("resolves lifecycle move targets from the workflow, not from literals", () => {
+ // Rebound (retry / reset / unassign) targets the hold column...
+ expect(resolveReboundTarget(BENCHMARK_IR)).toBe("todo");
+ // ...execution targets the wip column, and intake is its own thing.
+ expect(columnsWithFlag(BENCHMARK_IR, "countsTowardWip")[0]).toBe("in-progress");
+ expect(columnsWithFlag(BENCHMARK_IR, "intake")[0]).toBe("ideas");
+
+ /*
+ The point of deriving rather than hardcoding: a workflow with NO `todo` and NO `triage` still
+ resolves a real destination. Under the old literals the operator's Retry button either threw
+ or parked the card in a column the workflow never declared.
+ */
+ const renamed = parseWorkflowIr({
+ version: "v2",
+ name: "renamed-lifecycle",
+ columns: [
+ { id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
+ { id: "queue", name: "Queue", traits: [{ trait: "hold", config: { release: "capacity" } }] },
+ { id: "building", name: "Building", traits: [{ trait: "wip" }] },
+ { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
+ ],
+ nodes: [
+ { id: "start", kind: "start", column: "inbox" },
+ { id: "build", kind: "prompt", column: "building", config: { seam: "execute" } },
+ { id: "end", kind: "end", column: "shipped" },
+ ],
+ edges: [{ from: "start", to: "build" }, { from: "build", to: "end", condition: "success" }],
+ } as never) as WorkflowIr;
+
+ expect(resolveReboundTarget(renamed)).toBe("queue");
+ expect(columnsWithFlag(renamed, "countsTowardWip")[0]).toBe("building");
+ expect(columnsWithFlag(renamed, "intake")[0]).toBe("inbox");
+ });
+});
+
+describe("U12: GitHub issue state keys on complete/archived traits", () => {
+ /** Classify by TRAIT, the way the re-keyed caller does, rather than by literal id. */
+ const classifyByTrait = (ir: WorkflowIr) => {
+ const complete = new Set(columnsWithFlag(ir, "complete"));
+ const archived = new Set(columnsWithFlag(ir, "archived"));
+ return (columnId: string) => ({ complete: complete.has(columnId), archived: archived.has(columnId) });
+ };
+
+ it("closes the issue when a card reaches a workflow's own complete column", () => {
+ const classify = classifyByTrait(BENCHMARK_IR);
+ // The benchmark's complete column happens to be `done`, but the decision is trait-driven:
+ expect(decideIssueAction("merging", "done", classify)).toEqual({
+ action: "close",
+ stateReason: "completed",
+ });
+ // A move between non-terminal columns is not a GitHub event at all.
+ expect(decideIssueAction("in-review", "merging", classify)).toBeNull();
+ });
+
+ it("closes on a RENAMED complete column, which the literal mapping missed entirely", () => {
+ const renamed = parseWorkflowIr({
+ version: "v2",
+ name: "renamed-terminal",
+ columns: [
+ { id: "building", name: "Building", traits: [{ trait: "wip" }] },
+ { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
+ { id: "dropped", name: "Dropped", traits: [{ trait: "archived" }] },
+ ],
+ nodes: [
+ { id: "start", kind: "start", column: "building" },
+ { id: "end", kind: "end", column: "shipped" },
+ ],
+ edges: [{ from: "start", to: "end" }],
+ } as never) as WorkflowIr;
+ const classify = classifyByTrait(renamed);
+
+ expect(decideIssueAction("building", "shipped", classify)).toEqual({
+ action: "close",
+ stateReason: "completed",
+ });
+ expect(decideIssueAction("building", "dropped", classify)).toEqual({
+ action: "close",
+ stateReason: "not_planned",
+ });
+ expect(decideIssueAction("shipped", "building", classify)).toEqual({
+ action: "reopen",
+ stateReason: "reopened",
+ });
+ expect(decideIssueAction("dropped", "shipped", classify)).toEqual({
+ action: "reopen",
+ stateReason: "reopened",
+ });
+
+ // The regression this replaces: with the legacy literal classifier, none of it fires.
+ expect(decideIssueAction("building", "shipped", legacyColumnLifecycleClass)).toBeNull();
+ });
+
+ it("keeps the default workflow byte-identical under the default classifier", () => {
+ expect(decideIssueAction("in-review", "done")).toEqual({ action: "close", stateReason: "completed" });
+ expect(decideIssueAction("done", "archived")).toEqual({ action: "close", stateReason: "completed" });
+ expect(decideIssueAction("todo", "archived")).toEqual({ action: "close", stateReason: "not_planned" });
+ expect(decideIssueAction("archived", "done")).toEqual({ action: "reopen", stateReason: "reopened" });
+ expect(decideIssueAction("done", "todo")).toEqual({ action: "reopen", stateReason: "reopened" });
+ expect(decideIssueAction("todo", "in-progress")).toBeNull();
+ expect(decideIssueAction("archived", "archived")).toBeNull();
+ });
+});
+
+/*
+FNXC:WorkflowColumns 2026-07-19-2c:20 (U12 / R11 first half):
+EDITOR BUILDABILITY. R11 asks that the 6-column benchmark be constructible in the workflow editor,
+not merely hand-written as a test fixture. The editor's save path validates through
+`parseWorkflowIr` (via `validateWorkflowIrDryRun`), so parsing the benchmark IR and getting back a
+usable v2 graph IS the buildability claim — and because U11's runner drives this exact object, the
+saved artifact is provably byte-usable by the runtime.
+*/
+describe("U12: the 6-column benchmark is editor-buildable (R11)", () => {
+ it("passes save validation and round-trips its columns, traits, nodes and caps", () => {
+ const parsed = parseWorkflowIr(BENCHMARK_IR as never) as WorkflowIr & {
+ columns: Array<{ id: string; traits: Array<{ trait: string }> }>;
+ nodes: Array<{ id: string; column?: string; config?: Record }>;
+ edges: Array<{ from: string; to: string; condition?: string; kind?: string }>;
+ };
+
+ expect(parsed.version).toBe("v2");
+ // Six columns, in the operator's documented order.
+ expect(parsed.columns.map((c) => c.id)).toEqual([
+ "ideas",
+ "todo",
+ "in-progress",
+ "in-review",
+ "merging",
+ "done",
+ ]);
+ // Each column kept the traits the contract assigns it.
+ const traitsOf = (id: string) =>
+ parsed.columns.find((c) => c.id === id)!.traits.map((t) => t.trait).sort();
+ expect(traitsOf("ideas")).toEqual(["intake"]);
+ expect(traitsOf("todo")).toEqual(["hold", "reset-on-entry"]);
+ expect(traitsOf("in-progress")).toEqual(["abort-on-exit", "timing", "wip"]);
+ expect(traitsOf("in-review")).toEqual(["merge-blocker", "stall-detection"]);
+ // Merging carries the human gate — the operator contract's settled placement.
+ expect(traitsOf("merging")).toEqual(["human-review", "merge"]);
+ expect(traitsOf("done")).toEqual(["complete"]);
+
+ // Review nodes sit in the hold column (pre-release Plan Review) and In-review respectively.
+ const nodeColumn = (id: string) => parsed.nodes.find((n) => n.id === id)?.column;
+ expect(nodeColumn("plan-review")).toBe("todo");
+ expect(nodeColumn("code-review")).toBe("in-review");
+ // The remediation node sits in In-progress — that placement is what makes the REVISE
+ // round-trip a visible backward column move rather than an in-place retry.
+ expect(nodeColumn("code-review-remediation")).toBe("in-progress");
+
+ // The remediation edges survive as rework edges (the only legal cycles).
+ const reworkEdges = parsed.edges.filter((e) => e.kind === "rework");
+ expect(reworkEdges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ from: "plan-replan", to: "plan-review" }),
+ expect.objectContaining({ from: "code-review-remediation", to: "code-review" }),
+ ]),
+ );
+
+ // Per-node caps are workflow config and survive the round trip.
+ const configOf = (id: string) => parsed.nodes.find((n) => n.id === id)?.config ?? {};
+ expect(configOf("plan-review").maxReworkCycles).toBe(1);
+ expect(configOf("code-review").maxReworkCycles).toBe(3);
+ });
+
+ /*
+ The negative case R11 asks for. "Node references a missing column" must be REJECTED at save, not
+ silently accepted and then discovered at runtime when the boundary tries to move a card into a
+ column that does not exist.
+ */
+ it("rejects a node pointing at a column the workflow does not declare", () => {
+ const broken = {
+ ...(BENCHMARK_IR as unknown as Record),
+ nodes: (BENCHMARK_IR as unknown as { nodes: Array> }).nodes.map((n) =>
+ n.id === "merge-attempt" ? { ...n, column: "nonexistent-column" } : n,
+ ),
+ };
+
+ expect(() => parseWorkflowIr(broken as never)).toThrow(
+ /references undefined column 'nonexistent-column'/,
+ );
+ });
+
+ it("still rejects the invalid graph when every column id is novel", () => {
+ const broken = {
+ version: "v2",
+ name: "novel-ids",
+ columns: [{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }],
+ nodes: [
+ { id: "start", kind: "start", column: "inbox" },
+ { id: "work", kind: "prompt", column: "not-declared" },
+ { id: "end", kind: "end", column: "inbox" },
+ ],
+ edges: [{ from: "start", to: "work" }, { from: "work", to: "end", condition: "success" }],
+ };
+ expect(() => parseWorkflowIr(broken as never)).toThrow(/references undefined column/);
+ });
+});
diff --git a/packages/dashboard/src/github-tracking-state.ts b/packages/dashboard/src/github-tracking-state.ts
index c746b2343e..ebe7f9d8d7 100644
--- a/packages/dashboard/src/github-tracking-state.ts
+++ b/packages/dashboard/src/github-tracking-state.ts
@@ -19,36 +19,62 @@ interface TaskMovedEvent {
};
};
};
- // #1403: the store's `task:moved` event now carries `ColumnId` (custom column
- // ids admitted). These handlers only literal-compare against legacy ids, so a
- // string-widened field is safe.
+ // #1403: the store's `task:moved` event carries `ColumnId` (custom column ids
+ // admitted). U12 re-keys the decision on the complete/archived traits, so a
+ // workflow-defined terminal column maps to GitHub state like `done` does.
from: string;
to: string;
}
+/*
+FNXC:WorkflowColumns 2026-07-19-2b:50 (U12 / R2):
+GitHub open/closed state keys on the `complete` and `archived` TRAITS, not the literal ids `done`
+and `archived`. A user-authored workflow whose terminal column is called something else never
+closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`.
+
+`classify` is injected rather than resolved here so this stays a pure decision function (the
+caller owns IR resolution). Its default reproduces the legacy literal mapping exactly, so every
+existing caller and the default workflow are byte-identical.
+*/
+export interface ColumnLifecycleClass {
+ complete: boolean;
+ archived: boolean;
+}
+
+export const legacyColumnLifecycleClass = (columnId: string): ColumnLifecycleClass => ({
+ complete: columnId === "done",
+ archived: columnId === "archived",
+});
+
export function decideIssueAction(
from: string,
to: string,
+ classify: (columnId: string) => ColumnLifecycleClass = legacyColumnLifecycleClass,
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
- if (from === "archived" && to === "done") {
+ const fromClass = classify(from);
+ const toClass = classify(to);
+
+ // Un-archiving back into the completed column re-opens the issue.
+ if (fromClass.archived && toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
- if (to === "done" && from !== "done") {
+ if (toClass.complete && !fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
- if (to === "archived") {
- if (from === "done") {
+ if (toClass.archived) {
+ if (fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
- if (from !== "archived") {
+ if (!fromClass.archived) {
return { action: "close", stateReason: "not_planned" };
}
return null;
}
- if (from === "done" && to !== "done") {
+ // Leaving the completed column re-opens the issue.
+ if (fromClass.complete && !toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts
index 0bbb774cd4..15b7c8cbd7 100644
--- a/packages/dashboard/src/routes/register-task-workflow-routes.ts
+++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts
@@ -45,6 +45,9 @@ import {
isWorkflowColumnsEnabled,
resolveWorkflowIrForTask,
workflowHasColumn,
+ columnHasFlag,
+ columnsWithFlag,
+ resolveReboundTarget,
resolveColumnFlags,
TransitionRejectionError,
getPlannerInterventionTimeline,
@@ -109,6 +112,53 @@ async function clearRebuiltSpecWorkflowPins(store: TaskStore, taskId: string): P
}
}
+/*
+FNXC:WorkflowColumns 2026-07-19-2b:30 (U12 / R2 / R11):
+IR-derived move targets for the operator lifecycle routes.
+
+Retry / reset / re-engage all moved the card with a hardcoded `"todo"` or `"in-progress"`. On a
+user-authored workflow those ids may not exist at all, so the operator's Retry button either threw
+or silently parked the card in a column the workflow never declared. These resolve the destination
+from the TASK'S OWN workflow by TRAIT — the rebound target is the `hold` column (falling back to
+`intake`), the execution target is the column carrying `wip`.
+
+Both fall back to the legacy literal when the IR cannot be resolved or declares no columns (v1),
+so `builtin:coding` — whose hold column IS `todo` and whose wip column IS `in-progress` — keeps
+byte-identical behavior (KTD-7).
+*/
+async function resolveReboundColumnForTask(store: TaskStore, taskId: string): Promise {
+ try {
+ const ir = await resolveWorkflowIrForTask(store, taskId);
+ return resolveReboundTarget(ir) ?? "todo";
+ } catch {
+ return "todo";
+ }
+}
+
+/*
+FNXC:WorkflowColumns 2026-07-19-2b:35 (U12 / R2):
+Spec revision rehomes to the workflow's INTAKE column (where specification happens), which is a
+different preference from the rebound target above — rebound prefers `hold`, respecify prefers
+`intake`. `builtin:coding`'s intake column IS `triage`, so the default path is unchanged.
+*/
+async function resolveIntakeColumnForTask(store: TaskStore, taskId: string): Promise {
+ try {
+ const ir = await resolveWorkflowIrForTask(store, taskId);
+ return columnsWithFlag(ir, "intake")[0] ?? "triage";
+ } catch {
+ return "triage";
+ }
+}
+
+async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise {
+ try {
+ const ir = await resolveWorkflowIrForTask(store, taskId);
+ return columnsWithFlag(ir, "countsTowardWip")[0] ?? "in-progress";
+ } catch {
+ return "in-progress";
+ }
+}
+
function isArtifactType(value: string): value is ArtifactType {
return ARTIFACT_TYPES.has(value as ArtifactType);
}
@@ -728,7 +778,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
}
- const reengagedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true });
+ const reengageColumn = await resolveWipColumnForTask(scopedStore, task.id);
+ const reengagedTask = await scopedStore.moveTask(task.id, reengageColumn, { preserveProgress: true });
await triggerCommentWakeForAssignedAgent(scopedStore, reengagedTask, wake);
return { task: reengagedTask, reengaged: true };
}
@@ -1617,8 +1668,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
const { store: scopedStore } = await getProjectContext(req);
const { column, preserveProgress } = req.body;
- if (!column || !COLUMNS.includes(column as Column)) {
- throw badRequest(`Invalid column. Must be one of: ${COLUMNS.join(", ")}`);
+ /*
+ FNXC:WorkflowColumns 2026-07-19-2b:15 (U12 / R2 / R11):
+ Validate against the TASK'S WORKFLOW, not the legacy six-id enum. This endpoint rejected
+ every workflow-defined column outright — a board built on a custom workflow could not move a
+ card into its own `Merging` column, the API answered 400 "Must be one of: triage, todo, ...".
+ That is the closed-enum blocker the cutover exists to remove.
+ Resolution failure or a v1 (columnless) IR falls back to the legacy set, so the default
+ workflow and older definitions behave exactly as before.
+ */
+ if (typeof column !== "string" || !column) {
+ throw badRequest("Invalid column. Expected a non-empty column id.");
+ }
+ const moveTargetIr = await resolveWorkflowIrForTask(scopedStore, req.params.id).catch(() => undefined);
+ const declaresColumns = Array.isArray((moveTargetIr as { columns?: unknown[] } | undefined)?.columns);
+ const columnIsValid = moveTargetIr && declaresColumns
+ ? workflowHasColumn(moveTargetIr, column)
+ : COLUMNS.includes(column as Column);
+ if (!columnIsValid) {
+ const allowed = moveTargetIr && declaresColumns
+ ? ((moveTargetIr as unknown as { columns: Array<{ id: string }> }).columns.map((c) => c.id))
+ : [...COLUMNS];
+ throw badRequest(`Invalid column. Must be one of: ${allowed.join(", ")}`);
}
if (preserveProgress != null && typeof preserveProgress !== "boolean") {
throw badRequest("preserveProgress must be a boolean");
@@ -1662,7 +1733,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// already exists, so any prior committed progress survives even
// though the on-disk worktree directory is freshly allocated.
let allocateWorktree: ((reservedNames: Set) => string | null) | undefined;
- if ((column as Column) === "in-progress") {
+ /*
+ FNXC:WorkflowColumns 2026-07-19-2b:20 (U12 / R2):
+ Allocate a worktree when promoting into a WIP column, keyed on the trait rather than the
+ literal `in-progress` id. A custom workflow's execution column carries `wip` under its own
+ name, and without this it landed in-progress with a null worktree.
+ */
+ const targetIsWip = moveTargetIr && declaresColumns
+ ? columnHasFlag(moveTargetIr, column, "countsTowardWip")
+ : column === "in-progress";
+ if (targetIsWip) {
const existing = await scopedStore.getTask(req.params.id);
if (existing) {
const settings = await scopedStore.getSettings();
@@ -2455,6 +2535,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
if (isMissingWorktreeSessionRetry) {
+ /*
+ FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review):
+ Resolve the rebound destination BEFORE logging so the audit entry reports the real
+ trait-derived column instead of a hardcoded "todo", which misleads on custom boards.
+ */
+ const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
await scopedStore.updateTask(req.params.id, {
status: null,
@@ -2465,8 +2551,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
- await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → todo, preserving progress${retryLogSuffix})`);
- const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
+ await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → ${reboundColumn}, preserving progress${retryLogSuffix})`);
+ const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true });
res.json(updated);
return;
}
@@ -2479,6 +2565,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
FNXC:WorkflowRetry 2026-06-29-02:18:
Dashboard retry for an in-review execution failure re-enters the workflow graph from parse/execution, so it must clear persisted foreach step-instance pins. Otherwise a stale pin from the failed run makes the retry hit the same parse pin-mismatch immediately.
*/
+ /*
+ FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review):
+ Resolve the rebound destination BEFORE logging so the audit entry reports the real
+ trait-derived column instead of a hardcoded "todo", which misleads on custom boards.
+ */
+ const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
await scopedStore.updateTask(req.params.id, {
status: null,
@@ -2489,10 +2581,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.logEntry(
req.params.id,
isInReviewExecutionStall
- ? `Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
- : `Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
+ ? `Retry requested from dashboard (stranded in-review execution retry → ${reboundColumn}, preserving progress${retryLogSuffix})`
+ : `Retry requested from dashboard (execution failure in-review → ${reboundColumn}, preserving progress${retryLogSuffix})`,
);
- const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
+ const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true });
res.json(updated);
return;
}
@@ -2565,7 +2657,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)");
- const updated = await scopedStore.moveTask(req.params.id, "todo");
+ const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
+ const updated = await scopedStore.moveTask(req.params.id, reboundColumn);
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -2644,7 +2737,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
"Task reset by user — all progress cleared, fresh worktree and branch will be allocated",
);
- await scopedStore.moveTask(req.params.id, "todo");
+ const resetColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
+ await scopedStore.moveTask(req.params.id, resetColumn);
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
let updated = await scopedStore.getTask(req.params.id);
if (!updated) {
@@ -3494,7 +3588,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
// Move to todo and clear status
- const updated = await scopedStore.moveTask(task.id, "todo");
+ const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id);
+ const updated = await scopedStore.moveTask(task.id, reboundColumn);
await scopedStore.updateTask(task.id, {
status: undefined,
...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}),
@@ -4248,9 +4343,21 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Get current task state
const task = await scopedStore.getTask(req.params.id);
- // If task is already in triage, skip the transition check and moveTask.
- // Just reset for replanning in place.
- if (task.column === "triage") {
+ /*
+ FNXC:WorkflowColumns 2026-07-19-11:10 (U12 review):
+ The in-place-reset early return must key on the workflow-resolved intake target, not only
+ the literal "triage". On a custom board whose intake column isn't "triage" (e.g. "backlog"),
+ a task already sitting at intake would otherwise fall through to
+ `canTransition = task.column !== respecifyTarget` === false and be rejected — permanently
+ blocking spec revision in exactly the column where respecify belongs. The literal "triage"
+ check is kept alongside so legacy behavior stays byte-identical even if a custom workflow
+ declares a non-intake column literally named "triage".
+ */
+ const respecifyTarget = await resolveIntakeColumnForTask(scopedStore, task.id);
+
+ // If task is already at its workflow's intake column, skip the transition
+ // check and moveTask. Just reset for replanning in place.
+ if (task.column === "triage" || task.column === respecifyTarget) {
// Log the revision request
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
@@ -4273,11 +4380,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy
// closed union. A non-legacy custom column id has no legacy transition row,
// so it correctly resolves to "cannot transition" here.
+ /*
+ FNXC:WorkflowColumns 2026-07-19-02:40 (U12 / R2):
+ `VALID_TRANSITIONS` is keyed by the closed legacy enum, so `isColumn(task.column)` was false
+ for every workflow-defined column and spec revision was unreachable on a custom board —
+ rejected with "Move task to 'todo' or 'in-progress' first", naming columns that workflow may
+ not have. Custom workflow columns are always eligible: the already-at-intake case returned
+ above, and the workflow itself declares the intake column we send the card to, so there is
+ no legacy table to consult. Legacy columns keep the legacy table.
+ */
const canTransition =
- isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage");
+ !isColumn(task.column) || VALID_TRANSITIONS[task.column].includes("triage");
if (!canTransition) {
throw badRequest(
- `Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`,
+ `Cannot request spec revision for tasks in '${task.column}' column.`,
);
}
@@ -4285,7 +4401,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
// Move to triage for replanning
- const updated = await scopedStore.moveTask(task.id, "triage");
+ const updated = await scopedStore.moveTask(task.id, respecifyTarget);
// Remove the existing spec so replanning starts from the task
// description and feedback rather than revising stale PROMPT.md content.
@@ -5291,7 +5407,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (lastDoneStep) {
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
}
- updatedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true });
+ const prFeedbackColumn = await resolveWipColumnForTask(scopedStore, task.id);
+ updatedTask = await scopedStore.moveTask(task.id, prFeedbackColumn, { preserveProgress: true });
}
const hasActiveSession = Boolean(updatedTask.sessionFile);
@@ -5323,7 +5440,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
assigneeUserId: null,
status: null,
});
- const task = await scopedStore.moveTask(req.params.id, "todo");
+ const unassignColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
+ const task = await scopedStore.moveTask(req.params.id, unassignColumn);
res.json(task);
} catch (err: unknown) {
if (err instanceof ApiError) {