feat: expose slice replanning over workflow MCP

This commit is contained in:
Jeremy 2026-04-09 12:08:42 -05:00
parent 70458467ff
commit d116cff601
7 changed files with 403 additions and 41 deletions

View file

@ -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 {

View file

@ -64,6 +64,28 @@ type WorkflowToolExecutors = {
},
basePath?: string,
) => Promise<unknown>;
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<unknown>;
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<string, unknown>,
): Promise<unknown> {
const { executeReplanSlice } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeReplanSlice(args as any, projectDir));
}
async function handleCompleteMilestone(
projectDir: string,
args: Record<string, unknown>,
@ -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<string, unknown>) => {
const { projectDir, ...replanArgs } = args as { projectDir: string } & Record<string, unknown>;
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<string, unknown>) => {
const { projectDir, ...replanArgs } = args as { projectDir: string } & Record<string, unknown>;
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.",

View file

@ -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 = {

View file

@ -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/);
});

View file

@ -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<string, unknown> | undefined;
const insertedTask = db!.prepare(
"SELECT title FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?",
).get("M006", "S06", "T08") as Record<string, unknown> | undefined;
assert.equal(updatedTask?.title, "Pending task (updated)");
assert.equal(insertedTask?.title, "Remediation task");
} finally {
closeDatabase();
cleanup(base);
}
});

View file

@ -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<CompleteMilestoneParams> &
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<ToolExecutionResult> {
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;
}

View file

@ -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",