claude-plan-review 0.3.2 → 0.5.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/store.js CHANGED
@@ -1,14 +1,29 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import {
3
+ closeSync,
3
4
  mkdirSync,
5
+ openSync,
4
6
  readdirSync,
5
7
  readFileSync,
6
8
  writeFileSync,
9
+ writeSync,
7
10
  existsSync,
8
11
  rmSync,
9
12
  } from "node:fs";
10
13
  import { basename, join } from "node:path";
11
- import { PROJECTS_DIR, REVIEWS_DIR, projectDir, projectKey } from "./paths.js";
14
+ import {
15
+ PROJECTS_DIR,
16
+ REVIEWS_DIR,
17
+ docPath,
18
+ manifestPath,
19
+ projectDir,
20
+ projectKey,
21
+ versionDir,
22
+ } from "./paths.js";
23
+
24
+ /** Auto-rejection reason written when a pending review is force-deleted in the UI. */
25
+ const DELETE_REASON =
26
+ "This plan was deleted in the review UI — re-present or ask the user how to proceed";
12
27
 
13
28
  // ---------- helpers ----------
14
29
  const enc = (o) => JSON.stringify(o, null, 2);
@@ -37,6 +52,28 @@ const versionMeta = (key, n) => join(projectDir(key), "versions", `${pad(n)}.jso
37
52
  const commentsPath = (key, n) => join(projectDir(key), "comments", `${pad(n)}.json`);
38
53
  const reviewPath = (id) => join(REVIEWS_DIR, `${id}.json`);
39
54
 
55
+ // ---------- double-fire dedup (one ExitPlanMode call → one review) ----------
56
+ // Two hook entries in different settings scopes can both fire for the SAME
57
+ // ExitPlanMode tool call; they arrive with an identical tool_use_id. We serialize
58
+ // them with an exclusive-create marker file keyed by tool_use_id: the first
59
+ // process to create the marker "wins" and records the review, writing its id back
60
+ // into the marker; concurrent losers read that id and reuse the review instead of
61
+ // creating a duplicate. The marker uses a NON-.json extension so it's invisible to
62
+ // listReviews() (which globs *.json). Fail-open: any error skips dedup.
63
+ const toolUseMarkerPath = (id) =>
64
+ join(REVIEWS_DIR, `tooluse-${String(id).replace(/[^a-zA-Z0-9_-]/g, "_")}.lock`);
65
+
66
+ /** Synchronous sleep (recordPlan is sync, called from the blocking hook). */
67
+ function sleepSync(ms) {
68
+ try {
69
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
70
+ } catch {
71
+ // SharedArrayBuffer unavailable → best-effort busy wait
72
+ const end = Date.now() + ms;
73
+ while (Date.now() < end) {}
74
+ }
75
+ }
76
+
40
77
  // ---------- projects / versions ----------
41
78
  export function getProjectMeta(key) {
42
79
  return existsSync(metaPath(key)) ? readJSON(metaPath(key), null) : null;
@@ -59,15 +96,67 @@ export function setStorage(key, channelId, info) {
59
96
  return meta.storage[channelId];
60
97
  }
61
98
 
99
+ /** Canonical content hash for dedup — stable across both creation paths. */
100
+ function hashTree(tree) {
101
+ if (tree.kind === "tree") {
102
+ const recs = [...tree.docs]
103
+ .sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0))
104
+ .map((d) => `${d.slug} ${d.title} ${d.parent || ""} ${d.body}`);
105
+ return sha(recs.join(" "));
106
+ }
107
+ return sha(tree.markdown ?? "");
108
+ }
109
+
110
+ /** Persist a tree version: manifest.json + one <slug>.md per doc. */
111
+ function writeTree(key, version, tree) {
112
+ mkdirSync(versionDir(key, version), { recursive: true });
113
+ const docs = tree.docs.map((d) => {
114
+ const file = `${d.slug}.md`;
115
+ writeFileSync(docPath(key, version, file), d.body ?? "");
116
+ return { slug: d.slug, title: d.title, parent: d.parent ?? null, file, order: d.order };
117
+ });
118
+ writeFileSync(manifestPath(key, version), enc({ schema: 1, root: tree.root || "root", docs }));
119
+ }
120
+
62
121
  /**
63
122
  * Record a freshly-presented plan. De-dupes by content hash: an identical
64
123
  * re-presentation reuses the existing version (and keeps its comments),
65
124
  * otherwise a new immutable version is written. Always creates a new review.
125
+ *
126
+ * Takes a normalized `tree` (parsePlan output — {kind:"single",markdown} or
127
+ * {kind:"tree",root,docs}); a legacy `plan` string is accepted as a single doc.
66
128
  */
67
129
  export function recordPlan(opts) {
68
130
  const key = projectKey(opts.cwd);
69
131
  ensureDirs(key);
70
- const hash = sha(opts.plan);
132
+
133
+ // Double-fire dedup: if another invocation with the same tool_use_id is already
134
+ // in flight (or done), reuse its review rather than creating a second one.
135
+ let markerFd = null;
136
+ const markerFile = opts.toolUseId ? toolUseMarkerPath(opts.toolUseId) : null;
137
+ if (markerFile) {
138
+ try {
139
+ markerFd = openSync(markerFile, "wx"); // atomic exclusive create — we won the race
140
+ } catch (e) {
141
+ if (e && e.code === "EEXIST") {
142
+ // Someone else is recording (or has recorded) this tool call. Wait for
143
+ // them to publish the reviewId, then reuse it.
144
+ for (let i = 0; i < 120; i++) {
145
+ const m = readJSON(markerFile, null);
146
+ if (m && m.reviewId && existsSync(reviewPath(m.reviewId))) {
147
+ return { key, version: m.version, reviewId: m.reviewId, reused: true };
148
+ }
149
+ sleepSync(50);
150
+ }
151
+ // Winner never published (crashed mid-write) → fall through and record our own.
152
+ }
153
+ // Any other error → skip dedup, record normally.
154
+ }
155
+ }
156
+
157
+ const tree =
158
+ opts.tree ?? { kind: "single", markdown: opts.plan != null ? opts.plan : "" };
159
+ const hash = hashTree(tree);
71
160
 
72
161
  let meta = getProjectMeta(key);
73
162
  let version;
@@ -78,7 +167,10 @@ export function recordPlan(opts) {
78
167
  meta.updatedAt = now();
79
168
  } else {
80
169
  version = (meta?.currentVersion ?? 0) + 1;
81
- writeFileSync(versionMd(key, version), opts.plan);
170
+ const kind = tree.kind === "tree" ? "tree" : "single";
171
+ const docCount = kind === "tree" ? tree.docs.length : 1;
172
+ if (kind === "tree") writeTree(key, version, tree);
173
+ else writeFileSync(versionMd(key, version), tree.markdown ?? "");
82
174
  writeFileSync(
83
175
  versionMeta(key, version),
84
176
  enc({
@@ -87,6 +179,8 @@ export function recordPlan(opts) {
87
179
  sessionId: opts.sessionId,
88
180
  toolUseId: opts.toolUseId,
89
181
  hash,
182
+ kind,
183
+ docCount,
90
184
  planFilePath: opts.planFilePath,
91
185
  }),
92
186
  );
@@ -115,10 +209,26 @@ export function recordPlan(opts) {
115
209
  sessionId: opts.sessionId,
116
210
  toolUseId: opts.toolUseId,
117
211
  status: "pending",
212
+ // provenance of the submission: "hook" (plan-mode ExitPlanMode), "mcp"
213
+ // (plan_review_submit tool) or "api" (POST /api/plans). Only mcp/api
214
+ // reviews are gated by the Stop hook; missing ⇒ "hook" (never gated).
215
+ origin: opts.origin || "hook",
118
216
  createdAt: now(),
119
217
  }),
120
218
  );
121
- return { key, version, reviewId };
219
+
220
+ // Publish the reviewId into the marker so any concurrent duplicate invocation
221
+ // reuses it instead of creating a second review.
222
+ if (markerFd !== null) {
223
+ try {
224
+ writeSync(markerFd, JSON.stringify({ toolUseId: opts.toolUseId, key, version, reviewId }));
225
+ } catch {}
226
+ try {
227
+ closeSync(markerFd);
228
+ } catch {}
229
+ }
230
+
231
+ return { key, version, reviewId, reused: false };
122
232
  }
123
233
 
124
234
  export function listProjects() {
@@ -136,7 +246,8 @@ export function listProjects() {
136
246
  versions: listVersions(m.key).length,
137
247
  pending: pendingByKey.get(m.key) ?? 0,
138
248
  }))
139
- .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
249
+ // guard legacy metas that predate updatedAt
250
+ .sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
140
251
  }
141
252
 
142
253
  export function listVersions(key) {
@@ -146,28 +257,121 @@ export function listVersions(key) {
146
257
  .filter((f) => f.endsWith(".json"))
147
258
  .map((f) => readJSON(join(dir, f), null))
148
259
  .filter(Boolean)
260
+ // defaults for legacy metas (missing kind ⇒ single)
261
+ .map((m) => ({ kind: "single", docCount: 1, ...m }))
149
262
  .sort((a, b) => a.n - b.n);
150
263
  }
151
264
 
265
+ /** True when version n is stored as a tree (manifest present). */
266
+ function isTree(key, n) {
267
+ return existsSync(manifestPath(key, n));
268
+ }
269
+
270
+ /**
271
+ * Flatten a tree version into one markdown string — each doc's body preceded by
272
+ * a title heading (level by depth), in manifest order. Feeds diff / gist save /
273
+ * the flat comment-snippet fallback.
274
+ */
275
+ function flattenManifest(key, n, manifest) {
276
+ const bySlug = new Map(manifest.docs.map((d) => [d.slug, d]));
277
+ const depth = (slug) => {
278
+ let d = 0;
279
+ let cur = bySlug.get(slug);
280
+ const seen = new Set();
281
+ while (cur && cur.parent && !seen.has(cur.slug)) {
282
+ seen.add(cur.slug);
283
+ d++;
284
+ cur = bySlug.get(cur.parent);
285
+ }
286
+ return d;
287
+ };
288
+ const parts = [];
289
+ for (const d of manifest.docs) {
290
+ const h = "#".repeat(Math.min(6, depth(d.slug) + 1));
291
+ const body = existsSync(docPath(key, n, d.file))
292
+ ? readFileSync(docPath(key, n, d.file), "utf8")
293
+ : "";
294
+ // Skip the synthesized heading when the body already opens with a heading
295
+ // whose text equals the doc title (avoids a duplicate "# Title").
296
+ const first = body.split("\n").find((l) => l.trim());
297
+ const firstHeading = first && first.match(/^\s*#{1,6}\s+(.+?)\s*$/);
298
+ const chunk =
299
+ firstHeading && firstHeading[1].trim() === String(d.title).trim()
300
+ ? body
301
+ : `${h} ${d.title}\n\n${body}`;
302
+ parts.push(chunk.replace(/\s+$/, ""));
303
+ }
304
+ return parts.join("\n\n");
305
+ }
306
+
152
307
  export function getVersion(key, n) {
308
+ if (isTree(key, n)) {
309
+ const manifest = readJSON(manifestPath(key, n), null);
310
+ if (!manifest) return null;
311
+ return {
312
+ kind: "tree",
313
+ meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "tree" }),
314
+ manifest,
315
+ markdown: flattenManifest(key, n, manifest),
316
+ };
317
+ }
153
318
  if (!existsSync(versionMd(key, n))) return null;
154
319
  return {
320
+ kind: "single",
155
321
  markdown: readFileSync(versionMd(key, n), "utf8"),
156
- meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "" }),
322
+ meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "single" }),
157
323
  };
158
324
  }
159
325
 
326
+ /** Return the doc manifest; single-doc versions synthesize a single root doc. */
327
+ export function getManifest(key, n) {
328
+ if (isTree(key, n)) return readJSON(manifestPath(key, n), null);
329
+ if (!existsSync(versionMd(key, n))) return null;
330
+ const md = readFileSync(versionMd(key, n), "utf8");
331
+ const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
332
+ return {
333
+ schema: 1,
334
+ root: "root",
335
+ docs: [{ slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, file: `${pad(n)}.md`, order: 0 }],
336
+ };
337
+ }
338
+
339
+ /** One document's content; single-doc "root" resolves to the flat markdown. */
340
+ export function getDoc(key, n, slug) {
341
+ if (isTree(key, n)) {
342
+ const manifest = readJSON(manifestPath(key, n), null);
343
+ const d = manifest?.docs.find((x) => x.slug === slug);
344
+ if (!d) return null;
345
+ const md = existsSync(docPath(key, n, d.file)) ? readFileSync(docPath(key, n, d.file), "utf8") : "";
346
+ return { slug: d.slug, title: d.title, parent: d.parent ?? null, markdown: md };
347
+ }
348
+ if (slug !== "root" || !existsSync(versionMd(key, n))) return null;
349
+ const md = readFileSync(versionMd(key, n), "utf8");
350
+ const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
351
+ return { slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, markdown: md };
352
+ }
353
+
160
354
  // ---------- comments ----------
161
- export function listComments(key, n) {
355
+ function readComments(key, n) {
162
356
  return readJSON(commentsPath(key, n), []);
163
357
  }
164
358
 
359
+ export function listComments(key, n, doc) {
360
+ // legacy comments predate `doc` → default to "root"
361
+ const all = readComments(key, n).map((c) => ({ ...c, doc: c.doc || "root" }));
362
+ return doc == null ? all : all.filter((c) => c.doc === doc);
363
+ }
364
+
165
365
  export function addComment(key, n, c) {
166
- const comments = listComments(key, n);
366
+ const comments = readComments(key, n);
167
367
  const comment = {
168
368
  id: randomBytes(6).toString("hex"),
369
+ doc: c.doc || "root",
169
370
  line: c.line,
170
371
  lineEnd: c.line == null ? null : (c.lineEnd ?? c.line),
372
+ // the exact rendered text the reviewer selected in the preview (if any) —
373
+ // quoted back to Claude verbatim, which is more precise than a line slice.
374
+ quote: c.quote ? String(c.quote).slice(0, 2000) : null,
171
375
  body: c.body,
172
376
  author: c.author || "me",
173
377
  createdAt: now(),
@@ -178,7 +382,7 @@ export function addComment(key, n, c) {
178
382
  }
179
383
 
180
384
  export function deleteComment(key, n, id) {
181
- const comments = listComments(key, n);
385
+ const comments = readComments(key, n);
182
386
  const next = comments.filter((c) => c.id !== id);
183
387
  if (next.length === comments.length) return false;
184
388
  writeFileSync(commentsPath(key, n), enc(next));
@@ -222,19 +426,26 @@ export function resolveReview(id, decision) {
222
426
  }
223
427
 
224
428
  /**
225
- * Format a version's comments for the model.
429
+ * Format a version's comments for the model, grouped per document (labeled by
430
+ * doc title when the plan is a tree). Line snippets are read from THAT doc's own
431
+ * markdown, not the flattened blob.
226
432
  * mode "reject" → "address these, then re-present" (always returns text, even with no comments).
227
433
  * mode "approve" → "plan is approved, incorporate these notes" (returns "" when there are none).
228
434
  */
229
435
  function compileComments(key, n, mode) {
230
- const comments = listComments(key, n);
231
- const lineComments = comments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
232
- const general = comments.filter((c) => c.line == null);
436
+ const all = listComments(key, n);
437
+ const anyLine = all.some((c) => c.line != null);
438
+ const anyGeneral = all.some((c) => c.line == null);
233
439
 
234
- if (mode === "approve" && !lineComments.length && !general.length) return "";
440
+ if (mode === "approve" && !anyLine && !anyGeneral) return "";
235
441
 
236
- const md = getVersion(key, n)?.markdown ?? "";
237
- const lines = md.split("\n");
442
+ const manifest = getManifest(key, n);
443
+ const orderOf = new Map();
444
+ const titleOf = new Map();
445
+ for (const d of manifest?.docs ?? []) {
446
+ orderOf.set(d.slug, d.order ?? 0);
447
+ titleOf.set(d.slug, d.title);
448
+ }
238
449
 
239
450
  const parts = [
240
451
  mode === "approve"
@@ -243,31 +454,158 @@ function compileComments(key, n, mode) {
243
454
  : "The reviewer requested changes to this plan in the plan-review UI. " +
244
455
  "Address each comment below, then re-present the revised plan with ExitPlanMode.",
245
456
  ];
246
- if (lineComments.length) {
247
- parts.push("\nLine comments:");
248
- for (const c of lineComments) {
249
- const s = c.line;
250
- const e = c.lineEnd ?? s;
251
- const label = e !== s ? `lines ${s}-${e}` : `line ${s}`;
252
- const snippet = lines
253
- .slice(s - 1, e)
254
- .map((l) => l.trim())
255
- .filter(Boolean)
256
- .slice(0, 3)
257
- .join(" / ");
258
- parts.push(`- [${label}] "${snippet}"\n → ${c.body}`);
457
+
458
+ const docSlugs = [...new Set(all.map((c) => c.doc))].sort(
459
+ (a, b) => (orderOf.get(a) ?? 1e9) - (orderOf.get(b) ?? 1e9) || (a < b ? -1 : a > b ? 1 : 0),
460
+ );
461
+ const multi = docSlugs.length > 1;
462
+
463
+ for (const slug of docSlugs) {
464
+ const docComments = all.filter((c) => c.doc === slug);
465
+ const lineComments = docComments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
466
+ const general = docComments.filter((c) => c.line == null);
467
+ if (!lineComments.length && !general.length) continue;
468
+
469
+ const lines = (getDoc(key, n, slug)?.markdown ?? "").split("\n");
470
+
471
+ if (multi) parts.push(`\n## ${titleOf.get(slug) || slug}`);
472
+ if (lineComments.length) {
473
+ parts.push("\nLine comments:");
474
+ for (const c of lineComments) {
475
+ const s = c.line;
476
+ const e = c.lineEnd ?? s;
477
+ const label = e !== s ? `lines ${s}-${e}` : `line ${s}`;
478
+ // Prefer the reviewer's actual selected text; fall back to the source lines.
479
+ const snippet = c.quote
480
+ ? c.quote.replace(/\s+/g, " ").trim().slice(0, 300)
481
+ : lines
482
+ .slice(s - 1, e)
483
+ .map((l) => l.trim())
484
+ .filter(Boolean)
485
+ .slice(0, 3)
486
+ .join(" / ");
487
+ parts.push(`- [${label}] "${snippet}"\n → ${c.body}`);
488
+ }
489
+ }
490
+ if (general.length) {
491
+ parts.push("\nGeneral comments:");
492
+ for (const c of general) parts.push(`- ${c.body}`);
259
493
  }
260
494
  }
261
- if (general.length) {
262
- parts.push("\nGeneral comments:");
263
- for (const c of general) parts.push(`- ${c.body}`);
264
- }
265
- if (mode === "reject" && !lineComments.length && !general.length) {
495
+
496
+ if (mode === "reject" && !anyLine && !anyGeneral) {
266
497
  parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
267
498
  }
268
499
  return parts.join("\n");
269
500
  }
270
501
 
502
+ // ---------- deletion ----------
503
+ /** Remove every on-disk artifact for one version (flat file, tree dir, comments). */
504
+ function removeVersionFiles(key, n) {
505
+ for (const path of [versionMd(key, n), versionMeta(key, n), commentsPath(key, n)]) {
506
+ try {
507
+ rmSync(path, { force: true });
508
+ } catch {}
509
+ }
510
+ try {
511
+ rmSync(versionDir(key, n), { recursive: true, force: true });
512
+ } catch {}
513
+ }
514
+
515
+ /**
516
+ * Recompute a project's meta after deletions.
517
+ * currentVersion = max surviving version; latestHash = that meta's stored hash.
518
+ * Unparsable metas are skipped; latestHash is never set to "".
519
+ * Zero versions left ⇒ the whole project dir is removed and null is returned.
520
+ */
521
+ export function recomputeMeta(key) {
522
+ const versions = listVersions(key); // parseable metas only, sorted asc
523
+ if (!versions.length) {
524
+ try {
525
+ rmSync(projectDir(key), { recursive: true, force: true });
526
+ } catch {}
527
+ return null;
528
+ }
529
+ const meta = getProjectMeta(key);
530
+ if (!meta) return null;
531
+ const latest = versions[versions.length - 1];
532
+ meta.currentVersion = latest.n;
533
+ if (latest.hash) meta.latestHash = latest.hash; // never blank it out
534
+ meta.updatedAt = now();
535
+ writeFileSync(metaPath(key), enc(meta));
536
+ return meta;
537
+ }
538
+
539
+ /**
540
+ * Delete a single version. A pending review blocks the delete unless force, in
541
+ * which case the review is force-rejected first (a live polling hook then
542
+ * unblocks via its normal deny path).
543
+ */
544
+ export function deleteVersion(key, n, opts = {}) {
545
+ const force = !!opts.force;
546
+ const pending = listPendingReviews(key, n);
547
+ if (pending.length && !force) {
548
+ return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
549
+ }
550
+ for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
551
+ removeVersionFiles(key, n);
552
+ return { deleted: true, blocked: false, meta: recomputeMeta(key) };
553
+ }
554
+
555
+ /** Delete an entire project (all versions + its reviews). */
556
+ export function deleteProject(key, opts = {}) {
557
+ const force = !!opts.force;
558
+ const pending = listPendingReviews(key);
559
+ if (pending.length && !force) {
560
+ return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
561
+ }
562
+ for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
563
+ try {
564
+ rmSync(projectDir(key), { recursive: true, force: true });
565
+ } catch {}
566
+ deleteReviewsForProject(key);
567
+ return { deleted: true, blocked: false, meta: null };
568
+ }
569
+
570
+ // ---------- review helpers ----------
571
+ export function listPendingReviews(key, n) {
572
+ return listReviews().filter(
573
+ (r) => r.projectKey === key && r.status === "pending" && (n == null || r.version === n),
574
+ );
575
+ }
576
+
577
+ /** Force-reject a review with a fixed reason (does not touch version files). */
578
+ export function rejectReviewForced(id, reason) {
579
+ const review = getReview(id);
580
+ if (!review) return null;
581
+ review.status = "rejected";
582
+ review.resolvedAt = now();
583
+ review.reason = reason;
584
+ review.forced = true;
585
+ writeFileSync(reviewPath(id), enc(review));
586
+ return review;
587
+ }
588
+
589
+ export function deleteReviewsForProject(key) {
590
+ for (const r of listReviews()) {
591
+ if (r.projectKey === key) {
592
+ try {
593
+ rmSync(reviewPath(r.id), { force: true });
594
+ } catch {}
595
+ }
596
+ }
597
+ }
598
+
599
+ export function deleteReviewsForVersion(key, n) {
600
+ for (const r of listReviews()) {
601
+ if (r.projectKey === key && r.version === n) {
602
+ try {
603
+ rmSync(reviewPath(r.id), { force: true });
604
+ } catch {}
605
+ }
606
+ }
607
+ }
608
+
271
609
  // ---------- maintenance ----------
272
610
  export function pruneResolvedReviews(maxAgeMs = 1000 * 60 * 60 * 24 * 7) {
273
611
  const cutoff = Date.now() - maxAgeMs;