claude-plan-review 0.3.2 → 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/server.js CHANGED
@@ -7,6 +7,10 @@ import { DEFAULT_PORT, ROOT, SERVER_FILE } from "./paths.js";
7
7
  import {
8
8
  addComment,
9
9
  deleteComment,
10
+ deleteProject,
11
+ deleteVersion,
12
+ getDoc,
13
+ getManifest,
10
14
  getProjectMeta,
11
15
  getReview,
12
16
  getStorage,
@@ -16,9 +20,11 @@ import {
16
20
  listReviews,
17
21
  listVersions,
18
22
  pruneResolvedReviews,
23
+ recordPlan,
19
24
  resolveReview,
20
25
  setStorage,
21
26
  } from "./store.js";
27
+ import { parsePlan, rewriteWikiLinks, validateDocs } from "./plan-parse.js";
22
28
  import { getChannel, listChannelStatus } from "./channels/index.js";
23
29
 
24
30
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -27,10 +33,80 @@ const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
27
33
 
28
34
  marked.setOptions({ gfm: true, breaks: false });
29
35
 
30
- function renderMarkdown(md) {
31
- const html = marked.parse(md, { async: false });
36
+ /**
37
+ * Render markdown, wrapping each top-level block in a `.md-block` div that
38
+ * carries its SOURCE line range (data-line-start / data-line-end). The browser
39
+ * uses these to map a text selection made in the rendered preview back to
40
+ * source line numbers, so comments stay line-anchored (same model the diff and
41
+ * comment store already use) while the reviewer works entirely in the preview.
42
+ *
43
+ * With `opts` ({key, version, validSlugs}) cross-document links are rewritten so
44
+ * they navigate inside the review UI instead of hitting the `doc:` pseudo-scheme:
45
+ * 1. pre-lex — `[[slug]]`/`[[slug|text]]` become `[text](doc:slug)`;
46
+ * 2. post-lex — every `link` token whose href starts `doc:` is rewritten to
47
+ * `?project=KEY&version=N&doc=SLUG` and its anchor tagged `data-doc="SLUG"`
48
+ * (the UI delegates clicks on `#preview a[data-doc]` to in-app nav).
49
+ * Only slugs present in `validSlugs` are rewritten. Called with NO opts the
50
+ * output is byte-identical to the plain single-document render.
51
+ */
52
+ function renderMarkdown(md, opts) {
53
+ let src = md || "";
54
+ if (opts) src = rewriteWikiLinks(src); // step 1: pre-lex wiki-link normalization
55
+
56
+ const tokens = marked.lexer(src);
57
+
58
+ // step 2: rewrite `doc:` link tokens in place; remember href→slug so the
59
+ // rendered anchors can be tagged with data-doc (walkTokens can't add attrs).
60
+ const hrefToSlug = new Map();
61
+ if (opts) {
62
+ const validSlugs = opts.validSlugs ? new Set(opts.validSlugs) : null;
63
+ marked.walkTokens(tokens, (tok) => {
64
+ if (tok.type !== "link" || typeof tok.href !== "string" || !tok.href.startsWith("doc:")) {
65
+ return;
66
+ }
67
+ const slug = tok.href.slice(4).trim();
68
+ if (validSlugs && !validSlugs.has(slug)) return;
69
+ const params = [`doc=${encodeURIComponent(slug)}`];
70
+ if (opts.key != null) params.unshift(`version=${encodeURIComponent(opts.version)}`);
71
+ if (opts.key != null) params.unshift(`project=${encodeURIComponent(opts.key)}`);
72
+ const href = `?${params.join("&")}`;
73
+ tok.href = href;
74
+ // marked v14 leaves `&` literal in hrefs; register the escaped form too so
75
+ // the tag step is robust to a renderer that HTML-escapes the attribute.
76
+ hrefToSlug.set(href, slug);
77
+ hrefToSlug.set(href.replace(/&/g, "&"), slug);
78
+ });
79
+ }
80
+
81
+ let offset = 0; // running char offset into `src`; token.raw values concatenate to src
82
+ const out = [];
83
+ for (const tok of tokens) {
84
+ const raw = tok.raw || "";
85
+ if (tok.type !== "space") {
86
+ const before = src.slice(0, offset);
87
+ const startLine = before.length ? before.split("\n").length : 1;
88
+ const trimmed = raw.replace(/\s+$/, ""); // ignore trailing blank lines in the range
89
+ const endLine = src.slice(0, offset + trimmed.length).split("\n").length;
90
+ const one = [tok];
91
+ one.links = tokens.links; // preserve reference-style link definitions
92
+ let html = marked.parser(one);
93
+ if (hrefToSlug.size) html = tagDocLinks(html, hrefToSlug);
94
+ out.push(
95
+ `<div class="md-block" data-line-start="${startLine}" data-line-end="${endLine}">${html}</div>`,
96
+ );
97
+ }
98
+ offset += raw.length;
99
+ }
32
100
  // light sanitization — local tool, content authored by your own Claude session
33
- return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
101
+ return out.join("\n").replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
102
+ }
103
+
104
+ /** Tag rewritten cross-doc anchors with `data-doc` (matched by exact href). */
105
+ function tagDocLinks(html, hrefToSlug) {
106
+ return html.replace(/<a\s+href="([^"]*)"/g, (m, href) => {
107
+ const slug = hrefToSlug.get(href);
108
+ return slug ? `<a data-doc="${slug}" href="${href}"` : m;
109
+ });
34
110
  }
35
111
 
36
112
  const CONTENT_TYPES = {
@@ -123,18 +199,53 @@ async function handleApi(req, res, seg, url) {
123
199
  if (a === "projects" && !b) return sendJSON(res, listProjects());
124
200
 
125
201
  if (a === "projects" && b && !c) {
202
+ // DELETE /api/projects/:key?force= — whole project
203
+ if (method === "DELETE") {
204
+ if (!getProjectMeta(b)) return sendJSON(res, { error: "no such project" }, 404);
205
+ const r = deleteProject(b, { force: isForce(url) });
206
+ if (r.blocked)
207
+ return sendJSON(res, { error: "pending review blocks deletion", pendingReviews: r.pendingReviews }, 409);
208
+ return sendJSON(res, r);
209
+ }
126
210
  const meta = getProjectMeta(b);
127
211
  if (!meta) return sendJSON(res, { error: "no such project" }, 404);
128
212
  return sendJSON(res, { meta, versions: listVersions(b) });
129
213
  }
130
214
 
131
- // /api/projects/:key/diff?from=&to=
215
+ // POST /api/projects/:key/bulk-delete — sibling of `versions`, immune to the NaN trap.
216
+ // ALWAYS 200 with partial success: {deleted:[n…], blocked:[{n,pendingReviews}…], meta}.
217
+ // (Never 409 — that stays only on single-version DELETE and project DELETE.) Done per
218
+ // version so a blocked one never stops the rest; force deletes blocked ones too.
219
+ if (a === "projects" && b && c === "bulk-delete" && !d && method === "POST") {
220
+ const body = JSON.parse((await readBody(req)) || "{}");
221
+ const versions = Array.isArray(body.versions)
222
+ ? body.versions.map(Number).filter(Number.isFinite)
223
+ : [];
224
+ if (!versions.length) return sendJSON(res, { error: "`versions` must be a non-empty array" }, 400);
225
+ const force = !!body.force;
226
+ const deleted = [];
227
+ const blocked = [];
228
+ for (const n of versions) {
229
+ const r = deleteVersion(b, n, { force });
230
+ if (r.deleted) deleted.push(n);
231
+ else if (r.blocked) blocked.push({ n, pendingReviews: r.pendingReviews });
232
+ }
233
+ return sendJSON(res, { deleted, blocked, meta: getProjectMeta(b) });
234
+ }
235
+
236
+ // /api/projects/:key/diff?from=&to=&doc= (doc-scoped; default root)
132
237
  if (a === "projects" && b && c === "diff") {
133
238
  const from = Number(url.searchParams.get("from"));
134
239
  const to = Number(url.searchParams.get("to"));
240
+ const doc = url.searchParams.get("doc") || "root";
241
+ const fromDoc = getDoc(b, from, doc);
242
+ const toDoc = getDoc(b, to, doc);
135
243
  return sendJSON(res, {
136
- from: { n: from, markdown: getVersion(b, from)?.markdown ?? "" },
137
- to: { n: to, markdown: getVersion(b, to)?.markdown ?? "" },
244
+ doc,
245
+ fromMissing: !fromDoc,
246
+ toMissing: !toDoc,
247
+ from: { n: from, markdown: fromDoc?.markdown ?? "", missing: !fromDoc },
248
+ to: { n: to, markdown: toDoc?.markdown ?? "", missing: !toDoc },
138
249
  });
139
250
  }
140
251
 
@@ -147,21 +258,59 @@ async function handleApi(req, res, seg, url) {
147
258
  if (a === "projects" && b && c === "versions" && d) {
148
259
  const key = b;
149
260
  const n = Number(d);
261
+ if (!Number.isFinite(n)) return sendJSON(res, { error: "bad version number" }, 400);
262
+
263
+ // /api/projects/:key/versions/:n (GET metadata | DELETE the version)
150
264
  if (!e) {
151
- const v = getVersion(key, n);
152
- if (!v) return sendJSON(res, { error: "no such version" }, 404);
153
- return sendJSON(res, { n, markdown: v.markdown, html: renderMarkdown(v.markdown), meta: v.meta });
265
+ if (method === "DELETE") {
266
+ const r = deleteVersion(key, n, { force: isForce(url) });
267
+ if (r.blocked)
268
+ return sendJSON(res, { error: "pending review blocks deletion", pendingReviews: r.pendingReviews }, 409);
269
+ return sendJSON(res, r);
270
+ }
271
+ if (method === "GET") {
272
+ const v = getVersion(key, n);
273
+ if (!v) return sendJSON(res, { error: "no such version" }, 404);
274
+ const payload = { n, kind: v.kind, meta: v.meta, manifest: getManifest(key, n) };
275
+ if (v.kind === "single") {
276
+ payload.markdown = v.markdown;
277
+ payload.html = renderMarkdown(v.markdown);
278
+ }
279
+ return sendJSON(res, payload);
280
+ }
281
+ return sendJSON(res, { error: "method not allowed" }, 405);
282
+ }
283
+
284
+ // /api/projects/:key/versions/:n/docs/:slug (GET one rendered document)
285
+ if (e === "docs" && seg[5] && method === "GET") {
286
+ const doc = getDoc(key, n, seg[5]);
287
+ if (!doc) return sendJSON(res, { error: "no such doc" }, 404);
288
+ const validSlugs = (getManifest(key, n)?.docs ?? []).map((x) => x.slug);
289
+ return sendJSON(res, {
290
+ slug: doc.slug,
291
+ title: doc.title,
292
+ parent: doc.parent,
293
+ markdown: doc.markdown,
294
+ html: renderMarkdown(doc.markdown, { key, version: n, validSlugs }),
295
+ });
154
296
  }
297
+
298
+ // /api/projects/:key/versions/:n/comments (GET ?doc= filtered | POST)
155
299
  if (e === "comments" && !seg[5]) {
156
- if (method === "GET") return sendJSON(res, listComments(key, n));
300
+ if (method === "GET") {
301
+ const doc = url.searchParams.get("doc");
302
+ return sendJSON(res, listComments(key, n, doc == null ? undefined : doc));
303
+ }
157
304
  if (method === "POST") {
158
305
  const body = JSON.parse((await readBody(req)) || "{}");
159
306
  if (!body.body?.trim()) return sendJSON(res, { error: "empty comment" }, 400);
160
307
  return sendJSON(
161
308
  res,
162
309
  addComment(key, n, {
310
+ doc: body.doc || "root",
163
311
  line: body.line ?? null,
164
312
  lineEnd: body.lineEnd ?? null,
313
+ quote: body.quote ?? null,
165
314
  body: body.body.trim(),
166
315
  }),
167
316
  );
@@ -177,6 +326,12 @@ async function handleApi(req, res, seg, url) {
177
326
  }
178
327
  }
179
328
 
329
+ // POST /api/plans — tools-first plan creation (same store path as the hook)
330
+ if (a === "plans" && !b && method === "POST") {
331
+ const body = JSON.parse((await readBody(req)) || "{}");
332
+ return handleCreatePlan(res, body, url);
333
+ }
334
+
180
335
  // /api/reviews ...
181
336
  if (a === "reviews" && !b) return sendJSON(res, listReviews());
182
337
  if (a === "reviews" && b === "pending")
@@ -194,6 +349,102 @@ async function handleApi(req, res, seg, url) {
194
349
  return sendJSON(res, { error: "unknown endpoint" }, 404);
195
350
  }
196
351
 
352
+ /** True when a `?force=` query flag is present and not explicitly falsey. */
353
+ function isForce(url) {
354
+ if (!url.searchParams.has("force")) return false;
355
+ const v = url.searchParams.get("force");
356
+ return v !== "false" && v !== "0";
357
+ }
358
+
359
+ /**
360
+ * Normalize a parent attribute → parent slug, or null for a top-level doc.
361
+ * Mirrors plan-parse/mcp's normParent (absent/""/"root"/self ⇒ top-level) so a
362
+ * `docs`-array POST stores an identical tree to the marker-parsed path.
363
+ */
364
+ function normParent(parent, slug) {
365
+ if (parent == null) return null;
366
+ const v = String(parent).trim();
367
+ if (v === "" || v === "root" || v === slug) return null;
368
+ return v;
369
+ }
370
+
371
+ /** Turn a caller-supplied docs array into the store's tree shape. */
372
+ function docsToTree(docs) {
373
+ const normalized = docs.map((d, i) => ({
374
+ slug: d.slug,
375
+ title: d.title,
376
+ parent: normParent(d.parent, d.slug),
377
+ body: d.body ?? "",
378
+ order: i,
379
+ }));
380
+ const root = normalized.find((d) => d.parent === null)?.slug ?? "root";
381
+ return { kind: "tree", root, docs: normalized };
382
+ }
383
+
384
+ /**
385
+ * POST /api/plans — record a plan and open a review WITHOUT the hook. Accepts
386
+ * exactly one of `docs` (structured tree), `markdown` (single doc), or `plan`
387
+ * (raw marker string the server parses). Records through the SAME `recordPlan`
388
+ * the hook uses, so store shapes are identical. 201 {key,version,reviewId,
389
+ * reviewUrl} on success; 400 {error, issues?} on a malformed plan.
390
+ */
391
+ async function handleCreatePlan(res, body, url) {
392
+ const cwd = body?.cwd;
393
+ if (typeof cwd !== "string" || !cwd.trim()) {
394
+ return sendJSON(res, { error: "`cwd` is required (absolute project path)." }, 400);
395
+ }
396
+ const provided = ["docs", "markdown", "plan"].filter(
397
+ (k) => body[k] !== undefined && body[k] !== null,
398
+ );
399
+ if (provided.length !== 1) {
400
+ return sendJSON(
401
+ res,
402
+ {
403
+ error: provided.length
404
+ ? `Provide exactly one content field — got ${provided.join(", ")}. Use only one of docs / markdown / plan.`
405
+ : "Provide exactly one of `docs`, `markdown`, or `plan`.",
406
+ },
407
+ 400,
408
+ );
409
+ }
410
+
411
+ let tree;
412
+ if (provided[0] === "docs") {
413
+ if (!Array.isArray(body.docs) || body.docs.length === 0) {
414
+ return sendJSON(res, { error: "`docs` must be a non-empty array of {slug, title, parent?, body}." }, 400);
415
+ }
416
+ const issues = validateDocs(body.docs);
417
+ if (issues.length) return sendJSON(res, { error: "The plan documents are invalid.", issues }, 400);
418
+ tree = docsToTree(body.docs);
419
+ } else {
420
+ const str = body[provided[0]];
421
+ if (typeof str !== "string" || !str.trim()) {
422
+ return sendJSON(res, { error: `\`${provided[0]}\` must be a non-empty string.` }, 400);
423
+ }
424
+ let parsed;
425
+ try {
426
+ parsed = parsePlan(str);
427
+ } catch (err) {
428
+ return sendJSON(res, { error: `Could not parse the plan: ${String(err?.message || err)}` }, 400);
429
+ }
430
+ if (parsed.error) return sendJSON(res, { error: "The plan documents are invalid.", issues: parsed.error.issues }, 400);
431
+ tree = parsed;
432
+ }
433
+
434
+ let recorded;
435
+ try {
436
+ recorded = recordPlan({ cwd, tree, origin: "api" });
437
+ } catch (err) {
438
+ return sendJSON(res, { error: `Failed to record the plan: ${String(err?.message || err)}` }, 500);
439
+ }
440
+ const { key, version, reviewId } = recorded;
441
+ const rootDoc = (tree && tree.root) || "root";
442
+ const reviewUrl = `${url.origin}/?project=${encodeURIComponent(
443
+ key,
444
+ )}&version=${version}&review=${reviewId}&doc=${encodeURIComponent(rootDoc)}`;
445
+ return sendJSON(res, { key, version, reviewId, reviewUrl }, 201);
446
+ }
447
+
197
448
  /** POST /api/projects/:key/versions/:n/save — create or overwrite the channel's store. */
198
449
  async function handleSave(req, res, key, n) {
199
450
  const body = JSON.parse((await readBody(req)) || "{}");
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Claude Code Stop hook — hard enforcement for tools-first plan reviews.
3
+ *
4
+ * The MCP / HTTP submission paths (plan_review_submit, POST /api/plans) are
5
+ * non-blocking: Claude submits a plan and could end its turn before the user
6
+ * decides. plan_review_check long-polls and its tool-result text steers Claude to
7
+ * keep polling, but nothing PREVENTS the turn from ending. This Stop hook closes
8
+ * that gap: if the turn is about to end while a recent mcp/api-origin review is
9
+ * still pending, it blocks and tells Claude to resume polling.
10
+ *
11
+ * stdin = { session_id, cwd, stop_hook_active, ... }
12
+ * stdout = { decision:"block", reason } to block; nothing (exit 0) to allow.
13
+ *
14
+ * Invariants (mirror hook.js):
15
+ * • stop_hook_active === true → exit 0 immediately (never double-block).
16
+ * • Fail open on ANY error → exit 0 so a bug never traps the user's turn.
17
+ * • Only mcp/api reviews, still pending, created within the last 5 hours are
18
+ * gated. Hook-origin (and legacy origin-less) reviews are never gated — the
19
+ * PreToolUse hook already blocks synchronously for those.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { projectKey } from "./paths.js";
23
+ import { listReviews } from "./store.js";
24
+
25
+ /** Only reviews this fresh are gated — an abandoned review shouldn't trap forever. */
26
+ const GATE_WINDOW_MS = 5 * 60 * 60 * 1000;
27
+
28
+ function main() {
29
+ let input = {};
30
+ try {
31
+ input = JSON.parse(readFileSync(0, "utf8")); // fd 0 = stdin (node + bun)
32
+ } catch {
33
+ process.exit(0); // unparseable stdin → don't interfere
34
+ }
35
+
36
+ // Never double-block: if we already blocked once this stop cycle, let it end.
37
+ if (input.stop_hook_active) process.exit(0);
38
+
39
+ try {
40
+ const cwd = input.cwd || process.cwd();
41
+ const key = projectKey(cwd);
42
+ const cutoff = Date.now() - GATE_WINDOW_MS;
43
+ const pending = listReviews()
44
+ .filter(
45
+ (r) =>
46
+ r.projectKey === key &&
47
+ r.status === "pending" &&
48
+ (r.origin === "mcp" || r.origin === "api") &&
49
+ r.createdAt &&
50
+ Date.parse(r.createdAt) >= cutoff,
51
+ )
52
+ // newest first — reference the most recent pending review
53
+ .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
54
+
55
+ if (pending.length) {
56
+ const r = pending[0];
57
+ process.stdout.write(
58
+ JSON.stringify({
59
+ decision: "block",
60
+ reason:
61
+ `A plan review you submitted is still pending (review ${r.id}, version ${r.version}). ` +
62
+ `Call plan_review_check with {reviewId:"${r.id}", wait:20} in a loop until it resolves, ` +
63
+ `then act on the decision. If ~5 hours pass with no decision, ask the user.`,
64
+ }),
65
+ );
66
+ }
67
+ } catch {
68
+ process.exit(0); // any failure → fail open
69
+ }
70
+ process.exit(0);
71
+ }
72
+
73
+ main();