import { mkdir, writeFile, rename, access } from "node:fs/promises"; import { dirname, join } from "node:path"; import { randomUUID } from "node:crypto"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { parseDocument, Scalar } from "yaml"; import { eq, and } from "../drizzle.js"; import { db } from "../db.js"; import { markdownBacklogItems } from "../schema.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { resolveRepoRoot } from "../lib/repo-root.js"; import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts"; import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts"; import { toolCatch, toolErr, toolOk } from "./tool-result.js"; const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const kindSchema = z.enum(["plan", "epic", "task"]); const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]); const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]); const createBacklogItemInputSchema = z .object({ workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), kind: kindSchema, slug: z .string() .min(1) .max(200) .regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"), title: z.string().min(1).max(500), planSlug: z .string() .min(1) .max(200) .regex(slugRe, "planSlug must be lowercase-hyphenated") .optional(), epicSlug: z .string() .min(1) .max(200) .regex(slugRe, "epicSlug must be lowercase-hyphenated") .optional(), status: statusSchema.optional(), priority: prioritySchema.optional(), bodyMarkdown: z.string().optional(), agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"), }) .superRefine((input, ctx) => { if (input.kind === "plan") { if (input.epicSlug) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "epicSlug must not be provided for kind=plan", path: ["epicSlug"], }); } if (input.planSlug && input.planSlug !== input.slug) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "planSlug for kind=plan must equal slug or be omitted", path: ["planSlug"], }); } } if (input.kind === "epic") { if (!input.planSlug?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "planSlug is required for kind=epic", path: ["planSlug"], }); } } if (input.kind === "task") { if (!input.planSlug?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "planSlug is required for kind=task", path: ["planSlug"], }); } if (!input.epicSlug?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "epicSlug is required for kind=task", path: ["epicSlug"], }); } } }); function todayIso(): string { return new Date().toISOString().slice(0, 10); } function defaultStatusForKind(kind: z.infer): string { if (kind === "task") return "ready"; return "draft"; } function defaultBodyForKind(kind: string, title: string): string { if (kind === "plan") { return `# ${title}\n\nShort summary of the initiative and why it exists.\n`; } if (kind === "epic") { return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`; } return `# ${title}\n\nOne or two sentences.\n`; } function repoPathFor(input: { kind: z.infer; slug: string; planSlug?: string; epicSlug?: string; }): string { if (input.kind === "plan") { return `plans/Plan-${input.slug}/Plan-${input.slug}.md`; } if (input.kind === "epic") { return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`; } return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`; } function buildFrontmatterYaml(input: { kind: string; slug: string; title: string; planSlug: string | null; epicSlug: string | null; status: string; priority: string; agentPrompt: string | null; }): string { const doc = parseDocument(""); // Ensure we start from empty map if (!doc.contents) { // parseDocument("") yields empty; ensure contents is mapping } doc.set("kind", input.kind); doc.set("slug", input.slug); doc.set("title", input.title); if (input.planSlug) doc.set("plan_slug", input.planSlug); if (input.epicSlug) doc.set("epic_slug", input.epicSlug); doc.set("status", input.status); doc.set("priority", input.priority); doc.set("updated_at", todayIso()); if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") { const isMultiline = input.agentPrompt.includes("\n"); const node = doc.createNode(input.agentPrompt); if (node instanceof Scalar) { node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN; } doc.set("agent_prompt", node); } const yamlText = String(doc).replace(/\n$/, ""); return yamlText; } async function fileExists(absPath: string): Promise { try { await access(absPath); return true; } catch { return false; } } async function atomicWrite(absPath: string, contents: string): Promise { const dir = dirname(absPath); await mkdir(dir, { recursive: true }); const tmp = join(dir, `.${randomUUID()}.tmp`); await writeFile(tmp, contents, "utf8"); await rename(tmp, absPath); } export function registerCreateBacklogItemTool(mcp: McpServer): void { mcp.registerTool( "create-backlog-item", { description: "Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.", inputSchema: createBacklogItemInputSchema, }, async (args) => { try { const input = createBacklogItemInputSchema.parse(args); const ws = await resolveWorkspaceHandle(input.workspace); const effectivePlanSlug = input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null); const effectiveEpicSlug = input.kind === "task" ? (input.epicSlug?.trim() ?? null) : input.kind === "epic" ? null : null; const status = input.status ?? defaultStatusForKind(input.kind); const priority = input.priority ?? "P2"; const agentPrompt = input.agent_prompt !== undefined && input.agent_prompt !== null ? input.agent_prompt : null; const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title); const repoPath = repoPathFor({ kind: input.kind, slug: input.slug, planSlug: effectivePlanSlug ?? undefined, epicSlug: effectiveEpicSlug ?? undefined, }); const repoRoot = resolveRepoRoot(); if (!repoRoot) { return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file"); } const absPath = join(repoRoot, ...repoPath.split("/")); // Duplicate check: file exists if (await fileExists(absPath)) { return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); } // Duplicate check: DB row exists for same workspace+repoPath const [existingRow] = await db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) { return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); } // Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse) // Plan files per template do not carry plan_slug/epic_slug const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug; const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null; const yamlText = buildFrontmatterYaml({ kind: input.kind, slug: input.slug, title: input.title, planSlug: yamlPlanSlug, epicSlug: yamlEpicSlug, status, priority, agentPrompt, }); const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`; // Ensure body ends with newline const normalizedBody = body.endsWith("\n") ? body : `${body}\n`; const raw = `---\n${yamlText}\n---${normalizedBody}`; const contentHash = hashFileContents(raw); // Frontmatter stored in DB (mirrors sync's parsed frontmatter) const frontmatter: Record = { kind: input.kind, slug: input.slug, title: input.title, status, priority, updated_at: todayIso(), }; if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug; if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug; if (agentPrompt !== null && agentPrompt.trim() !== "") { frontmatter.agent_prompt = agentPrompt; } // Atomic write .tmp → rename await atomicWrite(absPath, raw); // Upsert markdown_backlog_items with contentHash // Compute parentId by looking up parent repo path if any const parentPath = parentRepoPath(repoPath); let parentId: string | null = null; if (parentPath) { const [parentRow] = await db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath))) .limit(1); parentId = parentRow?.id ?? null; } let insertedId: string | null = null; try { const [inserted] = await db .insert(markdownBacklogItems) .values({ workspaceId: ws.id, kind: input.kind, slug: input.slug, planSlug: effectivePlanSlug ?? input.slug, epicSlug: effectiveEpicSlug, parentId, repoPath, title: input.title, status, priority, owner: null, frontmatter, bodyMarkdown: normalizedBody.replace(/^\n/, ""), contentHash, workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null, }) .returning({ id: markdownBacklogItems.id }); insertedId = inserted?.id ?? null; } catch (e) { // Unique violation → CONFLICT (race with concurrent writer) const msg = e instanceof Error ? e.message : String(e); if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) { return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); } throw e; } // If we had a parent but it was inserted concurrently after our lookup, // refresh parentId (best-effort) if (parentPath && !parentId) { const [parentRow] = await db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath))) .limit(1); if (parentRow) { await db .update(markdownBacklogItems) .set({ parentId: parentRow.id }) .where(eq(markdownBacklogItems.id, insertedId!)); parentId = parentRow.id; } } return toolOk({ workspace: { id: ws.id, slug: ws.slug, name: ws.name }, repoPath, absPath, contentHash, backlogItem: { id: insertedId, kind: input.kind, slug: input.slug, planSlug: effectivePlanSlug, epicSlug: effectiveEpicSlug, title: input.title, status, priority, parentId, }, }); } catch (e) { return toolCatch(e); } }, ); }