124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
|
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
|
|
import { eq } from "../drizzle.js";
|
||
|
|
import { z } from "zod";
|
||
|
|
import { db } from "../db.js";
|
||
|
|
import { objects } from "../schema.js";
|
||
|
|
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* create_brief — Phase 2a lifecycle MCP tool.
|
||
|
|
*
|
||
|
|
* Convention (see agent-pipeline plan §7): a brief is a `task` object whose
|
||
|
|
* `parentId` is the convoy (`project`). Status starts at "ready" (the brief
|
||
|
|
* itself is ready to dispatch; the convoy's status governs the lifecycle).
|
||
|
|
*
|
||
|
|
* Local-first: role-architect writes `.convoys/<slug>/brief-N-*.md` FIRST,
|
||
|
|
* then calls this. If this fails, queues to `.pending-mcp-sync.jsonl`.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const createBriefInputSchema = z.object({
|
||
|
|
convoyId: z
|
||
|
|
.string()
|
||
|
|
.uuid()
|
||
|
|
.describe("Convoy project id (returned by create_convoy)."),
|
||
|
|
briefNumber: z.number().int().min(1).max(99),
|
||
|
|
title: z.string().min(1).max(500),
|
||
|
|
filesAllowlist: z
|
||
|
|
.array(z.string())
|
||
|
|
.min(1)
|
||
|
|
.describe(
|
||
|
|
"Files this brief is allowed to edit. Used by the reviewer to detect scope expansion.",
|
||
|
|
),
|
||
|
|
dependsOn: z
|
||
|
|
.array(z.number().int().min(1).max(99))
|
||
|
|
.default([])
|
||
|
|
.describe(
|
||
|
|
"Brief numbers this brief depends on. Empty = parallel-safe (can fan out via /multitask).",
|
||
|
|
),
|
||
|
|
acceptanceCriteria: z
|
||
|
|
.array(z.string().min(1))
|
||
|
|
.min(1)
|
||
|
|
.describe("Acceptance criteria, one per line."),
|
||
|
|
briefMarkdown: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.describe(
|
||
|
|
"Brief body. Becomes the task description; the agent reads only this file when implementing.",
|
||
|
|
),
|
||
|
|
});
|
||
|
|
|
||
|
|
function initialDescription(input: z.infer<typeof createBriefInputSchema>): string {
|
||
|
|
const meta = [
|
||
|
|
`<!-- pipeline:brief -->`,
|
||
|
|
`<!-- brief: ${input.briefNumber} -->`,
|
||
|
|
`<!-- depends_on: ${input.dependsOn.length === 0 ? "[]" : input.dependsOn.join(",")} -->`,
|
||
|
|
`<!-- files: ${input.filesAllowlist.join(",")} -->`,
|
||
|
|
].join("\n");
|
||
|
|
const acceptance = [
|
||
|
|
"## Acceptance criteria",
|
||
|
|
"",
|
||
|
|
...input.acceptanceCriteria.map((c) => `- [ ] ${c}`),
|
||
|
|
].join("\n");
|
||
|
|
return `${meta}\n\n${input.briefMarkdown.trim()}\n\n${acceptance}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function registerCreateBriefTool(mcp: McpServer): void {
|
||
|
|
mcp.registerTool(
|
||
|
|
"create_brief",
|
||
|
|
{
|
||
|
|
description:
|
||
|
|
"Create a brief as a `task` child of a convoy `project`. Validates that the parent exists and is a project.",
|
||
|
|
inputSchema: createBriefInputSchema,
|
||
|
|
},
|
||
|
|
async (args) => {
|
||
|
|
try {
|
||
|
|
const input = createBriefInputSchema.parse(args);
|
||
|
|
|
||
|
|
const [parent] = await db
|
||
|
|
.select({ id: objects.id, type: objects.type, workspaceId: objects.workspaceId })
|
||
|
|
.from(objects)
|
||
|
|
.where(eq(objects.id, input.convoyId))
|
||
|
|
.limit(1);
|
||
|
|
|
||
|
|
if (!parent) {
|
||
|
|
return toolErr(`Convoy not found: ${input.convoyId}`);
|
||
|
|
}
|
||
|
|
if (parent.type !== "project") {
|
||
|
|
return toolErr(
|
||
|
|
`Object ${input.convoyId} is type '${parent.type}', expected 'project'. Briefs must be children of convoys.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const [created] = await db
|
||
|
|
.insert(objects)
|
||
|
|
.values({
|
||
|
|
type: "task",
|
||
|
|
title: `Brief ${input.briefNumber}: ${input.title}`,
|
||
|
|
parentId: parent.id,
|
||
|
|
workspaceId: parent.workspaceId,
|
||
|
|
description: initialDescription(input),
|
||
|
|
status: "ready",
|
||
|
|
})
|
||
|
|
.returning();
|
||
|
|
|
||
|
|
if (!created) {
|
||
|
|
return toolErr("Failed to create brief task");
|
||
|
|
}
|
||
|
|
|
||
|
|
return toolOk({
|
||
|
|
brief: {
|
||
|
|
id: created.id,
|
||
|
|
briefNumber: input.briefNumber,
|
||
|
|
title: created.title,
|
||
|
|
convoyId: parent.id,
|
||
|
|
filesAllowlist: input.filesAllowlist,
|
||
|
|
dependsOn: input.dependsOn,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
return toolCatch(e);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|