diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md
index 48db90a417..746a4a1c05 100644
--- a/docs/workflow-steps.md
+++ b/docs/workflow-steps.md
@@ -82,7 +82,7 @@ Use this inventory as the documentation map for current workflow behavior:
| Routing boundary | Agents may select/change a workflow only for explicit user requests or tasks they created; no-commit markers do not imply Quick fix or any other workflow. | This page, [Selecting workflows](#selecting-workflows); [Agents](./agents.md#interactive-cli-chat). |
| Dashboard board/list/graph selection | Board/List/Header/Graph share durable per-project workflow selection; stale saved ids fall back to a valid workflow. Board adds a dashboard-only **All workflows** aggregate and task workflow-name badges; Graph uses **All workflows** for the full active graph. | [Dashboard Guide → Board View](./dashboard-guide.md#board-view), [Graph View](./dashboard-guide.md#graph-view), and [Workflow Selection and Editor](./dashboard-guide.md#workflow-selection-and-editor). |
| Create/planning forwarding | Quick-create task creation, Planning Mode, Subtask Breakdown, and the New Task dialog forward the active real workflow id when creating tasks; **All workflows** quick-create chooses a real workflow intake/default column instead of saving a synthetic aggregate id. | [Dashboard Guide → Planning Mode](./dashboard-guide.md#planning-mode). |
-| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
+| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. The full lifecycle — create → parked → operator "Start" promotion → poll-time todo-discovery of the still-unplanned (bootstrap-stub) card — is regression-tested at the engine (triage poll ordering/discovery), UI (`TaskCard` Start affordance), and store (create → `moveTask` promotion) layers (FN-7596). | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
### Skill-backed workflow steps
diff --git a/packages/core/src/__tests__/store-create-intake-column.test.ts b/packages/core/src/__tests__/store-create-intake-column.test.ts
index f2c94ea21a..382400c501 100644
--- a/packages/core/src/__tests__/store-create-intake-column.test.ts
+++ b/packages/core/src/__tests__/store-create-intake-column.test.ts
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
+import { buildBootstrapPrompt } from "../mesh-task-replication.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-04-11:30:
@@ -80,4 +81,29 @@ describe("createTask intake-column wiring (Coding (Ideas))", () => {
// A direct todo create is NOT an intake column, so it must NOT get the bootstrap stub.
expect(prompt).not.toBe(`# ${task.id}\n\n${task.description}\n`);
});
+
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
+ FN-7596 pins the store-level contract the engine's todo-discovery poll (packages/engine/src/triage.ts eligibleTodoTasks) depends on: promoting a parked Ideas card via moveTask alone must NOT plan it. Only the triage service's bootstrap-prompt discovery loop plans a promoted-but-unplanned todo card; moveTask is a pure column transition.
+ */
+ it("promotes an Ideas-parked task to todo without planning it (still bootstrap-stub PROMPT.md)", async () => {
+ const store = harness.store();
+ // Custom (non-legacy) column transitions are validated against the workflow IR
+ // only when the workflowColumns compatibility flag is enabled (KTD-1).
+ await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
+ const task = await store.createTask({
+ description: "ideas lifecycle promotion task",
+ workflowId: "builtin:coding-ideas",
+ });
+ expect(task.column).toBe("ideas");
+
+ const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
+ expect(moved.column).toBe("todo");
+
+ const prompt = await readFile(
+ join(harness.rootDir(), ".fusion", "tasks", task.id, "PROMPT.md"),
+ "utf-8",
+ );
+ expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
+ });
});
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 9cd637d2ab..83c87f28a4 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -6411,3 +6411,156 @@ describe("TaskCard custom field badges (U13/KTD-14)", () => {
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
});
+
+/*
+FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
+FN-7596 regression-tests the TaskCard "Start" affordance that promotes a Coding (Ideas) manual-intake card. `showStartAction` requires taskColumnFlags.intake and a non-"triage" column; `startTargetColumn` derives the destination from `taskMoveColumns` (first non-intake/non-archived/non-hiddenFromBoard column) rather than a hard-coded "todo" string, per the FNXC comment at its call site.
+*/
+describe("TaskCard Start affordance (FN-7596)", () => {
+ it("renders the Start button for a manual-intake column with onMoveTask provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("card-start-FN-001")).toBeInTheDocument();
+ });
+
+ it("omits the Start button when the column is not flagged as an intake", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
+ });
+
+ it("omits the Start button for the triage column even when intake is flagged", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
+ });
+
+ it("omits the Start button when no onMoveTask handler is provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
+ });
+
+ it("derives the Start target from taskMoveColumns instead of a hard-coded 'todo' string", async () => {
+ const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "custom-working-stage" as any }));
+ const addToast = vi.fn();
+ // The intake column itself, plus a non-intake working column that is NOT literally
+ // named "todo", must win over any coincidental fallback — proving derivation, not a hard-coded string.
+ const taskMoveColumns = [
+ { id: "ideas" as any, label: "Ideas", flags: { intake: true } },
+ { id: "custom-working-stage" as any, label: "Custom Working Stage", flags: {} },
+ { id: "todo" as any, label: "Todo", flags: {} },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-start-FN-001"));
+
+ await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "custom-working-stage"));
+ });
+
+ it("falls back to 'todo' when taskMoveColumns metadata is unavailable", async () => {
+ const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-start-FN-001"));
+
+ await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo"));
+ });
+
+ it("disables the button and shows the Starting label while the move is in flight, then shows a success toast", async () => {
+ let resolveMove: (task: ReturnType) => void = () => {};
+ const onMoveTask = vi.fn().mockImplementation(
+ () => new Promise((resolve) => { resolveMove = resolve; }),
+ );
+ const addToast = vi.fn();
+
+ render(
+ ,
+ );
+
+ const startButton = screen.getByTestId("card-start-FN-001");
+ fireEvent.click(startButton);
+
+ await waitFor(() => expect(startButton).toBeDisabled());
+ expect(startButton.textContent).toContain("Starting");
+
+ resolveMove(makeTask({ column: "todo" }));
+
+ await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-001"), "success"));
+ await waitFor(() => expect(startButton).not.toBeDisabled());
+ });
+
+ it("shows an error toast when the Start move fails", async () => {
+ const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked"));
+ const addToast = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-start-FN-001"));
+
+ await waitFor(() => expect(addToast).toHaveBeenCalledWith("move blocked", "error"));
+ });
+});
diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts
index 506bab4734..37e0cb310d 100644
--- a/packages/engine/src/__tests__/triage.test.ts
+++ b/packages/engine/src/__tests__/triage.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
-import { builtinSeamPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
+import { builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import {
TriageProcessor,
buildSpecificationPrompt,
@@ -2392,6 +2392,123 @@ describe("TriageProcessor", () => {
});
});
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
+ FN-7596 pins the Coding (Ideas) manual-intake lifecycle at the poll-dispatch boundary: an `ideas`-column card must stay parked (never auto-dispatched via `eligibleTriageTasks`, which only matches `column === "triage"`), while a promoted `todo`-column card whose PROMPT.md is still the bootstrap stub must be discovered and specified via `eligibleTodoTasks`'s bootstrap-prompt file check. A `todo` card with a real (non-bootstrap) spec must NOT be re-dispatched, guarding against double-specifying an already-planned card.
+ */
+ describe("Coding (Ideas) manual-intake discovery (FN-7596)", () => {
+ it("excludes a parked ideas-column task from the poll's specify-dispatch set", async () => {
+ const tasks: Task[] = [
+ createTriageTask({ id: "FN-IDEAS-PARKED", column: "ideas" as any, priority: "urgent" }),
+ ];
+
+ const triageStore = createMockStore({
+ listTasks: vi.fn().mockResolvedValue(tasks),
+ getSettings: vi.fn().mockResolvedValue({
+ maxConcurrent: 10,
+ maxTriageConcurrent: 10,
+ pollIntervalMs: 10_000,
+ groupOverlappingFiles: false,
+ autoMerge: true,
+ }),
+ });
+ const triageProcessor = new TriageProcessor(triageStore, rootDir);
+ const specifySpy = vi
+ .spyOn(triageProcessor, "specifyTask")
+ .mockResolvedValue(undefined);
+
+ (triageProcessor as any).running = true;
+ await (triageProcessor as any).poll();
+
+ expect(specifySpy).not.toHaveBeenCalled();
+ });
+
+ it("discovers a promoted todo-column task whose PROMPT.md is still the bootstrap stub", async () => {
+ const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-discovery-");
+ const promotedId = "FN-IDEAS-PROMOTED";
+ try {
+ const promotedTask = createTriageTask({
+ id: promotedId,
+ title: "Promoted from Ideas intake",
+ description: "Promoted intake task",
+ column: "todo",
+ priority: "urgent",
+ });
+ await mkdir(join(tempRoot, ".fusion", "tasks", promotedId), { recursive: true });
+ await writeFile(
+ join(tempRoot, ".fusion", "tasks", promotedId, "PROMPT.md"),
+ buildBootstrapPrompt(promotedId, promotedTask.title, promotedTask.description),
+ "utf-8",
+ );
+
+ const triageStore = createMockStore({
+ listTasks: vi.fn().mockResolvedValue([promotedTask]),
+ getSettings: vi.fn().mockResolvedValue({
+ maxConcurrent: 10,
+ maxTriageConcurrent: 10,
+ pollIntervalMs: 10_000,
+ groupOverlappingFiles: false,
+ autoMerge: true,
+ }),
+ });
+ const triageProcessor = new TriageProcessor(triageStore, tempRoot);
+ const specifySpy = vi
+ .spyOn(triageProcessor, "specifyTask")
+ .mockResolvedValue(undefined);
+
+ (triageProcessor as any).running = true;
+ await (triageProcessor as any).poll();
+
+ expect(specifySpy).toHaveBeenCalledTimes(1);
+ expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: promotedId }));
+ } finally {
+ await cleanupTriageFixtureRoot(tempRoot);
+ }
+ });
+
+ it("does not re-dispatch a todo-column task whose PROMPT.md already carries a real (non-bootstrap) spec", async () => {
+ const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-planned-");
+ const plannedId = "FN-IDEAS-PLANNED";
+ try {
+ const plannedTask = createTriageTask({
+ id: plannedId,
+ title: "Already planned todo task",
+ description: "Already planned intake task",
+ column: "todo",
+ priority: "urgent",
+ });
+ await mkdir(join(tempRoot, ".fusion", "tasks", plannedId), { recursive: true });
+ await writeFile(
+ join(tempRoot, ".fusion", "tasks", plannedId, "PROMPT.md"),
+ `# Task: ${plannedId} - Already planned todo task\n\n## Mission\n\nThis task carries a real spec, not the bootstrap stub.\n`,
+ "utf-8",
+ );
+
+ const triageStore = createMockStore({
+ listTasks: vi.fn().mockResolvedValue([plannedTask]),
+ getSettings: vi.fn().mockResolvedValue({
+ maxConcurrent: 10,
+ maxTriageConcurrent: 10,
+ pollIntervalMs: 10_000,
+ groupOverlappingFiles: false,
+ autoMerge: true,
+ }),
+ });
+ const triageProcessor = new TriageProcessor(triageStore, tempRoot);
+ const specifySpy = vi
+ .spyOn(triageProcessor, "specifyTask")
+ .mockResolvedValue(undefined);
+
+ (triageProcessor as any).running = true;
+ await (triageProcessor as any).poll();
+
+ expect(specifySpy).not.toHaveBeenCalled();
+ } finally {
+ await cleanupTriageFixtureRoot(tempRoot);
+ }
+ });
+ });
+
it("runs deterministic validation without calling the spec reviewer", async () => {
const taskId = "FN-001";
const testRootDir = await createTriageFixtureRoot("fusion-triage-plan-validation-");