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-");