diff --git a/.changeset/fn-8441-separate-plan-from-prompt.md b/.changeset/fn-8441-separate-plan-from-prompt.md new file mode 100644 index 0000000000..fc23085aa6 --- /dev/null +++ b/.changeset/fn-8441-separate-plan-from-prompt.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2d2820e353..056499cd6f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +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` diff --git a/packages/core/src/__tests__/planning-plan-md.test.ts b/packages/core/src/__tests__/planning-plan-md.test.ts new file mode 100644 index 0000000000..de893be6a1 --- /dev/null +++ b/packages/core/src/__tests__/planning-plan-md.test.ts @@ -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"], + })); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index b9601a688b..85d725d32f 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -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, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9cb8bf3fd2..df0dc4c4f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/planning-plan-md.ts b/packages/core/src/planning-plan-md.ts new file mode 100644 index 0000000000..11fa53fcf5 --- /dev/null +++ b/packages/core/src/planning-plan-md.ts @@ -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 | 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!), + }; +} diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts index 3dcf26f750..7e513d320c 100644 --- a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -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(); }); diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 5f0b1473ba..9711dfd93f 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -110,6 +110,16 @@ vi.mock("@fusion/core", async (importOriginal) => { const { createCoreMock } = await import("../test/mockCoreEngine.js"); return createCoreMock(() => importOriginal(), { 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 () => { diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 031428f89e..2432d24c82 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -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 { diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 3bb97992c4..f5dc69fb82 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -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(); 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", diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 2d576e8b0d..b2eac6f92d 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index a8efde27fb..daf013c619 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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}`; }