363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
|
|
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<typeof kindSchema>): 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<typeof kindSchema>;
|
||
|
|
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<boolean> {
|
||
|
|
try {
|
||
|
|
await access(absPath);
|
||
|
|
return true;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||
|
|
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<string, unknown> = {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|