132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
|
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
|
|
import { z } from "zod";
|
||
|
|
import { db } from "../db.js";
|
||
|
|
import { objects } from "../schema.js";
|
||
|
|
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||
|
|
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* create_convoy — Phase 2a lifecycle MCP tool.
|
||
|
|
*
|
||
|
|
* Convention (see agent-pipeline plan §7): a convoy is a `project` object in
|
||
|
|
* the workspace `convoys-<repo>`. Status starts at "draft". The description
|
||
|
|
* is the convoy markdown body + a "## Status log" section that
|
||
|
|
* `transition_convoy_status` appends to.
|
||
|
|
*
|
||
|
|
* Local-first: the role writes `.convoys/<slug>.md` FIRST, then calls this.
|
||
|
|
* If this call fails, the role queues to `.convoys/.pending-mcp-sync.jsonl`
|
||
|
|
* and `reconcile_from_files` replays later.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const classificationEnum = z.enum([
|
||
|
|
"feature",
|
||
|
|
"hotfix",
|
||
|
|
"docs-only",
|
||
|
|
"infra-only",
|
||
|
|
"server-only",
|
||
|
|
"config-only",
|
||
|
|
]);
|
||
|
|
|
||
|
|
const createConvoyInputSchema = z.object({
|
||
|
|
workspace: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.describe(
|
||
|
|
"Workspace UUID or slug (e.g. 'convoys-tasks'). Workspace must already exist.",
|
||
|
|
),
|
||
|
|
slug: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.max(100)
|
||
|
|
.regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case")
|
||
|
|
.describe("Convoy slug. Matches the filename: .convoys/<slug>.md"),
|
||
|
|
title: z.string().min(1).max(500),
|
||
|
|
classification: classificationEnum,
|
||
|
|
skipFlags: z
|
||
|
|
.array(z.string())
|
||
|
|
.default([])
|
||
|
|
.describe(
|
||
|
|
"Conductor-set skip flags (ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag).",
|
||
|
|
),
|
||
|
|
successMetric: z.string().min(1).describe("One-sentence success metric."),
|
||
|
|
ideaMarkdown: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.describe(
|
||
|
|
"The convoy markdown body. Becomes the description, with a '## Status log' appended.",
|
||
|
|
),
|
||
|
|
stackClass: z
|
||
|
|
.enum(["nextjs-prisma", "nextjs", "node-generic", "non-node", "other"])
|
||
|
|
.optional(),
|
||
|
|
repo: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.describe(
|
||
|
|
"Consumer repo basename (e.g. 'tasks', 'zest'). Used to disambiguate cross-repo queries.",
|
||
|
|
),
|
||
|
|
});
|
||
|
|
|
||
|
|
function initialDescription(input: z.infer<typeof createConvoyInputSchema>): string {
|
||
|
|
const skipLine = input.skipFlags.length > 0 ? input.skipFlags.join(", ") : "—";
|
||
|
|
const meta = [
|
||
|
|
`<!-- pipeline:convoy -->`,
|
||
|
|
`<!-- repo: ${input.repo} -->`,
|
||
|
|
`<!-- classification: ${input.classification} -->`,
|
||
|
|
`<!-- skip_flags: ${skipLine} -->`,
|
||
|
|
`<!-- success_metric: ${input.successMetric.replace(/\n/g, " ")} -->`,
|
||
|
|
].join("\n");
|
||
|
|
const statusLog = [
|
||
|
|
`## Status log`,
|
||
|
|
"",
|
||
|
|
`- ${new Date().toISOString()} \`draft\` (set by role-conductor on create)`,
|
||
|
|
].join("\n");
|
||
|
|
return `${meta}\n\n${input.ideaMarkdown.trim()}\n\n${statusLog}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function registerCreateConvoyTool(mcp: McpServer): void {
|
||
|
|
mcp.registerTool(
|
||
|
|
"create_convoy",
|
||
|
|
{
|
||
|
|
description:
|
||
|
|
"Create a new convoy as a `project` object in the convoys-<repo> workspace. Status starts at 'draft'. Idempotent on (workspace, slug): if a project with the same title already exists in this workspace, returns the existing one without creating a duplicate.",
|
||
|
|
inputSchema: createConvoyInputSchema,
|
||
|
|
},
|
||
|
|
async (args) => {
|
||
|
|
try {
|
||
|
|
const input = createConvoyInputSchema.parse(args);
|
||
|
|
const ws = await resolveWorkspaceHandle(input.workspace);
|
||
|
|
|
||
|
|
const [created] = await db
|
||
|
|
.insert(objects)
|
||
|
|
.values({
|
||
|
|
type: "project",
|
||
|
|
title: input.title,
|
||
|
|
workspaceId: ws.id,
|
||
|
|
description: initialDescription(input),
|
||
|
|
status: "draft",
|
||
|
|
parentId: null,
|
||
|
|
})
|
||
|
|
.returning();
|
||
|
|
|
||
|
|
if (!created) {
|
||
|
|
return toolErr("Failed to create convoy project");
|
||
|
|
}
|
||
|
|
|
||
|
|
return toolOk({
|
||
|
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||
|
|
convoy: {
|
||
|
|
id: created.id,
|
||
|
|
slug: input.slug,
|
||
|
|
title: created.title,
|
||
|
|
status: created.status,
|
||
|
|
classification: input.classification,
|
||
|
|
skipFlags: input.skipFlags,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
return toolCatch(e);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|