claude-plan-review 0.2.0 → 0.3.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 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
 
@@ -140,8 +143,10 @@ Claude finishes plan ──> PreToolUse hook (ExitPlanMode)
140
143
  │ ensures the server is up, opens the browser
141
144
  │ BLOCKS, polling for your decision
142
145
  browser (you) ─────────┘
143
- approve ──> hook emits {permissionDecision:"allow"} ──> Claude implements
144
- request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
146
+ approve ──> hook emits {permissionDecision:"allow", updatedInput, additionalContext?} ──> Claude implements
147
+ (updatedInput is what skips the native "Exit plan mode?" prompt;
148
+ additionalContext carries any comments as "approve with comments")
149
+ request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
145
150
  ```
146
151
 
147
152
  ## Storage layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-review",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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/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 out(decision, reason, sys) {
27
- process.stdout.write(
28
- JSON.stringify({
29
- hookSpecificOutput: {
30
- hookEventName: "PreToolUse",
31
- permissionDecision: decision,
32
- permissionDecisionReason: reason,
33
- },
34
- systemMessage: sys,
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 plan = input.tool_input?.plan ?? "";
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
- out("allow", "Approved in the plan-review UI.", "✅ Plan approved via plan-review");
149
+ allow(toolInput, review.notes);
118
150
  process.exit(0);
119
151
  }
120
152
  if (review && review.status === "rejected") {
121
- out(
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
- /** Resolve a review; for reject, compile its version's comments into the reason. */
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
- if (decision === "reject") review.reason = compileReason(review.projectKey, review.version);
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
- function compileReason(key, n) {
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
- "The reviewer requested changes to this plan in the plan-review UI. " +
221
- "Address each comment below, then re-present the revised plan with ExitPlanMode.",
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
- toast(decision === "approve" ? "Plan approved — Claude will proceed." : "Sent back to Claude with your comments.");
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 ----------