diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index f684700ed..0b65e13ef 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -3,6 +3,7 @@ * * Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker * Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge + * Workflow tools (3): gsd_summary_save, gsd_task_complete, gsd_milestone_status * * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16 * cannot resolve the SDK's subpath exports statically (same pattern as @@ -19,6 +20,7 @@ import { readHistory } from './readers/metrics.js'; import { readCaptures } from './readers/captures.js'; import { readKnowledge } from './readers/knowledge.js'; import { runDoctorLite } from './readers/doctor-lite.js'; +import { registerWorkflowTools } from './workflow-tools.js'; // --------------------------------------------------------------------------- // Constants @@ -405,5 +407,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{ }, ); + registerWorkflowTools(server); + return { server }; } diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts new file mode 100644 index 000000000..62242f887 --- /dev/null +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -0,0 +1,127 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { registerWorkflowTools } from "./workflow-tools.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-mcp-workflow-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { + rmSync(base, { recursive: true, force: true }); + } catch { + // swallow + } +} + +function makeMockServer() { + const tools: Array<{ + name: string; + description: string; + params: Record; + handler: (args: Record) => Promise; + }> = []; + return { + tools, + tool( + name: string, + description: string, + params: Record, + handler: (args: Record) => Promise, + ) { + tools.push({ name, description, params, handler }); + }, + }; +} + +describe("workflow MCP tools", () => { + it("registers the three workflow tools", () => { + const server = makeMockServer(); + registerWorkflowTools(server as any); + + assert.equal(server.tools.length, 3); + assert.deepEqual( + server.tools.map((t) => t.name), + ["gsd_summary_save", "gsd_task_complete", "gsd_milestone_status"], + ); + }); + + it("gsd_summary_save writes artifact through the shared executor", async () => { + const base = makeTmpBase(); + try { + const server = makeMockServer(); + registerWorkflowTools(server as any); + const tool = server.tools.find((t) => t.name === "gsd_summary_save"); + assert.ok(tool, "summary tool should be registered"); + + const result = await tool!.handler({ + projectDir: base, + milestone_id: "M001", + slice_id: "S01", + artifact_type: "SUMMARY", + content: "# Summary\n\nHello", + }); + + const text = (result as any).content[0].text as string; + assert.match(text, /Saved SUMMARY artifact/); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md")), + "summary file should exist on disk", + ); + } finally { + cleanup(base); + } + }); + + it("gsd_task_complete and gsd_milestone_status work end-to-end", async () => { + const base = makeTmpBase(); + try { + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true }); + writeFileSync( + join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), + "# S01\n\n- [ ] **T01: Demo** `est:5m`\n", + ); + + const server = makeMockServer(); + registerWorkflowTools(server as any); + const taskTool = server.tools.find((t) => t.name === "gsd_task_complete"); + const statusTool = server.tools.find((t) => t.name === "gsd_milestone_status"); + assert.ok(taskTool, "task tool should be registered"); + assert.ok(statusTool, "status tool should be registered"); + + const taskResult = await taskTool!.handler({ + projectDir: base, + taskId: "T01", + sliceId: "S01", + milestoneId: "M001", + oneLiner: "Completed task", + narrative: "Did the work", + verification: "npm test", + }); + + assert.match((taskResult as any).content[0].text as string, /Completed task T01/); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md")), + "task summary should be written to disk", + ); + + const statusResult = await statusTool!.handler({ + projectDir: base, + milestoneId: "M001", + }); + const parsed = JSON.parse((statusResult as any).content[0].text as string); + assert.equal(parsed.milestoneId, "M001"); + assert.equal(parsed.sliceCount, 1); + assert.equal(parsed.slices[0].id, "S01"); + } finally { + cleanup(base); + } + }); +}); diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts new file mode 100644 index 000000000..dae45a2df --- /dev/null +++ b/packages/mcp-server/src/workflow-tools.ts @@ -0,0 +1,196 @@ +/** + * Workflow MCP tools — exposes the core GSD mutation/read handlers over MCP. + */ + +import { z } from "zod"; + +const SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const; + +type WorkflowToolExecutors = { + SUPPORTED_SUMMARY_ARTIFACT_TYPES: readonly string[]; + executeMilestoneStatus: (params: { milestoneId: string }) => Promise; + executeSummarySave: ( + params: { + milestone_id: string; + slice_id?: string; + task_id?: string; + artifact_type: string; + content: string; + }, + basePath?: string, + ) => Promise; + executeTaskComplete: ( + params: { + taskId: string; + sliceId: string; + milestoneId: string; + oneLiner: string; + narrative: string; + verification: string; + deviations?: string; + knownIssues?: string; + keyFiles?: string[]; + keyDecisions?: string[]; + blockerDiscovered?: boolean; + verificationEvidence?: Array< + { command: string; exitCode: number; verdict: string; durationMs: number } | string + >; + }, + basePath?: string, + ) => Promise; +}; + +let workflowToolExecutorsPromise: Promise | null = null; + +async function getWorkflowToolExecutors(): Promise { + if (!workflowToolExecutorsPromise) { + const jsUrl = new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url).href; + const tsUrl = new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.ts", import.meta.url).href; + workflowToolExecutorsPromise = import(jsUrl) + .catch(() => import(tsUrl)) as Promise; + } + return workflowToolExecutorsPromise; +} + +interface McpToolServer { + tool( + name: string, + description: string, + params: Record, + handler: (args: Record) => Promise, + ): unknown; +} + +async function withProjectDir(projectDir: string, fn: () => Promise): Promise { + const originalCwd = process.cwd(); + try { + process.chdir(projectDir); + return await fn(); + } finally { + process.chdir(originalCwd); + } +} + +export function registerWorkflowTools(server: McpToolServer): void { + server.tool( + "gsd_summary_save", + "Save a GSD summary/research/context/assessment artifact to the database and disk.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + milestone_id: z.string().describe("Milestone ID (e.g. M001)"), + slice_id: z.string().optional().describe("Slice ID (e.g. S01)"), + task_id: z.string().optional().describe("Task ID (e.g. T01)"), + artifact_type: z.enum(SUMMARY_ARTIFACT_TYPES).describe("Artifact type to save"), + content: z.string().describe("The full markdown content of the artifact"), + }, + async (args: Record) => { + const { projectDir, milestone_id, slice_id, task_id, artifact_type, content } = args as { + projectDir: string; + milestone_id: string; + slice_id?: string; + task_id?: string; + artifact_type: string; + content: string; + }; + const { executeSummarySave } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => + executeSummarySave({ milestone_id, slice_id, task_id, artifact_type, content }, projectDir), + ); + }, + ); + + server.tool( + "gsd_task_complete", + "Record a completed task to the GSD database and render its SUMMARY.md.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + taskId: z.string().describe("Task ID (e.g. T01)"), + sliceId: z.string().describe("Slice ID (e.g. S01)"), + milestoneId: z.string().describe("Milestone ID (e.g. M001)"), + oneLiner: z.string().describe("One-line summary of what was accomplished"), + narrative: z.string().describe("Detailed narrative of what happened during the task"), + verification: z.string().describe("What was verified and how"), + deviations: z.string().optional().describe("Deviations from the task plan"), + knownIssues: z.string().optional().describe("Known issues discovered but not fixed"), + keyFiles: z.array(z.string()).optional().describe("List of key files created or modified"), + keyDecisions: z.array(z.string()).optional().describe("List of key decisions made during this task"), + blockerDiscovered: z.boolean().optional().describe("Whether a plan-invalidating blocker was discovered"), + verificationEvidence: z.array(z.union([ + z.object({ + command: z.string(), + exitCode: z.number(), + verdict: z.string(), + durationMs: z.number(), + }), + z.string(), + ])).optional().describe("Verification evidence entries"), + }, + async (args: Record) => { + const { + projectDir, + taskId, + sliceId, + milestoneId, + oneLiner, + narrative, + verification, + deviations, + knownIssues, + keyFiles, + keyDecisions, + blockerDiscovered, + verificationEvidence, + } = args as { + projectDir: string; + taskId: string; + sliceId: string; + milestoneId: string; + oneLiner: string; + narrative: string; + verification: string; + deviations?: string; + knownIssues?: string; + keyFiles?: string[]; + keyDecisions?: string[]; + blockerDiscovered?: boolean; + verificationEvidence?: Array< + { command: string; exitCode: number; verdict: string; durationMs: number } | string + >; + }; + const { executeTaskComplete } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => + executeTaskComplete( + { + taskId, + sliceId, + milestoneId, + oneLiner, + narrative, + verification, + deviations, + knownIssues, + keyFiles, + keyDecisions, + blockerDiscovered, + verificationEvidence, + }, + projectDir, + ), + ); + }, + ); + + server.tool( + "gsd_milestone_status", + "Read the current status of a milestone and all its slices from the GSD database.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + milestoneId: z.string().describe("Milestone ID to query (e.g. M001)"), + }, + async (args: Record) => { + const { projectDir, milestoneId } = args as { projectDir: string; milestoneId: string }; + const { executeMilestoneStatus } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executeMilestoneStatus({ milestoneId })); + }, + ); +} diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index b1eb8acca..21af85aef 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -18,6 +18,7 @@ import type { import { EventStream } from "@gsd/pi-ai"; import { execSync } from "node:child_process"; import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js"; +import { buildWorkflowMcpServers } from "../gsd/workflow-mcp.js"; import type { SDKAssistantMessage, SDKMessage, @@ -163,6 +164,7 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: * beta flags, and other configuration without mocking the full SDK. */ export function buildSdkOptions(modelId: string, prompt: string): Record { + const mcpServers = buildWorkflowMcpServers(); return { pathToClaudeCodeExecutable: getClaudePath(), model: modelId, @@ -173,6 +175,7 @@ export function buildSdkOptions(modelId: string, prompt: string): Record { "non-sonnet models should have empty betas", ); }); + + test("buildSdkOptions includes workflow MCP server config when env is set", () => { + const prev = { + GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND, + GSD_WORKFLOW_MCP_NAME: process.env.GSD_WORKFLOW_MCP_NAME, + GSD_WORKFLOW_MCP_ARGS: process.env.GSD_WORKFLOW_MCP_ARGS, + GSD_WORKFLOW_MCP_ENV: process.env.GSD_WORKFLOW_MCP_ENV, + GSD_WORKFLOW_MCP_CWD: process.env.GSD_WORKFLOW_MCP_CWD, + }; + try { + process.env.GSD_WORKFLOW_MCP_COMMAND = "node"; + process.env.GSD_WORKFLOW_MCP_NAME = "gsd-workflow"; + process.env.GSD_WORKFLOW_MCP_ARGS = JSON.stringify(["packages/mcp-server/dist/cli.js"]); + process.env.GSD_WORKFLOW_MCP_ENV = JSON.stringify({ GSD_CLI_PATH: "/tmp/gsd" }); + process.env.GSD_WORKFLOW_MCP_CWD = "/tmp/project"; + + const options = buildSdkOptions("claude-sonnet-4-20250514", "test"); + assert.deepEqual(options.mcpServers, { + "gsd-workflow": { + command: "node", + args: ["packages/mcp-server/dist/cli.js"], + env: { GSD_CLI_PATH: "/tmp/gsd" }, + cwd: "/tmp/project", + }, + }); + } finally { + process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; + process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; + process.env.GSD_WORKFLOW_MCP_ARGS = prev.GSD_WORKFLOW_MCP_ARGS; + process.env.GSD_WORKFLOW_MCP_ENV = prev.GSD_WORKFLOW_MCP_ENV; + process.env.GSD_WORKFLOW_MCP_CWD = prev.GSD_WORKFLOW_MCP_CWD; + } + }); + + test("buildSdkOptions omits workflow MCP server config when env is unset", () => { + const prev = { + GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND, + GSD_WORKFLOW_MCP_NAME: process.env.GSD_WORKFLOW_MCP_NAME, + GSD_WORKFLOW_MCP_ARGS: process.env.GSD_WORKFLOW_MCP_ARGS, + GSD_WORKFLOW_MCP_ENV: process.env.GSD_WORKFLOW_MCP_ENV, + GSD_WORKFLOW_MCP_CWD: process.env.GSD_WORKFLOW_MCP_CWD, + }; + try { + delete process.env.GSD_WORKFLOW_MCP_COMMAND; + delete process.env.GSD_WORKFLOW_MCP_NAME; + delete process.env.GSD_WORKFLOW_MCP_ARGS; + delete process.env.GSD_WORKFLOW_MCP_ENV; + delete process.env.GSD_WORKFLOW_MCP_CWD; + + const originalCwd = process.cwd(); + const emptyDir = mkdtempSync(join(tmpdir(), "claude-mcp-none-")); + process.chdir(emptyDir); + const options = buildSdkOptions("claude-sonnet-4-20250514", "test"); + process.chdir(originalCwd); + assert.equal((options as any).mcpServers, undefined); + rmSync(emptyDir, { recursive: true, force: true }); + } finally { + process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; + process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; + process.env.GSD_WORKFLOW_MCP_ARGS = prev.GSD_WORKFLOW_MCP_ARGS; + process.env.GSD_WORKFLOW_MCP_ENV = prev.GSD_WORKFLOW_MCP_ENV; + process.env.GSD_WORKFLOW_MCP_CWD = prev.GSD_WORKFLOW_MCP_CWD; + } + }); + + test("buildSdkOptions auto-detects local workflow MCP dist CLI when present", () => { + const prev = { + GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND, + GSD_WORKFLOW_MCP_NAME: process.env.GSD_WORKFLOW_MCP_NAME, + GSD_WORKFLOW_MCP_ARGS: process.env.GSD_WORKFLOW_MCP_ARGS, + GSD_WORKFLOW_MCP_ENV: process.env.GSD_WORKFLOW_MCP_ENV, + GSD_WORKFLOW_MCP_CWD: process.env.GSD_WORKFLOW_MCP_CWD, + GSD_CLI_PATH: process.env.GSD_CLI_PATH, + }; + const originalCwd = process.cwd(); + const repoDir = mkdtempSync(join(tmpdir(), "claude-mcp-detect-")); + try { + delete process.env.GSD_WORKFLOW_MCP_COMMAND; + delete process.env.GSD_WORKFLOW_MCP_NAME; + delete process.env.GSD_WORKFLOW_MCP_ARGS; + delete process.env.GSD_WORKFLOW_MCP_ENV; + delete process.env.GSD_WORKFLOW_MCP_CWD; + process.env.GSD_CLI_PATH = "/tmp/gsd"; + + const distDir = join(repoDir, "packages", "mcp-server", "dist"); + mkdirSync(distDir, { recursive: true }); + writeFileSync(join(distDir, "cli.js"), "#!/usr/bin/env node\n"); + process.chdir(repoDir); + const resolvedRepoDir = realpathSync(repoDir); + + const options = buildSdkOptions("claude-sonnet-4-20250514", "test"); + assert.deepEqual(options.mcpServers, { + "gsd-workflow": { + command: process.execPath, + args: [realpathSync(resolve(repoDir, "packages", "mcp-server", "dist", "cli.js"))], + env: { GSD_CLI_PATH: "/tmp/gsd" }, + cwd: resolvedRepoDir, + }, + }); + } finally { + process.chdir(originalCwd); + rmSync(repoDir, { recursive: true, force: true }); + process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; + process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; + process.env.GSD_WORKFLOW_MCP_ARGS = prev.GSD_WORKFLOW_MCP_ARGS; + process.env.GSD_WORKFLOW_MCP_ENV = prev.GSD_WORKFLOW_MCP_ENV; + process.env.GSD_WORKFLOW_MCP_CWD = prev.GSD_WORKFLOW_MCP_CWD; + process.env.GSD_CLI_PATH = prev.GSD_CLI_PATH; + } + }); }); describe("stream-adapter — Windows Claude path lookup (#3770)", () => { diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 18634c486..df8575abd 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -8,15 +8,10 @@ import { ensureDbOpen } from "./dynamic-tools.js"; import { StringEnum } from "@gsd/pi-ai"; import { logError } from "../workflow-logger.js"; import { getErrorMessage } from "../error-utils.js"; -import { shouldBlockContextArtifactSave } from "./write-gate.js"; - -const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const; - -export function isSupportedSummaryArtifactType( - artifactType: string, -): artifactType is (typeof SUPPORTED_SUMMARY_ARTIFACT_TYPES)[number] { - return (SUPPORTED_SUMMARY_ARTIFACT_TYPES as readonly string[]).includes(artifactType); -} +import { + executeSummarySave, + executeTaskComplete, +} from "../tools/workflow-tool-executors.js"; /** * Register an alias tool that shares the same execute function as its canonical counterpart. @@ -286,63 +281,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // ─── gsd_summary_save (formerly gsd_save_summary) ────────────────────── const summarySaveExecute = 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 save artifact." }], - details: { operation: "save_summary", error: "db_unavailable" } as any, - }; - } - if (!isSupportedSummaryArtifactType(params.artifact_type)) { - return { - content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${SUPPORTED_SUMMARY_ARTIFACT_TYPES.join(", ")}` }], - details: { operation: "save_summary", error: "invalid_artifact_type" } as any, - }; - } - const contextGuard = shouldBlockContextArtifactSave( - params.artifact_type, - params.milestone_id ?? null, - params.slice_id ?? null, - ); - if (contextGuard.block) { - return { - content: [{ type: "text" as const, text: `Error saving artifact: ${contextGuard.reason ?? "context write blocked"}` }], - details: { operation: "save_summary", error: "context_write_blocked" } as any, - }; - } - try { - let relativePath: string; - if (params.task_id && params.slice_id) { - relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`; - } else if (params.slice_id) { - relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`; - } else { - relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; - } - const { saveArtifactToDb } = await import("../db-writer.js"); - await saveArtifactToDb( - { - path: relativePath, - artifact_type: params.artifact_type, - content: params.content, - milestone_id: params.milestone_id, - slice_id: params.slice_id, - task_id: params.task_id, - }, - process.cwd(), - ); - return { - content: [{ type: "text" as const, text: `Saved ${params.artifact_type} artifact to ${relativePath}` }], - details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `gsd_summary_save tool failed: ${msg}`, { tool: "gsd_summary_save", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }], - details: { operation: "save_summary", error: msg } as any, - }; - } + return executeSummarySave(params, process.cwd()); }; const summarySaveTool = { @@ -717,46 +656,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // ─── gsd_task_complete (gsd_complete_task alias) ──────────────────────── const taskCompleteExecute = 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 task." }], - details: { operation: "complete_task", error: "db_unavailable" } as any, - }; - } - try { - // Coerce string items to objects for verificationEvidence (#3541). - const coerced = { ...params }; - coerced.verificationEvidence = (params.verificationEvidence ?? []).map((v: any) => - typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v, - ); - - const { handleCompleteTask } = await import("../tools/complete-task.js"); - const result = await handleCompleteTask(coerced, process.cwd()); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: `Error completing task: ${result.error}` }], - details: { operation: "complete_task", error: result.error } as any, - }; - } - return { - content: [{ type: "text" as const, text: `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }], - details: { - operation: "complete_task", - taskId: result.taskId, - sliceId: result.sliceId, - milestoneId: result.milestoneId, - summaryPath: result.summaryPath, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `complete_task tool failed: ${msg}`, { tool: "gsd_task_complete", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error completing task: ${msg}` }], - details: { operation: "complete_task", error: msg } as any, - }; - } + return executeTaskComplete(params, process.cwd()); }; const taskCompleteTool = { diff --git a/src/resources/extensions/gsd/bootstrap/query-tools.ts b/src/resources/extensions/gsd/bootstrap/query-tools.ts index 30ecefecf..56ac70afd 100644 --- a/src/resources/extensions/gsd/bootstrap/query-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/query-tools.ts @@ -2,8 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { ExtensionAPI } from "@gsd/pi-coding-agent"; - -import { logWarning } from "../workflow-logger.js"; +import { executeMilestoneStatus } from "../tools/workflow-tool-executors.js"; export function registerQueryTools(pi: ExtensionAPI): void { pi.registerTool({ @@ -21,79 +20,7 @@ export function registerQueryTools(pi: ExtensionAPI): void { milestoneId: Type.String({ description: "Milestone ID to query (e.g. M001)" }), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - try { - // Open the DB if not already open — safe for read-only use since - // ensureDbOpen() only creates/migrates when .gsd/ has content (#3644). - const { ensureDbOpen } = await import("./dynamic-tools.js"); - const dbAvailable = await ensureDbOpen(); - const { - getMilestone, - getSliceStatusSummary, - getSliceTaskCounts, - _getAdapter, - } = await import("../gsd-db.js"); - - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available." }], - details: { operation: "milestone_status", error: "db_unavailable" } as any, - }; - } - - // Wrap all reads in a single transaction for snapshot consistency. - // SQLite WAL mode guarantees reads within a transaction see a single - // consistent snapshot, preventing torn reads from concurrent writes. - const adapter = _getAdapter()!; - adapter.exec("BEGIN"); // eslint-disable-line -- SQLite exec, not child_process - try { - const milestone = getMilestone(params.milestoneId); - if (!milestone) { - adapter.exec("COMMIT"); // eslint-disable-line - return { - content: [{ type: "text" as const, text: `Milestone ${params.milestoneId} not found in database.` }], - details: { operation: "milestone_status", milestoneId: params.milestoneId, found: false } as any, - }; - } - - const sliceStatuses = getSliceStatusSummary(params.milestoneId); - - const slices = sliceStatuses.map((s) => { - const counts = getSliceTaskCounts(params.milestoneId, s.id); - return { - id: s.id, - status: s.status, - taskCounts: counts, - }; - }); - - adapter.exec("COMMIT"); // eslint-disable-line - - const result = { - milestoneId: milestone.id, - title: milestone.title, - status: milestone.status, - createdAt: milestone.created_at, - completedAt: milestone.completed_at, - sliceCount: slices.length, - slices, - }; - - return { - content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], - details: { operation: "milestone_status", milestoneId: milestone.id, sliceCount: slices.length } as any, - }; - } catch (txErr) { - try { adapter.exec("ROLLBACK"); } catch { /* swallow */ } // eslint-disable-line - throw txErr; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logWarning("tool", `gsd_milestone_status tool failed: ${msg}`); - return { - content: [{ type: "text" as const, text: `Error querying milestone status: ${msg}` }], - details: { operation: "milestone_status", error: msg } as any, - }; - } + return executeMilestoneStatus(params); }, }); } diff --git a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts new file mode 100644 index 000000000..a54d83f13 --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + openDatabase, + closeDatabase, + _getAdapter, +} from "../gsd-db.ts"; +import { + executeSummarySave, + executeTaskComplete, + executeMilestoneStatus, +} from "../tools/workflow-tool-executors.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-workflow-executors-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* swallow */ } +} + +function openTestDb(base: string): void { + openDatabase(join(base, ".gsd", "gsd.db")); +} + +async function inProjectDir(dir: string, fn: () => Promise): Promise { + const originalCwd = process.cwd(); + try { + process.chdir(dir); + return await fn(); + } finally { + process.chdir(originalCwd); + } +} + +function seedMilestone(milestoneId: string, title: string, status = "active"): void { + const db = _getAdapter(); + if (!db) throw new Error("DB not open"); + db.prepare( + "INSERT OR REPLACE INTO milestones (id, title, status, created_at) VALUES (?, ?, ?, ?)", + ).run(milestoneId, title, status, new Date().toISOString()); +} + +function seedSlice(milestoneId: string, sliceId: string, status: string): void { + const db = _getAdapter(); + if (!db) throw new Error("DB not open"); + db.prepare( + "INSERT OR REPLACE INTO slices (milestone_id, id, title, status, created_at) VALUES (?, ?, ?, ?, ?)", + ).run(milestoneId, sliceId, `Slice ${sliceId}`, status, new Date().toISOString()); +} + +test("executeSummarySave persists artifact and returns computed path", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + const result = await inProjectDir(base, () => executeSummarySave({ + milestone_id: "M001", + slice_id: "S01", + artifact_type: "SUMMARY", + content: "# Summary\n\ncontent", + }, base)); + + assert.equal(result.details.operation, "save_summary"); + assert.equal(result.details.path, "milestones/M001/slices/S01/S01-SUMMARY.md"); + + const filePath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + assert.ok(existsSync(filePath), "summary artifact should be written to disk"); + assert.match(readFileSync(filePath, "utf-8"), /# Summary/); + } finally { + closeDatabase(); + cleanup(base); + } +}); + +test("executeTaskComplete coerces string verificationEvidence entries", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + const planDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, "S01-PLAN.md"), "# S01\n\n- [ ] **T01: Demo** `est:5m`\n"); + + const result = await inProjectDir(base, () => executeTaskComplete({ + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + oneLiner: "Completed task", + narrative: "Did the work", + verification: "npm test", + verificationEvidence: ["npm test"], + }, base)); + + assert.equal(result.details.operation, "complete_task"); + assert.equal(result.details.taskId, "T01"); + + const db = _getAdapter(); + assert.ok(db, "DB should be open"); + const rows = db!.prepare( + "SELECT command, exit_code, verdict, duration_ms FROM verification_evidence WHERE milestone_id = ? AND slice_id = ? AND task_id = ?", + ).all("M001", "S01", "T01") as Array>; + + assert.equal(rows.length, 1, "one coerced verification evidence row should be inserted"); + assert.equal(rows[0]["command"], "npm test"); + assert.equal(rows[0]["exit_code"], -1); + assert.match(String(rows[0]["verdict"]), /coerced from string/); + + const summaryPath = String(result.details.summaryPath); + assert.ok(existsSync(summaryPath), "task summary should be written to disk"); + } finally { + closeDatabase(); + cleanup(base); + } +}); + +test("executeMilestoneStatus returns milestone metadata and slice counts", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + seedMilestone("M001", "Milestone One"); + seedSlice("M001", "S01", "active"); + const db = _getAdapter(); + db!.prepare( + "INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)", + ).run("M001", "S01", "T01", "Task T01", "pending"); + + const result = await inProjectDir(base, () => executeMilestoneStatus({ milestoneId: "M001" })); + const parsed = JSON.parse(result.content[0].text); + + assert.equal(parsed.milestoneId, "M001"); + assert.equal(parsed.title, "Milestone One"); + assert.equal(parsed.sliceCount, 1); + assert.equal(parsed.slices[0].id, "S01"); + assert.equal(parsed.slices[0].taskCounts.pending, 1); + } 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 new file mode 100644 index 000000000..60e638d4d --- /dev/null +++ b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts @@ -0,0 +1,228 @@ +import { ensureDbOpen } from "../bootstrap/dynamic-tools.js"; +import { shouldBlockContextArtifactSave } from "../bootstrap/write-gate.js"; +import { + getMilestone, + getSliceStatusSummary, + getSliceTaskCounts, + _getAdapter, +} from "../gsd-db.js"; +import { saveArtifactToDb } from "../db-writer.js"; +import { handleCompleteTask } from "./complete-task.js"; +import { logError, logWarning } from "../workflow-logger.js"; + +export const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const; + +export function isSupportedSummaryArtifactType( + artifactType: string, +): artifactType is (typeof SUPPORTED_SUMMARY_ARTIFACT_TYPES)[number] { + return (SUPPORTED_SUMMARY_ARTIFACT_TYPES as readonly string[]).includes(artifactType); +} + +export interface ToolExecutionResult { + content: Array<{ type: "text"; text: string }>; + details: Record; +} + +export interface SummarySaveParams { + milestone_id: string; + slice_id?: string; + task_id?: string; + artifact_type: string; + content: string; +} + +export async function executeSummarySave( + params: SummarySaveParams, + basePath: string = process.cwd(), +): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available. Cannot save artifact." }], + details: { operation: "save_summary", error: "db_unavailable" }, + }; + } + if (!isSupportedSummaryArtifactType(params.artifact_type)) { + return { + content: [{ type: "text", text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${SUPPORTED_SUMMARY_ARTIFACT_TYPES.join(", ")}` }], + details: { operation: "save_summary", error: "invalid_artifact_type" }, + }; + } + const contextGuard = shouldBlockContextArtifactSave( + params.artifact_type, + params.milestone_id ?? null, + params.slice_id ?? null, + ); + if (contextGuard.block) { + return { + content: [{ type: "text", text: `Error saving artifact: ${contextGuard.reason ?? "context write blocked"}` }], + details: { operation: "save_summary", error: "context_write_blocked" }, + }; + } + try { + let relativePath: string; + if (params.task_id && params.slice_id) { + relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`; + } else if (params.slice_id) { + relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`; + } else { + relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; + } + + await saveArtifactToDb( + { + path: relativePath, + artifact_type: params.artifact_type, + content: params.content, + milestone_id: params.milestone_id, + slice_id: params.slice_id, + task_id: params.task_id, + }, + basePath, + ); + return { + content: [{ type: "text", text: `Saved ${params.artifact_type} artifact to ${relativePath}` }], + details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `gsd_summary_save tool failed: ${msg}`, { tool: "gsd_summary_save", error: String(err) }); + return { + content: [{ type: "text", text: `Error saving artifact: ${msg}` }], + details: { operation: "save_summary", error: msg }, + }; + } +} + +type VerificationEvidenceInput = + | { + command: string; + exitCode: number; + verdict: string; + durationMs: number; + } + | string; + +export interface TaskCompleteParams { + taskId: string; + sliceId: string; + milestoneId: string; + oneLiner: string; + narrative: string; + verification: string; + deviations?: string; + knownIssues?: string; + keyFiles?: string[]; + keyDecisions?: string[]; + blockerDiscovered?: boolean; + verificationEvidence?: VerificationEvidenceInput[]; +} + +export async function executeTaskComplete( + params: TaskCompleteParams, + basePath: string = process.cwd(), +): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete task." }], + details: { operation: "complete_task", error: "db_unavailable" }, + }; + } + try { + const coerced = { ...params }; + coerced.verificationEvidence = (params.verificationEvidence ?? []).map((v) => + typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v, + ); + + const result = await handleCompleteTask(coerced as any, basePath); + if ("error" in result) { + return { + content: [{ type: "text", text: `Error completing task: ${result.error}` }], + details: { operation: "complete_task", error: result.error }, + }; + } + return { + content: [{ type: "text", text: `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }], + details: { + operation: "complete_task", + taskId: result.taskId, + sliceId: result.sliceId, + milestoneId: result.milestoneId, + summaryPath: result.summaryPath, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `complete_task tool failed: ${msg}`, { tool: "gsd_task_complete", error: String(err) }); + return { + content: [{ type: "text", text: `Error completing task: ${msg}` }], + details: { operation: "complete_task", error: msg }, + }; + } +} + +export interface MilestoneStatusParams { + milestoneId: string; +} + +export async function executeMilestoneStatus( + params: MilestoneStatusParams, +): Promise { + try { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available." }], + details: { operation: "milestone_status", error: "db_unavailable" }, + }; + } + + const adapter = _getAdapter()!; + adapter.exec("BEGIN"); + try { + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + adapter.exec("COMMIT"); + return { + content: [{ type: "text", text: `Milestone ${params.milestoneId} not found in database.` }], + details: { operation: "milestone_status", milestoneId: params.milestoneId, found: false }, + }; + } + + const sliceStatuses = getSliceStatusSummary(params.milestoneId); + const slices = sliceStatuses.map((s) => ({ + id: s.id, + status: s.status, + taskCounts: getSliceTaskCounts(params.milestoneId, s.id), + })); + + adapter.exec("COMMIT"); + + const result = { + milestoneId: milestone.id, + title: milestone.title, + status: milestone.status, + createdAt: milestone.created_at, + completedAt: milestone.completed_at, + sliceCount: slices.length, + slices, + }; + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: { operation: "milestone_status", milestoneId: milestone.id, sliceCount: slices.length }, + }; + } catch (txErr) { + try { adapter.exec("ROLLBACK"); } catch { /* swallow */ } + throw txErr; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logWarning("tool", `gsd_milestone_status tool failed: ${msg}`); + return { + content: [{ type: "text", text: `Error querying milestone status: ${msg}` }], + details: { operation: "milestone_status", error: msg }, + }; + } +}