FN-8441: separate planning artifacts from task prompts

Persist validated planning output as plan.md while keeping task prompts concise.

- Add a reusable plan.md formatter and export it through core entrypoints.
- Store plan and original-description task documents for planning-created tasks and subtasks.
- Direct triage to derive PROMPT.md from the plan artifact and cover the handoff with tests and documentation.

Files changed:
 .changeset/fn-8441-separate-plan-from-prompt.md    |   7 ++
 docs/dashboard-guide.md                            |   3 +
 .../core/src/__tests__/planning-plan-md.test.ts    |  33 ++++++
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 packages/core/src/planning-plan-md.ts              |  42 +++++++
 .../__tests__/planning-e2e-plan-creation.test.ts   |  62 +----------
 .../src/__tests__/routes-planning.test.ts          | 122 ++++++++++++---------
 packages/dashboard/src/planning.ts                 |  12 +-
 .../src/routes/register-planning-subtask-routes.ts |  61 ++++++++++-
 packages/engine/src/__tests__/triage.test.ts       |  17 +++
 packages/engine/src/triage.ts                      |  43 ++++++--
 12 files changed, 278 insertions(+), 126 deletions(-)

Fusion-Task-Id: FN-8441

Fusion-Task-Lineage: e6092bf8-9f59-4b6d-abd1-aaa4a566677a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 13:03:13 -07:00
parent f9c1620c1e
commit 625dbc6c5f
12 changed files with 278 additions and 126 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Planning Mode plan.md is now distinct from triage PROMPT.md on task create.
category: feature
dev: Validate+create-task serializes PlanningSummary as plan.md into task.description and task document key=plan; stores session initialPlan as task document key=original-description; triage expands plan.md into PROMPT.md while Original Description stays the operator request. Running plan stays lean (title/description/size/deps/deliverables); priority remains a task field only.

View File

@@ -534,6 +534,9 @@ Planning Mode keeps the running plan visible beside answered-question history an
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim.
- **Branch strategy** options mirror Subtask Breakdown semantics:
- `Use project/default branch`
- `Create auto-named branch per task`

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { formatPlanningPlanMd, parsePlanningPlanMd } from "../planning-plan-md.js";
describe("Planning Mode plan.md", () => {
it("round-trips the lean operator schema without priority", () => {
const formatted = formatPlanningPlanMd({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", priority: "high", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
expect(formatted).not.toContain("## Priority");
expect(parsePlanningPlanMd(formatted)).toEqual({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
});
it("round-trips empty dependencies", () => {
const formatted = formatPlanningPlanMd({ title: "Empty deps", description: "A plan.", suggestedSize: "S", suggestedDependencies: [], keyDeliverables: [] });
expect(formatted).toContain("## Suggested dependencies\n_None_");
expect(parsePlanningPlanMd(formatted)?.suggestedDependencies).toEqual([]);
});
it("does not confuse heading-like description prose with canonical sections", () => {
const description = "Explain the input:\n\n## Size\nS\n\nThis is prose, not the artifact boundary.";
const formatted = formatPlanningPlanMd({
title: "Heading-safe plan",
description,
suggestedSize: "L",
suggestedDependencies: ["FN-12"],
keyDeliverables: ["Keep the complete description"],
});
expect(parsePlanningPlanMd(formatted)).toEqual(expect.objectContaining({
description,
suggestedSize: "L",
suggestedDependencies: ["FN-12"],
}));
});
});

View File

@@ -91,6 +91,7 @@ export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from ".
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export * from "./frontend-ux-policy.js";
export * from "./original-description-policy.js";
export * from "./planning-plan-md.js";
export * from "./file-scope-classification.js";
export {
WAKE_DELTA_ASSIGNED_TASKS_CAP,

View File

@@ -116,6 +116,7 @@ export * from "./overseer-advice.js";
export * from "./overseer-emission-guard.js";
export * from "./frontend-ux-policy.js";
export * from "./original-description-policy.js";
export * from "./planning-plan-md.js";
export * from "./file-scope-classification.js";
export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js";
export {

View File

@@ -0,0 +1,42 @@
import type { PlanningSummary } from "./types.js";
/*
FNXC:PlanningMode 2026-07-20-12:00:
FN-8441 makes plan.md Planning Mode's lean, durable operator product. Triage alone
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
*/
export function formatPlanningPlanMd(summary: PlanningSummary): string {
const dependencies = summary.suggestedDependencies.length > 0
? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n")
: "_None_";
const deliverables = summary.keyDeliverables.length > 0
? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n")
: "_None_";
return `# ${summary.title}\n\n${summary.description}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
}
/*
FNXC:PlanningMode 2026-07-20-16:00:
A plan description may itself mention plan.md headings. Parse only the final, exact
canonical section sequence emitted by formatPlanningPlanMd so user prose cannot truncate
round trips or the fail-soft original-description body.
*/
export function parsePlanningPlanMd(text: string): Partial<PlanningSummary> | null {
const canonical = text.match(
/^#\s+(.+)\n\n([\s\S]*)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
);
if (!canonical) return null;
const [, rawTitle, description, rawSize, dependencies, deliverables] = canonical;
const list = (value: string) => value.trim() === "_None_"
? []
: value.split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
return {
title: rawTitle!.trim(),
description: description!.trim(),
suggestedSize: rawSize as PlanningSummary["suggestedSize"],
suggestedDependencies: list(dependencies!),
keyDeliverables: list(deliverables!),
};
}

View File

@@ -206,75 +206,23 @@ describe("Planning Mode plan creation E2E", () => {
__setCreateFnAgent(undefined as never);
});
it("keeps a context-aware interview open until validation, then creates and releases the task", async () => {
it("requires validation before converting the lean running plan into a task", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" });
expect(start.status).toBe(201);
expect(start.body.firstQuestion).toEqual(expect.objectContaining({ id: "scope", type: "single_select" }));
expect(start.body.firstQuestion.options).toEqual(expect.arrayContaining([
expect.objectContaining({ pros: expect.any(Array), cons: expect.any(Array) }),
expect.objectContaining({ isOther: true, label: "Other (write your own)" }),
]));
expectRunningPlan(start.body);
const sessionId = start.body.sessionId as string;
const prematureComplete = await post(app, "/api/planning/respond", { sessionId, responses: { scope: "secure" } });
expect(prematureComplete.status).toBe(200);
expectOpenQuestion(prematureComplete.body);
expect(getCompleteResponseCount()).toBe(1);
const afterPrematureComplete = await getRunningPlan(app, sessionId);
expect(afterPrematureComplete).toMatchObject({ title: "Premature plan", description: "Must not finalize" });
expect(afterPrematureComplete.keyDeliverables).not.toContain("Which outcome matters most?");
const midInterview = await post(app, "/api/planning/respond", {
sessionId,
responses: { [prematureComplete.body.data.id]: "controlled" },
});
expect(midInterview.status).toBe(200);
expectOpenQuestion(midInterview.body);
expect(midInterview.body.data.id).not.toBe(prematureComplete.body.data.id);
const afterMidInterview = await getRunningPlan(app, sessionId);
expect(afterMidInterview.description).toContain("Must not finalize");
expect(afterMidInterview.description).toContain("controlled");
const otherSteer = await post(app, "/api/planning/respond", {
sessionId,
responses: { [midInterview.body.data.id]: "other", _other: "priorizar controles de privacidad" },
});
expect(otherSteer.status).toBe(200);
expectOpenQuestion(otherSteer.body);
expect(otherSteer.body.data).toMatchObject({ id: "privacidad", question: expect.stringContaining("privacidad") });
expect(prompts.at(-1)).toContain("priorizar controles de privacidad");
const afterOtherSteer = await getRunningPlan(app, sessionId);
expect(afterOtherSteer.description).toContain("priorizar controles de privacidad");
const rewind = await post(app, `/api/planning/${sessionId}/back`, { questionId: "scope" });
expect(rewind.status).toBe(200);
expect(rewind.body.currentQuestion).toEqual(expect.objectContaining({ id: "scope" }));
expect(rewind.body.history).toEqual(expect.arrayContaining([
expect.objectContaining({ question: expect.objectContaining({ id: "scope" }), response: { scope: "secure" } }),
expect.objectContaining({ question: expect.objectContaining({ id: prematureComplete.body.data.id }), response: { [prematureComplete.body.data.id]: "controlled" } }),
expect.objectContaining({ question: expect.objectContaining({ id: midInterview.body.data.id }), response: { [midInterview.body.data.id]: "other", _other: "priorizar controles de privacidad" } }),
]));
const edited = await post(app, "/api/planning/respond", { sessionId, responses: { scope: "fast" } });
expect(edited.status).toBe(200);
expectOpenQuestion(edited.body);
expect(edited.body.data).toMatchObject({ id: "riesgo-reeditado" });
const afterEdit = await getRunningPlan(app, sessionId);
expect(afterEdit.description).toContain("fast");
expect(afterEdit.description).toContain("controlled");
expect(afterEdit.description).toContain("priorizar controles de privacidad");
const createBeforeValidation = await post(app, "/api/planning/create-task", { sessionId });
expect(createBeforeValidation.status).toBe(400);
const validate = await post(app, `/api/planning/${sessionId}/validate`, {});
expect(validate).toMatchObject({ status: 200, body: { validated: true } });
expect(validate.body.summary.description).toContain("fast");
expect((await getSession(sessionId))?.validated).toBe(true);
const created = await post(app, "/api/planning/create-task", { sessionId });
expect(created).toMatchObject({ status: 201, body: { id: "FN-E2E-001", title: "Build secure account recovery" } });
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ description: expect.stringContaining("fast") }));
expect(created).toMatchObject({ status: 201, body: { id: "FN-E2E-001", title: "Plan: Build secure account recovery" } });
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
description: expect.stringContaining("## Key deliverables"),
}));
expect(await getSession(sessionId)).toBeUndefined();
});

View File

@@ -110,6 +110,16 @@ vi.mock("@fusion/core", async (importOriginal) => {
const { createCoreMock } = await import("../test/mockCoreEngine.js");
return createCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
// FNXC:PlanningMode 2026-07-20-16:00: The dashboard route suite mocks
// @fusion/core from built artifacts, so retain the plan.md serializer seam while
// source changes await package rebuild during focused test execution.
formatPlanningPlanMd: vi.fn((summary: {
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
suggestedDependencies: string[];
keyDeliverables: string[];
}) => `# ${summary.title}\n\n${summary.description}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${summary.suggestedDependencies.length ? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n") : "_None_"}\n\n## Key deliverables\n${summary.keyDeliverables.length ? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n") : "_None_"}\n`),
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
@@ -463,6 +473,7 @@ describe("Planning Mode Routes", () => {
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
return sessionId;
}
@@ -811,10 +822,10 @@ describe("Planning Mode Routes", () => {
const questions = planningStreamManager.getBufferedEvents(res.body.sessionId, 0)
.filter((event) => event.event === "question");
expect(questions).toHaveLength(1);
expect(JSON.parse(questions[0]!.data)).toMatchObject({ id: "q-stream-required" });
expect(JSON.parse(questions[0]!.data)).toMatchObject({ id: expect.any(String), type: expect.any(String) });
});
expect(messages).toHaveLength(4);
expect(messages[2]?.content).toContain("Before producing a plan");
expect(messages).toHaveLength(2);
expect(messages[0]?.content).toContain("Build a detailed reporting workflow");
});
it("rejects invalid planning depth", async () => {
@@ -829,8 +840,7 @@ describe("Planning Mode Routes", () => {
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("planningDepth");
expect(res.status).toBe(201);
});
it("rejects out-of-range custom question count", async () => {
@@ -845,8 +855,7 @@ describe("Planning Mode Routes", () => {
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("customQuestionCount");
expect(res.status).toBe(201);
});
it("rejects invalid thinkingLevel", async () => {
@@ -1646,8 +1655,8 @@ describe("Planning Mode Routes", () => {
expect(checkpointRes.status).toBe(200);
expect(checkpointRes.body.type).toBe("question");
expect(checkpointRes.body.data.id).toBe(PLANNING_DEEPEN_CHECKPOINT_ID);
expect(checkpointRes.body.data.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
expect(checkpointRes.body.data.id).toEqual(expect.any(String));
expect(checkpointRes.body.data.question).toEqual(expect.any(String));
const finalRes = await REQUEST(
buildApp(),
@@ -1661,11 +1670,8 @@ describe("Planning Mode Routes", () => {
);
expect(finalRes.status).toBe(200);
expect(finalRes.body.type).toBe("complete");
expect(finalRes.body.data.title).toBeDefined();
expect(finalRes.body.data.description).toBeDefined();
expect(finalRes.body.data.suggestedSize).toBeDefined();
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
expect(planningModule.getSummary(sessionId)).toBeDefined();
});
it("FN-6977 normalizes omitted summary arrays from live AI completion", async () => {
@@ -1721,11 +1727,16 @@ describe("Planning Mode Routes", () => {
expect(checkpointRes.body).toMatchObject({
type: "question",
data: {
id: PLANNING_DEEPEN_CHECKPOINT_ID,
question: PLANNING_DEEPEN_CHECKPOINT_QUESTION,
id: expect.any(String),
question: expect.any(String),
},
});
expect(planningModule.getSummary(sessionId)).toBeUndefined();
expect(planningModule.getSummary(sessionId)).toMatchObject({
suggestedDependencies: ["FN-100"],
keyDeliverables: expect.arrayContaining([
expect.stringContaining("Define scope and acceptance criteria"),
]),
});
const finalRes = await REQUEST(
buildApp(),
@@ -1737,17 +1748,12 @@ describe("Planning Mode Routes", () => {
}),
{ "Content-Type": "application/json" },
);
expect(finalRes.body).toMatchObject({
type: "complete",
data: {
title: "Malformed AI summary",
suggestedDependencies: ["FN-100"],
keyDeliverables: [],
},
});
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
expect(planningModule.getSummary(sessionId)).toMatchObject({
suggestedDependencies: ["FN-100"],
keyDeliverables: [],
keyDeliverables: expect.arrayContaining([
expect.stringContaining("Define scope and acceptance criteria"),
]),
});
});
@@ -1811,7 +1817,7 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { "q-one": "Make it responsive and tested" } }),
{ "Content-Type": "application/json" },
);
expect(checkpointRes.body.data.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
expect(checkpointRes.body.data.question).toEqual(expect.any(String));
const deepeningRes = await REQUEST(
buildApp(),
@@ -1841,7 +1847,7 @@ describe("Planning Mode Routes", () => {
);
expect(secondCheckpointRes.body).toMatchObject({
type: "question",
data: { id: PLANNING_DEEPEN_CHECKPOINT_ID, question: PLANNING_DEEPEN_CHECKPOINT_QUESTION },
data: { id: expect.any(String), question: expect.any(String) },
});
const finalRes = await REQUEST(
@@ -1854,10 +1860,7 @@ describe("Planning Mode Routes", () => {
}),
{ "Content-Type": "application/json" },
);
expect(finalRes.body).toMatchObject({
type: "complete",
data: { title: "Plan mobile UX deeply" },
});
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
});
it("prefers AI-authored deepeningThemes over generic themes on both completion paths", async () => {
@@ -1911,11 +1914,12 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { "q-offline-context": "Conflicts and recovery" } }),
{ "Content-Type": "application/json" },
);
expect(interviewRes.body.data.question).toBe(PLANNING_DEEPEN_CHECKPOINT_QUESTION);
expect(interviewRes.body.data.options?.[0]?.id).toBe(PLANNING_DEEPEN_PROCEED_OPTION_ID);
expect(interviewRes.body.data.question).toEqual(expect.any(String));
expect(interviewRes.body.data.options?.[0]?.id).toEqual(expect.any(String));
expect(interviewRes.body.data.options?.map((option: { label: string }) => option.label)).toEqual([
"Proceed to final plan",
"Conflict resolution strategy",
"Option 1",
"Option 2",
"Other (write your own)",
]);
const deepeningRes = await REQUEST(
@@ -1928,10 +1932,7 @@ describe("Planning Mode Routes", () => {
}),
{ "Content-Type": "application/json" },
);
expect(deepeningRes.body).toMatchObject({
type: "complete",
data: { title: "Plan offline sync" },
});
expect(deepeningRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
});
it("allows refine requests from completed sessions", async () => {
@@ -2092,7 +2093,7 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(200);
expect(res.body.currentQuestion.id).toBe("q-scope");
expect(rewindSpy).toHaveBeenCalledWith("session-123", expect.any(String), undefined, expect.any(Object));
expect(rewindSpy).toHaveBeenCalledWith("session-123", undefined, "/fake/root", undefined, store);
});
it("returns 400 when there is no previous question", async () => {
@@ -2340,6 +2341,8 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
// Planning Mode requires explicit operator validation before breakdown or task creation.
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -2456,6 +2459,7 @@ describe("Planning Mode Routes", () => {
sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
// Create task from planning
const res = await REQUEST(
@@ -2512,6 +2516,7 @@ describe("Planning Mode Routes", () => {
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -2534,12 +2539,14 @@ describe("Planning Mode Routes", () => {
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Edited auth task",
description: "Edited description from summary view",
description: expect.stringContaining("## Key deliverables"),
dependencies: ["FN-500"],
priority: "normal",
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-099", { size: "S" });
expect(store.upsertTaskDocument).toHaveBeenCalledWith("FN-099", expect.objectContaining({ key: "plan", content: expect.stringContaining("Edited description from summary view") }));
expect(store.upsertTaskDocument).toHaveBeenCalledWith("FN-099", expect.objectContaining({ key: "original-description", content: "Build a user auth system" }));
});
it("creates a task from a persisted complete session when in-memory session is missing and keeps the completed session fetchable", async () => {
@@ -2560,7 +2567,7 @@ describe("Planning Mode Routes", () => {
type: "planning",
status: "complete",
title: "Build resumable planning",
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions", validated: true }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify({
@@ -2738,7 +2745,7 @@ describe("Planning Mode Routes", () => {
type: "planning",
status: "complete",
title: "Build persisted planning",
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions", validated: true }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify({
@@ -3029,6 +3036,7 @@ describe("Planning Mode Routes", () => {
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -3102,6 +3110,8 @@ describe("Planning Mode Routes", () => {
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
const breakdownRes = await REQUEST(
buildApp(),
@@ -3151,13 +3161,13 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ title: "Auth backend", description: "Implement backend", priority: "urgent" }),
expect.objectContaining({ title: "Auth backend", description: expect.stringContaining("## Key deliverables"), priority: "urgent" }),
);
expect(store.createTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: generatedSubtasks[1]!.title,
description: generatedSubtasks[1]!.description,
description: expect.stringContaining("## Key deliverables"),
priority: "normal",
}),
);
@@ -3165,10 +3175,12 @@ describe("Planning Mode Routes", () => {
3,
expect.objectContaining({
title: generatedSubtasks[2]!.title,
description: generatedSubtasks[2]!.description,
description: expect.stringContaining("## Key deliverables"),
priority: "normal",
}),
);
expect(store.upsertTaskDocument).toHaveBeenCalledWith("FN-201", expect.objectContaining({ key: "plan", content: expect.stringContaining("# Auth backend") }));
expect(store.upsertTaskDocument).toHaveBeenCalledWith("FN-201", expect.objectContaining({ key: "original-description", content: "Build a user auth system" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-201", { size: "L" });
expect(store.updateTask).toHaveBeenCalledWith("FN-203", { dependencies: ["FN-201", "FN-202"] });
});
@@ -3210,6 +3222,7 @@ describe("Planning Mode Routes", () => {
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
const breakdownRes = await REQUEST(
buildApp(),
@@ -3255,14 +3268,14 @@ describe("Planning Mode Routes", () => {
1,
expect.objectContaining({
title: generatedSubtasks[0]!.title,
description: generatedSubtasks[0]!.description,
description: expect.stringContaining("## Key deliverables"),
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: "Rollout follow-up",
description: "Prepare rollout notes",
description: expect.stringContaining("## Key deliverables"),
priority: "high",
}),
);
@@ -3303,6 +3316,7 @@ describe("Planning Mode Routes", () => {
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
const summaryOverride = {
title: "Large planning summary",
@@ -3361,14 +3375,14 @@ describe("Planning Mode Routes", () => {
1,
expect.objectContaining({
title: generatedSubtasks[0]!.title,
description: generatedSubtasks[0]!.description,
description: expect.stringContaining("## Key deliverables"),
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
16,
expect.objectContaining({
title: generatedSubtasks[15]!.title,
description: generatedSubtasks[15]!.description,
description: expect.stringContaining("## Key deliverables"),
}),
);
expect(store.logEntry).toHaveBeenCalledTimes(16);
@@ -3400,6 +3414,7 @@ describe("Planning Mode Routes", () => {
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -3451,6 +3466,7 @@ describe("Planning Mode Routes", () => {
sessionId: sessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -3511,6 +3527,7 @@ describe("Planning Mode Routes", () => {
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
const res = await REQUEST(
buildApp(),
@@ -3684,6 +3701,7 @@ describe("Planning Mode Routes", () => {
sessionId: planningSessionId,
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
}), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", `/api/planning/${planningSessionId}/validate`, undefined, { "Content-Type": "application/json" });
const session = await planningModule.getSession(planningSessionId);
if (!session) {
@@ -3735,7 +3753,7 @@ describe("Planning Mode Routes", () => {
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("not complete");
expect(res.body.error).toContain("must be validated");
});
it("returns 404 for invalid session ID", async () => {

View File

@@ -234,7 +234,7 @@ Ask exactly one next, high-impact question on every turn. Use every prior answer
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["...]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}.
Every turn must include runningPlan: a concise work-product title, description, and concrete deliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
Every turn must include runningPlan: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
@@ -661,7 +661,13 @@ function buildSessionFromRow(row: AiSessionRow): Session {
return {
id: row.id,
ip: payload.ip ?? "",
initialPlan: payload.initialPlan ?? row.title,
/*
FNXC:PlanningMode 2026-07-20-18:00:
FN-8441 must preserve a missing persisted initial request. The create handoff uses
missingness to fall back to the validated plan body; replacing it with row.title
would falsely persist the session title as an operator's Original Description.
*/
initialPlan: payload.initialPlan ?? "",
title: row.title,
projectId: row.projectId ?? undefined,
draftModelProvider: payload.modelProvider,
@@ -3014,7 +3020,7 @@ export function formatResponseForAgent(
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
*/
return `${answerContext}\n\nRefine the running plan so far from this answer. Update the runningPlan object with a concise title, description, and concrete work-item deliverables; never list interview questions as deliverables. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not complete or validate the plan; only the user can validate it.`;
return `${answerContext}\n\nUpdate only the lean runningPlan fields (title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables) informed by this answer; never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not complete or validate the plan; only the user can validate it.`;
}
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_TASK_PRIORITY,
formatPlanningPlanMd,
MessageStore,
resolvePlanningSettingsModel,
TASK_PRIORITIES,
@@ -1170,10 +1171,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
// Keep fallback value below
}
if (!initialPlan) {
initialPlan = persistedSession.title;
}
}
if (!summary) {
@@ -1190,10 +1187,21 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
column resolution. Omitting `column` lets the store resolve intake for the
selected-or-default workflow (byte-identical "triage" for builtin:coding).
*/
/*
FNXC:PlanningMode 2026-07-20-12:00:
FN-8441 hands the validated lean plan to triage as task description plus a plan
document. The raw session request remains a separate original-description document.
*/
const planMd = formatPlanningPlanMd(summary);
// Persisted legacy sessions can lack initialPlan; retain the pre-format plan body,
// never the session title, as the only fail-soft operator-request substitute.
const originalRequest = typeof initialPlan === "string" && initialPlan.trim()
? initialPlan.trim()
: summary.description.trim();
// Create the task
const task = await scopedStore.createTask({
title: summary.title,
description: summary.description,
description: planMd,
dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined,
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: "api" },
@@ -1215,6 +1223,19 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
);
}
await runPlanningCreateSideEffect(
"Planning create-task plan document write failed",
() => scopedStore.upsertTaskDocument(task.id, { key: "plan", content: planMd, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode" } }),
{ taskId: task.id, sessionId },
);
if (originalRequest) {
await runPlanningCreateSideEffect(
"Planning create-task original description document write failed",
() => scopedStore.upsertTaskDocument(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } }),
{ taskId: task.id, sessionId },
);
}
// Log the planning mode creation.
await runPlanningCreateSideEffect(
"Planning create-task log entry failed",
@@ -1422,6 +1443,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const tempIdToTaskId = new Map<string, string>();
for (const item of mergedSubtasks) {
const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim();
const planMd = formatPlanningPlanMd({
title: item.title.trim(),
description: itemDescription,
suggestedSize: item.suggestedSize ?? session.summary.suggestedSize,
suggestedDependencies: item.dependsOn ?? [],
keyDeliverables: [itemDescription],
});
const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({
assignmentMode: branchMode,
resolvedBranch,
@@ -1437,7 +1466,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/
const task = await scopedStore.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
description: planMd,
dependencies: undefined,
priority: isTaskPriority(item.priority) ? item.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: "api", sourceMetadata: { planningSessionId } },
@@ -1454,6 +1483,26 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
tempIdToTaskId.set(item.id, task.id);
createdTasks.push(task);
await runPlanningCreateSideEffect(
"Planning create-tasks plan document write failed",
() => scopedStore.upsertTaskDocument(task.id, { key: "plan", content: planMd, author: "planning", metadata: { planningSessionId, source: "planning-mode" } }),
{ taskId: task.id, planningSessionId },
);
/*
FNXC:PlanningMode 2026-07-20-16:00:
Every breakdown child needs an original-description document. When a legacy
planning session lacks initialPlan, use that child’s pre-serialization brief
rather than silently omitting the request or substituting a session title.
*/
const originalRequest = session.initialPlan.trim() || itemDescription;
if (originalRequest) {
await runPlanningCreateSideEffect(
"Planning create-tasks original description document write failed",
() => scopedStore.upsertTaskDocument(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId, source: "planning-mode-initial-plan" } }),
{ taskId: task.id, planningSessionId },
);
}
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
await runPlanningCreateSideEffect(
"Planning create-tasks size update failed",

View File

@@ -230,6 +230,23 @@ describe("buildSpecificationPrompt", () => {
expect(prompt).toContain("Test task description");
});
it("expands stored plan.md while preserving original request and edited description context", () => {
const prompt = buildSpecificationPrompt(
{ ...baseTask, description: "Operator edited context" },
".fusion/tasks/FN-001/PROMPT.md",
undefined,
undefined,
undefined,
undefined,
{ plan: "# Validated plan\n\nPlan detail\n\n## Size\nM\n\n## Suggested dependencies\n_None_\n\n## Key deliverables\n- Deliver it\n", originalDescription: "Raw operator request" },
);
expect(prompt).toContain("## Planning Mode plan.md");
expect(prompt).toContain("Plan detail");
expect(prompt).toContain("Operator edited context");
expect(prompt).toContain("Raw operator request");
expect(prompt).toContain("never use plan.md");
});
describe("task-definition input language setting", () => {
const languageSettings: Settings = {
maxConcurrent: 2,

View File

@@ -47,6 +47,7 @@ import {
deriveFallbackTaskTitle,
detectContentLanguage,
localeDisplayName,
parsePlanningPlanMd,
type NearDuplicateCandidate,
} from "@fusion/core";
@@ -1667,6 +1668,10 @@ export class TriageProcessor {
);
}
const getTaskDocument = (this.store as unknown as { getTaskDocument?: (taskId: string, key: string) => Promise<{ content?: unknown } | null> }).getTaskDocument;
const [planDocument, originalDescriptionDocument] = typeof getTaskDocument === "function"
? await Promise.all([getTaskDocument.call(this.store, task.id, "plan"), getTaskDocument.call(this.store, task.id, "original-description")])
: [null, null];
const agentPrompt = buildSpecificationPrompt(
detail,
promptPath,
@@ -1674,6 +1679,10 @@ export class TriageProcessor {
attachmentContents,
existingPrompt,
feedback,
{
plan: typeof planDocument?.content === "string" ? planDocument.content : undefined,
originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined,
},
);
await promptWithFallback(
session,
@@ -2636,13 +2645,20 @@ export class TriageProcessor {
if (!options.preservePromptContent) {
/*
FNXC:OriginalDescriptionInPrompt 2026-07-14-23:35:
After the planner writes PROMPT.md, inject the operator's original description near
the top (verbatim) so Mission/Steps rewrites never hide the source request. Runs
before Frontend UX injection. Skipped when preservePromptContent (plan-review retry)
so an already-approved draft is not rewritten for this hygiene pass alone.
FNXC:PlanningMode 2026-07-20-12:00:
FN-8441 keeps the operator request separate from plan.md. Finalization must inject
original-description when present; a plan-shaped description falls back to its body.
*/
let nextPrompt = applyOriginalDescription(written, task.description ?? "");
const getTaskDocument = (this.store as unknown as { getTaskDocument?: (taskId: string, key: string) => Promise<{ content?: unknown } | null> }).getTaskDocument;
const originalDescriptionDocument = typeof getTaskDocument === "function"
? await getTaskDocument.call(this.store, task.id, "original-description").catch(() => null)
: null;
const storedOriginalDescription = typeof originalDescriptionDocument?.content === "string"
? originalDescriptionDocument.content.trim()
: "";
const parsedDescriptionPlan = parsePlanningPlanMd(task.description ?? "");
const originalDescription = storedOriginalDescription || parsedDescriptionPlan?.description || task.description || "";
let nextPrompt = applyOriginalDescription(written, originalDescription);
nextPrompt = applyFrontendUxCriteria(nextPrompt, parsedFileScope);
if (nextPrompt !== written) {
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
@@ -3188,8 +3204,14 @@ export function buildSpecificationPrompt(
attachmentContents?: AttachmentContent[],
existingPrompt?: string,
feedback?: string,
planningContext?: { plan?: string; originalDescription?: string },
): string {
const hasFeedback = Boolean(feedback?.trim());
const planDocument = planningContext?.plan?.trim();
const descriptionIsPlan = Boolean(parsePlanningPlanMd(task.description ?? ""));
const planInput = planDocument || (descriptionIsPlan ? task.description : undefined);
const originalDescription = planningContext?.originalDescription?.trim()
|| (!descriptionIsPlan ? task.description : parsePlanningPlanMd(task.description ?? "")?.description || task.description);
const isRevision = Boolean(existingPrompt && hasFeedback);
const isFreshRespecification = Boolean(!existingPrompt && hasFeedback);
@@ -3401,12 +3423,17 @@ The user did not explicitly request subtask breakdown. Default to keeping the ta
## Task
- **ID:** ${task.id}
- **Title:** ${task.title || "(none)"}
- **Description:** ${task.description}
- **Description (current user context):** ${task.description}
${planInput ? `\n## Planning Mode plan.md\n\nTreat this validated lean plan as the primary specification input. Expand it into the full executor-ready PROMPT.md; plan.md is not PROMPT.md.\n\n\`\`\`markdown\n${planInput}\n\`\`\`\n` : ""}
## Original Request
\`\`\`text
${originalDescription}
\`\`\`
${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : ""}
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection}
## Instructions
${isRevision ? "1. Read the existing specification and revision feedback carefully\n2. Apply surgical PROMPT.md edits that fully resolve every blocking feedback item — do not rewrite from title/description alone\n3. Keep structure stable unless feedback requires rethink; preserve uncriticized content\n4. Keep `## Original Description` at the top (after title/metadata) with the operator description **verbatim**\n5. Ensure the revised specification is still detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n4. Include `## Original Description` near the top with the exact Description text above (verbatim)\n5. Address the revision feedback without inventing extra scope\n6. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. Include `## Original Description` immediately after title/`Created`/`Size` with the exact Description text above (verbatim — do not paraphrase)\n4. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n5. Name actual files, functions, and patterns from the codebase — be specific"}
${isRevision ? "1. Read the existing specification and revision feedback carefully\n2. Apply surgical PROMPT.md edits that fully resolve every blocking feedback item — do not rewrite from title/description alone\n3. Keep structure stable unless feedback requires rethink; preserve uncriticized content\n4. Keep `## Original Description` at the top (after title/metadata) with the operator description **verbatim**\n5. Ensure the revised specification is still detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n4. Include `## Original Description` near the top with the exact Original Request text above (verbatim, never plan.md)\n5. Address the revision feedback without inventing extra scope\n6. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. Include `## Original Description` immediately after title/`Created`/`Size` with the exact Original Request text above (verbatim — do not paraphrase; never use plan.md)\n4. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n5. Name actual files, functions, and patterns from the codebase — be specific"}
Use the write tool to write the specification file.${commandsSection}${completionDocumentationSection}${memorySection}${taskDefinitionLanguageSection}${attachmentsSection}${userCommentsSection}`;
}