diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index 855596821..59d932a9b 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -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>; + 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); + } + }); }); diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index dfe652ec2..f5f1d9628 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -92,6 +92,76 @@ type WorkflowToolExecutors = { }, basePath?: string, ) => Promise; + 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; + 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; + 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; + executeSaveGateResult: ( + params: { + milestoneId: string; + sliceId: string; + gateId: string; + taskId?: string; + verdict: "pass" | "flag" | "omitted"; + rationale: string; + findings?: string; + }, + ) => Promise; 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, +): Promise { + const { executeCompleteMilestone } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executeCompleteMilestone(args as any, projectDir)); +} + +async function handleValidateMilestone( + projectDir: string, + args: Record, +): Promise { + const { executeValidateMilestone } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executeValidateMilestone(args as any, projectDir)); +} + +async function handleReassessRoadmap( + projectDir: string, + args: Record, +): Promise { + const { executeReassessRoadmap } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executeReassessRoadmap(args as any, projectDir)); +} + +async function handleSaveGateResult( + projectDir: string, + args: Record, +): Promise { + 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) => { + const { projectDir, ...milestoneArgs } = args as { projectDir: string } & Record; + 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) => { + const { projectDir, ...milestoneArgs } = args as { projectDir: string } & Record; + 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) => { + const { projectDir, ...validationArgs } = args as { projectDir: string } & Record; + 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) => { + const { projectDir, ...validationArgs } = args as { projectDir: string } & Record; + 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) => { + const { projectDir, ...reassessArgs } = args as { projectDir: string } & Record; + return handleReassessRoadmap(projectDir, reassessArgs); + }, + ); + + server.tool( + "gsd_roadmap_reassess", + "Alias for gsd_reassess_roadmap. Reassess a roadmap after slice completion.", + reassessRoadmapSchema, + async (args: Record) => { + const { projectDir, ...reassessArgs } = args as { projectDir: string } & Record; + return handleReassessRoadmap(projectDir, reassessArgs); + }, + ); + + server.tool( + "gsd_save_gate_result", + "Save a quality gate result to the GSD database.", + saveGateResultSchema, + async (args: Record) => { + const { projectDir, ...gateArgs } = args as { projectDir: string } & Record; + return handleSaveGateResult(projectDir, gateArgs); + }, + ); + server.tool( "gsd_summary_save", "Save a GSD summary/research/context/assessment artifact to the database and disk.", diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index dd605c708..9538c953a 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -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 = { diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 4dd7c4f00..3e3c949b9 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -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/); }); 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 66f28980b..e8598b200 100644 --- a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts @@ -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>; + 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 | undefined; + assert.equal(row?.status, "complete"); + assert.equal(row?.verdict, "pass"); + assert.equal(row?.rationale, "Looks good."); + } 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 0e481819b..93ee7ce81 100644 --- a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +++ b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts @@ -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 & Record; 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 { + 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 { + 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 { + 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 { + 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(), diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index ed9dc0b95..680d10202 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -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 {