From d116cff6013bfb1b43ac455297d89f7618c1824b Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 12:08:42 -0500 Subject: [PATCH] feat: expose slice replanning over workflow MCP --- .../mcp-server/src/workflow-tools.test.ts | 152 +++++++++++++++++- packages/mcp-server/src/workflow-tools.ts | 71 ++++++++ .../extensions/gsd/bootstrap/db-tools.ts | 36 +---- .../extensions/gsd/tests/workflow-mcp.test.ts | 21 ++- .../gsd/tests/workflow-tool-executors.test.ts | 120 +++++++++++++- .../gsd/tools/workflow-tool-executors.ts | 42 +++++ src/resources/extensions/gsd/workflow-mcp.ts | 2 + 7 files changed, 403 insertions(+), 41 deletions(-) diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index 59d932a9b..c4dc15387 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -48,16 +48,18 @@ function makeMockServer() { } describe("workflow MCP tools", () => { - it("registers the fifteen workflow tools", () => { + it("registers the seventeen workflow tools", () => { const server = makeMockServer(); registerWorkflowTools(server as any); - assert.equal(server.tools.length, 15); + assert.equal(server.tools.length, 17); assert.deepEqual( server.tools.map((t) => t.name), [ "gsd_plan_milestone", "gsd_plan_slice", + "gsd_replan_slice", + "gsd_slice_replan", "gsd_slice_complete", "gsd_complete_slice", "gsd_complete_milestone", @@ -245,6 +247,152 @@ describe("workflow MCP tools", () => { } }); + it("gsd_replan_slice and gsd_slice_replan work end-to-end", async () => { + const base = makeTmpBase(); + try { + const server = makeMockServer(); + registerWorkflowTools(server as any); + const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone"); + const sliceTool = server.tools.find((t) => t.name === "gsd_plan_slice"); + const taskTool = server.tools.find((t) => t.name === "gsd_task_complete"); + const canonicalTool = server.tools.find((t) => t.name === "gsd_replan_slice"); + const aliasTool = server.tools.find((t) => t.name === "gsd_slice_replan"); + assert.ok(milestoneTool, "milestone planning tool should be registered"); + assert.ok(sliceTool, "slice planning tool should be registered"); + assert.ok(taskTool, "task completion tool should be registered"); + assert.ok(canonicalTool, "slice replanning tool should be registered"); + assert.ok(aliasTool, "slice replanning alias should be registered"); + + await milestoneTool!.handler({ + projectDir: base, + milestoneId: "M099", + title: "Slice replanning", + vision: "Drive replan parity over MCP.", + slices: [ + { + sliceId: "S09", + title: "Replan slice", + risk: "medium", + depends: [], + demo: "Slice replans after a blocker task completes.", + goal: "Prepare replan state.", + successCriteria: "Plan and replan artifacts update over MCP.", + proofLevel: "integration", + integrationClosure: "Replan uses the shared executor path.", + observabilityImpact: "Tests cover replan artifacts.", + }, + ], + }); + await sliceTool!.handler({ + projectDir: base, + milestoneId: "M099", + sliceId: "S09", + goal: "Plan a slice that will be replanned.", + tasks: [ + { + taskId: "T09", + title: "Blocker task", + description: "Finish the blocker-discovery task.", + estimate: "5m", + files: ["src/blocker.ts"], + verify: "node --test", + inputs: ["M099-ROADMAP.md"], + expectedOutput: ["T09-SUMMARY.md"], + }, + { + taskId: "T10", + title: "Pending task", + description: "Original follow-up task.", + estimate: "10m", + files: ["src/pending.ts"], + verify: "node --test", + inputs: ["S09-PLAN.md"], + expectedOutput: ["Updated plan"], + }, + ], + }); + await taskTool!.handler({ + projectDir: base, + milestoneId: "M099", + sliceId: "S09", + taskId: "T09", + oneLiner: "Completed blocker task", + narrative: "Prepared the slice for replanning.", + verification: "node --test", + }); + + const canonicalResult = await canonicalTool!.handler({ + projectDir: base, + milestoneId: "M099", + sliceId: "S09", + blockerTaskId: "T09", + blockerDescription: "Original approach is no longer viable.", + whatChanged: "Updated the remaining task and added remediation work.", + updatedTasks: [ + { + taskId: "T10", + title: "Pending task (updated)", + description: "Updated follow-up task after replanning.", + estimate: "15m", + files: ["src/pending.ts", "src/replanned.ts"], + verify: "node --test", + inputs: ["S09-PLAN.md"], + expectedOutput: ["Updated plan"], + }, + { + taskId: "T11", + title: "Remediation task", + description: "New task introduced by the replan.", + estimate: "20m", + files: ["src/remediation.ts"], + verify: "node --test", + inputs: ["S09-REPLAN.md"], + expectedOutput: ["Remediation patch"], + }, + ], + removedTaskIds: [], + }); + assert.match((canonicalResult as any).content[0].text as string, /Replanned slice S09/); + + const aliasResult = await aliasTool!.handler({ + projectDir: base, + milestoneId: "M099", + sliceId: "S09", + blockerTaskId: "T09", + blockerDescription: "Alias path confirms the same replan flow.", + whatChanged: "Removed the remediation task after the alias check.", + updatedTasks: [ + { + taskId: "T10", + title: "Pending task (updated again)", + description: "Alias adjusted the remaining pending task.", + estimate: "12m", + files: ["src/pending.ts"], + verify: "node --test", + inputs: ["S09-PLAN.md"], + expectedOutput: ["Updated plan"], + }, + ], + removedTaskIds: ["T11"], + }); + assert.match((aliasResult as any).content[0].text as string, /Replanned slice S09/); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M099", "slices", "S09", "S09-REPLAN.md")), + "replan artifact should exist on disk", + ); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M099", "slices", "S09", "S09-PLAN.md")), + "updated plan should exist on disk", + ); + const removedTask = _getAdapter()!.prepare( + "SELECT id FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?", + ).get("M099", "S09", "T11"); + assert.equal(removedTask, undefined, "alias should remove the replanned task"); + } finally { + cleanup(base); + } + }); + it("gsd_slice_complete and gsd_complete_slice work end-to-end", async () => { const base = makeTmpBase(); try { diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index f5f1d9628..523005943 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -64,6 +64,28 @@ type WorkflowToolExecutors = { }, basePath?: string, ) => Promise; + executeReplanSlice: ( + params: { + milestoneId: string; + sliceId: string; + blockerTaskId: string; + blockerDescription: string; + whatChanged: string; + updatedTasks: Array<{ + taskId: string; + title: string; + description: string; + estimate: string; + files: string[]; + verify: string; + inputs: string[]; + expectedOutput: string[]; + fullPlanMd?: string; + }>; + removedTaskIds: string[]; + }, + basePath?: string, + ) => Promise; executeSliceComplete: ( params: { sliceId: string; @@ -287,6 +309,14 @@ async function handleSliceComplete( return withProjectDir(projectDir, () => executeSliceComplete(args as any, projectDir)); } +async function handleReplanSlice( + projectDir: string, + args: Record, +): Promise { + const { executeReplanSlice } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executeReplanSlice(args as any, projectDir)); +} + async function handleCompleteMilestone( projectDir: string, args: Record, @@ -382,6 +412,27 @@ const saveGateResultSchema = { findings: z.string().optional().describe("Detailed markdown findings"), }; +const replanSliceSchema = { + projectDir: z.string().describe("Absolute path to the project directory"), + milestoneId: z.string().describe("Milestone ID (e.g. M001)"), + sliceId: z.string().describe("Slice ID (e.g. S01)"), + blockerTaskId: z.string().describe("Task ID that discovered the blocker"), + blockerDescription: z.string().describe("Description of the blocker"), + whatChanged: z.string().describe("Summary of what changed in the plan"), + updatedTasks: z.array(z.object({ + taskId: z.string(), + title: z.string(), + description: z.string(), + estimate: z.string(), + files: z.array(z.string()), + verify: z.string(), + inputs: z.array(z.string()), + expectedOutput: z.array(z.string()), + fullPlanMd: z.string().optional(), + })).describe("Tasks to upsert into the replanned slice"), + removedTaskIds: z.array(z.string()).describe("Task IDs to remove from the slice"), +}; + export function registerWorkflowTools(server: McpToolServer): void { server.tool( "gsd_plan_milestone", @@ -461,6 +512,26 @@ export function registerWorkflowTools(server: McpToolServer): void { }, ); + server.tool( + "gsd_replan_slice", + "Replan a slice after a blocker is discovered, preserving completed tasks and re-rendering PLAN.md + REPLAN.md.", + replanSliceSchema, + async (args: Record) => { + const { projectDir, ...replanArgs } = args as { projectDir: string } & Record; + return handleReplanSlice(projectDir, replanArgs); + }, + ); + + server.tool( + "gsd_slice_replan", + "Alias for gsd_replan_slice. Replan a slice after a blocker is discovered.", + replanSliceSchema, + async (args: Record) => { + const { projectDir, ...replanArgs } = args as { projectDir: string } & Record; + return handleReplanSlice(projectDir, replanArgs); + }, + ); + server.tool( "gsd_slice_complete", "Record a completed slice to the GSD database, render SUMMARY.md + UAT.md, and update roadmap projection.", diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 9538c953a..862f22eee 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -12,6 +12,7 @@ import { executeCompleteMilestone, executePlanMilestone, executePlanSlice, + executeReplanSlice, executeReassessRoadmap, executeSaveGateResult, executeSliceComplete, @@ -916,40 +917,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // ─── gsd_replan_slice (gsd_slice_replan alias) ───────────────────────── const replanSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot replan slice." }], - details: { operation: "replan_slice", error: "db_unavailable" } as any, - }; - } - try { - const { handleReplanSlice } = await import("../tools/replan-slice.js"); - const result = await handleReplanSlice(params, process.cwd()); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: `Error replanning slice: ${result.error}` }], - details: { operation: "replan_slice", error: result.error } as any, - }; - } - return { - content: [{ type: "text" as const, text: `Replanned slice ${result.sliceId} (${result.milestoneId})` }], - details: { - operation: "replan_slice", - milestoneId: result.milestoneId, - sliceId: result.sliceId, - replanPath: result.replanPath, - planPath: result.planPath, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `replan_slice tool failed: ${msg}`, { tool: "gsd_replan_slice", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error replanning slice: ${msg}` }], - details: { operation: "replan_slice", error: msg } as any, - }; - } + return executeReplanSlice(params, process.cwd()); }; const replanSliceTool = { diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 3e3c949b9..2176519d6 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -240,7 +240,7 @@ test("transport compatibility now allows complete-milestone over workflow MCP su assert.equal(error, null); }); -test("transport compatibility still blocks units whose MCP tools are not exposed", () => { +test("transport compatibility now allows replan-slice over workflow MCP surface", () => { const error = getWorkflowTransportSupportError( "claude-code", ["gsd_replan_slice"], @@ -254,7 +254,24 @@ test("transport compatibility still blocks units whose MCP tools are not exposed }, ); - assert.match(error ?? "", /requires gsd_replan_slice/); + assert.equal(error, null); +}); + +test("transport compatibility still blocks units whose MCP tools are not exposed", () => { + const error = getWorkflowTransportSupportError( + "claude-code", + ["gsd_skip_slice"], + { + projectRoot: "/tmp/project", + env: { GSD_WORKFLOW_MCP_COMMAND: "node" }, + surface: "auto-mode", + unitType: "skip-slice", + authMode: "externalCli", + baseUrl: "local://claude-code", + }, + ); + + assert.match(error ?? "", /requires gsd_skip_slice/); assert.match(error ?? "", /currently exposes only/); }); diff --git a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts index e8598b200..a43080980 100644 --- a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts @@ -13,15 +13,16 @@ import { } from "../gsd-db.ts"; import { executeCompleteMilestone, - executeValidateMilestone, + executePlanMilestone, + executePlanSlice, + executeReplanSlice, executeReassessRoadmap, executeSaveGateResult, executeSummarySave, executeTaskComplete, executeMilestoneStatus, - executePlanMilestone, - executePlanSlice, executeSliceComplete, + executeValidateMilestone, } from "../tools/workflow-tool-executors.ts"; function makeTmpBase(): string { @@ -505,3 +506,116 @@ test("executeSaveGateResult validates inputs and persists verdicts", async () => cleanup(base); } }); + +test("executeReplanSlice rewrites pending tasks and renders replan artifacts", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + await inProjectDir(base, () => executePlanMilestone({ + milestoneId: "M006", + title: "Milestone Six", + vision: "Exercise slice replanning.", + slices: [ + { + sliceId: "S06", + title: "Replan slice", + risk: "medium", + depends: [], + demo: "Slice can be replanned after a blocker task completes.", + goal: "Prepare replan state.", + successCriteria: "PLAN and REPLAN artifacts update.", + proofLevel: "integration", + integrationClosure: "Replan shares the workflow executor path.", + observabilityImpact: "Executor test covers replan output files.", + }, + ], + }, base)); + await inProjectDir(base, () => executePlanSlice({ + milestoneId: "M006", + sliceId: "S06", + goal: "Plan a slice that will be replanned.", + tasks: [ + { + taskId: "T06", + title: "Blocker task", + description: "Finish the blocker-discovery task.", + estimate: "5m", + files: ["src/blocker.ts"], + verify: "node --test", + inputs: ["M006-ROADMAP.md"], + expectedOutput: ["T06-SUMMARY.md"], + }, + { + taskId: "T07", + title: "Pending task", + description: "Original follow-up task.", + estimate: "10m", + files: ["src/pending.ts"], + verify: "node --test", + inputs: ["S06-PLAN.md"], + expectedOutput: ["Updated plan"], + }, + ], + }, base)); + await inProjectDir(base, () => executeTaskComplete({ + milestoneId: "M006", + sliceId: "S06", + taskId: "T06", + oneLiner: "Completed blocker task", + narrative: "The blocker was identified and documented.", + verification: "node --test", + }, base)); + + const result = await inProjectDir(base, () => executeReplanSlice({ + milestoneId: "M006", + sliceId: "S06", + blockerTaskId: "T06", + blockerDescription: "Original approach no longer works.", + whatChanged: "Adjusted the remaining tasks and added a remediation task.", + updatedTasks: [ + { + taskId: "T07", + title: "Pending task (updated)", + description: "Updated follow-up task after replanning.", + estimate: "15m", + files: ["src/pending.ts", "src/replanned.ts"], + verify: "node --test", + inputs: ["S06-PLAN.md"], + expectedOutput: ["Updated plan"], + }, + { + taskId: "T08", + title: "Remediation task", + description: "New task introduced by the replan.", + estimate: "20m", + files: ["src/remediation.ts"], + verify: "node --test", + inputs: ["S06-REPLAN.md"], + expectedOutput: ["Remediation patch"], + }, + ], + removedTaskIds: [], + }, base)); + + assert.equal(result.details.operation, "replan_slice"); + const planPath = String(result.details.planPath); + const replanPath = String(result.details.replanPath); + assert.ok(existsSync(planPath), "replanned plan should exist on disk"); + assert.ok(existsSync(replanPath), "replan artifact should exist on disk"); + assert.match(readFileSync(planPath, "utf-8"), /T08/); + assert.match(readFileSync(replanPath, "utf-8"), /Adjusted the remaining tasks/); + + const db = _getAdapter(); + const updatedTask = db!.prepare( + "SELECT title FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?", + ).get("M006", "S06", "T07") as Record | undefined; + const insertedTask = db!.prepare( + "SELECT title FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?", + ).get("M006", "S06", "T08") as Record | undefined; + assert.equal(updatedTask?.title, "Pending task (updated)"); + assert.equal(insertedTask?.title, "Remediation task"); + } finally { + closeDatabase(); + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts index 93ee7ce81..92a43133e 100644 --- a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +++ b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts @@ -18,6 +18,8 @@ import type { PlanMilestoneParams } from "./plan-milestone.js"; import { handlePlanMilestone } from "./plan-milestone.js"; import type { PlanSliceParams } from "./plan-slice.js"; import { handlePlanSlice } from "./plan-slice.js"; +import type { ReplanSliceParams } from "./replan-slice.js"; +import { handleReplanSlice } from "./replan-slice.js"; import type { ReassessRoadmapParams } from "./reassess-roadmap.js"; import { handleReassessRoadmap } from "./reassess-roadmap.js"; import type { ValidateMilestoneParams } from "./validate-milestone.js"; @@ -137,6 +139,7 @@ export type CompleteMilestoneExecutorParams = Partial & export type SliceCompleteExecutorParams = CompleteSliceParams; export type PlanMilestoneExecutorParams = PlanMilestoneParams; export type PlanSliceExecutorParams = PlanSliceParams; +export type ReplanSliceExecutorParams = ReplanSliceParams; export type ValidateMilestoneExecutorParams = ValidateMilestoneParams; export type ReassessRoadmapExecutorParams = ReassessRoadmapParams; @@ -518,6 +521,45 @@ export async function executePlanSlice( } } +export async function executeReplanSlice( + params: ReplanSliceExecutorParams, + basePath: string = process.cwd(), +): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available. Cannot replan slice." }], + details: { operation: "replan_slice", error: "db_unavailable" }, + }; + } + try { + const result = await handleReplanSlice(params, basePath); + if ("error" in result) { + return { + content: [{ type: "text", text: `Error replanning slice: ${result.error}` }], + details: { operation: "replan_slice", error: result.error }, + }; + } + return { + content: [{ type: "text", text: `Replanned slice ${result.sliceId} (${result.milestoneId})` }], + details: { + operation: "replan_slice", + milestoneId: result.milestoneId, + sliceId: result.sliceId, + replanPath: result.replanPath, + planPath: result.planPath, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `replan_slice tool failed: ${msg}`, { tool: "gsd_replan_slice", error: String(err) }); + return { + content: [{ type: "text", text: `Error replanning slice: ${msg}` }], + details: { operation: "replan_slice", error: msg }, + }; + } +} + export interface MilestoneStatusParams { milestoneId: string; } diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index 680d10202..70cdf5202 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -28,9 +28,11 @@ const MCP_WORKFLOW_TOOL_SURFACE = new Set([ "gsd_milestone_validate", "gsd_plan_milestone", "gsd_plan_slice", + "gsd_replan_slice", "gsd_reassess_roadmap", "gsd_roadmap_reassess", "gsd_save_gate_result", + "gsd_slice_replan", "gsd_slice_complete", "gsd_summary_save", "gsd_task_complete",