feat: expose core GSD workflow tools over MCP

This commit is contained in:
Jeremy 2026-04-09 11:30:02 -05:00
parent 146318df0b
commit 4ea87a33d6
9 changed files with 824 additions and 181 deletions

View file

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

View file

@ -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<string, unknown>;
handler: (args: Record<string, unknown>) => Promise<unknown>;
}> = [];
return {
tools,
tool(
name: string,
description: string,
params: Record<string, unknown>,
handler: (args: Record<string, unknown>) => Promise<unknown>,
) {
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);
}
});
});

View file

@ -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<unknown>;
executeSummarySave: (
params: {
milestone_id: string;
slice_id?: string;
task_id?: string;
artifact_type: string;
content: string;
},
basePath?: string,
) => Promise<unknown>;
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<unknown>;
};
let workflowToolExecutorsPromise: Promise<WorkflowToolExecutors> | null = null;
async function getWorkflowToolExecutors(): Promise<WorkflowToolExecutors> {
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<WorkflowToolExecutors>;
}
return workflowToolExecutorsPromise;
}
interface McpToolServer {
tool(
name: string,
description: string,
params: Record<string, unknown>,
handler: (args: Record<string, unknown>) => Promise<unknown>,
): unknown;
}
async function withProjectDir<T>(projectDir: string, fn: () => Promise<T>): Promise<T> {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
const { projectDir, milestoneId } = args as { projectDir: string; milestoneId: string };
const { executeMilestoneStatus } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeMilestoneStatus({ milestoneId }));
},
);
}

View file

@ -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<string, unknown> {
const mcpServers = buildWorkflowMcpServers();
return {
pathToClaudeCodeExecutable: getClaudePath(),
model: modelId,
@ -173,6 +175,7 @@ export function buildSdkOptions(modelId: string, prompt: string): Record<string,
allowDangerouslySkipPermissions: true,
settingSources: ["project"],
systemPrompt: { type: "preset", preset: "claude_code" },
...(mcpServers ? { mcpServers } : {}),
betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
};
}

View file

@ -1,5 +1,8 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import {
makeStreamExhaustedErrorMessage,
buildPromptFromContext,
@ -127,6 +130,116 @@ describe("stream-adapter — session persistence (#2859)", () => {
"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)", () => {

View file

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

View file

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

View file

@ -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<T>(dir: string, fn: () => Promise<T>): Promise<T> {
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<Record<string, unknown>>;
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);
}
});

View file

@ -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<string, unknown>;
}
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<ToolExecutionResult> {
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<ToolExecutionResult> {
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<ToolExecutionResult> {
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 },
};
}
}