claude-plan-review 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.js ADDED
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Local MCP (Model Context Protocol) server for claude-plan-review.
3
+ *
4
+ * Exposes two tools Claude can call to get a plan reviewed WITHOUT being in plan
5
+ * mode (the tools-first creation path):
6
+ * • plan_review_submit — record a plan (single doc, raw markdown, or a
7
+ * multi-document tree), open the browser review UI, return the review id.
8
+ * • plan_review_check — poll a review for the reviewer's decision.
9
+ *
10
+ * Transport: stdio, newline-delimited JSON-RPC 2.0 (the MCP stdio framing —
11
+ * one JSON message per line, no embedded newlines). Hand-rolled, ZERO new deps.
12
+ * Implemented subset of MCP spec 2025-06-18: initialize handshake,
13
+ * notifications/initialized, tools/list, tools/call, ping.
14
+ *
15
+ * Records go through the SAME store.recordPlan as the PreToolUse hook, so the
16
+ * review UI, dedup, and the hook's own polling all see identical shapes.
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import { readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
23
+ import { buildDenyReason, parsePlan, validateDocs } from "./plan-parse.js";
24
+ import { getReview, recordPlan } from "./store.js";
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const SERVER_SCRIPT = join(HERE, "server.js");
28
+
29
+ // Latest MCP protocol revision we implement. We echo the client's requested
30
+ // version back when it sends one (the compatible behaviour for a version-
31
+ // agnostic tools server), else advertise this.
32
+ const PROTOCOL_VERSION = "2025-06-18";
33
+ const SERVER_INFO = { name: "claude-plan-review", version: "0.4.0" };
34
+
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+
37
+ // Long-poll tuning for plan_review_check. RECOMMENDED_WAIT is the value we steer
38
+ // Claude to pass — short enough to stay well under Claude Code's MCP limits for a
39
+ // stdio server (no per-request timer; ~30-min idle timeout; a main-conversation
40
+ // call auto-backgrounds after ~2 min on v2.1.212+), long enough to usually catch
41
+ // a fast decision in one call. MAX_WAIT caps a single call; the loop re-issues.
42
+ const RECOMMENDED_WAIT_S = 20;
43
+ const MAX_WAIT_S = 120;
44
+ const CHECK_POLL_MS = 700;
45
+
46
+ // ---------- server lifecycle (reimplemented locally; mirrors hook.js) ----------
47
+ function serverPort() {
48
+ try {
49
+ return JSON.parse(readFileSync(SERVER_FILE, "utf8")).port || DEFAULT_PORT;
50
+ } catch {
51
+ return DEFAULT_PORT;
52
+ }
53
+ }
54
+
55
+ async function isUp(port) {
56
+ try {
57
+ const res = await fetch(`http://localhost:${port}/api/health`, {
58
+ signal: AbortSignal.timeout(500),
59
+ });
60
+ return res.ok;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function ensureServer() {
67
+ const port = serverPort();
68
+ if (await isUp(port)) return port;
69
+ // spawn detached with the SAME runtime running this process (node or bun)
70
+ const child = spawn(process.execPath, [SERVER_SCRIPT, String(port)], {
71
+ detached: true,
72
+ stdio: "ignore",
73
+ });
74
+ child.unref();
75
+ for (let i = 0; i < 40; i++) {
76
+ if (await isUp(port)) return port;
77
+ await sleep(150);
78
+ }
79
+ return port; // best effort
80
+ }
81
+
82
+ function openBrowser(targetUrl) {
83
+ const cmd =
84
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
85
+ const args = process.platform === "win32" ? ["/c", "start", "", targetUrl] : [targetUrl];
86
+ try {
87
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
88
+ } catch {
89
+ /* non-fatal */
90
+ }
91
+ }
92
+
93
+ // ---------- tool definitions ----------
94
+ const TOOLS = [
95
+ {
96
+ name: "plan_review_submit",
97
+ title: "Submit a plan for human review",
98
+ description:
99
+ "Submit an implementation plan for the user to review, comment on, and approve or reject " +
100
+ "in a local browser UI — WITHOUT being in plan mode. Use this whenever the user should sign " +
101
+ "off on a plan before you implement it, especially for a large or multi-part plan that reads " +
102
+ "best as a navigable tree of documents.\n\n" +
103
+ "Provide EXACTLY ONE of these content fields:\n" +
104
+ " • docs — a pre-structured document tree. Each entry: {slug, title, parent?, body}. " +
105
+ "`slug` is a kebab-case id (^[a-z0-9][a-z0-9-]*$), unique; `title` is a human heading; " +
106
+ "`parent` is another doc's slug (omit it for the single top-level doc — conventionally " +
107
+ "slugged \"root\" — and set children's parent to that slug). Cross-doc links: [[slug]], " +
108
+ "[[slug|text]] or [text](doc:slug).\n" +
109
+ " • markdown — a single markdown document (no tree).\n" +
110
+ " • plan — a raw plan string that may embed document-separator markers " +
111
+ "(<!--doc slug=... title=\"...\" parent=...--> lines); the server splits it into a tree.\n\n" +
112
+ "On success returns { reviewId, reviewUrl, projectKey, version }. The call does NOT block on " +
113
+ "the decision — after submitting, call plan_review_check with the returned reviewId to see " +
114
+ "whether the user approved or requested changes. If the plan is structurally invalid the call " +
115
+ "returns an error listing every problem to fix before resubmitting.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ cwd: {
120
+ type: "string",
121
+ description:
122
+ "Absolute path of the project/worktree this plan is for (usually your working directory). Required.",
123
+ },
124
+ docs: {
125
+ type: "array",
126
+ description:
127
+ "A pre-structured document tree. Provide this OR markdown OR plan (exactly one).",
128
+ items: {
129
+ type: "object",
130
+ properties: {
131
+ slug: { type: "string", description: "Kebab-case id, unique across docs." },
132
+ title: { type: "string", description: "Human-readable document title." },
133
+ parent: {
134
+ type: "string",
135
+ description:
136
+ "Slug of the parent doc. Omit for the single top-level doc (conventionally slugged \"root\"); children reference their parent's slug.",
137
+ },
138
+ body: { type: "string", description: "Markdown body of this document." },
139
+ },
140
+ required: ["slug", "title", "body"],
141
+ },
142
+ },
143
+ markdown: {
144
+ type: "string",
145
+ description:
146
+ "A single markdown document. Provide this OR docs OR plan (exactly one).",
147
+ },
148
+ plan: {
149
+ type: "string",
150
+ description:
151
+ "A raw plan string, optionally with <!--doc ...--> separator markers. Provide this OR docs OR markdown (exactly one).",
152
+ },
153
+ },
154
+ required: ["cwd"],
155
+ },
156
+ },
157
+ {
158
+ name: "plan_review_check",
159
+ title: "Check a plan review's decision",
160
+ description:
161
+ "Check whether the user has decided on a plan submitted with plan_review_submit. " +
162
+ "Returns { status: \"pending\" | \"approved\" | \"rejected\", notes?, reason? }.\n\n" +
163
+ "This tool LONG-POLLS: pass `wait` (seconds) and the call blocks server-side until the user " +
164
+ `decides or \`wait\` elapses, then returns. Use \`wait: ${RECOMMENDED_WAIT_S}\` and call it in a ` +
165
+ "loop while the status is \"pending\" — do NOT end your turn while a review is pending. " +
166
+ "Give up only after ~5 hours total and ask the user how to proceed.\n" +
167
+ " • pending — the user has not decided yet. Call this tool again immediately with " +
168
+ `wait: ${RECOMMENDED_WAIT_S}.\n` +
169
+ " • approved — proceed with implementation. `notes` (if present) are the reviewer's comments " +
170
+ "to incorporate as you go (\"approve with comments\").\n" +
171
+ " • rejected — do NOT implement. `reason` explains the requested changes; revise the plan and " +
172
+ "resubmit with plan_review_submit.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ reviewId: {
177
+ type: "string",
178
+ description: "The reviewId returned by plan_review_submit.",
179
+ },
180
+ wait: {
181
+ type: "number",
182
+ description:
183
+ `Seconds to block waiting for a decision before returning (long-poll). Default 0 ` +
184
+ `(return immediately); capped at ${MAX_WAIT_S}. Recommended: ${RECOMMENDED_WAIT_S}. ` +
185
+ `If still pending when it returns, call again with the same wait.`,
186
+ },
187
+ },
188
+ required: ["reviewId"],
189
+ },
190
+ },
191
+ ];
192
+
193
+ // ---------- tool implementations ----------
194
+ /**
195
+ * Normalize a caller-supplied docs array into the store's tree shape. Mirrors
196
+ * plan-parse's normParent EXACTLY (absent/empty/self ⇒ top-level; "root" is a
197
+ * normal slug reference, so a child with parent:"root" nests under the doc
198
+ * slugged "root") so the stored tree is byte-identical to what the parser
199
+ * produces for the same content.
200
+ */
201
+ function normParent(parent, slug) {
202
+ if (parent == null) return null;
203
+ const v = String(parent).trim();
204
+ if (v === "" || v === slug) return null;
205
+ return v;
206
+ }
207
+ function docsToTree(docs) {
208
+ const normalized = docs.map((d, i) => ({
209
+ slug: d.slug,
210
+ title: d.title,
211
+ parent: normParent(d.parent, d.slug),
212
+ body: d.body ?? "",
213
+ order: i,
214
+ }));
215
+ const root = normalized.find((d) => d.parent === null)?.slug ?? "root";
216
+ return { kind: "tree", root, docs: normalized };
217
+ }
218
+
219
+ function runSubmit(args) {
220
+ const cwd = args?.cwd;
221
+ if (typeof cwd !== "string" || !cwd.trim()) {
222
+ return toolError("plan_review_submit requires `cwd` (the absolute project path).");
223
+ }
224
+
225
+ // Exactly one content field.
226
+ const provided = ["docs", "markdown", "plan"].filter(
227
+ (k) => args[k] !== undefined && args[k] !== null,
228
+ );
229
+ if (provided.length === 0) {
230
+ return toolError("Provide exactly one of `docs`, `markdown`, or `plan`.");
231
+ }
232
+ if (provided.length > 1) {
233
+ return toolError(
234
+ `Provide exactly one content field — got ${provided.join(", ")}. Use only one of docs / markdown / plan.`,
235
+ );
236
+ }
237
+
238
+ // Build a normalized tree, collecting all validation issues.
239
+ let tree;
240
+ if (provided[0] === "docs") {
241
+ if (!Array.isArray(args.docs) || args.docs.length === 0) {
242
+ return toolError("`docs` must be a non-empty array of {slug, title, parent?, body}.");
243
+ }
244
+ const issues = validateDocs(args.docs);
245
+ if (issues && issues.length) return toolError(buildDenyReason(issues));
246
+ tree = docsToTree(args.docs);
247
+ } else {
248
+ const str = args[provided[0]];
249
+ if (typeof str !== "string" || !str.trim()) {
250
+ return toolError(`\`${provided[0]}\` must be a non-empty string.`);
251
+ }
252
+ let parsed;
253
+ try {
254
+ parsed = parsePlan(str);
255
+ } catch (e) {
256
+ return toolError(`Could not parse the plan: ${e?.message || e}`);
257
+ }
258
+ if (parsed && parsed.error) return toolError(buildDenyReason(parsed.error.issues));
259
+ tree = parsed;
260
+ }
261
+
262
+ // Record through the SAME store path the hook uses.
263
+ let recorded;
264
+ try {
265
+ recorded = recordPlan({ cwd, tree, origin: "mcp" });
266
+ } catch (e) {
267
+ return toolError(`Failed to record the plan: ${e?.message || e}`);
268
+ }
269
+ const { key, version, reviewId } = recorded;
270
+
271
+ return { pending: { key, version, reviewId, tree } };
272
+ }
273
+
274
+ async function finishSubmit(pending) {
275
+ const { key, version, reviewId, tree } = pending;
276
+ const rootDoc = (tree && tree.root) || "root";
277
+ const port = await ensureServer();
278
+ const reviewUrl = `http://localhost:${port}/?project=${encodeURIComponent(
279
+ key,
280
+ )}&version=${version}&review=${reviewId}&doc=${encodeURIComponent(rootDoc)}`;
281
+ openBrowser(reviewUrl);
282
+
283
+ const result = { reviewId, reviewUrl, projectKey: key, version };
284
+ return toolText(
285
+ `Plan submitted for review (version ${version}). The review UI is opening in the browser.\n\n` +
286
+ `Next: call plan_review_check with {reviewId: "${reviewId}", wait: ${RECOMMENDED_WAIT_S}} NOW ` +
287
+ `and keep calling it in a loop while status is "pending". Do not end your turn while the ` +
288
+ `review is pending. Give up and ask the user only after ~5 hours total.\n\n` +
289
+ JSON.stringify(result, null, 2),
290
+ result,
291
+ );
292
+ }
293
+
294
+ async function runCheck(args) {
295
+ const reviewId = args?.reviewId;
296
+ if (typeof reviewId !== "string" || !reviewId.trim()) {
297
+ return toolError("plan_review_check requires `reviewId` (from plan_review_submit).");
298
+ }
299
+ let review = getReview(reviewId);
300
+ if (!review) {
301
+ return toolError(`No review found for reviewId "${reviewId}".`);
302
+ }
303
+
304
+ // Long-poll: while `wait` seconds remain and the review is still pending, poll
305
+ // the store. Each `await sleep` yields the event loop, so the stdin read loop
306
+ // keeps dispatching (and answering) OTHER incoming requests meanwhile.
307
+ let wait = Number(args?.wait);
308
+ if (!Number.isFinite(wait) || wait < 0) wait = 0;
309
+ wait = Math.min(wait, MAX_WAIT_S);
310
+ if (wait > 0 && review.status === "pending") {
311
+ const deadline = Date.now() + wait * 1000;
312
+ while (Date.now() < deadline && review.status === "pending") {
313
+ await sleep(CHECK_POLL_MS);
314
+ review = getReview(reviewId) || review;
315
+ }
316
+ }
317
+
318
+ const result = { status: review.status };
319
+ if (review.notes) result.notes = review.notes;
320
+ if (review.reason) result.reason = review.reason;
321
+
322
+ let hint;
323
+ if (review.status === "approved") {
324
+ hint = result.notes
325
+ ? "APPROVED with comments — proceed with implementation, incorporating each note."
326
+ : "APPROVED — proceed with implementation.";
327
+ } else if (review.status === "rejected") {
328
+ hint = "CHANGES REQUESTED — do not implement; revise per `reason` and resubmit.";
329
+ } else {
330
+ hint =
331
+ `Still pending — call plan_review_check again now with wait:${RECOMMENDED_WAIT_S}. ` +
332
+ "Do not end your turn. Give up after ~5 hours total and ask the user.";
333
+ }
334
+ return toolText(`${hint}\n\n${JSON.stringify(result, null, 2)}`, result);
335
+ }
336
+
337
+ // ---------- tool-result helpers ----------
338
+ function toolText(text, structured) {
339
+ const res = { content: [{ type: "text", text }] };
340
+ if (structured !== undefined) res.structuredContent = structured;
341
+ return res;
342
+ }
343
+ function toolError(text) {
344
+ return { content: [{ type: "text", text }], isError: true };
345
+ }
346
+
347
+ // ---------- JSON-RPC plumbing ----------
348
+ function send(msg) {
349
+ process.stdout.write(JSON.stringify(msg) + "\n");
350
+ }
351
+ function reply(id, result) {
352
+ send({ jsonrpc: "2.0", id, result });
353
+ }
354
+ function replyError(id, code, message, data) {
355
+ const error = { code, message };
356
+ if (data !== undefined) error.data = data;
357
+ send({ jsonrpc: "2.0", id, error });
358
+ }
359
+
360
+ async function handleMessage(msg) {
361
+ if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
362
+ // Malformed request with an id → invalid-request error; otherwise ignore.
363
+ if (msg && msg.id !== undefined && msg.id !== null) {
364
+ replyError(msg.id, -32600, "Invalid Request");
365
+ }
366
+ return;
367
+ }
368
+ const { id, method, params } = msg;
369
+ const isNotification = id === undefined || id === null;
370
+
371
+ switch (method) {
372
+ case "initialize": {
373
+ const requested = params?.protocolVersion;
374
+ const protocolVersion = typeof requested === "string" ? requested : PROTOCOL_VERSION;
375
+ reply(id, {
376
+ protocolVersion,
377
+ capabilities: { tools: {} },
378
+ serverInfo: SERVER_INFO,
379
+ instructions:
380
+ "Tools to get a plan reviewed by the user in a local browser UI. Call plan_review_submit " +
381
+ "to open a review, then plan_review_check to poll for the decision.",
382
+ });
383
+ return;
384
+ }
385
+ case "notifications/initialized":
386
+ case "notifications/cancelled":
387
+ return; // notifications: no response
388
+ case "ping":
389
+ if (!isNotification) reply(id, {});
390
+ return;
391
+ case "tools/list":
392
+ reply(id, { tools: TOOLS });
393
+ return;
394
+ case "tools/call": {
395
+ const name = params?.name;
396
+ const args = params?.arguments ?? {};
397
+ try {
398
+ if (name === "plan_review_submit") {
399
+ const r = runSubmit(args);
400
+ // runSubmit returns either a tool-result (error) or {pending}
401
+ reply(id, r.pending ? await finishSubmit(r.pending) : r);
402
+ } else if (name === "plan_review_check") {
403
+ reply(id, await runCheck(args));
404
+ } else {
405
+ replyError(id, -32602, `Unknown tool: ${name}`);
406
+ }
407
+ } catch (e) {
408
+ // Never crash the server on a tool bug — report as a tool error.
409
+ reply(id, toolError(`Tool "${name}" failed: ${e?.message || e}`));
410
+ }
411
+ return;
412
+ }
413
+ default:
414
+ if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
415
+ return;
416
+ }
417
+ }
418
+
419
+ // ---------- stdin loop (newline-delimited JSON) ----------
420
+ export function startMcpServer() {
421
+ let buffer = "";
422
+ process.stdin.setEncoding("utf8");
423
+ process.stdin.on("data", (chunk) => {
424
+ buffer += chunk;
425
+ let nl;
426
+ while ((nl = buffer.indexOf("\n")) >= 0) {
427
+ const line = buffer.slice(0, nl).trim();
428
+ buffer = buffer.slice(nl + 1);
429
+ if (!line) continue;
430
+ let msg;
431
+ try {
432
+ msg = JSON.parse(line);
433
+ } catch {
434
+ // Unparseable line → JSON-RPC parse error (no id available).
435
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
436
+ continue;
437
+ }
438
+ // Process sequentially; handleMessage is async but ordering of replies is
439
+ // best-effort and each carries its own id, so we don't await here.
440
+ Promise.resolve(handleMessage(msg)).catch(() => {});
441
+ }
442
+ });
443
+ process.stdin.on("end", () => process.exit(0));
444
+ }
package/src/paths.js CHANGED
@@ -23,3 +23,20 @@ export function projectKey(cwd) {
23
23
  export function projectDir(key) {
24
24
  return join(PROJECTS_DIR, key);
25
25
  }
26
+
27
+ const pad = (n) => String(n).padStart(4, "0");
28
+
29
+ /** Directory holding a tree version's manifest + per-doc markdown (tree kind only). */
30
+ export function versionDir(key, n) {
31
+ return join(projectDir(key), "versions", pad(n));
32
+ }
33
+
34
+ /** manifest.json path inside a tree version's directory. */
35
+ export function manifestPath(key, n) {
36
+ return join(versionDir(key, n), "manifest.json");
37
+ }
38
+
39
+ /** Path to one document file (e.g. "api.md") inside a tree version's directory. */
40
+ export function docPath(key, n, file) {
41
+ return join(versionDir(key, n), file);
42
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Pure, dependency-free parser for multi-document plans.
3
+ *
4
+ * A plan is a single markdown string. Documents are delimited by an HTML-comment
5
+ * marker, alone on its own line (invisible when rendered as plain markdown):
6
+ *
7
+ * <!--doc slug=root title="Overview"-->
8
+ * <!--doc slug=api title="API Design" parent=root-->
9
+ *
10
+ * Zero markers ⇒ a single flat document (stored exactly as today). Content before
11
+ * the first marker becomes the root document. The scan is fence-aware: marker
12
+ * lines inside fenced code blocks (``` or ~~~) are ignored.
13
+ */
14
+
15
+ const MARKER_RE = /^\s*<!--doc\s+(.*?)\s*-->\s*$/;
16
+ const FENCE_RE = /^\s*(```|~~~)/;
17
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
18
+
19
+ /** Parse `key=value` / `key="quoted value"` pairs, order-independent. */
20
+ function parseAttrs(str) {
21
+ const attrs = {};
22
+ const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
23
+ let m;
24
+ while ((m = re.exec(str))) {
25
+ attrs[m[1]] = m[2] ?? m[3] ?? m[4] ?? "";
26
+ }
27
+ return attrs;
28
+ }
29
+
30
+ /** Normalize a parent attribute → parent slug, or null for a top-level doc. */
31
+ function normParent(parent, slug) {
32
+ if (parent == null) return null;
33
+ const v = String(parent).trim();
34
+ if (v === "" || v === slug) return null;
35
+ return v;
36
+ }
37
+
38
+ /** Cross-doc link targets referenced from a doc body. */
39
+ function linkTargets(body) {
40
+ const s = String(body || "");
41
+ const out = [];
42
+ let m;
43
+ const wiki = /\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g;
44
+ while ((m = wiki.exec(s))) out.push(m[1].trim());
45
+ const md = /\]\(doc:([^)\s]+)\)/g;
46
+ while ((m = md.exec(s))) out.push(m[1].trim());
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Validate a document set, collecting ALL issues. Also used for pre-structured
52
+ * docs coming from POST /api/plans. Returns Issue[] ({code,message,marker?}).
53
+ */
54
+ export function validateDocs(docs) {
55
+ const list = Array.isArray(docs) ? docs : [];
56
+ const issues = [];
57
+ const withMarker = (i, d) => (d && d.marker ? { ...i, marker: d.marker } : i);
58
+
59
+ const slugSet = new Set();
60
+ const seen = new Set();
61
+ const dupReported = new Set();
62
+
63
+ for (const d of list) {
64
+ const slug = d.slug == null ? "" : String(d.slug);
65
+ if (!slug) {
66
+ issues.push(
67
+ withMarker(
68
+ { code: "missing-slug", message: `A document is missing its \`slug\`${d.title ? ` (title "${d.title}")` : ""}.` },
69
+ d,
70
+ ),
71
+ );
72
+ } else {
73
+ if (!SLUG_RE.test(slug)) {
74
+ issues.push(
75
+ withMarker({ code: "bad-slug", message: `Slug "${slug}" is invalid — use kebab-case matching ^[a-z0-9][a-z0-9-]*$.` }, d),
76
+ );
77
+ }
78
+ if (seen.has(slug) && !dupReported.has(slug)) {
79
+ issues.push(withMarker({ code: "duplicate-slug", message: `Slug "${slug}" is used by more than one document.` }, d));
80
+ dupReported.add(slug);
81
+ }
82
+ seen.add(slug);
83
+ slugSet.add(slug);
84
+ }
85
+ if (!d.title || !String(d.title).trim()) {
86
+ issues.push(withMarker({ code: "missing-title", message: `Document "${slug || "(no slug)"}" is missing its \`title\`.` }, d));
87
+ }
88
+ }
89
+
90
+ // exactly one root (top-level doc with no parent)
91
+ const roots = list.filter((d) => normParent(d.parent, d.slug) === null);
92
+ if (roots.length === 0) {
93
+ issues.push({ code: "no-root", message: "No root document — exactly one document must have no parent." });
94
+ } else if (roots.length > 1) {
95
+ issues.push({
96
+ code: "multiple-roots",
97
+ message: `Multiple root documents (${roots.map((d) => d.slug || "?").join(", ")}) — exactly one document may have no parent.`,
98
+ });
99
+ }
100
+
101
+ // unknown parents
102
+ for (const d of list) {
103
+ const p = normParent(d.parent, d.slug);
104
+ if (p !== null && !slugSet.has(p)) {
105
+ issues.push(withMarker({ code: "unknown-parent", message: `Document "${d.slug}" references unknown parent "${p}".` }, d));
106
+ }
107
+ }
108
+
109
+ // parent cycles
110
+ const bySlug = new Map(list.filter((d) => d.slug).map((d) => [String(d.slug), d]));
111
+ const cycled = new Set();
112
+ for (const d of list) {
113
+ if (!d.slug) continue;
114
+ const visited = new Set();
115
+ let cur = d;
116
+ while (cur) {
117
+ const s = String(cur.slug);
118
+ if (visited.has(s)) {
119
+ cycled.add(s);
120
+ break;
121
+ }
122
+ visited.add(s);
123
+ const p = normParent(cur.parent, cur.slug);
124
+ if (p === null) break;
125
+ cur = bySlug.get(p);
126
+ }
127
+ }
128
+ for (const s of cycled) {
129
+ issues.push({ code: "parent-cycle", message: `Document "${s}" is part of a parent cycle.` });
130
+ }
131
+
132
+ // dangling cross-doc links
133
+ for (const d of list) {
134
+ for (const target of linkTargets(d.body)) {
135
+ if (!slugSet.has(target)) {
136
+ issues.push(
137
+ withMarker({ code: "dangling-link", message: `Document "${d.slug}" links to "${target}", which is not a defined slug.` }, d),
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ return issues;
144
+ }
145
+
146
+ /**
147
+ * Parse a plan string.
148
+ * → {kind:"single", markdown}
149
+ * → {kind:"tree", root, docs:[{slug,title,parent|null,body,order}]}
150
+ * → {error:{issues:[{code,message,marker?}]}}
151
+ */
152
+ export function parsePlan(planString) {
153
+ const src = String(planString ?? "");
154
+ const lines = src.split("\n");
155
+
156
+ let inFence = false;
157
+ const markers = [];
158
+ for (let i = 0; i < lines.length; i++) {
159
+ const line = lines[i];
160
+ if (FENCE_RE.test(line)) {
161
+ inFence = !inFence;
162
+ continue;
163
+ }
164
+ if (inFence) continue;
165
+ const m = MARKER_RE.exec(line);
166
+ if (m) markers.push({ attrs: parseAttrs(m[1]), line: i, raw: line.trim() });
167
+ }
168
+
169
+ if (markers.length === 0) {
170
+ return { kind: "single", markdown: src };
171
+ }
172
+
173
+ const docs = [];
174
+ let order = 0;
175
+
176
+ // content before the first marker → root document
177
+ const preContent = lines.slice(0, markers[0].line).join("\n");
178
+ if (preContent.trim()) {
179
+ const h1 = preContent.match(/^\s*#\s+(.+?)\s*$/m);
180
+ docs.push({
181
+ slug: "root",
182
+ title: h1 ? h1[1].trim() : "Overview",
183
+ parent: null,
184
+ body: preContent.replace(/\s+$/, ""),
185
+ order: order++,
186
+ });
187
+ }
188
+
189
+ for (let k = 0; k < markers.length; k++) {
190
+ const { attrs, line, raw } = markers[k];
191
+ const bodyStart = line + 1;
192
+ const bodyEnd = k + 1 < markers.length ? markers[k + 1].line : lines.length;
193
+ const body = lines.slice(bodyStart, bodyEnd).join("\n").replace(/\s+$/, "");
194
+ docs.push({
195
+ slug: attrs.slug,
196
+ title: attrs.title,
197
+ parent: normParent(attrs.parent, attrs.slug),
198
+ body,
199
+ order: order++,
200
+ marker: raw,
201
+ });
202
+ }
203
+
204
+ const issues = validateDocs(docs);
205
+ if (issues.length) return { error: { issues } };
206
+
207
+ const root = docs.find((d) => d.parent === null);
208
+ return {
209
+ kind: "tree",
210
+ root: root ? root.slug : "root",
211
+ docs: docs.map((d) => ({ slug: d.slug, title: d.title, parent: d.parent, body: d.body, order: d.order })),
212
+ };
213
+ }
214
+
215
+ /** Rewrite '[[slug]]' / '[[slug|text]]' wiki links to '[text](doc:slug)' (pre-lex step). */
216
+ export function rewriteWikiLinks(md) {
217
+ return String(md ?? "").replace(/\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, slug, text) => {
218
+ const s = String(slug).trim();
219
+ const t = text != null && String(text).trim() ? String(text).trim() : s;
220
+ return `[${t}](doc:${s})`;
221
+ });
222
+ }
223
+
224
+ /** Build the verbatim deny-reason the hook feeds back on a malformed tree. */
225
+ export function buildDenyReason(issues) {
226
+ const lines = [
227
+ "Your multi-document plan has structural problems. Fix them and re-present with ExitPlanMode.",
228
+ "",
229
+ "Marker format — one HTML comment per document, on its own line:",
230
+ ' <!--doc slug=<kebab-case-id> title="Human Title" parent=<parent-slug>-->',
231
+ "Rules: exactly one root doc (no parent); every slug unique; every parent must",
232
+ "exist; cross-doc links use [[slug]] or [text](doc:slug) and must target a defined slug.",
233
+ "",
234
+ "Problems found:",
235
+ ];
236
+ for (const it of Array.isArray(issues) ? issues : []) lines.push(` • ${it.message}`);
237
+ return lines.join("\n");
238
+ }