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/README.md +126 -4
- package/package.json +2 -1
- package/skill/SKILL.md +86 -0
- package/src/cli.js +192 -7
- package/src/hook.js +31 -8
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -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 +309 -33
- package/src/ui/app.js +649 -108
- package/src/ui/index.html +51 -15
- package/src/ui/style.css +73 -18
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
|
-
|
|
31
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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")
|
|
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)) || "{}");
|
package/src/stop-gate.js
ADDED
|
@@ -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();
|