feat: expose milestone workflow tools over MCP

This commit is contained in:
Jeremy 2026-04-09 12:04:07 -05:00
parent af24dcb3c3
commit 70458467ff
7 changed files with 1018 additions and 154 deletions

View file

@ -5,6 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/gsd/gsd-db.ts";
import { registerWorkflowTools } from "./workflow-tools.ts";
function makeTmpBase(): string {
@ -14,6 +15,11 @@ function makeTmpBase(): string {
}
function cleanup(base: string): void {
try {
closeDatabase();
} catch {
// swallow
}
try {
rmSync(base, { recursive: true, force: true });
} catch {
@ -42,11 +48,11 @@ function makeMockServer() {
}
describe("workflow MCP tools", () => {
it("registers the eight workflow tools", () => {
it("registers the fifteen workflow tools", () => {
const server = makeMockServer();
registerWorkflowTools(server as any);
assert.equal(server.tools.length, 8);
assert.equal(server.tools.length, 15);
assert.deepEqual(
server.tools.map((t) => t.name),
[
@ -54,6 +60,13 @@ describe("workflow MCP tools", () => {
"gsd_plan_slice",
"gsd_slice_complete",
"gsd_complete_slice",
"gsd_complete_milestone",
"gsd_milestone_complete",
"gsd_validate_milestone",
"gsd_milestone_validate",
"gsd_reassess_roadmap",
"gsd_roadmap_reassess",
"gsd_save_gate_result",
"gsd_summary_save",
"gsd_task_complete",
"gsd_complete_task",
@ -307,6 +320,7 @@ describe("workflow MCP tools", () => {
uatContent: "## UAT\n\nPASS",
});
assert.match((canonicalResult as any).content[0].text as string, /Completed slice S03/);
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M004",
@ -378,4 +392,279 @@ describe("workflow MCP tools", () => {
cleanup(base);
}
});
it("gsd_validate_milestone and gsd_milestone_complete 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 completeSliceTool = server.tools.find((t) => t.name === "gsd_slice_complete");
const validateTool = server.tools.find((t) => t.name === "gsd_validate_milestone");
const completeMilestoneAlias = server.tools.find((t) => t.name === "gsd_milestone_complete");
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(completeSliceTool, "slice completion tool should be registered");
assert.ok(validateTool, "milestone validation tool should be registered");
assert.ok(completeMilestoneAlias, "milestone completion alias should be registered");
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M005",
title: "Milestone lifecycle",
vision: "Drive validation and completion over MCP.",
slices: [
{
sliceId: "S05",
title: "Lifecycle slice",
risk: "medium",
depends: [],
demo: "Milestone can validate and complete.",
goal: "Seed milestone completion state.",
successCriteria: "Summary and validation artifacts are written.",
proofLevel: "integration",
integrationClosure: "Lifecycle tools share the MCP bridge.",
observabilityImpact: "Tests cover milestone end-to-end behavior.",
},
],
});
await sliceTool!.handler({
projectDir: base,
milestoneId: "M005",
sliceId: "S05",
goal: "Prepare a complete milestone.",
tasks: [
{
taskId: "T05",
title: "Lifecycle task",
description: "Seed a fully completed slice.",
estimate: "10m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M005-ROADMAP.md"],
expectedOutput: ["M005-VALIDATION.md", "M005-SUMMARY.md"],
},
],
});
await taskTool!.handler({
projectDir: base,
milestoneId: "M005",
sliceId: "S05",
taskId: "T05",
oneLiner: "Completed lifecycle task",
narrative: "Prepared the milestone for closure.",
verification: "node --test",
});
await completeSliceTool!.handler({
projectDir: base,
milestoneId: "M005",
sliceId: "S05",
sliceTitle: "Lifecycle Slice",
oneLiner: "Completed lifecycle slice",
narrative: "Closed the milestone slice.",
verification: "node --test",
uatContent: "## UAT\n\nPASS",
});
const validationResult = await validateTool!.handler({
projectDir: base,
milestoneId: "M005",
verdict: "pass",
remediationRound: 0,
successCriteriaChecklist: "- [x] Lifecycle verified",
sliceDeliveryAudit: "| Slice | Verdict |\n| --- | --- |\n| S05 | pass |",
crossSliceIntegration: "No cross-slice mismatches found.",
requirementCoverage: "No requirement gaps remain.",
verdictRationale: "The milestone delivered its scope.",
});
assert.match((validationResult as any).content[0].text as string, /Validated milestone M005/);
const completionResult = await completeMilestoneAlias!.handler({
projectDir: base,
milestoneId: "M005",
title: "Milestone lifecycle",
oneLiner: "Milestone closed successfully",
narrative: "Validation passed and all slices were complete.",
verificationPassed: true,
});
assert.match((completionResult as any).content[0].text as string, /Completed milestone M005/);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M005", "M005-VALIDATION.md")),
"validation artifact should exist on disk",
);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M005", "M005-SUMMARY.md")),
"milestone summary should exist on disk",
);
} finally {
cleanup(base);
}
});
it("gsd_reassess_roadmap, gsd_roadmap_reassess, and gsd_save_gate_result 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 completeSliceTool = server.tools.find((t) => t.name === "gsd_slice_complete");
const reassessTool = server.tools.find((t) => t.name === "gsd_reassess_roadmap");
const reassessAlias = server.tools.find((t) => t.name === "gsd_roadmap_reassess");
const gateTool = server.tools.find((t) => t.name === "gsd_save_gate_result");
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(completeSliceTool, "slice completion tool should be registered");
assert.ok(reassessTool, "roadmap reassessment tool should be registered");
assert.ok(reassessAlias, "roadmap reassessment alias should be registered");
assert.ok(gateTool, "gate result tool should be registered");
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M006",
title: "Roadmap reassessment",
vision: "Drive gate results and reassessment over MCP.",
slices: [
{
sliceId: "S06",
title: "Completed slice",
risk: "medium",
depends: [],
demo: "Completed slice triggers reassessment.",
goal: "Seed reassessment state.",
successCriteria: "Assessment and roadmap artifacts are written.",
proofLevel: "integration",
integrationClosure: "Roadmap updates share the MCP bridge.",
observabilityImpact: "Tests cover reassessment behavior.",
},
{
sliceId: "S07",
title: "Follow-up slice",
risk: "low",
depends: ["S06"],
demo: "Follow-up slice remains pending.",
goal: "Leave room for roadmap edits.",
successCriteria: "Roadmap mutation succeeds.",
proofLevel: "integration",
integrationClosure: "Pending slice can be modified after reassessment.",
observabilityImpact: "Tests observe roadmap mutation output.",
},
],
});
await sliceTool!.handler({
projectDir: base,
milestoneId: "M006",
sliceId: "S06",
goal: "Complete the first slice.",
tasks: [
{
taskId: "T06",
title: "Seed completed slice",
description: "Prepare gate and reassessment state.",
estimate: "10m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M006-ROADMAP.md"],
expectedOutput: ["S06-ASSESSMENT.md", "M006-ROADMAP.md"],
},
],
});
const gateResult = await gateTool!.handler({
projectDir: base,
milestoneId: "M006",
sliceId: "S06",
gateId: "Q3",
verdict: "pass",
rationale: "Threat surface is covered.",
findings: "No new attack surface was introduced.",
});
assert.match((gateResult as any).content[0].text as string, /Gate Q3 result saved/);
const gateRows = _getAdapter()!.prepare(
"SELECT status, verdict, rationale FROM quality_gates WHERE milestone_id = ? AND slice_id = ? AND gate_id = ?",
).all("M006", "S06", "Q3") as Array<Record<string, unknown>>;
assert.equal(gateRows.length, 1);
assert.equal(gateRows[0]["status"], "complete");
assert.equal(gateRows[0]["verdict"], "pass");
await taskTool!.handler({
projectDir: base,
milestoneId: "M006",
sliceId: "S06",
taskId: "T06",
oneLiner: "Completed reassessment task",
narrative: "Prepared the slice for reassessment.",
verification: "node --test",
});
await completeSliceTool!.handler({
projectDir: base,
milestoneId: "M006",
sliceId: "S06",
sliceTitle: "Completed slice",
oneLiner: "Completed reassessment slice",
narrative: "Closed the completed slice before reassessment.",
verification: "node --test",
uatContent: "## UAT\n\nPASS",
});
const reassessResult = await reassessTool!.handler({
projectDir: base,
milestoneId: "M006",
completedSliceId: "S06",
verdict: "roadmap-adjusted",
assessment: "Insert remediation work after the completed slice.",
sliceChanges: {
modified: [
{
sliceId: "S07",
title: "Follow-up slice (adjusted)",
risk: "medium",
depends: ["S06"],
demo: "Adjusted demo",
},
],
added: [
{
sliceId: "S08",
title: "Remediation slice",
risk: "high",
depends: ["S07"],
demo: "Remediation demo",
},
],
removed: [],
},
});
assert.match((reassessResult as any).content[0].text as string, /Reassessed roadmap for milestone M006 after S06/);
const reassessAliasResult = await reassessAlias!.handler({
projectDir: base,
milestoneId: "M006",
completedSliceId: "S06",
verdict: "roadmap-confirmed",
assessment: "No further changes needed after the first reassessment.",
sliceChanges: {
modified: [],
added: [],
removed: [],
},
});
assert.match((reassessAliasResult as any).content[0].text as string, /Reassessed roadmap for milestone M006 after S06/);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M006", "slices", "S06", "S06-ASSESSMENT.md")),
"assessment artifact should exist on disk",
);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M006", "M006-ROADMAP.md")),
"roadmap artifact should exist on disk",
);
} finally {
cleanup(base);
}
});
});

View file

@ -92,6 +92,76 @@ type WorkflowToolExecutors = {
},
basePath?: string,
) => Promise<unknown>;
executeCompleteMilestone: (
params: {
milestoneId: string;
title: string;
oneLiner: string;
narrative: string;
verificationPassed: boolean;
successCriteriaResults?: string;
definitionOfDoneResults?: string;
requirementOutcomes?: string;
keyDecisions?: string[];
keyFiles?: string[];
lessonsLearned?: string[];
followUps?: string;
deviations?: string;
},
basePath?: string,
) => Promise<unknown>;
executeValidateMilestone: (
params: {
milestoneId: string;
verdict: "pass" | "needs-attention" | "needs-remediation";
remediationRound: number;
successCriteriaChecklist: string;
sliceDeliveryAudit: string;
crossSliceIntegration: string;
requirementCoverage: string;
verificationClasses?: string;
verdictRationale: string;
remediationPlan?: string;
},
basePath?: string,
) => Promise<unknown>;
executeReassessRoadmap: (
params: {
milestoneId: string;
completedSliceId: string;
verdict: string;
assessment: string;
sliceChanges: {
modified: Array<{
sliceId: string;
title: string;
risk?: string;
depends?: string[];
demo?: string;
}>;
added: Array<{
sliceId: string;
title: string;
risk?: string;
depends?: string[];
demo?: string;
}>;
removed: string[];
};
},
basePath?: string,
) => Promise<unknown>;
executeSaveGateResult: (
params: {
milestoneId: string;
sliceId: string;
gateId: string;
taskId?: string;
verdict: "pass" | "flag" | "omitted";
rationale: string;
findings?: string;
},
) => Promise<unknown>;
executeSummarySave: (
params: {
milestone_id: string;
@ -217,6 +287,101 @@ async function handleSliceComplete(
return withProjectDir(projectDir, () => executeSliceComplete(args as any, projectDir));
}
async function handleCompleteMilestone(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { executeCompleteMilestone } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeCompleteMilestone(args as any, projectDir));
}
async function handleValidateMilestone(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { executeValidateMilestone } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeValidateMilestone(args as any, projectDir));
}
async function handleReassessRoadmap(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { executeReassessRoadmap } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeReassessRoadmap(args as any, projectDir));
}
async function handleSaveGateResult(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { executeSaveGateResult } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeSaveGateResult(args as any));
}
const completeMilestoneSchema = {
projectDir: z.string().describe("Absolute path to the project directory"),
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
title: z.string().describe("Milestone title"),
oneLiner: z.string().describe("One-sentence summary of what the milestone achieved"),
narrative: z.string().describe("Detailed narrative of what happened during the milestone"),
verificationPassed: z.boolean().describe("Must be true after milestone verification succeeds"),
successCriteriaResults: z.string().optional(),
definitionOfDoneResults: z.string().optional(),
requirementOutcomes: z.string().optional(),
keyDecisions: z.array(z.string()).optional(),
keyFiles: z.array(z.string()).optional(),
lessonsLearned: z.array(z.string()).optional(),
followUps: z.string().optional(),
deviations: z.string().optional(),
};
const validateMilestoneSchema = {
projectDir: z.string().describe("Absolute path to the project directory"),
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
verdict: z.enum(["pass", "needs-attention", "needs-remediation"]).describe("Validation verdict"),
remediationRound: z.number().describe("Remediation round (0 for first validation)"),
successCriteriaChecklist: z.string().describe("Markdown checklist of success criteria with evidence"),
sliceDeliveryAudit: z.string().describe("Markdown auditing each slice's claimed vs delivered output"),
crossSliceIntegration: z.string().describe("Markdown describing cross-slice issues or closure"),
requirementCoverage: z.string().describe("Markdown describing requirement coverage and gaps"),
verificationClasses: z.string().optional(),
verdictRationale: z.string().describe("Why this verdict was chosen"),
remediationPlan: z.string().optional(),
};
const roadmapSliceChangeSchema = z.object({
sliceId: z.string(),
title: z.string(),
risk: z.string().optional(),
depends: z.array(z.string()).optional(),
demo: z.string().optional(),
});
const reassessRoadmapSchema = {
projectDir: z.string().describe("Absolute path to the project directory"),
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
completedSliceId: z.string().describe("Slice ID that just completed"),
verdict: z.string().describe("Assessment verdict such as roadmap-confirmed or roadmap-adjusted"),
assessment: z.string().describe("Assessment text explaining the roadmap decision"),
sliceChanges: z.object({
modified: z.array(roadmapSliceChangeSchema),
added: z.array(roadmapSliceChangeSchema),
removed: z.array(z.string()),
}).describe("Slice changes to apply"),
};
const saveGateResultSchema = {
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)"),
gateId: z.enum(["Q3", "Q4", "Q5", "Q6", "Q7", "Q8"]).describe("Gate ID"),
taskId: z.string().optional().describe("Task ID for task-scoped gates"),
verdict: z.enum(["pass", "flag", "omitted"]).describe("Gate verdict"),
rationale: z.string().describe("One-sentence justification"),
findings: z.string().optional().describe("Detailed markdown findings"),
};
export function registerWorkflowTools(server: McpToolServer): void {
server.tool(
"gsd_plan_milestone",
@ -396,6 +561,76 @@ export function registerWorkflowTools(server: McpToolServer): void {
},
);
server.tool(
"gsd_complete_milestone",
"Record a completed milestone to the GSD database and render its SUMMARY.md.",
completeMilestoneSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...milestoneArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleCompleteMilestone(projectDir, milestoneArgs);
},
);
server.tool(
"gsd_milestone_complete",
"Alias for gsd_complete_milestone. Record a completed milestone to the GSD database and render its SUMMARY.md.",
completeMilestoneSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...milestoneArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleCompleteMilestone(projectDir, milestoneArgs);
},
);
server.tool(
"gsd_validate_milestone",
"Validate a milestone, persist validation results to the GSD database, and render VALIDATION.md.",
validateMilestoneSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...validationArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleValidateMilestone(projectDir, validationArgs);
},
);
server.tool(
"gsd_milestone_validate",
"Alias for gsd_validate_milestone. Validate a milestone and render VALIDATION.md.",
validateMilestoneSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...validationArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleValidateMilestone(projectDir, validationArgs);
},
);
server.tool(
"gsd_reassess_roadmap",
"Reassess a milestone roadmap after a slice completes, writing ASSESSMENT.md and re-rendering ROADMAP.md.",
reassessRoadmapSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...reassessArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleReassessRoadmap(projectDir, reassessArgs);
},
);
server.tool(
"gsd_roadmap_reassess",
"Alias for gsd_reassess_roadmap. Reassess a roadmap after slice completion.",
reassessRoadmapSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...reassessArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleReassessRoadmap(projectDir, reassessArgs);
},
);
server.tool(
"gsd_save_gate_result",
"Save a quality gate result to the GSD database.",
saveGateResultSchema,
async (args: Record<string, unknown>) => {
const { projectDir, ...gateArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleSaveGateResult(projectDir, gateArgs);
},
);
server.tool(
"gsd_summary_save",
"Save a GSD summary/research/context/assessment artifact to the database and disk.",

View file

@ -9,11 +9,15 @@ import { StringEnum } from "@gsd/pi-ai";
import { logError } from "../workflow-logger.js";
import { getErrorMessage } from "../error-utils.js";
import {
executeCompleteMilestone,
executePlanMilestone,
executePlanSlice,
executeReassessRoadmap,
executeSaveGateResult,
executeSliceComplete,
executeSummarySave,
executeTaskComplete,
executeValidateMilestone,
} from "../tools/workflow-tool-executors.js";
/**
@ -833,42 +837,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_complete_milestone ────────────────────────────────────────────
const milestoneCompleteExecute = 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 milestone." }],
details: { operation: "complete_milestone", error: "db_unavailable" } as any,
};
}
try {
// ── Input sanitization: normalize markdown parameters (#3013) ──────
const { sanitizeCompleteMilestoneParams } = await import("./sanitize-complete-milestone.js");
const sanitized = sanitizeCompleteMilestoneParams(params);
const { handleCompleteMilestone } = await import("../tools/complete-milestone.js");
const result = await handleCompleteMilestone(sanitized, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error completing milestone: ${result.error}` }],
details: { operation: "complete_milestone", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Completed milestone ${result.milestoneId}. Summary written to ${result.summaryPath}` }],
details: {
operation: "complete_milestone",
milestoneId: result.milestoneId,
summaryPath: result.summaryPath,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `complete_milestone tool failed: ${msg}`, { tool: "gsd_complete_milestone", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error completing milestone: ${msg}` }],
details: { operation: "complete_milestone", error: msg } as any,
};
}
return executeCompleteMilestone(params, process.cwd());
};
const milestoneCompleteTool = {
@ -910,39 +879,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_validate_milestone (gsd_milestone_validate alias) ─────────────
const milestoneValidateExecute = 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 validate milestone." }],
details: { operation: "validate_milestone", error: "db_unavailable" } as any,
};
}
try {
const { handleValidateMilestone } = await import("../tools/validate-milestone.js");
const result = await handleValidateMilestone(params, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error validating milestone: ${result.error}` }],
details: { operation: "validate_milestone", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Validated milestone ${result.milestoneId} — verdict: ${result.verdict}. Written to ${result.validationPath}` }],
details: {
operation: "validate_milestone",
milestoneId: result.milestoneId,
verdict: result.verdict,
validationPath: result.validationPath,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `validate_milestone tool failed: ${msg}`, { tool: "gsd_validate_milestone", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error validating milestone: ${msg}` }],
details: { operation: "validate_milestone", error: msg } as any,
};
}
return executeValidateMilestone(params, process.cwd());
};
const milestoneValidateTool = {
@ -1059,40 +996,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_reassess_roadmap (gsd_roadmap_reassess alias) ─────────────────
const reassessRoadmapExecute = 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 reassess roadmap." }],
details: { operation: "reassess_roadmap", error: "db_unavailable" } as any,
};
}
try {
const { handleReassessRoadmap } = await import("../tools/reassess-roadmap.js");
const result = await handleReassessRoadmap(params, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error reassessing roadmap: ${result.error}` }],
details: { operation: "reassess_roadmap", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Reassessed roadmap for milestone ${result.milestoneId} after ${result.completedSliceId}` }],
details: {
operation: "reassess_roadmap",
milestoneId: result.milestoneId,
completedSliceId: result.completedSliceId,
assessmentPath: result.assessmentPath,
roadmapPath: result.roadmapPath,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `reassess_roadmap tool failed: ${msg}`, { tool: "gsd_reassess_roadmap", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error reassessing roadmap: ${msg}` }],
details: { operation: "reassess_roadmap", error: msg } as any,
};
}
return executeReassessRoadmap(params, process.cwd());
};
const reassessRoadmapTool = {
@ -1147,52 +1051,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_save_gate_result ──────────────────────────────────────────────
const saveGateResultExecute = 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." }],
details: { operation: "save_gate_result", error: "db_unavailable" } as any,
};
}
const validGates = ["Q3", "Q4", "Q5", "Q6", "Q7", "Q8"];
if (!validGates.includes(params.gateId)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid gateId "${params.gateId}". Must be one of: ${validGates.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_gate_id" } as any,
};
}
const validVerdicts = ["pass", "flag", "omitted"];
if (!validVerdicts.includes(params.verdict)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid verdict "${params.verdict}". Must be one of: ${validVerdicts.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_verdict" } as any,
};
}
try {
const { saveGateResult } = await import("../gsd-db.js");
const { invalidateStateCache } = await import("../state.js");
saveGateResult({
milestoneId: params.milestoneId,
sliceId: params.sliceId,
gateId: params.gateId,
taskId: params.taskId ?? "",
verdict: params.verdict,
rationale: params.rationale,
findings: params.findings ?? "",
});
invalidateStateCache();
return {
content: [{ type: "text" as const, text: `Gate ${params.gateId} result saved: verdict=${params.verdict}` }],
details: { operation: "save_gate_result", gateId: params.gateId, verdict: params.verdict } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `gsd_save_gate_result failed: ${msg}`, { tool: "gsd_save_gate_result", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error saving gate result: ${msg}` }],
details: { operation: "save_gate_result", error: msg } as any,
};
}
return executeSaveGateResult(params);
};
const saveGateResultTool = {

View file

@ -172,10 +172,61 @@ test("transport compatibility now allows complete-slice over workflow MCP surfac
assert.equal(error, null);
});
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
test("transport compatibility now allows reassess-roadmap over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_complete_milestone"],
["gsd_milestone_status", "gsd_reassess_roadmap"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "reassess-roadmap",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.equal(error, null);
});
test("transport compatibility now allows gate-evaluate over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_save_gate_result"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "gate-evaluate",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.equal(error, null);
});
test("transport compatibility now allows validate-milestone over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_milestone_status", "gsd_validate_milestone"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "validate-milestone",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.equal(error, null);
});
test("transport compatibility now allows complete-milestone over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_milestone_status", "gsd_complete_milestone"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
@ -186,7 +237,24 @@ test("transport compatibility still blocks units whose MCP tools are not exposed
},
);
assert.match(error ?? "", /requires gsd_complete_milestone/);
assert.equal(error, null);
});
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_replan_slice"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "replan-slice",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.match(error ?? "", /requires gsd_replan_slice/);
assert.match(error ?? "", /currently exposes only/);
});

View file

@ -9,8 +9,13 @@ import {
openDatabase,
closeDatabase,
_getAdapter,
insertGateRow,
} from "../gsd-db.ts";
import {
executeCompleteMilestone,
executeValidateMilestone,
executeReassessRoadmap,
executeSaveGateResult,
executeSummarySave,
executeTaskComplete,
executeMilestoneStatus,
@ -288,3 +293,215 @@ test("executeSliceComplete coerces string enrichment entries and writes summary/
cleanup(base);
}
});
test("executeValidateMilestone persists validation artifact and gate records", async () => {
const base = makeTmpBase();
try {
openTestDb(base);
seedMilestone("M002", "Milestone Two");
seedSlice("M002", "S02", "complete");
const result = await inProjectDir(base, () => executeValidateMilestone({
milestoneId: "M002",
verdict: "pass",
remediationRound: 0,
successCriteriaChecklist: "- [x] Works",
sliceDeliveryAudit: "| Slice | Result |\n| --- | --- |\n| S02 | pass |",
crossSliceIntegration: "No cross-slice issues.",
requirementCoverage: "All requirements covered.",
verdictRationale: "Everything passed.",
}, base));
assert.equal(result.details.operation, "validate_milestone");
const validationPath = String(result.details.validationPath);
assert.ok(existsSync(validationPath), "validation file should be written to disk");
const db = _getAdapter();
const gates = db!.prepare(
"SELECT gate_id, verdict FROM quality_gates WHERE milestone_id = ? ORDER BY gate_id",
).all("M002") as Array<Record<string, unknown>>;
assert.ok(gates.length > 0, "validation should seed milestone quality gates");
assert.equal(gates[0]["verdict"], "pass");
} finally {
closeDatabase();
cleanup(base);
}
});
test("executeCompleteMilestone sanitizes raw params and writes milestone summary", async () => {
const base = makeTmpBase();
try {
openTestDb(base);
seedMilestone("M003", "Milestone Three");
seedSlice("M003", "S03", "complete");
writeRoadmap(base, "M003", ["S03"]);
const db = _getAdapter();
db!.prepare(
"INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)",
).run("M003", "S03", "T03", "Task T03", "complete");
const result = await inProjectDir(base, () => executeCompleteMilestone({
milestoneId: "M003",
title: "Milestone Three",
oneLiner: "Completed milestone",
narrative: "Everything shipped.",
verificationPassed: "true",
keyDecisions: ["shared executor path"],
lessonsLearned: ["MCP transport stays generic"],
}, base));
assert.equal(result.details.operation, "complete_milestone");
const summaryPath = String(result.details.summaryPath);
assert.ok(existsSync(summaryPath), "milestone summary should be written to disk");
assert.match(readFileSync(summaryPath, "utf-8"), /shared executor path/);
} finally {
closeDatabase();
cleanup(base);
}
});
test("executeReassessRoadmap writes assessment and updates roadmap projection", async () => {
const base = makeTmpBase();
try {
openTestDb(base);
await inProjectDir(base, () => executePlanMilestone({
milestoneId: "M004",
title: "Milestone Four",
vision: "Exercise roadmap reassessment.",
slices: [
{
sliceId: "S04",
title: "Completed slice",
risk: "medium",
depends: [],
demo: "Completed slice works",
goal: "Complete the first slice.",
successCriteria: "S04 is complete.",
proofLevel: "integration",
integrationClosure: "Baseline flow is wired.",
observabilityImpact: "Executor test covers reassessment.",
},
{
sliceId: "S05",
title: "Follow-up slice",
risk: "medium",
depends: ["S04"],
demo: "Follow-up slice is adjusted",
goal: "Handle the follow-up work.",
successCriteria: "Roadmap gets updated.",
proofLevel: "integration",
integrationClosure: "Downstream work stays aligned.",
observabilityImpact: "Assessment artifact is rendered.",
},
],
}, base));
await inProjectDir(base, () => executePlanSlice({
milestoneId: "M004",
sliceId: "S04",
goal: "Complete the first slice.",
tasks: [
{
taskId: "T04",
title: "Finish slice",
description: "Close the completed slice.",
estimate: "5m",
files: ["src/file.ts"],
verify: "node --test",
inputs: ["M004-ROADMAP.md"],
expectedOutput: ["S04-SUMMARY.md", "S04-UAT.md"],
},
],
}, base));
await inProjectDir(base, () => executeTaskComplete({
milestoneId: "M004",
sliceId: "S04",
taskId: "T04",
oneLiner: "Completed task",
narrative: "Task finished.",
verification: "node --test",
}, base));
await inProjectDir(base, () => executeSliceComplete({
milestoneId: "M004",
sliceId: "S04",
sliceTitle: "Completed slice",
oneLiner: "Completed slice",
narrative: "Slice finished.",
verification: "node --test",
uatContent: "## UAT\n\nPASS",
}, base));
const result = await inProjectDir(base, () => executeReassessRoadmap({
milestoneId: "M004",
completedSliceId: "S04",
verdict: "roadmap-adjusted",
assessment: "Added a remediation slice.",
sliceChanges: {
modified: [
{
sliceId: "S05",
title: "Adjusted follow-up slice",
risk: "high",
depends: ["S04"],
demo: "Adjusted follow-up demo",
},
],
added: [
{
sliceId: "S06",
title: "Remediation slice",
risk: "medium",
depends: ["S05"],
demo: "Remediation slice demo",
},
],
removed: [],
},
}, base));
assert.equal(result.details.operation, "reassess_roadmap");
const assessmentPath = String(result.details.assessmentPath);
const roadmapPath = String(result.details.roadmapPath);
assert.ok(existsSync(assessmentPath), "assessment file should be written");
assert.ok(existsSync(roadmapPath), "roadmap should be re-rendered");
assert.match(readFileSync(roadmapPath, "utf-8"), /S06/);
} finally {
closeDatabase();
cleanup(base);
}
});
test("executeSaveGateResult validates inputs and persists verdicts", async () => {
const base = makeTmpBase();
try {
openTestDb(base);
seedMilestone("M005", "Milestone Five");
seedSlice("M005", "S05", "pending");
insertGateRow({
milestoneId: "M005",
sliceId: "S05",
gateId: "Q3",
scope: "slice",
});
const result = await inProjectDir(base, () => executeSaveGateResult({
milestoneId: "M005",
sliceId: "S05",
gateId: "Q3",
verdict: "pass",
rationale: "Looks good.",
findings: "No issues found.",
}));
assert.equal(result.details.operation, "save_gate_result");
const db = _getAdapter();
const row = db!.prepare(
"SELECT status, verdict, rationale FROM quality_gates WHERE milestone_id = ? AND slice_id = ? AND gate_id = ? AND task_id = ''",
).get("M005", "S05", "Q3") as Record<string, unknown> | undefined;
assert.equal(row?.status, "complete");
assert.equal(row?.verdict, "pass");
assert.equal(row?.rationale, "Looks good.");
} finally {
closeDatabase();
cleanup(base);
}
});

View file

@ -1,12 +1,16 @@
import { ensureDbOpen } from "../bootstrap/dynamic-tools.js";
import { sanitizeCompleteMilestoneParams } from "../bootstrap/sanitize-complete-milestone.js";
import { shouldBlockContextArtifactSave } from "../bootstrap/write-gate.js";
import {
getMilestone,
getSliceStatusSummary,
getSliceTaskCounts,
_getAdapter,
saveGateResult,
} from "../gsd-db.js";
import { saveArtifactToDb } from "../db-writer.js";
import type { CompleteMilestoneParams } from "./complete-milestone.js";
import { handleCompleteMilestone } from "./complete-milestone.js";
import { handleCompleteTask } from "./complete-task.js";
import type { CompleteSliceParams } from "../types.js";
import { handleCompleteSlice } from "./complete-slice.js";
@ -14,7 +18,12 @@ 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 { ReassessRoadmapParams } from "./reassess-roadmap.js";
import { handleReassessRoadmap } from "./reassess-roadmap.js";
import type { ValidateMilestoneParams } from "./validate-milestone.js";
import { handleValidateMilestone } from "./validate-milestone.js";
import { logError, logWarning } from "../workflow-logger.js";
import { invalidateStateCache } from "../state.js";
export const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const;
@ -124,9 +133,22 @@ export interface TaskCompleteParams {
verificationEvidence?: VerificationEvidenceInput[];
}
export type CompleteMilestoneExecutorParams = Partial<CompleteMilestoneParams> & Record<string, unknown>;
export type SliceCompleteExecutorParams = CompleteSliceParams;
export type PlanMilestoneExecutorParams = PlanMilestoneParams;
export type PlanSliceExecutorParams = PlanSliceParams;
export type ValidateMilestoneExecutorParams = ValidateMilestoneParams;
export type ReassessRoadmapExecutorParams = ReassessRoadmapParams;
export interface SaveGateResultParams {
milestoneId: string;
sliceId: string;
gateId: string;
taskId?: string;
verdict: "pass" | "flag" | "omitted";
rationale: string;
findings?: string;
}
export async function executeTaskComplete(
params: TaskCompleteParams,
@ -253,6 +275,173 @@ export async function executeSliceComplete(
}
}
export async function executeCompleteMilestone(
params: CompleteMilestoneExecutorParams,
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 milestone." }],
details: { operation: "complete_milestone", error: "db_unavailable" },
};
}
try {
const sanitized = sanitizeCompleteMilestoneParams(params);
const result = await handleCompleteMilestone(sanitized, basePath);
if ("error" in result) {
return {
content: [{ type: "text", text: `Error completing milestone: ${result.error}` }],
details: { operation: "complete_milestone", error: result.error },
};
}
return {
content: [{ type: "text", text: `Completed milestone ${result.milestoneId}. Summary written to ${result.summaryPath}` }],
details: {
operation: "complete_milestone",
milestoneId: result.milestoneId,
summaryPath: result.summaryPath,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `complete_milestone tool failed: ${msg}`, { tool: "gsd_complete_milestone", error: String(err) });
return {
content: [{ type: "text", text: `Error completing milestone: ${msg}` }],
details: { operation: "complete_milestone", error: msg },
};
}
}
export async function executeValidateMilestone(
params: ValidateMilestoneExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot validate milestone." }],
details: { operation: "validate_milestone", error: "db_unavailable" },
};
}
try {
const result = await handleValidateMilestone(params, basePath);
if ("error" in result) {
return {
content: [{ type: "text", text: `Error validating milestone: ${result.error}` }],
details: { operation: "validate_milestone", error: result.error },
};
}
return {
content: [{ type: "text", text: `Validated milestone ${result.milestoneId} — verdict: ${result.verdict}. Written to ${result.validationPath}` }],
details: {
operation: "validate_milestone",
milestoneId: result.milestoneId,
verdict: result.verdict,
validationPath: result.validationPath,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `validate_milestone tool failed: ${msg}`, { tool: "gsd_validate_milestone", error: String(err) });
return {
content: [{ type: "text", text: `Error validating milestone: ${msg}` }],
details: { operation: "validate_milestone", error: msg },
};
}
}
export async function executeReassessRoadmap(
params: ReassessRoadmapExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot reassess roadmap." }],
details: { operation: "reassess_roadmap", error: "db_unavailable" },
};
}
try {
const result = await handleReassessRoadmap(params, basePath);
if ("error" in result) {
return {
content: [{ type: "text", text: `Error reassessing roadmap: ${result.error}` }],
details: { operation: "reassess_roadmap", error: result.error },
};
}
return {
content: [{ type: "text", text: `Reassessed roadmap for milestone ${result.milestoneId} after ${result.completedSliceId}` }],
details: {
operation: "reassess_roadmap",
milestoneId: result.milestoneId,
completedSliceId: result.completedSliceId,
assessmentPath: result.assessmentPath,
roadmapPath: result.roadmapPath,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `reassess_roadmap tool failed: ${msg}`, { tool: "gsd_reassess_roadmap", error: String(err) });
return {
content: [{ type: "text", text: `Error reassessing roadmap: ${msg}` }],
details: { operation: "reassess_roadmap", error: msg },
};
}
}
export async function executeSaveGateResult(
params: SaveGateResultParams,
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available." }],
details: { operation: "save_gate_result", error: "db_unavailable" },
};
}
const validGates = ["Q3", "Q4", "Q5", "Q6", "Q7", "Q8"];
if (!validGates.includes(params.gateId)) {
return {
content: [{ type: "text", text: `Error: Invalid gateId "${params.gateId}". Must be one of: ${validGates.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_gate_id" },
};
}
const validVerdicts = ["pass", "flag", "omitted"];
if (!validVerdicts.includes(params.verdict)) {
return {
content: [{ type: "text", text: `Error: Invalid verdict "${params.verdict}". Must be one of: ${validVerdicts.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_verdict" },
};
}
try {
saveGateResult({
milestoneId: params.milestoneId,
sliceId: params.sliceId,
gateId: params.gateId,
taskId: params.taskId ?? "",
verdict: params.verdict,
rationale: params.rationale,
findings: params.findings ?? "",
});
invalidateStateCache();
return {
content: [{ type: "text", text: `Gate ${params.gateId} result saved: verdict=${params.verdict}` }],
details: { operation: "save_gate_result", gateId: params.gateId, verdict: params.verdict },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `gsd_save_gate_result failed: ${msg}`, { tool: "gsd_save_gate_result", error: String(err) });
return {
content: [{ type: "text", text: `Error saving gate result: ${msg}` }],
details: { operation: "save_gate_result", error: msg },
};
}
}
export async function executePlanMilestone(
params: PlanMilestoneExecutorParams,
basePath: string = process.cwd(),

View file

@ -20,14 +20,21 @@ export interface WorkflowCapabilityOptions {
}
const MCP_WORKFLOW_TOOL_SURFACE = new Set([
"gsd_complete_milestone",
"gsd_complete_task",
"gsd_complete_slice",
"gsd_milestone_complete",
"gsd_milestone_status",
"gsd_milestone_validate",
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_reassess_roadmap",
"gsd_roadmap_reassess",
"gsd_save_gate_result",
"gsd_slice_complete",
"gsd_summary_save",
"gsd_task_complete",
"gsd_validate_milestone",
]);
function parseLookupOutput(output: Buffer | string): string {