feat: expose slice completion over workflow MCP

This commit is contained in:
Jeremy 2026-04-09 11:53:28 -05:00
parent 2f63012628
commit af24dcb3c3
7 changed files with 456 additions and 85 deletions

View file

@ -42,14 +42,23 @@ function makeMockServer() {
}
describe("workflow MCP tools", () => {
it("registers the six workflow tools", () => {
it("registers the eight workflow tools", () => {
const server = makeMockServer();
registerWorkflowTools(server as any);
assert.equal(server.tools.length, 6);
assert.equal(server.tools.length, 8);
assert.deepEqual(
server.tools.map((t) => t.name),
["gsd_plan_milestone", "gsd_plan_slice", "gsd_summary_save", "gsd_task_complete", "gsd_complete_task", "gsd_milestone_status"],
[
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_slice_complete",
"gsd_complete_slice",
"gsd_summary_save",
"gsd_task_complete",
"gsd_complete_task",
"gsd_milestone_status",
],
);
});
@ -222,4 +231,151 @@ describe("workflow MCP tools", () => {
cleanup(base);
}
});
it("gsd_slice_complete and gsd_complete_slice 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_slice_complete");
const aliasTool = server.tools.find((t) => t.name === "gsd_complete_slice");
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 completion tool should be registered");
assert.ok(aliasTool, "slice completion alias should be registered");
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M003",
title: "Demo milestone",
vision: "Prepare canonical slice completion state.",
slices: [
{
sliceId: "S03",
title: "Demo Slice",
risk: "medium",
depends: [],
demo: "Canonical slice completes through MCP.",
goal: "Seed workflow state.",
successCriteria: "Slice summary and UAT files are written.",
proofLevel: "integration",
integrationClosure: "Planning and completion share the MCP bridge.",
observabilityImpact: "Workflow tests cover canonical completion.",
},
],
});
await sliceTool!.handler({
projectDir: base,
milestoneId: "M003",
sliceId: "S03",
goal: "Complete canonical slice over MCP.",
tasks: [
{
taskId: "T03",
title: "Canonical task",
description: "Seed a completed task for slice completion.",
estimate: "5m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M003-ROADMAP.md"],
expectedOutput: ["S03-SUMMARY.md", "S03-UAT.md"],
},
],
});
await taskTool!.handler({
projectDir: base,
milestoneId: "M003",
sliceId: "S03",
taskId: "T03",
oneLiner: "Completed canonical task",
narrative: "Prepared the canonical slice for completion.",
verification: "node --test",
});
const canonicalResult = await canonicalTool!.handler({
projectDir: base,
milestoneId: "M003",
sliceId: "S03",
sliceTitle: "Demo Slice",
oneLiner: "Completed canonical slice",
narrative: "Did the slice work",
verification: "npm test",
uatContent: "## UAT\n\nPASS",
});
assert.match((canonicalResult as any).content[0].text as string, /Completed slice S03/);
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M004",
title: "Alias milestone",
vision: "Prepare alias slice completion state.",
slices: [
{
sliceId: "S04",
title: "Alias Slice",
risk: "medium",
depends: [],
demo: "Alias slice completes through MCP.",
goal: "Seed alias workflow state.",
successCriteria: "Alias summary and UAT files are written.",
proofLevel: "integration",
integrationClosure: "Alias reaches the shared slice executor.",
observabilityImpact: "Workflow tests cover alias completion.",
},
],
});
await sliceTool!.handler({
projectDir: base,
milestoneId: "M004",
sliceId: "S04",
goal: "Complete alias slice over MCP.",
tasks: [
{
taskId: "T04",
title: "Alias task",
description: "Seed a completed task for alias slice completion.",
estimate: "5m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M004-ROADMAP.md"],
expectedOutput: ["S04-SUMMARY.md", "S04-UAT.md"],
},
],
});
await taskTool!.handler({
projectDir: base,
milestoneId: "M004",
sliceId: "S04",
taskId: "T04",
oneLiner: "Completed alias task",
narrative: "Prepared the alias slice for completion.",
verification: "node --test",
});
const aliasResult = await aliasTool!.handler({
projectDir: base,
milestoneId: "M004",
sliceId: "S04",
sliceTitle: "Alias Slice",
oneLiner: "Completed alias slice",
narrative: "Did the slice work via alias",
verification: "npm test",
uatContent: "## UAT\n\nPASS",
});
assert.match((aliasResult as any).content[0].text as string, /Completed slice S04/);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M004", "slices", "S04", "S04-SUMMARY.md")),
"alias should write slice summary to disk",
);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M004", "slices", "S04", "S04-UAT.md")),
"alias should write slice UAT to disk",
);
} finally {
cleanup(base);
}
});
});

View file

@ -64,6 +64,34 @@ type WorkflowToolExecutors = {
},
basePath?: string,
) => Promise<unknown>;
executeSliceComplete: (
params: {
sliceId: string;
milestoneId: string;
sliceTitle: string;
oneLiner: string;
narrative: string;
verification: string;
uatContent: string;
deviations?: string;
knownLimitations?: string;
followUps?: string;
keyFiles?: string[] | string;
keyDecisions?: string[] | string;
patternsEstablished?: string[] | string;
observabilitySurfaces?: string[] | string;
provides?: string[] | string;
requirementsSurfaced?: string[] | string;
drillDownPaths?: string[] | string;
affects?: string[] | string;
requirementsAdvanced?: Array<{ id: string; how: string } | string>;
requirementsValidated?: Array<{ id: string; proof: string } | string>;
requirementsInvalidated?: Array<{ id: string; what: string } | string>;
filesModified?: Array<{ path: string; description: string } | string>;
requires?: Array<{ slice: string; provides: string } | string>;
},
basePath?: string,
) => Promise<unknown>;
executeSummarySave: (
params: {
milestone_id: string;
@ -181,6 +209,14 @@ async function handleTaskComplete(
);
}
async function handleSliceComplete(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { executeSliceComplete } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeSliceComplete(args as any, projectDir));
}
export function registerWorkflowTools(server: McpToolServer): void {
server.tool(
"gsd_plan_milestone",
@ -260,6 +296,106 @@ export function registerWorkflowTools(server: McpToolServer): void {
},
);
server.tool(
"gsd_slice_complete",
"Record a completed slice to the GSD database, render SUMMARY.md + UAT.md, and update roadmap projection.",
{
projectDir: z.string().describe("Absolute path to the project directory"),
sliceId: z.string().describe("Slice ID (e.g. S01)"),
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
sliceTitle: z.string().describe("Title of the slice"),
oneLiner: z.string().describe("One-line summary of what the slice accomplished"),
narrative: z.string().describe("Detailed narrative of what happened across all tasks"),
verification: z.string().describe("What was verified across all tasks"),
uatContent: z.string().describe("UAT test content (markdown body)"),
deviations: z.string().optional(),
knownLimitations: z.string().optional(),
followUps: z.string().optional(),
keyFiles: z.union([z.array(z.string()), z.string()]).optional(),
keyDecisions: z.union([z.array(z.string()), z.string()]).optional(),
patternsEstablished: z.union([z.array(z.string()), z.string()]).optional(),
observabilitySurfaces: z.union([z.array(z.string()), z.string()]).optional(),
provides: z.union([z.array(z.string()), z.string()]).optional(),
requirementsSurfaced: z.union([z.array(z.string()), z.string()]).optional(),
drillDownPaths: z.union([z.array(z.string()), z.string()]).optional(),
affects: z.union([z.array(z.string()), z.string()]).optional(),
requirementsAdvanced: z.array(z.union([
z.object({ id: z.string(), how: z.string() }),
z.string(),
])).optional(),
requirementsValidated: z.array(z.union([
z.object({ id: z.string(), proof: z.string() }),
z.string(),
])).optional(),
requirementsInvalidated: z.array(z.union([
z.object({ id: z.string(), what: z.string() }),
z.string(),
])).optional(),
filesModified: z.array(z.union([
z.object({ path: z.string(), description: z.string() }),
z.string(),
])).optional(),
requires: z.array(z.union([
z.object({ slice: z.string(), provides: z.string() }),
z.string(),
])).optional(),
},
async (args: Record<string, unknown>) => {
const { projectDir, ...sliceArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleSliceComplete(projectDir, sliceArgs);
},
);
server.tool(
"gsd_complete_slice",
"Alias for gsd_slice_complete. Record a completed slice to the GSD database and render summary/UAT artifacts.",
{
projectDir: z.string().describe("Absolute path to the project directory"),
sliceId: z.string().describe("Slice ID (e.g. S01)"),
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
sliceTitle: z.string().describe("Title of the slice"),
oneLiner: z.string().describe("One-line summary of what the slice accomplished"),
narrative: z.string().describe("Detailed narrative of what happened across all tasks"),
verification: z.string().describe("What was verified across all tasks"),
uatContent: z.string().describe("UAT test content (markdown body)"),
deviations: z.string().optional(),
knownLimitations: z.string().optional(),
followUps: z.string().optional(),
keyFiles: z.union([z.array(z.string()), z.string()]).optional(),
keyDecisions: z.union([z.array(z.string()), z.string()]).optional(),
patternsEstablished: z.union([z.array(z.string()), z.string()]).optional(),
observabilitySurfaces: z.union([z.array(z.string()), z.string()]).optional(),
provides: z.union([z.array(z.string()), z.string()]).optional(),
requirementsSurfaced: z.union([z.array(z.string()), z.string()]).optional(),
drillDownPaths: z.union([z.array(z.string()), z.string()]).optional(),
affects: z.union([z.array(z.string()), z.string()]).optional(),
requirementsAdvanced: z.array(z.union([
z.object({ id: z.string(), how: z.string() }),
z.string(),
])).optional(),
requirementsValidated: z.array(z.union([
z.object({ id: z.string(), proof: z.string() }),
z.string(),
])).optional(),
requirementsInvalidated: z.array(z.union([
z.object({ id: z.string(), what: z.string() }),
z.string(),
])).optional(),
filesModified: z.array(z.union([
z.object({ path: z.string(), description: z.string() }),
z.string(),
])).optional(),
requires: z.array(z.union([
z.object({ slice: z.string(), provides: z.string() }),
z.string(),
])).optional(),
},
async (args: Record<string, unknown>) => {
const { projectDir, ...sliceArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleSliceComplete(projectDir, sliceArgs);
},
);
server.tool(
"gsd_summary_save",
"Save a GSD summary/research/context/assessment artifact to the database and disk.",

View file

@ -11,6 +11,7 @@ import { getErrorMessage } from "../error-utils.js";
import {
executePlanMilestone,
executePlanSlice,
executeSliceComplete,
executeSummarySave,
executeTaskComplete,
} from "../tools/workflow-tool-executors.js";
@ -647,86 +648,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_slice_complete (gsd_complete_slice alias) ─────────────────────
const sliceCompleteExecute = 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 complete slice." }],
details: { operation: "complete_slice", error: "db_unavailable" } as any,
};
}
try {
// Coerce string items to objects for fields where LLMs sometimes pass
// plain strings instead of the expected { key, value } shape (#3541).
// Parses "key — value" or "key - value" format when possible.
const splitPair = (s: string): [string, string] => {
const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
};
const coerced = { ...params };
// Coerce simple string-array fields: LLMs sometimes pass a plain string
// instead of a single-element array (#3585).
const wrapArray = (v: any): any[] =>
v == null ? [] : Array.isArray(v) ? v : [v];
coerced.provides = wrapArray(params.provides);
coerced.keyFiles = wrapArray(params.keyFiles);
coerced.keyDecisions = wrapArray(params.keyDecisions);
coerced.patternsEstablished = wrapArray(params.patternsEstablished);
coerced.observabilitySurfaces = wrapArray(params.observabilitySurfaces);
coerced.requirementsSurfaced = wrapArray(params.requirementsSurfaced);
coerced.drillDownPaths = wrapArray(params.drillDownPaths);
coerced.affects = wrapArray(params.affects);
coerced.filesModified = wrapArray(params.filesModified).map((f: any) => {
if (typeof f !== "string") return f;
const [path, description] = splitPair(f);
return { path, description };
});
coerced.requires = wrapArray(params.requires).map((r: any) => {
if (typeof r !== "string") return r;
const [slice, provides] = splitPair(r);
return { slice, provides };
});
coerced.requirementsAdvanced = wrapArray(params.requirementsAdvanced).map((r: any) => {
if (typeof r !== "string") return r;
const [id, how] = splitPair(r);
return { id, how };
});
coerced.requirementsValidated = wrapArray(params.requirementsValidated).map((r: any) => {
if (typeof r !== "string") return r;
const [id, proof] = splitPair(r);
return { id, proof };
});
coerced.requirementsInvalidated = wrapArray(params.requirementsInvalidated).map((r: any) => {
if (typeof r !== "string") return r;
const [id, what] = splitPair(r);
return { id, what };
});
const { handleCompleteSlice } = await import("../tools/complete-slice.js");
const result = await handleCompleteSlice(coerced, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error completing slice: ${result.error}` }],
details: { operation: "complete_slice", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Completed slice ${result.sliceId} (${result.milestoneId})` }],
details: {
operation: "complete_slice",
sliceId: result.sliceId,
milestoneId: result.milestoneId,
summaryPath: result.summaryPath,
uatPath: result.uatPath,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `complete_slice tool failed: ${msg}`, { tool: "gsd_slice_complete", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error completing slice: ${msg}` }],
details: { operation: "complete_slice", error: msg } as any,
};
}
return executeSliceComplete(params, process.cwd());
};
const sliceCompleteTool = {

View file

@ -155,7 +155,7 @@ test("transport compatibility now allows plan-slice over workflow MCP surface",
assert.equal(error, null);
});
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
test("transport compatibility now allows complete-slice over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_complete_slice"],
@ -169,7 +169,24 @@ test("transport compatibility still blocks units whose MCP tools are not exposed
},
);
assert.match(error ?? "", /requires gsd_complete_slice/);
assert.equal(error, null);
});
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_complete_milestone"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "complete-milestone",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.match(error ?? "", /requires gsd_complete_milestone/);
assert.match(error ?? "", /currently exposes only/);
});

View file

@ -16,6 +16,7 @@ import {
executeMilestoneStatus,
executePlanMilestone,
executePlanSlice,
executeSliceComplete,
} from "../tools/workflow-tool-executors.ts";
function makeTmpBase(): string {
@ -58,6 +59,20 @@ function seedSlice(milestoneId: string, sliceId: string, status: string): void {
).run(milestoneId, sliceId, `Slice ${sliceId}`, status, new Date().toISOString());
}
function writeRoadmap(base: string, milestoneId: string, sliceIds: string[]): void {
const milestoneDir = join(base, ".gsd", "milestones", milestoneId);
mkdirSync(milestoneDir, { recursive: true });
const lines = [
`# ${milestoneId}: Workflow MCP planning`,
"",
"## Slices",
"",
...sliceIds.map((sliceId) => `- [ ] **${sliceId}: Slice ${sliceId}** \`risk:medium\` \`depends:[]\`\n - After this: demo`),
"",
];
writeFileSync(join(milestoneDir, `${milestoneId}-ROADMAP.md`), lines.join("\n"));
}
test("executeSummarySave persists artifact and returns computed path", async () => {
const base = makeTmpBase();
try {
@ -234,3 +249,42 @@ test("executePlanSlice writes task planning state and rendered plan artifacts",
cleanup(base);
}
});
test("executeSliceComplete coerces string enrichment entries and writes summary/UAT artifacts", async () => {
const base = makeTmpBase();
try {
openTestDb(base);
seedMilestone("M001", "Milestone One");
seedSlice("M001", "S01", "pending");
writeRoadmap(base, "M001", ["S01"]);
const db = _getAdapter();
db!.prepare(
"INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)",
).run("M001", "S01", "T01", "Task T01", "complete");
const result = await inProjectDir(base, () => executeSliceComplete({
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Slice S01",
oneLiner: "Completed slice",
narrative: "Implemented the slice",
verification: "node --test",
uatContent: "## UAT\n\nPASS",
provides: "shared executor path",
requirementsAdvanced: ["R001 - added slice completion support"],
filesModified: ["src/file.ts - updated logic"],
requires: ["S00 - upstream context"],
}, base));
assert.equal(result.details.operation, "complete_slice");
const summaryPath = String(result.details.summaryPath);
const uatPath = String(result.details.uatPath);
assert.ok(existsSync(summaryPath), "slice summary should be written to disk");
assert.ok(existsSync(uatPath), "slice UAT should be written to disk");
assert.match(readFileSync(summaryPath, "utf-8"), /shared executor path/);
assert.match(readFileSync(summaryPath, "utf-8"), /R001/);
} finally {
closeDatabase();
cleanup(base);
}
});

View file

@ -8,6 +8,8 @@ import {
} from "../gsd-db.js";
import { saveArtifactToDb } from "../db-writer.js";
import { handleCompleteTask } from "./complete-task.js";
import type { CompleteSliceParams } from "../types.js";
import { handleCompleteSlice } from "./complete-slice.js";
import type { PlanMilestoneParams } from "./plan-milestone.js";
import { handlePlanMilestone } from "./plan-milestone.js";
import type { PlanSliceParams } from "./plan-slice.js";
@ -122,6 +124,7 @@ export interface TaskCompleteParams {
verificationEvidence?: VerificationEvidenceInput[];
}
export type SliceCompleteExecutorParams = CompleteSliceParams;
export type PlanMilestoneExecutorParams = PlanMilestoneParams;
export type PlanSliceExecutorParams = PlanSliceParams;
@ -169,6 +172,87 @@ export async function executeTaskComplete(
}
}
export async function executeSliceComplete(
params: SliceCompleteExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete slice." }],
details: { operation: "complete_slice", error: "db_unavailable" },
};
}
try {
const splitPair = (s: string): [string, string] => {
const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
};
const wrapArray = (v: unknown): unknown[] =>
v == null ? [] : Array.isArray(v) ? v : [v];
const coerced = { ...params } as CompleteSliceParams & Record<string, unknown>;
coerced.provides = wrapArray(params.provides) as string[];
coerced.keyFiles = wrapArray(params.keyFiles) as string[];
coerced.keyDecisions = wrapArray(params.keyDecisions) as string[];
coerced.patternsEstablished = wrapArray(params.patternsEstablished) as string[];
coerced.observabilitySurfaces = wrapArray(params.observabilitySurfaces) as string[];
coerced.requirementsSurfaced = wrapArray(params.requirementsSurfaced) as string[];
coerced.drillDownPaths = wrapArray(params.drillDownPaths) as string[];
coerced.affects = wrapArray(params.affects) as string[];
coerced.filesModified = wrapArray(params.filesModified).map((f) => {
if (typeof f !== "string") return f;
const [path, description] = splitPair(f);
return { path, description };
}) as Array<{ path: string; description: string }>;
coerced.requires = wrapArray(params.requires).map((r) => {
if (typeof r !== "string") return r;
const [slice, provides] = splitPair(r);
return { slice, provides };
}) as Array<{ slice: string; provides: string }>;
coerced.requirementsAdvanced = wrapArray(params.requirementsAdvanced).map((r) => {
if (typeof r !== "string") return r;
const [id, how] = splitPair(r);
return { id, how };
}) as Array<{ id: string; how: string }>;
coerced.requirementsValidated = wrapArray(params.requirementsValidated).map((r) => {
if (typeof r !== "string") return r;
const [id, proof] = splitPair(r);
return { id, proof };
}) as Array<{ id: string; proof: string }>;
coerced.requirementsInvalidated = wrapArray(params.requirementsInvalidated).map((r) => {
if (typeof r !== "string") return r;
const [id, what] = splitPair(r);
return { id, what };
}) as Array<{ id: string; what: string }>;
const result = await handleCompleteSlice(coerced as CompleteSliceParams, basePath);
if ("error" in result) {
return {
content: [{ type: "text", text: `Error completing slice: ${result.error}` }],
details: { operation: "complete_slice", error: result.error },
};
}
return {
content: [{ type: "text", text: `Completed slice ${result.sliceId} (${result.milestoneId})` }],
details: {
operation: "complete_slice",
sliceId: result.sliceId,
milestoneId: result.milestoneId,
summaryPath: result.summaryPath,
uatPath: result.uatPath,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `complete_slice tool failed: ${msg}`, { tool: "gsd_slice_complete", error: String(err) });
return {
content: [{ type: "text", text: `Error completing slice: ${msg}` }],
details: { operation: "complete_slice", error: msg },
};
}
}
export async function executePlanMilestone(
params: PlanMilestoneExecutorParams,
basePath: string = process.cwd(),

View file

@ -21,9 +21,11 @@ export interface WorkflowCapabilityOptions {
const MCP_WORKFLOW_TOOL_SURFACE = new Set([
"gsd_complete_task",
"gsd_complete_slice",
"gsd_milestone_status",
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_slice_complete",
"gsd_summary_save",
"gsd_task_complete",
]);