diff --git a/.changeset/queued-to-plan-badge-engine-agreement.md b/.changeset/queued-to-plan-badge-engine-agreement.md
new file mode 100644
index 0000000000..e2e84cc886
--- /dev/null
+++ b/.changeset/queued-to-plan-badge-engine-agreement.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: "Queued to plan" and "Ready" badges now match what the engine will actually do with the card.
+category: fix
+dev: New shared `isTaskAwaitingPlanning` predicate (PROMPT.md seed-ness + replan park) replaces TaskCard's `steps.length` proxy; `GET /api/tasks` attaches transient `awaitingPlanning` for Todo rows (best-effort, capped at 200 reads/request), carried across same-column SSE updates while the step count is unchanged.
diff --git a/packages/core/src/__tests__/mesh-task-replication.test.ts b/packages/core/src/__tests__/mesh-task-replication.test.ts
index f97174377a..b2e145fca9 100644
--- a/packages/core/src/__tests__/mesh-task-replication.test.ts
+++ b/packages/core/src/__tests__/mesh-task-replication.test.ts
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
import {
buildBootstrapPrompt,
buildRefinementSeedPrompt,
+ isTaskAwaitingPlanning,
isUnplannedSeedPrompt,
} from "../mesh-task-replication.js";
import { applyOriginalDescription } from "../original-description-policy.js";
@@ -89,3 +90,46 @@ describe("mesh-task-replication", () => {
});
});
});
+
+/*
+FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+`isTaskAwaitingPlanning` is the single answer to "is this plan-in-place card waiting for a PLANNING
+slot?", shared by triage's todo-discovery and the `GET /api/tasks` board enrichment that drives the
+"Queued to plan" / "Ready" badge pair. Before it, the board inferred the answer from `steps.length`
+and disagreed with the engine in both directions.
+
+The three clauses are exactly triage's three todo-discovery branches, so each is pinned here:
+status park, missing spec, and seed-vs-real content. The step count is deliberately NOT an input —
+that is the whole point — so the content cases assert both step shapes.
+*/
+describe("isTaskAwaitingPlanning", () => {
+ const task = (overrides: Partial<{ id: string; title?: string; description: string; status?: string | null }> = {}) => ({
+ id: "FN-1",
+ title: "Title",
+ description: "desc",
+ ...overrides,
+ });
+
+ it("is true for a parked replan regardless of a real spec on disk", () => {
+ expect(isTaskAwaitingPlanning(task({ status: "needs-replan" }), "# FN-1: Title\n\n## Mission\n\nReal spec.\n")).toBe(true);
+ });
+
+ it("is true when PROMPT.md is missing", () => {
+ expect(isTaskAwaitingPlanning(task(), null)).toBe(true);
+ });
+
+ it("is true for either seed shape and false for a real spec", () => {
+ expect(isTaskAwaitingPlanning(task(), buildBootstrapPrompt("FN-1", "Title", "desc"))).toBe(true);
+ expect(isTaskAwaitingPlanning(task(), buildRefinementSeedPrompt("Title", "desc"))).toBe(true);
+ expect(isTaskAwaitingPlanning(task(), "# FN-1: Title\n\n## Steps\n\n1. Do it\n")).toBe(false);
+ });
+
+ it("ignores statuses that are not planning parks", () => {
+ for (const status of [undefined, null, "planning", "executing", "failed"]) {
+ expect(
+ isTaskAwaitingPlanning(task({ status }), "# FN-1: Title\n\n## Steps\n\n1. Do it\n"),
+ String(status),
+ ).toBe(false);
+ }
+ });
+});
diff --git a/packages/core/src/mesh-task-replication.ts b/packages/core/src/mesh-task-replication.ts
index 387cf8cf26..b921b3044b 100644
--- a/packages/core/src/mesh-task-replication.ts
+++ b/packages/core/src/mesh-task-replication.ts
@@ -68,3 +68,39 @@ export function isUnplannedSeedPrompt(
return title !== undefined
&& normalized === normalizeSeedText(buildRefinementSeedPrompt(title, description));
}
+
+/**
+ * Durable statuses that park a plan-in-place card for another planning pass regardless of what its
+ * PROMPT.md currently says. Mirrors triage todo-discovery's `needs-replan` branch — Plan Review
+ * rejected the current spec, so the real (rejected) prompt must not read as "already planned".
+ */
+const AWAITING_PLANNING_STATUSES = new Set(["needs-replan"]);
+
+/*
+FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+Whether a plan-in-place (Todo) card is waiting for a PLANNING slot rather than a WIP slot — the
+question the "Queued to plan" / "Ready" badge pair answers.
+
+Requirement: the badge must not contradict the engine. It used to infer "unplanned" from
+`steps.length === 0`, while every engine lane decides from PROMPT.md seed-ness, so the two disagreed
+in both directions: a card with a real spec but no parsed steps was labelled "Queued to plan" while
+the scheduler was actually treating it as a dispatch candidate (waiting for a WIP slot, i.e. Ready),
+and a re-seeded card still carrying steps from a previous pass was labelled "Ready" while triage was
+about to plan it. The badge exists to make the FN-8600 planning-capacity wait legible, so a wrong
+label sends operators to the wrong cap.
+
+`promptContent === null` means PROMPT.md is missing, which triage treats as unplanned (it
+regenerates the spec) rather than as planned. Kept pure — the fs read belongs to the caller, so
+this stays usable from the API layer, the engine, and tests alike.
+
+Callers: the `GET /api/tasks` board enrichment (`awaitingPlanning`) and triage's todo-discovery
+content branch. Do not re-open-code the status set or the seed check.
+*/
+export function isTaskAwaitingPlanning(
+ task: { id: string; title?: string; description: string; status?: string | null },
+ promptContent: string | null,
+): boolean {
+ if (task.status != null && AWAITING_PLANNING_STATUSES.has(task.status)) return true;
+ if (promptContent === null) return true;
+ return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description);
+}
diff --git a/packages/core/src/types/task-core.ts b/packages/core/src/types/task-core.ts
index 4c3071430d..97ff9a1eba 100644
--- a/packages/core/src/types/task-core.ts
+++ b/packages/core/src/types/task-core.ts
@@ -1068,6 +1068,17 @@ export interface Task {
* store or task.json. Consumed by FN-7516's `TaskCard` badge.
*/
plannerOverseerState?: PlannerOverseerRuntimeSnapshot;
+ /**
+ * FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ * Transient, API-populated answer to "is this plan-in-place card waiting for a PLANNING slot
+ * (rather than a WIP slot)?", derived from PROMPT.md seed-ness via `isTaskAwaitingPlanning` — the
+ * same predicate triage's todo-discovery uses. Attached best-effort to the `GET /api/tasks`
+ * payload for pre-WIP cards only (mirroring the additive `branchProgress` / `plannerOverseerState`
+ * board-payload convention) — NEVER written to the store or task.json, and absent on SSE payloads.
+ * Consumed by TaskCard's "Queued to plan" / "Ready" badge pair, which falls back to its old
+ * step-count heuristic when the field is absent.
+ */
+ awaitingPlanning?: boolean;
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index d233d5bb62..0528815ede 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -1412,12 +1412,28 @@ function TaskCardComponent({
FNXC:CodingIdeasWorkflow 2026-07-21-22:18:
Ready is the idle capacity-hold signal for Coding (Ideas) Todo cards that already have steps and no task.status. Plan Review (and any other agent-active work) also runs in Todo with status often cleared to null first, so Ready must suppress while plan-review is running or the card is agent-active — otherwise operators see both Ready and Reviewing on the same card.
*/
- const showReadyBadge = !isPaused
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ Which cap an idle Todo card is waiting on comes from the server's `awaitingPlanning` (derived from
+ PROMPT.md seed-ness by the SAME `isTaskAwaitingPlanning` predicate triage's todo-discovery uses),
+ because the client cannot read PROMPT.md. The old `steps.length` proxy disagreed with the engine in
+ both directions: a real spec that parsed to zero steps read as "Queued to plan" while the scheduler
+ was already treating it as a WIP-slot candidate, and a re-seeded card still carrying steps from a
+ previous pass read as "Ready" while triage was about to plan it.
+
+ The step-count heuristic remains the FALLBACK, not a second opinion: SSE task payloads are not
+ enriched, so a status-only live update or an older server leaves the field absent, and a card with
+ no steps is unplanned in the overwhelmingly common case. Deriving both badges from this one value
+ makes them strict complements — exactly one shows — instead of relying on the step count to keep
+ two independent conditions disjoint.
+ */
+ const awaitingPlanning = task.awaitingPlanning ?? ((task.steps?.length ?? 0) === 0);
+ const showIdleTodoBadge = !isPaused
&& task.column === "todo"
&& !visualStatus
- && (task.steps?.length ?? 0) > 0
&& !planReviewRunning
&& !isAgentActive;
+ const showReadyBadge = showIdleTodoBadge && !awaitingPlanning;
/*
FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
"Queued to plan" is the exact complement of Ready: same idle-in-Todo conditions, but the card has
@@ -1428,14 +1444,13 @@ function TaskCardComponent({
Three Todo states are now distinguishable: planning in flight (the "planning" status badge),
unplanned and waiting for a planning slot (this badge), planned and waiting for a WIP slot
- (Ready). The conditions are mutually exclusive by the steps count, so no card shows both.
+ (Ready).
+
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ Both badges now derive from the single `awaitingPlanning` value above, so "exact complement" is
+ structural rather than a property of the step count that two independent conditions had to agree on.
*/
- const showQueuedToPlanBadge = !isPaused
- && task.column === "todo"
- && !visualStatus
- && (task.steps?.length ?? 0) === 0
- && !planReviewRunning
- && !isAgentActive;
+ const showQueuedToPlanBadge = showIdleTodoBadge && awaitingPlanning;
// Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch.
// On touch-primary devices the `draggable` attribute still arms the browser's
// touch-drag heuristic, which intermittently hijacks horizontal swipes meant
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 2f200094ee..db72f487da 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -2718,6 +2718,73 @@ describe("TaskCard", () => {
unmount();
}
});
+
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ Original symptom: this badge claimed a card was waiting for a PLANNING slot when the engine was
+ never going to plan it. The step count is TaskCard's own proxy for "unplanned", while every engine
+ lane decides from PROMPT.md seed-ness, so the two disagreed in both directions. The server now
+ ships that answer as `awaitingPlanning` (same `isTaskAwaitingPlanning` predicate as triage's
+ todo-discovery) and it OUTRANKS the step count.
+
+ Surface enumeration — every combination of (flag present/absent) x (steps present/absent):
+ - flag false + no steps -> Ready (the reported repro: real spec that parsed to zero steps).
+ - flag true + steps -> Queued to plan (the reverse mislabel: re-seeded card with stale steps).
+ - flag absent -> step-count fallback preserved in both directions (SSE payloads, older server).
+ - exactly one of the two badges renders in every case, since both derive from one value.
+ */
+ describe("server awaitingPlanning outranks the step count", () => {
+ const readyBadge = (container: HTMLElement) =>
+ container.querySelector('[data-testid="card-ready-FN-QUEUED-PLAN"]');
+
+ it("renders Ready for a stepless card the server says is already planned", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(badge(container)).toBeNull();
+ expect(readyBadge(container)).toHaveTextContent("Ready");
+ });
+
+ it("renders Queued to plan for a card with stale steps the server says is unplanned", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(badge(container)).toHaveTextContent("Queued to plan");
+ expect(readyBadge(container)).toBeNull();
+ });
+
+ it("falls back to the step count in both directions when the field is absent", () => {
+ const stepless = render(
+ ,
+ );
+ expect(badge(stepless.container)).toHaveTextContent("Queued to plan");
+ expect(readyBadge(stepless.container)).toBeNull();
+ stepless.unmount();
+
+ const withSteps = render(
+ ,
+ );
+ expect(badge(withSteps.container)).toBeNull();
+ expect(readyBadge(withSteps.container)).toHaveTextContent("Ready");
+ });
+ });
});
it("renders the status badge after the card ID in DOM order", () => {
diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts
index e268fa9b1d..108a7c9ed3 100644
--- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts
@@ -1267,6 +1267,88 @@ describe("useTasks", () => {
expect(result.current.tasks[0].status).toBe("executing");
});
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ `awaitingPlanning` is attached by GET /api/tasks only, so an SSE update would wipe it and flip
+ TaskCard's badge back to its step-count fallback mid-stall. It is carried across same-column
+ updates, but must be DROPPED when the step count changes — planning finishing is exactly that,
+ and a stale `true` surviving it would keep claiming "Queued to plan" for a now-Ready card.
+ */
+ describe("awaitingPlanning enrichment across SSE updates", () => {
+ const todoTask = (overrides: Record) => createMockTask({
+ id: "FN-001",
+ column: "todo" as Column,
+ steps: [],
+ updatedAt: "2026-01-01T00:00:00Z",
+ ...overrides,
+ });
+
+ async function mountWith(initial: Record) {
+ mockFetchTasks.mockResolvedValueOnce([todoTask(initial)]);
+ const { result } = renderHook(() => useTasks());
+ await waitFor(() => {
+ expect(result.current.tasks).toHaveLength(1);
+ });
+ return result;
+ }
+
+ it("survives a status-only update that omits the field", async () => {
+ const result = await mountWith({ awaitingPlanning: true });
+
+ act(() => {
+ MockEventSource.instances[0]._emit("task:updated", todoTask({
+ status: "planning",
+ updatedAt: "2026-01-02T00:00:00Z",
+ }));
+ });
+
+ expect(result.current.tasks[0].awaitingPlanning).toBe(true);
+ });
+
+ it("is dropped when planning lands steps, so the fallback answers Ready", async () => {
+ const result = await mountWith({ awaitingPlanning: true });
+
+ act(() => {
+ MockEventSource.instances[0]._emit("task:updated", todoTask({
+ steps: [{ name: "Step 1", status: "pending" }],
+ updatedAt: "2026-01-02T00:00:00Z",
+ }));
+ });
+
+ expect(result.current.tasks[0].awaitingPlanning).toBeUndefined();
+ expect(result.current.tasks[0].steps).toHaveLength(1);
+ });
+
+ it("is dropped when steps are cleared, so the fallback answers queued", async () => {
+ const result = await mountWith({
+ awaitingPlanning: false,
+ steps: [{ name: "Step 1", status: "pending" }],
+ });
+
+ act(() => {
+ MockEventSource.instances[0]._emit("task:updated", todoTask({
+ steps: [],
+ updatedAt: "2026-01-02T00:00:00Z",
+ }));
+ });
+
+ expect(result.current.tasks[0].awaitingPlanning).toBeUndefined();
+ });
+
+ it("prefers a server value on the incoming payload over the carried one", async () => {
+ const result = await mountWith({ awaitingPlanning: true });
+
+ act(() => {
+ MockEventSource.instances[0]._emit("task:updated", todoTask({
+ awaitingPlanning: false,
+ updatedAt: "2026-01-02T00:00:00Z",
+ }));
+ });
+
+ expect(result.current.tasks[0].awaitingPlanning).toBe(false);
+ });
+ });
+
it("rapid status updates after column move are not rejected", async () => {
// Task starts in todo
const initialTask = createMockTask({
diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts
index 1e2a82fb79..b64811fbe5 100644
--- a/packages/dashboard/app/hooks/useTasks.ts
+++ b/packages/dashboard/app/hooks/useTasks.ts
@@ -168,9 +168,26 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
return a.localeCompare(b);
}
+/*
+FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+`awaitingPlanning` is attached by `GET /api/tasks` only — SSE task payloads come straight from the
+store, so a status-only live update would otherwise wipe it and flip TaskCard's badge back to its
+step-count fallback mid-stall. Carry it across same-column updates, but ONLY while the step count is
+unchanged: planning finishing is exactly a step-count change, and a stale `true` surviving that would
+keep claiming "Queued to plan" for a card that is now Ready until the next full board fetch. When it
+is dropped the fallback answers correctly in both directions (steps landed -> Ready, steps cleared ->
+queued), so the degraded state is never the wrong label.
+*/
+function carryAwaitingPlanning(current: Task, incoming: Task): boolean | undefined {
+ if (incoming.awaitingPlanning !== undefined) return incoming.awaitingPlanning;
+ const stepCountUnchanged = (current.steps?.length ?? 0) === (incoming.steps?.length ?? 0);
+ return stepCountUnchanged ? current.awaitingPlanning : undefined;
+}
+
function mergeSameColumnTask(current: Task, incoming: Task): Task {
return {
...incoming,
+ awaitingPlanning: carryAwaitingPlanning(current, incoming),
// Preserve stable execution metadata when a same-column live update arrives
// without the full task payload (common during status/log-only SSE updates).
columnMovedAt: current.columnMovedAt ?? incoming.columnMovedAt,
diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts
new file mode 100644
index 0000000000..d07624ecd3
--- /dev/null
+++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts
@@ -0,0 +1,187 @@
+// @vitest-environment node
+
+/*
+FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+Original symptom: a Todo card sat on the "Queued to plan" badge while the engine had no intention of
+planning it — TaskCard inferred "unplanned" from `steps.length === 0`, but triage's todo-discovery and
+the scheduler's dispatch filter both decide from PROMPT.md seed-ness, so the board named the wrong cap.
+
+These tests pin the server half: `GET /api/tasks` annotates Todo rows with `awaitingPlanning` derived
+from the shared `isTaskAwaitingPlanning` predicate.
+
+Surface enumeration (the invariant, not just the reported repro):
+ - seed PROMPT.md (bootstrap stub) -> true, whatever the step count says.
+ - real spec -> false, INCLUDING the reported repro shape (real spec, zero steps).
+ - re-seeded card still carrying steps from a previous pass -> true (the reverse mislabel).
+ - missing PROMPT.md -> true (triage regenerates the spec).
+ - unreadable-for-another-reason PROMPT.md -> field omitted, so the client falls back rather than
+ asserting a wrong label.
+ - `needs-replan` -> true even though its PROMPT.md is a real (rejected) spec.
+ - non-Todo rows -> field omitted entirely (payload stays byte-identical for them).
+*/
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import express from "express";
+import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { TaskStore, Task } from "@fusion/core";
+import { buildBootstrapPrompt } from "@fusion/core";
+import { createApiRoutes } from "../../routes.js";
+import { request as REQUEST } from "../../test-request.js";
+
+const REAL_SPEC = "# FN-X: Real spec\n\n## Steps\n\n1. Do the thing\n\n## File Scope\n\n- src/a.ts\n";
+
+let tasksRoot: string;
+
+function makeTask(overrides: Partial = {}): Task {
+ return {
+ id: "FN-001",
+ title: "A card",
+ description: "A description",
+ column: "todo",
+ dependencies: [],
+ steps: [],
+ currentStep: 0,
+ log: [],
+ createdAt: "2026-05-15T00:00:00.000Z",
+ updatedAt: "2026-05-15T00:00:00.000Z",
+ ...overrides,
+ } as Task;
+}
+
+/** Write PROMPT.md for a task; omit `content` to leave the file missing. */
+async function seedTaskDir(taskId: string, content?: string): Promise {
+ const dir = join(tasksRoot, taskId);
+ await mkdir(dir, { recursive: true });
+ if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content);
+}
+
+function createHarness(tasks: Task[]) {
+ const store: TaskStore = {
+ getRootDir: vi.fn(() => process.cwd()),
+ getProjectScopedPluginMcpServers: vi.fn(async () => []),
+ getTaskDir: vi.fn((id: string) => join(tasksRoot, id)),
+ getSettingsFast: vi.fn(async () => ({})),
+ listTasks: vi.fn(async () => tasks),
+ } as unknown as TaskStore;
+
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store));
+ return { app };
+}
+
+async function fetchTasks(tasks: Task[]): Promise>> {
+ const { app } = createHarness(tasks);
+ const res = await REQUEST(app, "GET", "/api/tasks");
+ expect(res.status).toBe(200);
+ return res.body as Array>;
+}
+
+beforeEach(async () => {
+ tasksRoot = await mkdtemp(join(tmpdir(), "fusion-awaiting-planning-"));
+});
+
+afterEach(async () => {
+ await rm(tasksRoot, { recursive: true, force: true });
+});
+
+describe("GET /tasks awaitingPlanning enrichment", () => {
+ it("marks a seed-prompt Todo card as awaiting planning", async () => {
+ const task = makeTask({ id: "FN-SEED" });
+ await seedTaskDir("FN-SEED", buildBootstrapPrompt("FN-SEED", task.title, task.description));
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row!.awaitingPlanning).toBe(true);
+ });
+
+ it("marks a real spec with ZERO parsed steps as NOT awaiting planning (the reported repro)", async () => {
+ // The exact shape that produced the wrong badge: no steps, but a real spec on disk, so the
+ // scheduler already treats the card as a dispatch candidate — it waits for a WIP slot.
+ const task = makeTask({ id: "FN-SPEC", steps: [] });
+ await seedTaskDir("FN-SPEC", REAL_SPEC);
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row!.awaitingPlanning).toBe(false);
+ });
+
+ it("marks a re-seeded card that still carries old steps as awaiting planning", async () => {
+ // The reverse mislabel: steps survive from a previous pass, so the step-count heuristic said
+ // "Ready" while triage was about to plan the card.
+ const task = makeTask({
+ id: "FN-RESEED",
+ steps: [{ name: "stale step from a previous pass", status: "todo" }],
+ } as Partial);
+ await seedTaskDir("FN-RESEED", buildBootstrapPrompt("FN-RESEED", task.title, task.description));
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row!.awaitingPlanning).toBe(true);
+ });
+
+ it("treats a missing PROMPT.md as awaiting planning", async () => {
+ const task = makeTask({ id: "FN-NOPROMPT" });
+ await seedTaskDir("FN-NOPROMPT");
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row!.awaitingPlanning).toBe(true);
+ });
+
+ it("omits the field when PROMPT.md is unreadable for another reason", async () => {
+ // A directory where the file should be: EISDIR, not ENOENT. That is not evidence either way, so
+ // the client must fall back instead of being handed a fabricated label.
+ const task = makeTask({ id: "FN-EISDIR" });
+ await mkdir(join(tasksRoot, "FN-EISDIR", "PROMPT.md"), { recursive: true });
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row).not.toHaveProperty("awaitingPlanning");
+ });
+
+ it("marks a needs-replan card as awaiting planning despite a real spec on disk", async () => {
+ const task = makeTask({ id: "FN-REPLAN", status: "needs-replan" } as Partial);
+ await seedTaskDir("FN-REPLAN", REAL_SPEC);
+
+ const [row] = await fetchTasks([task]);
+
+ expect(row!.awaitingPlanning).toBe(true);
+ });
+
+ it("leaves non-Todo rows untouched", async () => {
+ const inProgress = makeTask({ id: "FN-WIP", column: "in-progress" });
+ const inReview = makeTask({ id: "FN-REVIEW", column: "in-review" });
+ await seedTaskDir("FN-WIP", buildBootstrapPrompt("FN-WIP", inProgress.title, inProgress.description));
+ await seedTaskDir("FN-REVIEW", REAL_SPEC);
+
+ const rows = await fetchTasks([inProgress, inReview]);
+
+ for (const row of rows) {
+ expect(row).not.toHaveProperty("awaitingPlanning");
+ }
+ });
+
+ it("still returns the board when the enrichment cannot resolve task directories", async () => {
+ // Best-effort contract: a store without getTaskDir must not fail the board load.
+ const task = makeTask({ id: "FN-NODIR" });
+ const store = {
+ getRootDir: vi.fn(() => process.cwd()),
+ getProjectScopedPluginMcpServers: vi.fn(async () => []),
+ getSettingsFast: vi.fn(async () => ({})),
+ listTasks: vi.fn(async () => [task]),
+ } as unknown as TaskStore;
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store));
+
+ const res = await REQUEST(app, "GET", "/api/tasks");
+
+ expect(res.status).toBe(200);
+ const rows = res.body as Array>;
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).not.toHaveProperty("awaitingPlanning");
+ });
+});
diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts
index e50c3572bc..14282fa73d 100644
--- a/packages/dashboard/src/routes/register-task-workflow-routes.ts
+++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts
@@ -1,8 +1,16 @@
import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
+
+/**
+ * FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ * Per-request ceiling on `awaitingPlanning` PROMPT.md reads (one per Todo row). Boards this large
+ * are pathological; beyond the cap the remaining cards keep TaskCard's step-count fallback rather
+ * than turning one board load into thousands of file reads. Truncation is logged, never silent.
+ */
+const AWAITING_PLANNING_ENRICH_LIMIT = 200;
import { createReadStream } from "node:fs";
-import { stat } from "node:fs/promises";
+import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import type {
TaskStore,
@@ -88,7 +96,7 @@ import {
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import { resolveNativeStructurePreview } from "../native-structure-preview.js";
import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js";
-import { computePlanApprovalFingerprint, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core";
+import { computePlanApprovalFingerprint, isTaskAwaitingPlanning, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core";
import { FUSION_CLIENT_HEADER, resolveHttpDeleteCallerKind } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
// FNXC:TaskLookup404 2026-07-26-11:40: shared task-miss -> 404 mapping seam.
@@ -1031,6 +1039,60 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// fail the board load — fall through with the un-enriched task list.
}
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ Attach `awaitingPlanning` for plan-in-place (Todo) cards so the "Queued to plan" / "Ready"
+ badge pair names the cap the card is actually waiting on. Same additive, best-effort,
+ never-fail-the-board contract as the two enrichments above; the field is omitted (rather than
+ `false`) for every other row, so those payloads stay byte-identical.
+
+ Requirement: the badges must agree with the engine. TaskCard could only infer "unplanned" from
+ `steps.length === 0`, while triage's todo-discovery and the scheduler's dispatch filter both
+ decide from PROMPT.md seed-ness — so a card with a real spec but no parsed steps was labelled
+ "Queued to plan" while the scheduler was already treating it as a WIP-slot candidate, and a
+ re-seeded card still carrying old steps was labelled "Ready" while triage was about to plan it.
+ `isTaskAwaitingPlanning` is the shared predicate, so there is one answer per card.
+
+ Cost: one small file read per Todo row, only on this route (SSE payloads are not enriched —
+ TaskCard falls back to its step-count heuristic when the field is absent). Bounded by
+ AWAITING_PLANNING_ENRICH_LIMIT and logged when it truncates, so a huge Todo column degrades to
+ the heuristic instead of turning a board load into thousands of reads.
+ */
+ try {
+ const todoRows = tasks.filter((task) => task.column === "todo");
+ const enrichable = todoRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT);
+ if (todoRows.length > enrichable.length) {
+ severityAuditLog.warn(
+ `awaitingPlanning enrichment truncated: ${enrichable.length}/${todoRows.length} todo tasks ` +
+ "annotated (remaining cards fall back to the client step-count heuristic)",
+ );
+ }
+ if (enrichable.length > 0) {
+ const flagByTask = new Map();
+ await Promise.all(enrichable.map(async (task) => {
+ let promptContent: string | null = null;
+ try {
+ promptContent = await readFile(join(scopedStore.getTaskDir(task.id), "PROMPT.md"), "utf-8");
+ } catch (err: unknown) {
+ // A MISSING spec means unplanned (triage regenerates it), which the predicate encodes
+ // as `null`. Any other read fault is not evidence either way, so omit the field and
+ // let the client fall back rather than assert a wrong label.
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") return;
+ }
+ flagByTask.set(task.id, isTaskAwaitingPlanning(task, promptContent));
+ }));
+ if (flagByTask.size > 0) {
+ tasks = tasks.map((task) => {
+ const awaitingPlanning = flagByTask.get(task.id);
+ return awaitingPlanning === undefined ? task : { ...task, awaitingPlanning };
+ });
+ }
+ }
+ } catch {
+ // Awaiting-planning enrichment is best-effort and must never fail the
+ // board load — fall through with the un-enriched task list.
+ }
+
res.json(tasks);
} catch (err: unknown) {
if (err instanceof ApiError) {
diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts
index 66b9f00a86..b8b6f84495 100644
--- a/packages/engine/src/triage.ts
+++ b/packages/engine/src/triage.ts
@@ -17,6 +17,7 @@ import {
TaskDeletedError,
buildTriageMemoryInstructions,
isUnplannedSeedPrompt,
+ isTaskAwaitingPlanning,
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
resolveAgentPrompt,
@@ -1362,10 +1363,18 @@ export class TriageProcessor {
Only ENOENT is treated as unplanned; a genuine read fault (permissions, a directory in the
file's place) still skips the card, but now says so in the log instead of vanishing.
*/
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
+ Shared with the `GET /api/tasks` `awaitingPlanning` enrichment that drives the
+ "Queued to plan" / "Ready" badge pair, so the board cannot label a card's wait differently
+ from the lane that actually decides it. The three clauses of `isTaskAwaitingPlanning` are
+ exactly this loop's three branches: the `needs-replan` early-continue above, this content
+ check, and the ENOENT branch below (the helper's `null` case).
+ */
try {
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
const content = await readFile(promptPath, "utf-8");
- if (isUnplannedSeedPrompt(content, todoTask.id, todoTask.title, todoTask.description)) {
+ if (isTaskAwaitingPlanning(todoTask, content)) {
eligibleTodoTasks.push(todoTask);
}
} catch (err) {