claude-plan-review 0.2.0 → 0.3.1
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 +8 -12
- package/package.json +1 -1
- package/src/channels/gh.js +5 -3
- package/src/channels/gist.js +10 -3
- package/src/cli.js +0 -0
- package/src/hook.js +46 -18
- package/src/store.js +29 -6
- package/src/ui/app.js +11 -2
package/README.md
CHANGED
|
@@ -8,7 +8,10 @@ When Claude finishes a plan in **plan mode**, a `PreToolUse` hook on the
|
|
|
8
8
|
`ExitPlanMode` tool intercepts it, opens the plan in your browser, and **blocks**
|
|
9
9
|
until you decide:
|
|
10
10
|
|
|
11
|
-
- **Approve** → Claude exits plan mode and starts implementing
|
|
11
|
+
- **Approve** → Claude exits plan mode and starts implementing **immediately** —
|
|
12
|
+
no need to switch back to the terminal and confirm. (If you leave any comments
|
|
13
|
+
before approving, they ride along as guidance for Claude to incorporate while
|
|
14
|
+
implementing — "approve with comments".)
|
|
12
15
|
- **Request changes** → your line + general comments are sent back as the denial
|
|
13
16
|
reason; Claude stays in plan mode and revises, producing a new version.
|
|
14
17
|
|
|
@@ -57,15 +60,6 @@ npx claude-plan-review init --published # if you have Node
|
|
|
57
60
|
`--published` makes the hook run via the package runner (`bunx`/`npx`), so it works on
|
|
58
61
|
any machine — nothing to keep in your repo but the one settings entry.
|
|
59
62
|
|
|
60
|
-
### Option B — from a local clone (for hacking on it)
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
git clone https://github.com/pavlo-petrychenko/claude-plan-review
|
|
64
|
-
cd claude-plan-review
|
|
65
|
-
bun install # or: npm install
|
|
66
|
-
bun src/cli.js init /path/to/your/project # …or…
|
|
67
|
-
node src/cli.js init /path/to/your/project # writes an absolute-path hook command
|
|
68
|
-
```
|
|
69
63
|
|
|
70
64
|
### All projects at once (global)
|
|
71
65
|
|
|
@@ -140,8 +134,10 @@ Claude finishes plan ──> PreToolUse hook (ExitPlanMode)
|
|
|
140
134
|
│ ensures the server is up, opens the browser
|
|
141
135
|
│ BLOCKS, polling for your decision
|
|
142
136
|
browser (you) ─────────┘
|
|
143
|
-
approve ──> hook emits {permissionDecision:"allow"}
|
|
144
|
-
|
|
137
|
+
approve ──> hook emits {permissionDecision:"allow", updatedInput, additionalContext?} ──> Claude implements
|
|
138
|
+
(updatedInput is what skips the native "Exit plan mode?" prompt;
|
|
139
|
+
additionalContext carries any comments as "approve with comments")
|
|
140
|
+
request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
|
|
145
141
|
```
|
|
146
142
|
|
|
147
143
|
## Storage layout
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plan-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Review, comment on, and approve/reject Claude Code plans in a local GitHub-style web UI — with persistent comments, version history, and diffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/channels/gh.js
CHANGED
|
@@ -73,11 +73,13 @@ export function api(method, path, body) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
const errText = `${r.stderr}${r.stdout}`.trim();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const httpStatus = Number(errText.match(/HTTP (\d{3})/)?.[1]) || null;
|
|
77
|
+
// A genuine missing-scope error names the scope explicitly ("requires the
|
|
78
|
+
// 'gist' scope"). A bare 404 just means the gist is gone — don't conflate them.
|
|
79
|
+
const needsGistScope = /gist/i.test(errText) && /scope|requires/i.test(errText);
|
|
79
80
|
return {
|
|
80
81
|
ok: false,
|
|
82
|
+
httpStatus,
|
|
81
83
|
error: errText || (r.spawnError ? String(r.spawnError.message || r.spawnError) : "gh failed"),
|
|
82
84
|
needsGistScope,
|
|
83
85
|
};
|
package/src/channels/gist.js
CHANGED
|
@@ -47,12 +47,15 @@ export function available() {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function ghError(res) {
|
|
50
|
+
// Only blame the scope when the token actually lacks it — otherwise surface
|
|
51
|
+
// gh's real error so a deleted gist / network failure isn't misdiagnosed.
|
|
52
|
+
const missingScope = res.needsGistScope && !gh.status().scopes.includes("gist");
|
|
50
53
|
const e = new Error(
|
|
51
|
-
|
|
52
|
-
? `GitHub rejected the gist write — your gh token
|
|
54
|
+
missingScope
|
|
55
|
+
? `GitHub rejected the gist write — your gh token lacks the 'gist' scope. Run: ${REFRESH_CMD}`
|
|
53
56
|
: res.error || "gh request failed",
|
|
54
57
|
);
|
|
55
|
-
e.fixCommand =
|
|
58
|
+
e.fixCommand = missingScope ? REFRESH_CMD : null;
|
|
56
59
|
return e;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -87,6 +90,10 @@ export function update({ id: gistId, markdown, description, filename, oldFilenam
|
|
|
87
90
|
description: description ?? "",
|
|
88
91
|
files: { [fileKey]: filePatch },
|
|
89
92
|
});
|
|
93
|
+
// The bound gist was deleted on GitHub — recreate one and re-bind instead of failing.
|
|
94
|
+
if (!res.ok && res.httpStatus === 404) {
|
|
95
|
+
return create({ markdown, description, filename: newName });
|
|
96
|
+
}
|
|
90
97
|
if (!res.ok) throw ghError(res);
|
|
91
98
|
return {
|
|
92
99
|
id: res.data.id,
|
package/src/cli.js
CHANGED
|
File without changes
|
package/src/hook.js
CHANGED
|
@@ -23,17 +23,48 @@ const POLL_MS = 700;
|
|
|
23
23
|
|
|
24
24
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
25
|
|
|
26
|
-
function
|
|
27
|
-
process.stdout.write(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
function emit(payload) {
|
|
27
|
+
process.stdout.write(JSON.stringify(payload));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Approve and auto-proceed WITHOUT the terminal "Exit plan mode?" keypress.
|
|
32
|
+
*
|
|
33
|
+
* ExitPlanMode reports requiresUserInteraction()=true, so a bare allow falls
|
|
34
|
+
* through to the native prompt. The permission combiner only bypasses the
|
|
35
|
+
* prompt when the hook ALSO returns `updatedInput` (it treats that as "the hook
|
|
36
|
+
* already satisfied the user interaction"). So we echo the original tool input
|
|
37
|
+
* back. `additionalContext`, when present, carries the reviewer's notes into
|
|
38
|
+
* Claude's context — i.e. "approve with comments".
|
|
39
|
+
* (Verified against the Claude Code 2.1.185 binary.)
|
|
40
|
+
*/
|
|
41
|
+
function allow(toolInput, notes) {
|
|
42
|
+
const hookSpecificOutput = {
|
|
43
|
+
hookEventName: "PreToolUse",
|
|
44
|
+
permissionDecision: "allow",
|
|
45
|
+
permissionDecisionReason: notes
|
|
46
|
+
? "Approved with comments in the plan-review UI."
|
|
47
|
+
: "Approved in the plan-review UI.",
|
|
48
|
+
updatedInput: toolInput, // REQUIRED to skip the native approval prompt
|
|
49
|
+
};
|
|
50
|
+
if (notes) hookSpecificOutput.additionalContext = notes;
|
|
51
|
+
emit({
|
|
52
|
+
hookSpecificOutput,
|
|
53
|
+
systemMessage: notes
|
|
54
|
+
? "✅ Plan approved with comments via plan-review"
|
|
55
|
+
: "✅ Plan approved via plan-review",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deny(reason) {
|
|
60
|
+
emit({
|
|
61
|
+
hookSpecificOutput: {
|
|
62
|
+
hookEventName: "PreToolUse",
|
|
63
|
+
permissionDecision: "deny",
|
|
64
|
+
permissionDecisionReason: reason,
|
|
65
|
+
},
|
|
66
|
+
systemMessage: "📝 Changes requested via plan-review",
|
|
67
|
+
});
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
function serverPort() {
|
|
@@ -92,7 +123,8 @@ async function main() {
|
|
|
92
123
|
|
|
93
124
|
if (input.tool_name !== "ExitPlanMode") process.exit(0);
|
|
94
125
|
|
|
95
|
-
const
|
|
126
|
+
const toolInput = input.tool_input ?? {};
|
|
127
|
+
const plan = toolInput.plan ?? "";
|
|
96
128
|
if (!plan.trim()) process.exit(0);
|
|
97
129
|
|
|
98
130
|
const { key, version, reviewId } = recordPlan({
|
|
@@ -114,15 +146,11 @@ async function main() {
|
|
|
114
146
|
while (Date.now() < deadline) {
|
|
115
147
|
const review = getReview(reviewId);
|
|
116
148
|
if (review && review.status === "approved") {
|
|
117
|
-
|
|
149
|
+
allow(toolInput, review.notes);
|
|
118
150
|
process.exit(0);
|
|
119
151
|
}
|
|
120
152
|
if (review && review.status === "rejected") {
|
|
121
|
-
|
|
122
|
-
"deny",
|
|
123
|
-
review.reason || "Changes requested in the plan-review UI.",
|
|
124
|
-
"📝 Changes requested via plan-review",
|
|
125
|
-
);
|
|
153
|
+
deny(review.reason || "Changes requested in the plan-review UI.");
|
|
126
154
|
process.exit(0);
|
|
127
155
|
}
|
|
128
156
|
await sleep(POLL_MS);
|
package/src/store.js
CHANGED
|
@@ -198,27 +198,50 @@ export function getReview(id) {
|
|
|
198
198
|
return existsSync(reviewPath(id)) ? readJSON(reviewPath(id), null) : null;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a review.
|
|
203
|
+
* - reject → compile the version's comments into `reason` (fed back via deny).
|
|
204
|
+
* - approve → if the version has comments, compile them into `notes`
|
|
205
|
+
* (delivered to Claude as additionalContext on the allow — "approve with comments").
|
|
206
|
+
* Returns the review including `commentCount` so callers can reflect it in the UI.
|
|
207
|
+
*/
|
|
202
208
|
export function resolveReview(id, decision) {
|
|
203
209
|
const review = getReview(id);
|
|
204
210
|
if (!review) return null;
|
|
205
211
|
review.status = decision === "approve" ? "approved" : "rejected";
|
|
206
212
|
review.resolvedAt = now();
|
|
207
|
-
|
|
213
|
+
review.commentCount = listComments(review.projectKey, review.version).length;
|
|
214
|
+
if (decision === "reject") {
|
|
215
|
+
review.reason = compileComments(review.projectKey, review.version, "reject");
|
|
216
|
+
} else {
|
|
217
|
+
const notes = compileComments(review.projectKey, review.version, "approve");
|
|
218
|
+
if (notes) review.notes = notes;
|
|
219
|
+
}
|
|
208
220
|
writeFileSync(reviewPath(id), enc(review));
|
|
209
221
|
return review;
|
|
210
222
|
}
|
|
211
223
|
|
|
212
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Format a version's comments for the model.
|
|
226
|
+
* mode "reject" → "address these, then re-present" (always returns text, even with no comments).
|
|
227
|
+
* mode "approve" → "plan is approved, incorporate these notes" (returns "" when there are none).
|
|
228
|
+
*/
|
|
229
|
+
function compileComments(key, n, mode) {
|
|
213
230
|
const comments = listComments(key, n);
|
|
214
231
|
const lineComments = comments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
|
|
215
232
|
const general = comments.filter((c) => c.line == null);
|
|
233
|
+
|
|
234
|
+
if (mode === "approve" && !lineComments.length && !general.length) return "";
|
|
235
|
+
|
|
216
236
|
const md = getVersion(key, n)?.markdown ?? "";
|
|
217
237
|
const lines = md.split("\n");
|
|
218
238
|
|
|
219
239
|
const parts = [
|
|
220
|
-
|
|
221
|
-
"
|
|
240
|
+
mode === "approve"
|
|
241
|
+
? "The reviewer APPROVED this plan in the plan-review UI and left the notes below. " +
|
|
242
|
+
"Proceed with implementation, incorporating each note as you go."
|
|
243
|
+
: "The reviewer requested changes to this plan in the plan-review UI. " +
|
|
244
|
+
"Address each comment below, then re-present the revised plan with ExitPlanMode.",
|
|
222
245
|
];
|
|
223
246
|
if (lineComments.length) {
|
|
224
247
|
parts.push("\nLine comments:");
|
|
@@ -239,7 +262,7 @@ function compileReason(key, n) {
|
|
|
239
262
|
parts.push("\nGeneral comments:");
|
|
240
263
|
for (const c of general) parts.push(`- ${c.body}`);
|
|
241
264
|
}
|
|
242
|
-
if (!lineComments.length && !general.length) {
|
|
265
|
+
if (mode === "reject" && !lineComments.length && !general.length) {
|
|
243
266
|
parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
|
|
244
267
|
}
|
|
245
268
|
return parts.join("\n");
|
package/src/ui/app.js
CHANGED
|
@@ -359,13 +359,22 @@ async function refreshReview() {
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
async function resolve(decision) {
|
|
362
|
-
await api(`/api/reviews/${state.reviewId}/decision`, {
|
|
362
|
+
const review = await api(`/api/reviews/${state.reviewId}/decision`, {
|
|
363
363
|
method: "POST",
|
|
364
364
|
headers: { "content-type": "application/json" },
|
|
365
365
|
body: JSON.stringify({ decision }),
|
|
366
366
|
});
|
|
367
367
|
await refreshReview();
|
|
368
|
-
|
|
368
|
+
if (decision === "approve") {
|
|
369
|
+
const n = review?.commentCount || 0;
|
|
370
|
+
toast(
|
|
371
|
+
n
|
|
372
|
+
? `Approved with ${n} comment${n === 1 ? "" : "s"} — Claude will proceed and incorporate them.`
|
|
373
|
+
: "Plan approved — Claude will proceed.",
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
toast("Sent back to Claude with your comments.");
|
|
377
|
+
}
|
|
369
378
|
}
|
|
370
379
|
|
|
371
380
|
// ---------- storage / save ----------
|