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/README.md +132 -4
- package/package.json +2 -1
- package/skill/SKILL.md +90 -0
- package/src/cli.js +279 -9
- package/src/hook.js +37 -9
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-context.js +63 -0
- package/src/plan-parse.js +238 -0
- package/src/server.js +261 -10
- package/src/stop-gate.js +73 -0
- package/src/store.js +372 -34
- package/src/ui/app.js +639 -108
- package/src/ui/index.html +50 -15
- package/src/ui/style.css +73 -18
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
231
|
-
const
|
|
232
|
-
const
|
|
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" && !
|
|
440
|
+
if (mode === "approve" && !anyLine && !anyGeneral) return "";
|
|
235
441
|
|
|
236
|
-
const
|
|
237
|
-
const
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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;
|