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/store.js CHANGED
@@ -8,7 +8,19 @@ import {
8
8
  rmSync,
9
9
  } from "node:fs";
10
10
  import { basename, join } from "node:path";
11
- import { PROJECTS_DIR, REVIEWS_DIR, projectDir, projectKey } from "./paths.js";
11
+ import {
12
+ PROJECTS_DIR,
13
+ REVIEWS_DIR,
14
+ docPath,
15
+ manifestPath,
16
+ projectDir,
17
+ projectKey,
18
+ versionDir,
19
+ } from "./paths.js";
20
+
21
+ /** Auto-rejection reason written when a pending review is force-deleted in the UI. */
22
+ const DELETE_REASON =
23
+ "This plan was deleted in the review UI — re-present or ask the user how to proceed";
12
24
 
13
25
  // ---------- helpers ----------
14
26
  const enc = (o) => JSON.stringify(o, null, 2);
@@ -59,15 +71,42 @@ export function setStorage(key, channelId, info) {
59
71
  return meta.storage[channelId];
60
72
  }
61
73
 
74
+ /** Canonical content hash for dedup — stable across both creation paths. */
75
+ function hashTree(tree) {
76
+ if (tree.kind === "tree") {
77
+ const recs = [...tree.docs]
78
+ .sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0))
79
+ .map((d) => `${d.slug} ${d.title} ${d.parent || ""} ${d.body}`);
80
+ return sha(recs.join(" "));
81
+ }
82
+ return sha(tree.markdown ?? "");
83
+ }
84
+
85
+ /** Persist a tree version: manifest.json + one <slug>.md per doc. */
86
+ function writeTree(key, version, tree) {
87
+ mkdirSync(versionDir(key, version), { recursive: true });
88
+ const docs = tree.docs.map((d) => {
89
+ const file = `${d.slug}.md`;
90
+ writeFileSync(docPath(key, version, file), d.body ?? "");
91
+ return { slug: d.slug, title: d.title, parent: d.parent ?? null, file, order: d.order };
92
+ });
93
+ writeFileSync(manifestPath(key, version), enc({ schema: 1, root: tree.root || "root", docs }));
94
+ }
95
+
62
96
  /**
63
97
  * Record a freshly-presented plan. De-dupes by content hash: an identical
64
98
  * re-presentation reuses the existing version (and keeps its comments),
65
99
  * otherwise a new immutable version is written. Always creates a new review.
100
+ *
101
+ * Takes a normalized `tree` (parsePlan output — {kind:"single",markdown} or
102
+ * {kind:"tree",root,docs}); a legacy `plan` string is accepted as a single doc.
66
103
  */
67
104
  export function recordPlan(opts) {
68
105
  const key = projectKey(opts.cwd);
69
106
  ensureDirs(key);
70
- const hash = sha(opts.plan);
107
+ const tree =
108
+ opts.tree ?? { kind: "single", markdown: opts.plan != null ? opts.plan : "" };
109
+ const hash = hashTree(tree);
71
110
 
72
111
  let meta = getProjectMeta(key);
73
112
  let version;
@@ -78,7 +117,10 @@ export function recordPlan(opts) {
78
117
  meta.updatedAt = now();
79
118
  } else {
80
119
  version = (meta?.currentVersion ?? 0) + 1;
81
- writeFileSync(versionMd(key, version), opts.plan);
120
+ const kind = tree.kind === "tree" ? "tree" : "single";
121
+ const docCount = kind === "tree" ? tree.docs.length : 1;
122
+ if (kind === "tree") writeTree(key, version, tree);
123
+ else writeFileSync(versionMd(key, version), tree.markdown ?? "");
82
124
  writeFileSync(
83
125
  versionMeta(key, version),
84
126
  enc({
@@ -87,6 +129,8 @@ export function recordPlan(opts) {
87
129
  sessionId: opts.sessionId,
88
130
  toolUseId: opts.toolUseId,
89
131
  hash,
132
+ kind,
133
+ docCount,
90
134
  planFilePath: opts.planFilePath,
91
135
  }),
92
136
  );
@@ -115,6 +159,10 @@ export function recordPlan(opts) {
115
159
  sessionId: opts.sessionId,
116
160
  toolUseId: opts.toolUseId,
117
161
  status: "pending",
162
+ // provenance of the submission: "hook" (plan-mode ExitPlanMode), "mcp"
163
+ // (plan_review_submit tool) or "api" (POST /api/plans). Only mcp/api
164
+ // reviews are gated by the Stop hook; missing ⇒ "hook" (never gated).
165
+ origin: opts.origin || "hook",
118
166
  createdAt: now(),
119
167
  }),
120
168
  );
@@ -136,7 +184,8 @@ export function listProjects() {
136
184
  versions: listVersions(m.key).length,
137
185
  pending: pendingByKey.get(m.key) ?? 0,
138
186
  }))
139
- .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
187
+ // guard legacy metas that predate updatedAt
188
+ .sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
140
189
  }
141
190
 
142
191
  export function listVersions(key) {
@@ -146,28 +195,121 @@ export function listVersions(key) {
146
195
  .filter((f) => f.endsWith(".json"))
147
196
  .map((f) => readJSON(join(dir, f), null))
148
197
  .filter(Boolean)
198
+ // defaults for legacy metas (missing kind ⇒ single)
199
+ .map((m) => ({ kind: "single", docCount: 1, ...m }))
149
200
  .sort((a, b) => a.n - b.n);
150
201
  }
151
202
 
203
+ /** True when version n is stored as a tree (manifest present). */
204
+ function isTree(key, n) {
205
+ return existsSync(manifestPath(key, n));
206
+ }
207
+
208
+ /**
209
+ * Flatten a tree version into one markdown string — each doc's body preceded by
210
+ * a title heading (level by depth), in manifest order. Feeds diff / gist save /
211
+ * the flat comment-snippet fallback.
212
+ */
213
+ function flattenManifest(key, n, manifest) {
214
+ const bySlug = new Map(manifest.docs.map((d) => [d.slug, d]));
215
+ const depth = (slug) => {
216
+ let d = 0;
217
+ let cur = bySlug.get(slug);
218
+ const seen = new Set();
219
+ while (cur && cur.parent && !seen.has(cur.slug)) {
220
+ seen.add(cur.slug);
221
+ d++;
222
+ cur = bySlug.get(cur.parent);
223
+ }
224
+ return d;
225
+ };
226
+ const parts = [];
227
+ for (const d of manifest.docs) {
228
+ const h = "#".repeat(Math.min(6, depth(d.slug) + 1));
229
+ const body = existsSync(docPath(key, n, d.file))
230
+ ? readFileSync(docPath(key, n, d.file), "utf8")
231
+ : "";
232
+ // Skip the synthesized heading when the body already opens with a heading
233
+ // whose text equals the doc title (avoids a duplicate "# Title").
234
+ const first = body.split("\n").find((l) => l.trim());
235
+ const firstHeading = first && first.match(/^\s*#{1,6}\s+(.+?)\s*$/);
236
+ const chunk =
237
+ firstHeading && firstHeading[1].trim() === String(d.title).trim()
238
+ ? body
239
+ : `${h} ${d.title}\n\n${body}`;
240
+ parts.push(chunk.replace(/\s+$/, ""));
241
+ }
242
+ return parts.join("\n\n");
243
+ }
244
+
152
245
  export function getVersion(key, n) {
246
+ if (isTree(key, n)) {
247
+ const manifest = readJSON(manifestPath(key, n), null);
248
+ if (!manifest) return null;
249
+ return {
250
+ kind: "tree",
251
+ meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "tree" }),
252
+ manifest,
253
+ markdown: flattenManifest(key, n, manifest),
254
+ };
255
+ }
153
256
  if (!existsSync(versionMd(key, n))) return null;
154
257
  return {
258
+ kind: "single",
155
259
  markdown: readFileSync(versionMd(key, n), "utf8"),
156
- meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "" }),
260
+ meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "", kind: "single" }),
261
+ };
262
+ }
263
+
264
+ /** Return the doc manifest; single-doc versions synthesize a single root doc. */
265
+ export function getManifest(key, n) {
266
+ if (isTree(key, n)) return readJSON(manifestPath(key, n), null);
267
+ if (!existsSync(versionMd(key, n))) return null;
268
+ const md = readFileSync(versionMd(key, n), "utf8");
269
+ const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
270
+ return {
271
+ schema: 1,
272
+ root: "root",
273
+ docs: [{ slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, file: `${pad(n)}.md`, order: 0 }],
157
274
  };
158
275
  }
159
276
 
277
+ /** One document's content; single-doc "root" resolves to the flat markdown. */
278
+ export function getDoc(key, n, slug) {
279
+ if (isTree(key, n)) {
280
+ const manifest = readJSON(manifestPath(key, n), null);
281
+ const d = manifest?.docs.find((x) => x.slug === slug);
282
+ if (!d) return null;
283
+ const md = existsSync(docPath(key, n, d.file)) ? readFileSync(docPath(key, n, d.file), "utf8") : "";
284
+ return { slug: d.slug, title: d.title, parent: d.parent ?? null, markdown: md };
285
+ }
286
+ if (slug !== "root" || !existsSync(versionMd(key, n))) return null;
287
+ const md = readFileSync(versionMd(key, n), "utf8");
288
+ const h1 = md.match(/^\s*#\s+(.+?)\s*$/m);
289
+ return { slug: "root", title: h1 ? h1[1].trim() : "Overview", parent: null, markdown: md };
290
+ }
291
+
160
292
  // ---------- comments ----------
161
- export function listComments(key, n) {
293
+ function readComments(key, n) {
162
294
  return readJSON(commentsPath(key, n), []);
163
295
  }
164
296
 
297
+ export function listComments(key, n, doc) {
298
+ // legacy comments predate `doc` → default to "root"
299
+ const all = readComments(key, n).map((c) => ({ ...c, doc: c.doc || "root" }));
300
+ return doc == null ? all : all.filter((c) => c.doc === doc);
301
+ }
302
+
165
303
  export function addComment(key, n, c) {
166
- const comments = listComments(key, n);
304
+ const comments = readComments(key, n);
167
305
  const comment = {
168
306
  id: randomBytes(6).toString("hex"),
307
+ doc: c.doc || "root",
169
308
  line: c.line,
170
309
  lineEnd: c.line == null ? null : (c.lineEnd ?? c.line),
310
+ // the exact rendered text the reviewer selected in the preview (if any) —
311
+ // quoted back to Claude verbatim, which is more precise than a line slice.
312
+ quote: c.quote ? String(c.quote).slice(0, 2000) : null,
171
313
  body: c.body,
172
314
  author: c.author || "me",
173
315
  createdAt: now(),
@@ -178,7 +320,7 @@ export function addComment(key, n, c) {
178
320
  }
179
321
 
180
322
  export function deleteComment(key, n, id) {
181
- const comments = listComments(key, n);
323
+ const comments = readComments(key, n);
182
324
  const next = comments.filter((c) => c.id !== id);
183
325
  if (next.length === comments.length) return false;
184
326
  writeFileSync(commentsPath(key, n), enc(next));
@@ -222,19 +364,26 @@ export function resolveReview(id, decision) {
222
364
  }
223
365
 
224
366
  /**
225
- * Format a version's comments for the model.
367
+ * Format a version's comments for the model, grouped per document (labeled by
368
+ * doc title when the plan is a tree). Line snippets are read from THAT doc's own
369
+ * markdown, not the flattened blob.
226
370
  * mode "reject" → "address these, then re-present" (always returns text, even with no comments).
227
371
  * mode "approve" → "plan is approved, incorporate these notes" (returns "" when there are none).
228
372
  */
229
373
  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);
374
+ const all = listComments(key, n);
375
+ const anyLine = all.some((c) => c.line != null);
376
+ const anyGeneral = all.some((c) => c.line == null);
233
377
 
234
- if (mode === "approve" && !lineComments.length && !general.length) return "";
378
+ if (mode === "approve" && !anyLine && !anyGeneral) return "";
235
379
 
236
- const md = getVersion(key, n)?.markdown ?? "";
237
- const lines = md.split("\n");
380
+ const manifest = getManifest(key, n);
381
+ const orderOf = new Map();
382
+ const titleOf = new Map();
383
+ for (const d of manifest?.docs ?? []) {
384
+ orderOf.set(d.slug, d.order ?? 0);
385
+ titleOf.set(d.slug, d.title);
386
+ }
238
387
 
239
388
  const parts = [
240
389
  mode === "approve"
@@ -243,31 +392,158 @@ function compileComments(key, n, mode) {
243
392
  : "The reviewer requested changes to this plan in the plan-review UI. " +
244
393
  "Address each comment below, then re-present the revised plan with ExitPlanMode.",
245
394
  ];
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}`);
395
+
396
+ const docSlugs = [...new Set(all.map((c) => c.doc))].sort(
397
+ (a, b) => (orderOf.get(a) ?? 1e9) - (orderOf.get(b) ?? 1e9) || (a < b ? -1 : a > b ? 1 : 0),
398
+ );
399
+ const multi = docSlugs.length > 1;
400
+
401
+ for (const slug of docSlugs) {
402
+ const docComments = all.filter((c) => c.doc === slug);
403
+ const lineComments = docComments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
404
+ const general = docComments.filter((c) => c.line == null);
405
+ if (!lineComments.length && !general.length) continue;
406
+
407
+ const lines = (getDoc(key, n, slug)?.markdown ?? "").split("\n");
408
+
409
+ if (multi) parts.push(`\n## ${titleOf.get(slug) || slug}`);
410
+ if (lineComments.length) {
411
+ parts.push("\nLine comments:");
412
+ for (const c of lineComments) {
413
+ const s = c.line;
414
+ const e = c.lineEnd ?? s;
415
+ const label = e !== s ? `lines ${s}-${e}` : `line ${s}`;
416
+ // Prefer the reviewer's actual selected text; fall back to the source lines.
417
+ const snippet = c.quote
418
+ ? c.quote.replace(/\s+/g, " ").trim().slice(0, 300)
419
+ : lines
420
+ .slice(s - 1, e)
421
+ .map((l) => l.trim())
422
+ .filter(Boolean)
423
+ .slice(0, 3)
424
+ .join(" / ");
425
+ parts.push(`- [${label}] "${snippet}"\n → ${c.body}`);
426
+ }
427
+ }
428
+ if (general.length) {
429
+ parts.push("\nGeneral comments:");
430
+ for (const c of general) parts.push(`- ${c.body}`);
259
431
  }
260
432
  }
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) {
433
+
434
+ if (mode === "reject" && !anyLine && !anyGeneral) {
266
435
  parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
267
436
  }
268
437
  return parts.join("\n");
269
438
  }
270
439
 
440
+ // ---------- deletion ----------
441
+ /** Remove every on-disk artifact for one version (flat file, tree dir, comments). */
442
+ function removeVersionFiles(key, n) {
443
+ for (const path of [versionMd(key, n), versionMeta(key, n), commentsPath(key, n)]) {
444
+ try {
445
+ rmSync(path, { force: true });
446
+ } catch {}
447
+ }
448
+ try {
449
+ rmSync(versionDir(key, n), { recursive: true, force: true });
450
+ } catch {}
451
+ }
452
+
453
+ /**
454
+ * Recompute a project's meta after deletions.
455
+ * currentVersion = max surviving version; latestHash = that meta's stored hash.
456
+ * Unparsable metas are skipped; latestHash is never set to "".
457
+ * Zero versions left ⇒ the whole project dir is removed and null is returned.
458
+ */
459
+ export function recomputeMeta(key) {
460
+ const versions = listVersions(key); // parseable metas only, sorted asc
461
+ if (!versions.length) {
462
+ try {
463
+ rmSync(projectDir(key), { recursive: true, force: true });
464
+ } catch {}
465
+ return null;
466
+ }
467
+ const meta = getProjectMeta(key);
468
+ if (!meta) return null;
469
+ const latest = versions[versions.length - 1];
470
+ meta.currentVersion = latest.n;
471
+ if (latest.hash) meta.latestHash = latest.hash; // never blank it out
472
+ meta.updatedAt = now();
473
+ writeFileSync(metaPath(key), enc(meta));
474
+ return meta;
475
+ }
476
+
477
+ /**
478
+ * Delete a single version. A pending review blocks the delete unless force, in
479
+ * which case the review is force-rejected first (a live polling hook then
480
+ * unblocks via its normal deny path).
481
+ */
482
+ export function deleteVersion(key, n, opts = {}) {
483
+ const force = !!opts.force;
484
+ const pending = listPendingReviews(key, n);
485
+ if (pending.length && !force) {
486
+ return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
487
+ }
488
+ for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
489
+ removeVersionFiles(key, n);
490
+ return { deleted: true, blocked: false, meta: recomputeMeta(key) };
491
+ }
492
+
493
+ /** Delete an entire project (all versions + its reviews). */
494
+ export function deleteProject(key, opts = {}) {
495
+ const force = !!opts.force;
496
+ const pending = listPendingReviews(key);
497
+ if (pending.length && !force) {
498
+ return { deleted: false, blocked: true, meta: getProjectMeta(key), pendingReviews: pending };
499
+ }
500
+ for (const r of pending) rejectReviewForced(r.id, DELETE_REASON);
501
+ try {
502
+ rmSync(projectDir(key), { recursive: true, force: true });
503
+ } catch {}
504
+ deleteReviewsForProject(key);
505
+ return { deleted: true, blocked: false, meta: null };
506
+ }
507
+
508
+ // ---------- review helpers ----------
509
+ export function listPendingReviews(key, n) {
510
+ return listReviews().filter(
511
+ (r) => r.projectKey === key && r.status === "pending" && (n == null || r.version === n),
512
+ );
513
+ }
514
+
515
+ /** Force-reject a review with a fixed reason (does not touch version files). */
516
+ export function rejectReviewForced(id, reason) {
517
+ const review = getReview(id);
518
+ if (!review) return null;
519
+ review.status = "rejected";
520
+ review.resolvedAt = now();
521
+ review.reason = reason;
522
+ review.forced = true;
523
+ writeFileSync(reviewPath(id), enc(review));
524
+ return review;
525
+ }
526
+
527
+ export function deleteReviewsForProject(key) {
528
+ for (const r of listReviews()) {
529
+ if (r.projectKey === key) {
530
+ try {
531
+ rmSync(reviewPath(r.id), { force: true });
532
+ } catch {}
533
+ }
534
+ }
535
+ }
536
+
537
+ export function deleteReviewsForVersion(key, n) {
538
+ for (const r of listReviews()) {
539
+ if (r.projectKey === key && r.version === n) {
540
+ try {
541
+ rmSync(reviewPath(r.id), { force: true });
542
+ } catch {}
543
+ }
544
+ }
545
+ }
546
+
271
547
  // ---------- maintenance ----------
272
548
  export function pruneResolvedReviews(maxAgeMs = 1000 * 60 * 60 * 24 * 7) {
273
549
  const cutoff = Date.now() - maxAgeMs;