feat: add Coding (Ideas) workflow with manual Ideas intake and merged Todo planner column

Add builtin:coding-ideas, a capture-first variant of the default coding
pipeline. New cards land in a manual Ideas intake (autoTriage:false) and are
not auto-planned until an operator promotes them into the merged Todo
planner+capacity column, where the triage service plans them in place.

Engine foundation:
- createTask lands cards in the workflow intake column (resolvedEntryColumn)
  instead of hardcoding triage; default workflow is byte-identical.
- Triage poll discovers unplanned todo tasks (bootstrap-stub prompt) and
  plans them in place; finalizeApprovedTask skips the redundant move.
- Scheduler skips todo tasks that are planning or still carry a bootstrap
  prompt, so unplanned cards are never dispatched.

Dashboard:
- Start button on ideas cards (ideas -> todo move triggers planning).
- Ready badge on planned todo tasks waiting for an in-progress slot.
- ideas column label in board-workflows.

Tests: workflow IR round-trip/column/node-placement, createTask intake wiring,
and updated builtin catalog order assertion.
This commit is contained in:
gsxdsm
2026-07-04 00:12:39 -07:00
parent 20184acdfd
commit ecbbb29c2d
12 changed files with 409 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a "Coding (Ideas)" workflow with a manual Ideas intake and a merged Todo planner column.
category: feature
dev: New `builtin:coding-ideas` clones the default stepwise pipeline with an `ideas` intake (autoTriage:false) in front of a merged `todo` planner+capacity column. createTask lands cards in the workflow's intake column; the triage service plans unplanned todo tasks in place; the scheduler skips bootstrap-prompt todo tasks; TaskCard gains a Start button and a Ready badge.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_CODING_IDEAS_WORKFLOW_IR,
parseWorkflowIr,
serializeWorkflowIr,
getBuiltinWorkflow,
resolveEntryColumnId,
} from "../index.js";
import { resolveColumnFlags } from "../trait-registry.js";
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
describe("builtin coding-ideas workflow ir", () => {
it("parses and round-trips", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_IDEAS_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
expect(parsed.version).toBe("v2");
});
it("is registered in the builtin catalog as a selectable workflow", () => {
const workflow = getBuiltinWorkflow("builtin:coding-ideas");
expect(workflow).toBeDefined();
expect(workflow!.id).toBe("builtin:coding-ideas");
expect(workflow!.name).toBe("Coding (Ideas)");
expect(workflow!.kind).toBe("workflow");
expect(workflow!.ir).toBe(BUILTIN_CODING_IDEAS_WORKFLOW_IR);
});
it("declares the five-stage Ideas → Todo → In-progress → In-review → Done board shape plus archived", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
expect(ir.columns.map((c) => c.id)).toEqual([
"ideas",
"todo",
"in-progress",
"in-review",
"done",
"archived",
]);
});
it("makes the ideas column the manual (autoTriage:false) intake", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
const ideas = ir.columns.find((c) => c.id === "ideas")!;
expect(resolveColumnFlags(ideas).intake).toBe(true);
const intakeTrait = ideas.traits.find((t) => t.trait === "intake")!;
expect(intakeTrait.config).toEqual({ autoTriage: false });
// The entry column resolves to ideas (the intake column).
expect(resolveEntryColumnId(ir)).toBe("ideas");
});
it("merges the planner and capacity-hold stages into the todo column", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
const todo = ir.columns.find((c) => c.id === "todo")!;
const flags = resolveColumnFlags(todo);
expect(flags.hold).toBe(true);
expect(flags.resetOnEntry).toBe(true);
});
it("keeps the in-progress / in-review / done column traits from the default pipeline", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
expect(resolveColumnFlags(ir.columns.find((c) => c.id === "in-progress")!)).toMatchObject({
countsTowardWip: true,
abortOnExit: true,
timing: true,
});
expect(resolveColumnFlags(ir.columns.find((c) => c.id === "in-review")!)).toMatchObject({
mergeBlocker: true,
humanReview: true,
stallDetection: true,
mergeOrchestration: true,
});
expect(resolveColumnFlags(ir.columns.find((c) => c.id === "done")!).complete).toBe(true);
});
it("places the start node in ideas and the planning nodes in the merged todo column", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
const nodeColumn = (id: string) => ir.nodes.find((n) => n.id === id)?.column;
expect(nodeColumn("start")).toBe("ideas");
expect(nodeColumn("plan")).toBe("todo");
expect(nodeColumn("plan-review")).toBe("todo");
expect(nodeColumn("plan-replan")).toBe("todo");
});
it("retains the default-on optional plan/code review groups from the default coding graph", () => {
const workflow = getBuiltinWorkflow("builtin:coding-ideas")!;
const byId = new Map(workflow.ir.nodes.map((n) => [n.id, n]));
const planReview = byId.get("plan-review");
expect(planReview?.kind).toBe("optional-group");
expect(planReview?.config?.defaultOn).toBe(true);
const codeReview = byId.get("code-review");
expect(codeReview?.kind).toBe("optional-group");
expect(codeReview?.config?.defaultOn).toBe(true);
});
it("never leaves a node in a column the workflow does not declare", () => {
const ir = BUILTIN_CODING_IDEAS_WORKFLOW_IR as WorkflowIrV2;
const declared = new Set(ir.columns.map((c) => c.id));
for (const node of ir.nodes) {
expect(declared.has(node.column!), `node ${node.id} in undeclared column ${node.column}`).toBe(true);
}
});
});

View File

@@ -672,10 +672,10 @@ describe("built-in workflows", () => {
expect(defaultEnabledBuiltinWorkflowIds().length).toBeGreaterThanOrEqual(5);
expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([
"builtin:coding",
"builtin:coding-ideas",
"builtin:legacy-coding",
"builtin:quick-fix",
"builtin:review-heavy",
"builtin:marketing",
]);
expect(defaultEnabledBuiltinWorkflowIds()).toContain("builtin:stepwise-coding");
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-04-11:30:
Pin the createTask intake-column wiring: a task created against the Coding (Ideas) workflow (manual autoTriage:false intake) must land in the "ideas" column, not the legacy "triage" default, while the default Coding workflow keeps landing cards in "triage".
*/
describe("createTask intake-column wiring (Coding (Ideas))", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
it("lands a default-workflow task in triage (byte-identical regression guard)", async () => {
const store = harness.store();
const task = await store.createTask({ description: "default workflow task" });
expect(task.column).toBe("triage");
});
it("lands a Coding (Ideas) task in the ideas intake column when selected explicitly", async () => {
const store = harness.store();
const task = await store.createTask({
description: "ideas workflow task",
workflowId: "builtin:coding-ideas",
});
expect(task.column).toBe("ideas");
});
it("lands a Coding (Ideas) task in ideas when it is the project default workflow", async () => {
const store = harness.store();
await store.setDefaultWorkflowId("builtin:coding-ideas");
const task = await store.createTask({ description: "default ideas task" });
expect(task.column).toBe("ideas");
});
it("writes a bootstrap PROMPT.md for an ideas-column task (unplanned)", async () => {
const store = harness.store();
const task: Task = await store.createTask({
description: "ideas bootstrap prompt task",
workflowId: "builtin:coding-ideas",
});
const prompt = await readFile(
join(harness.rootDir(), ".fusion", "tasks", task.id, "PROMPT.md"),
"utf-8",
);
expect(prompt).toBe(`# ${task.id}\n\n${task.description}\n`);
});
});

View File

@@ -0,0 +1,93 @@
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrV2 } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "./builtin-stepwise-final-review-coding-workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-04-09:15:
Operators need a manual-capture intake ("Ideas") in front of the default coding pipeline so they can park tasks without the engine auto-planning them. This workflow clones the current default Coding graph (stepwise execution + final review) and swaps the board columns to a five-stage Ideas → Todo → In-progress → In-review → Done shape.
FNXC:CodingIdeasWorkflow 2026-07-04-09:18:
The "Ideas" column is the intake column with autoTriage disabled. Tasks created into this workflow land there and are NOT picked up by the triage service until an operator moves them to "Todo" (the merged planner + capacity column). Planning then runs in place inside "Todo"; a "ready" badge distinguishes planned (real PROMPT.md) tasks from unplanned (bootstrap stub) ones while they wait for an in-progress slot. See createTask intake-column wiring (store.ts) and the triage todo-discovery extension (triage.ts).
*/
/** The board columns for the Coding (Ideas) workflow. The "ideas" intake carries
* `autoTriage: false` so the engine's createTask intake-column wiring lands new
* cards there and the triage service leaves them alone until they are promoted
* into "todo". "todo" merges the legacy triage (planner) and todo (capacity
* hold) stages into one agent-staffed column. */
const CODING_IDEAS_COLUMNS: WorkflowIrColumn[] = [
{
id: "ideas",
name: "Ideas",
traits: [{ trait: "intake", config: { autoTriage: false } }],
},
{
id: "todo",
name: "Todo",
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
},
{
id: "in-progress",
name: "In progress",
traits: [
{ trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } },
{ trait: "abort-on-exit" },
{ trait: "timing" },
],
},
{
id: "in-review",
name: "In review",
traits: [{ trait: "merge-blocker" }, { trait: "human-review" }, { trait: "stall-detection" }, { trait: "merge" }],
},
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
];
/** Planning-stage node ids that sit in the legacy "triage" / "in-progress"
* columns in the cloned default graph. They are re-homed to the merged "todo"
* planner column so an agent is visibly working while the spec is produced. */
const PLANNING_NODE_IDS: Record<string, true> = {
plan: true,
"plan-review": true,
"plan-replan": true,
};
const RAW_BUILTIN_CODING_IDEAS_WORKFLOW_IR: WorkflowIr = (() => {
const ir = JSON.parse(JSON.stringify(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR)) as WorkflowIr;
ir.name = "builtin-coding-ideas";
const v2 = ir as WorkflowIrV2;
v2.columns = CODING_IDEAS_COLUMNS.map((column) => ({
...column,
traits: column.traits.map((trait) => ({
...trait,
config: trait.config ? { ...trait.config } : undefined,
})),
}));
/*
FNXC:CodingIdeasWorkflow 2026-07-04-09:30:
Re-home graph nodes to the new column shape: the start node becomes the "ideas" intake anchor; planning-stage nodes move to the merged "todo" column; every execution / review / merge / done node keeps its existing column id (in-progress / in-review / done), which still exists in the new column set. Unknown legacy columns (e.g. a leftover "triage" placement) default to "todo" so no node is ever left dangling in a column the workflow no longer declares.
*/
const knownColumnIds = new Set(v2.columns.map((c) => c.id));
for (const node of v2.nodes) {
if (node.kind === "start") {
node.column = "ideas";
continue;
}
if (PLANNING_NODE_IDS[node.id]) {
node.column = "todo";
continue;
}
if (!node.column || !knownColumnIds.has(node.column)) {
node.column = "todo";
}
}
v2.settings = BUILTIN_WORKFLOW_SETTINGS;
return ir;
})();
export const BUILTIN_CODING_IDEAS_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_IDEAS_WORKFLOW_IR);

View File

@@ -1,4 +1,5 @@
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import { BUILTIN_CODING_IDEAS_WORKFLOW_IR } from "./builtin-coding-ideas-workflow-ir.js";
import { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js";
import { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
@@ -356,6 +357,42 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
/*
* FNXC:CodingIdeasWorkflow 2026-07-04-09:40:
* The Coding (Ideas) variant adds a manual "Ideas" intake in front of the default stepwise pipeline. New cards land in "ideas" (autoTriage off) and are not planned until an operator promotes them into the merged "todo" planner column; from there the graph is identical to the default Coding workflow.
*/
{
id: "builtin:coding-ideas",
name: "Coding (Ideas)",
description:
"Capture-first coding pipeline: park ideas in a manual intake, then plan, execute per step, run the optional final code review, and merge.",
kind: "workflow",
ir: BUILTIN_CODING_IDEAS_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
plan: { x: 230, y: 160 },
"plan-review": { x: 400, y: 160 },
"plan-replan": { x: 400, y: 320 },
parse: { x: 570, y: 160 },
steps: { x: 740, y: 160 },
"browser-verification": { x: 910, y: 160 },
"browser-verification-remediation": { x: 910, y: 320 },
"code-review": { x: 1080, y: 160 },
"code-review-remediation": { x: 1080, y: 320 },
"completion-summary": { x: 1250, y: 160 },
"merge-gate": { x: 1420, y: 160 },
"branch-group-member-integration": { x: 1590, y: 80 },
"branch-group-promotion": { x: 1760, y: 80 },
"merge-attempt": { x: 1930, y: 160 },
"merge-retry": { x: 2100, y: 80 },
"recovery-router": { x: 2100, y: 240 },
"merge-manual-hold": { x: 1590, y: 240 },
"post-merge-verification": { x: 2270, y: 160 },
end: { x: 2440, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
{
id: "builtin:legacy-coding",
name: "Legacy coding",

View File

@@ -165,6 +165,7 @@ export type {
EffectiveAgentResult,
} from "./column-agent-resolver.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_CODING_IDEAS_WORKFLOW_IR } from "./builtin-coding-ideas-workflow-ir.js";
export { PLAN_REVIEW_GROUP_ID } from "./builtin-plan-review-group.js";
export { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
export {

View File

@@ -4639,6 +4639,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// When a project default workflow is configured, new tasks inherit it
// (compiled to steps) ahead of the legacy default-on step behavior.
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
let resolvedEntryColumn: string | undefined;
/*
FNXC:WorkflowCreation 2026-06-28-23:09:
User-facing task creation can submit a selected workflowId and optional-group toggles together. The visible workflow selection is operator intent and must persist as task_workflow_selection; enabledWorkflowSteps only overrides that workflow's default optional-group seed.
@@ -4657,6 +4658,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
? (resolvedWorkflowSteps ?? [])
: undefined;
resolvedWorkflowSteps = explicitStepIds ?? selected.stepIds;
resolvedEntryColumn = selected.entryColumnId;
pendingWorkflowSelection = {
workflowId: selected.workflowId,
stepIds: explicitStepIds ?? selected.stepIds,
@@ -4667,6 +4669,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
const inherited = await this.materializeDefaultWorkflowSteps();
if (inherited) {
resolvedWorkflowSteps = inherited.stepIds;
resolvedEntryColumn = inherited.entryColumnId;
pendingWorkflowSelection = inherited;
}
} catch (err) {
@@ -4708,7 +4711,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
title,
resolvedWorkflowSteps,
taskId,
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization, reservationCommit },
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization, reservationCommit, resolvedEntryColumn },
);
},
});
@@ -4833,6 +4836,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
: undefined;
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
let resolvedEntryColumn: string | undefined;
/*
FNXC:WorkflowCreation 2026-06-28-23:09:
Reserved-id task creation must match normal task creation: workflowId and enabledWorkflowSteps are independent create controls, so explicit optional toggles do not erase the selected workflow row.
@@ -4850,6 +4854,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
? (resolvedWorkflowSteps ?? [])
: undefined;
resolvedWorkflowSteps = explicitStepIds ?? selected.stepIds;
resolvedEntryColumn = selected.entryColumnId;
pendingWorkflowSelection = {
workflowId: selected.workflowId,
stepIds: explicitStepIds ?? selected.stepIds,
@@ -4862,6 +4867,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
const inherited = await this.materializeDefaultWorkflowSteps();
if (inherited) {
resolvedWorkflowSteps = inherited.stepIds;
resolvedEntryColumn = inherited.entryColumnId;
pendingWorkflowSelection = inherited;
}
} catch (err) {
@@ -4893,14 +4899,13 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
resolvedWorkflowSteps = [];
}
// U7c: selection seeds are optional-group node ids (not materialized
// `workflow_steps` rows), so a failed task creation strands nothing to clean.
const createdTask: Task = await this._createTaskInternal(input, title, resolvedWorkflowSteps, id, {
createdAt: options.createdAt,
updatedAt: options.updatedAt,
promptOverride: options.prompt,
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
reservationCommit: options.reservationCommit,
resolvedEntryColumn,
});
// Record the inherited workflow selection now that the task row exists.
@@ -4974,6 +4979,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
promptOverride?: string;
invokeTaskCreatedHook?: boolean;
reservationCommit?: { reservationId: string; nodeId: string };
/*
FNXC:CodingIdeasWorkflow 2026-07-04-10:02:
The resolved workflow's intake column id. When the caller omits an explicit `input.column`, the task lands here instead of the legacy "triage" default so workflows with a manual intake (e.g. Coding (Ideas) → "ideas") capture new cards without auto-planning them. Defaults to "triage" when unset, preserving byte-identical behavior for the default workflow.
*/
resolvedEntryColumn?: string;
},
): Promise<Task> {
const now = options?.createdAt ?? new Date().toISOString();
@@ -4999,7 +5009,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
branchContext: input.branchContext,
autoMerge: input.autoMerge,
autoMergeProvenance: input.autoMerge === undefined ? undefined : "user",
column: input.column || "triage",
column: input.column || options?.resolvedEntryColumn || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
noCommitsExpected: input.noCommitsExpected === true ? true : undefined,
@@ -5049,8 +5059,16 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
/*
FNXC:CodingIdeasWorkflow 2026-07-04-10:10:
A freshly created task has no specification yet regardless of which intake/planning column it lands in (triage, ideas, or a merged todo). Use the bootstrap stub for every pre-execution column so the spec-detection helpers (isBootstrapPromptStub) treat the card as unplanned until triage replaces it with a real PROMPT.md. Execution/review/done/archived columns keep the generated specified prompt for the legacy direct-create paths.
*/
const isPrePlanningColumn = task.column !== "in-progress"
&& task.column !== "in-review"
&& task.column !== "done"
&& task.column !== "archived";
const prompt = options?.promptOverride
?? (task.column === "triage"
?? (isPrePlanningColumn
? buildBootstrapPrompt(id, task.title, task.description)
: this.generateSpecifiedPrompt(task));
const validation = validateFileScopeInPromptContent(prompt);

View File

@@ -777,6 +777,7 @@ function TaskCardComponent({
const [isRetrying, setIsRetrying] = useState(false);
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
const [isAddressingPrFeedback, setIsAddressingPrFeedback] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
@@ -1406,7 +1407,8 @@ function TaskCardComponent({
|| Boolean(task.blockedBy)
|| Boolean(task.overlapBlockedBy)
|| Boolean(fanout && fanout.totalCount > 0);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || (showInReviewMoveControl && !metaRowVisible);
const showStartAction = task.column === "ideas" && Boolean(onMoveTask);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction || (showInReviewMoveControl && !metaRowVisible);
const renderInReviewMoveControl = () => (
<div className="card-send-back" ref={sendBackRef}>
@@ -2184,6 +2186,19 @@ function TaskCardComponent({
if (!onPromote || isPromoting) return;
void onPromote(task.id);
}, [isPromoting, onPromote, task.id]);
const handleStartClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onMoveTask || isStarting) return;
setIsStarting(true);
try {
await onMoveTask(task.id, "todo");
addToast(t("tasks.startedPlanning", "Started planning {{taskId}}", { taskId: task.id }), "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setIsStarting(false);
}
}, [addToast, isStarting, onMoveTask, t, task.id]);
const handleAddressPrFeedbackClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -2400,6 +2415,15 @@ function TaskCardComponent({
{isStuck ? t("tasks.stuck", "Stuck") : isAwaitingApproval ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput ? t("tasks.needsInput", "Needs input") : visualStatus === "merging-fix" ? t("tasks.statusMergingFix", "Merging fixes…") : getTaskStatusLabel(visualStatus, t)}
</span>
)}
{/*
FNXC:CodingIdeasWorkflow 2026-07-04-11:10:
In the merged planner/capacity "todo" column (Coding (Ideas)), a planned task with no active status is ready and waiting for an in-progress slot. Show a "Ready" badge so operators can distinguish planned cards from freshly promoted unplanned ones. Tasks still being planned surface the "planning" status badge above instead.
*/}
{!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0 && (
<span className="card-status-badge card-status-badge--todo ready" data-testid={`card-ready-${task.id}`}>
{t("tasks.ready", "Ready")}
</span>
)}
{hasInReviewStall && stallCopy && (
<span
className={`card-status-badge card-status-badge--in-review in-review-stall in-review-stall--${stallCopy.code}`}
@@ -3038,6 +3062,20 @@ function TaskCardComponent({
{isAddressingPrFeedback ? t("tasks.addressingPrFeedback", "Addressing…") : t("tasks.addressPrFeedback", "Address PR feedback")}
</button>
)}
{showStartAction && (
<button
type="button"
className="card-promote-action card-send-back-btn"
data-testid={`card-start-${task.id}`}
title={t("tasks.startTask", "Start — plan this task")}
aria-label={t("tasks.startTask", "Start — plan this task")}
disabled={isStarting}
onClick={handleStartClick}
>
<Zap size={12} />
{isStarting ? t("tasks.starting", "Starting…") : t("tasks.start", "Start")}
</button>
)}
{onPromote && (
<button
type="button"

View File

@@ -71,6 +71,7 @@ export interface BoardWorkflowsPayload {
}
const BUILTIN_WORKFLOW_COLUMN_LABELS: Record<string, string> = {
ideas: "Ideas",
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",

View File

@@ -14,6 +14,7 @@ import {
type AgentStore,
type Settings,
TransitionRejectionError,
buildBootstrapPrompt,
} from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
@@ -1403,8 +1404,28 @@ export class Scheduler {
if (t.column !== "todo" || t.paused) return false;
// Skip tasks with a recovery backoff that hasn't elapsed yet
if (t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now) return false;
// FNXC:CodingIdeasWorkflow 2026-07-04-10:45: a todo task with status "planning" is being specified in place by the triage service (merged planner/capacity column in Coding (Ideas)); it must not be dispatched until planning finishes and the status clears.
if (t.status === "planning") return false;
return true;
});
/*
FNXC:CodingIdeasWorkflow 2026-07-04-10:46:
Exclude unplanned todo tasks whose PROMPT.md is still the bootstrap stub. In a merged planner/capacity column a freshly promoted card has no real spec yet; dispatching it would execute the stub. Normal-workflow todo tasks always carry a real spec (triage writes it before moving them to todo), so this filter is a no-op for them. This closes the gap between the operator promoting a card and the triage service picking it up.
*/
todo = (
await Promise.all(
todo.map(async (t) => {
try {
const content = await readFile(getPromptPath(this.store.getTasksDir(), t.id), "utf-8");
if (content === buildBootstrapPrompt(t.id, t.title, t.description)) return null;
} catch {
// Missing prompt is handled by filesystem validation below; keep the candidate.
}
return t;
}),
)
).filter((t): t is Task => t !== null);
// Filter out tasks belonging to blocked missions
if (todo.length > 0 && this.options.missionStore) {

View File

@@ -14,6 +14,7 @@ import {
PLAN_REVIEW_GROUP_ID,
TaskDeletedError,
buildTriageMemoryInstructions,
buildBootstrapPrompt,
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
resolveAgentPrompt,
@@ -788,7 +789,30 @@ export class TriageProcessor {
// Skip tasks with a recovery backoff that hasn't elapsed yet
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
);
const triageTasks = sortTasksByPriorityThenAgeAndId(eligibleTriageTasks).sort((a, b) => {
/*
Workflows with a manual intake (e.g. Coding (Ideas)) merge the planner and capacity-hold stages into a single "todo" column. The triage service must also discover "todo" tasks whose PROMPT.md is still the bootstrap stub — they have been promoted out of the manual intake but not yet planned in place. Planned todo tasks carry a real spec and are left for the scheduler. The bootstrap-prompt file check is the ground-truth unplanned signal; it is false for every normal-workflow todo task because triage writes a real spec before it ever moves a card into todo.
*/
const eligibleTodoTasksRaw = allTasks.filter(
(t) => t.column === "todo" && !this.processing.has(t.id) && !t.paused
&& t.status !== "awaiting-approval"
&& t.status !== "failed"
&& t.status !== "stuck-killed"
&& t.status !== "planning"
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
);
const eligibleTodoTasks: Task[] = [];
for (const todoTask of eligibleTodoTasksRaw) {
try {
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
const content = await readFile(promptPath, "utf-8");
if (content === buildBootstrapPrompt(todoTask.id, todoTask.title, todoTask.description)) {
eligibleTodoTasks.push(todoTask);
}
} catch {
// Missing/unreadable prompt — skip; the scheduler's filesystem validation handles it.
}
}
const triageTasks = sortTasksByPriorityThenAgeAndId([...eligibleTriageTasks, ...eligibleTodoTasks]).sort((a, b) => {
const priorityCmp = compareTaskPriority(a.priority, b.priority);
if (priorityCmp !== 0) {
return priorityCmp;
@@ -813,7 +837,7 @@ export class TriageProcessor {
// Only planning tasks count against the triage limit; execution is governed by maxConcurrent.
const maxTriageConcurrent = settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2;
const planning = allTasks.filter(
(t) => t.column === "triage" && t.status === "planning" && !t.paused,
(t) => (t.column === "triage" || t.column === "todo") && t.status === "planning" && !t.paused,
).length;
const activeAgents = planning;
@@ -2565,7 +2589,13 @@ export class TriageProcessor {
}
}
await this.store.moveTask(task.id, "todo");
/*
FNXC:CodingIdeasWorkflow 2026-07-04-10:35:
A task planned in place inside the merged "todo" column (Coding (Ideas) and any workflow with a manual intake) is already where it needs to be. Skipping the move avoids a redundant same-column transition that would re-run reset-on-entry and capacity trait hooks on a card that never left the column. Legacy triage tasks (column "triage") still move to "todo" as before.
*/
if (task.column !== "todo") {
await this.store.moveTask(task.id, "todo");
}
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
await this.store.updateTask(task.id, { title: promptDeclaredTitle });