feat: expose task completion alias over workflow MCP

This commit is contained in:
Jeremy 2026-04-09 11:48:05 -05:00
parent f7008107fb
commit 2f63012628
4 changed files with 147 additions and 57 deletions

View file

@ -42,14 +42,14 @@ function makeMockServer() {
}
describe("workflow MCP tools", () => {
it("registers the five workflow tools", () => {
it("registers the six workflow tools", () => {
const server = makeMockServer();
registerWorkflowTools(server as any);
assert.equal(server.tools.length, 5);
assert.equal(server.tools.length, 6);
assert.deepEqual(
server.tools.map((t) => t.name),
["gsd_plan_milestone", "gsd_plan_slice", "gsd_summary_save", "gsd_task_complete", "gsd_milestone_status"],
["gsd_plan_milestone", "gsd_plan_slice", "gsd_summary_save", "gsd_task_complete", "gsd_complete_task", "gsd_milestone_status"],
);
});
@ -125,6 +125,40 @@ describe("workflow MCP tools", () => {
}
});
it("gsd_complete_task alias delegates to gsd_task_complete behavior", async () => {
const base = makeTmpBase();
try {
mkdirSync(join(base, ".gsd", "milestones", "M002", "slices", "S02"), { recursive: true });
writeFileSync(
join(base, ".gsd", "milestones", "M002", "slices", "S02", "S02-PLAN.md"),
"# S02\n\n- [ ] **T02: Demo** `est:5m`\n",
);
const server = makeMockServer();
registerWorkflowTools(server as any);
const aliasTool = server.tools.find((t) => t.name === "gsd_complete_task");
assert.ok(aliasTool, "task completion alias should be registered");
const result = await aliasTool!.handler({
projectDir: base,
taskId: "T02",
sliceId: "S02",
milestoneId: "M002",
oneLiner: "Completed task via alias",
narrative: "Did the work through alias",
verification: "npm test",
});
assert.match((result as any).content[0].text as string, /Completed task T02/);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M002", "slices", "S02", "tasks", "T02-SUMMARY.md")),
"alias should write task summary to disk",
);
} finally {
cleanup(base);
}
});
it("gsd_plan_milestone and gsd_plan_slice work end-to-end", async () => {
const base = makeTmpBase();
try {

View file

@ -126,6 +126,61 @@ async function withProjectDir<T>(projectDir: string, fn: () => Promise<T>): Prom
}
}
async function handleTaskComplete(
projectDir: string,
args: Record<string, unknown>,
): Promise<unknown> {
const {
taskId,
sliceId,
milestoneId,
oneLiner,
narrative,
verification,
deviations,
knownIssues,
keyFiles,
keyDecisions,
blockerDiscovered,
verificationEvidence,
} = args as {
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,
),
);
}
export function registerWorkflowTools(server: McpToolServer): void {
server.tool(
"gsd_plan_milestone",
@ -259,57 +314,40 @@ export function registerWorkflowTools(server: McpToolServer): void {
])).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,
),
);
const { projectDir, ...taskArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleTaskComplete(projectDir, taskArgs);
},
);
server.tool(
"gsd_complete_task",
"Alias for 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, ...taskArgs } = args as { projectDir: string } & Record<string, unknown>;
return handleTaskComplete(projectDir, taskArgs);
},
);

View file

@ -104,7 +104,7 @@ test("transport compatibility fails cleanly when MCP server is unavailable", ()
assert.match(error ?? "", /workflow MCP server is not configured or discoverable/);
});
test("transport compatibility fails cleanly when unit requires unsupported tools", () => {
test("transport compatibility now allows auto execute-task over workflow MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_complete_task"],
@ -118,8 +118,7 @@ test("transport compatibility fails cleanly when unit requires unsupported tools
},
);
assert.match(error ?? "", /requires gsd_complete_task/);
assert.match(error ?? "", /currently exposes only/);
assert.equal(error, null);
});
test("transport compatibility ignores API-backed providers", () => {
@ -156,6 +155,24 @@ test("transport compatibility now allows plan-slice over workflow MCP surface",
assert.equal(error, null);
});
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_complete_slice"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "complete-slice",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.match(error ?? "", /requires gsd_complete_slice/);
assert.match(error ?? "", /currently exposes only/);
});
test("guided-flow source enforces workflow compatibility preflight", () => {
const src = readSrc("guided-flow.ts");
assert.match(src, /getRequiredWorkflowToolsForGuidedUnit/);

View file

@ -20,6 +20,7 @@ export interface WorkflowCapabilityOptions {
}
const MCP_WORKFLOW_TOOL_SURFACE = new Set([
"gsd_complete_task",
"gsd_milestone_status",
"gsd_plan_milestone",
"gsd_plan_slice",