feat: expose core GSD workflow tools over MCP
This commit is contained in:
parent
146318df0b
commit
4ea87a33d6
9 changed files with 824 additions and 181 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
127
packages/mcp-server/src/workflow-tools.test.ts
Normal file
127
packages/mcp-server/src/workflow-tools.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
196
packages/mcp-server/src/workflow-tools.ts
Normal file
196
packages/mcp-server/src/workflow-tools.ts
Normal 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 }));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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"] : [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
228
src/resources/extensions/gsd/tools/workflow-tool-executors.ts
Normal file
228
src/resources/extensions/gsd/tools/workflow-tool-executors.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue