diffprism 0.49.0 → 1.1.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/dist/bin.js CHANGED
@@ -2,28 +2,286 @@
2
2
  import {
3
3
  isPrRef,
4
4
  parsePrRef
5
- } from "./chunk-24B33UN6.js";
5
+ } from "./chunk-EPU4F7WT.js";
6
6
  import {
7
7
  demo
8
- } from "./chunk-7UQM4WBZ.js";
8
+ } from "./chunk-FWPE5EA6.js";
9
9
  import {
10
+ COMMIT_GATE_DIFF_REF,
11
+ DEFAULT_DIFF_REF,
12
+ MCP_TOOL_NAMES,
13
+ REPORT_HINT,
14
+ RETIRED_MCP_TOOL_NAMES,
15
+ ReviewTimeoutError,
16
+ ReviewerAskedError,
17
+ buildFeedbackUrl,
18
+ currentVersion,
10
19
  describeVersion,
11
20
  ensureServer,
12
21
  getBuildInfo,
13
22
  isServerAlive,
23
+ mcpToolPermission,
24
+ readLastError,
14
25
  readServerFile,
26
+ recordError,
15
27
  startGlobalServer,
16
28
  submitReviewToServer
17
- } from "./chunk-EPUCA2N5.js";
29
+ } from "./chunk-KRHZSUEK.js";
18
30
  import {
19
31
  getDiff
20
- } from "./chunk-QGWYCEJN.js";
32
+ } from "./chunk-3GMPE2ZR.js";
21
33
  import "./chunk-DHCVZGHE.js";
22
34
  import "./chunk-JSBRDJBE.js";
23
35
 
24
36
  // cli/src/index.ts
25
37
  import { Command } from "commander";
26
38
 
39
+ // cli/src/commands/hook.ts
40
+ import { execFileSync } from "child_process";
41
+ import fs from "fs";
42
+ import path from "path";
43
+ var DEFAULT_MIN_LINES = 120;
44
+ var RETRY_ADVICE = "The review stays open in DiffPrism \u2014 once the reviewer decides, run git commit again to pick up the decision. Don't open another review or change the staged files meanwhile.";
45
+ var MARKER_START = "# >>> diffprism >>>";
46
+ var MARKER_END = "# <<< diffprism <<<";
47
+ var HOOK_LINE = "diffprism hook pre-commit || exit 1";
48
+ async function preCommitHook(flags = {}) {
49
+ const cwd = process.cwd();
50
+ const minLines = resolveMinLines(flags, cwd);
51
+ let changedLines;
52
+ try {
53
+ const { diffSet } = getDiff(COMMIT_GATE_DIFF_REF, { cwd });
54
+ changedLines = diffSet.files.reduce(
55
+ (total, file) => total + file.additions + file.deletions,
56
+ 0
57
+ );
58
+ } catch (err) {
59
+ recordError("hook pre-commit", err);
60
+ fail(`Could not read the staged diff: ${message(err)}
61
+ ${REPORT_HINT}`);
62
+ return;
63
+ }
64
+ if (changedLines === 0) {
65
+ process.exit(0);
66
+ }
67
+ if (changedLines < minLines) {
68
+ process.exit(0);
69
+ }
70
+ console.error(
71
+ `${changedLines} staged lines (gate at ${minLines}) \u2014 opening DiffPrism review...`
72
+ );
73
+ console.error(`Waiting for a decision in the browser. ${RETRY_ADVICE}`);
74
+ const stopWaiting = onInterrupt((signal) => {
75
+ console.error("");
76
+ console.error(`Interrupted (${signal}) before a decision. ${RETRY_ADVICE}`);
77
+ process.exit(1);
78
+ });
79
+ let review2 = null;
80
+ try {
81
+ const serverInfo = await ensureServer({ dev: flags.dev });
82
+ const { result } = await submitReviewToServer(serverInfo, COMMIT_GATE_DIFF_REF, {
83
+ cwd,
84
+ diffRef: COMMIT_GATE_DIFF_REF,
85
+ title: "Pre-commit review"
86
+ });
87
+ review2 = result;
88
+ } catch (err) {
89
+ stopWaiting();
90
+ if (err instanceof ReviewerAskedError) {
91
+ printQuestions(err.threads);
92
+ fail(
93
+ `Commit blocked: the reviewer asked you something before deciding. Answer each question with the DiffPrism reply tool (session_id: ${err.sessionId}, annotation_id as listed), then run git commit again \u2014 the review stays open and the decision still comes.`
94
+ );
95
+ return;
96
+ }
97
+ if (err instanceof ReviewTimeoutError) {
98
+ fail(`Commit blocked: no decision after ${Math.round(err.waitedMs / 1e3)}s. ${RETRY_ADVICE}`);
99
+ return;
100
+ }
101
+ recordError("hook pre-commit", err);
102
+ fail(`DiffPrism could not run the review: ${message(err)}
103
+ ${REPORT_HINT}`);
104
+ return;
105
+ }
106
+ stopWaiting();
107
+ const decision = review2?.decision;
108
+ if (decision === "approved" || decision === "approved_with_comments") {
109
+ const summary = review2?.summary?.trim();
110
+ const comments = review2?.comments ?? [];
111
+ if (summary || comments.length > 0) {
112
+ printFeedback(review2);
113
+ }
114
+ console.error(`Review: ${decision} \u2014 proceeding.`);
115
+ process.exit(0);
116
+ }
117
+ if (decision === "changes_requested") {
118
+ printFeedback(review2);
119
+ fail("Commit blocked: the review requested changes.");
120
+ return;
121
+ }
122
+ if (decision === "dismissed") {
123
+ fail("Commit blocked: the review was dismissed without a decision.");
124
+ return;
125
+ }
126
+ fail(
127
+ `Commit blocked: no review decision was returned (got ${String(decision)}).`
128
+ );
129
+ }
130
+ function printQuestions(threads) {
131
+ console.error("");
132
+ for (const t of threads) {
133
+ const last = t.replies?.at(-1)?.body ?? t.body;
134
+ console.error(` ${t.file}:${t.line} (annotation_id: ${t.id})`);
135
+ for (const line of last.split("\n")) {
136
+ console.error(` ${line}`);
137
+ }
138
+ }
139
+ console.error("");
140
+ }
141
+ function printFeedback(review2) {
142
+ const summary = review2?.summary?.trim();
143
+ const comments = review2?.comments ?? [];
144
+ console.error("");
145
+ if (!summary && comments.length === 0) {
146
+ console.error(" The review left no summary and no inline comments.");
147
+ console.error(" Ask what needs changing \u2014 there is nothing here to act on.");
148
+ console.error("");
149
+ return;
150
+ }
151
+ if (summary) {
152
+ for (const line of summary.split("\n")) {
153
+ console.error(` ${line}`);
154
+ }
155
+ if (comments.length > 0) {
156
+ console.error("");
157
+ }
158
+ }
159
+ for (const c of comments) {
160
+ console.error(` ${c.file}:${c.line} [${c.type}] ${c.body}`);
161
+ }
162
+ console.error("");
163
+ }
164
+ function installHook() {
165
+ const hookPath = resolveHookPath(process.cwd());
166
+ const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, "utf8") : "";
167
+ if (existing.includes(MARKER_START)) {
168
+ console.log(`Already installed in ${hookPath}`);
169
+ return;
170
+ }
171
+ const block = `${MARKER_START}
172
+ ${HOOK_LINE}
173
+ ${MARKER_END}
174
+ `;
175
+ const base = existing === "" ? "#!/bin/sh\n" : ensureTrailingNewline(existing);
176
+ const created = existing === "";
177
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
178
+ fs.writeFileSync(hookPath, `${base}
179
+ ${block}`);
180
+ fs.chmodSync(hookPath, 493);
181
+ console.log(`${created ? "Created" : "Updated"} ${hookPath}`);
182
+ console.log(
183
+ `Staged changes of ${DEFAULT_MIN_LINES}+ lines now open a review before the commit lands.`
184
+ );
185
+ console.log("Tune with: git config diffprism.gate-lines <n>");
186
+ console.log("Remove with: diffprism hook uninstall");
187
+ }
188
+ function uninstallHook() {
189
+ const hookPath = resolveHookPath(process.cwd());
190
+ if (!fs.existsSync(hookPath)) {
191
+ console.log("Nothing to remove \u2014 no hook file.");
192
+ return;
193
+ }
194
+ const existing = fs.readFileSync(hookPath, "utf8");
195
+ if (!existing.includes(MARKER_START)) {
196
+ console.log(`Nothing to remove \u2014 no diffprism block in ${hookPath}`);
197
+ return;
198
+ }
199
+ const cleaned = removeMarkedBlock(existing);
200
+ if (/^\s*(#![^\n]*)?\s*$/.test(cleaned)) {
201
+ fs.rmSync(hookPath);
202
+ console.log(`Removed ${hookPath} (it contained nothing else).`);
203
+ return;
204
+ }
205
+ fs.writeFileSync(hookPath, cleaned);
206
+ console.log(`Removed the diffprism block from ${hookPath}`);
207
+ }
208
+ function removeMarkedBlock(contents) {
209
+ const lines = contents.split("\n");
210
+ const out = [];
211
+ let inside = false;
212
+ for (const line of lines) {
213
+ if (line.trim() === MARKER_START) {
214
+ inside = true;
215
+ continue;
216
+ }
217
+ if (line.trim() === MARKER_END) {
218
+ inside = false;
219
+ continue;
220
+ }
221
+ if (!inside) {
222
+ out.push(line);
223
+ }
224
+ }
225
+ return ensureTrailingNewline(out.join("\n").replace(/\n{3,}/g, "\n\n"));
226
+ }
227
+ function onInterrupt(handler) {
228
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
229
+ for (const signal of signals) {
230
+ process.once(signal, handler);
231
+ }
232
+ return () => {
233
+ for (const signal of signals) {
234
+ process.removeListener(signal, handler);
235
+ }
236
+ };
237
+ }
238
+ function resolveMinLines(flags, cwd) {
239
+ const fromFlag = flags.minLines ? Number.parseInt(flags.minLines, 10) : NaN;
240
+ if (Number.isFinite(fromFlag) && fromFlag > 0) {
241
+ return fromFlag;
242
+ }
243
+ const configured = Number.parseInt(
244
+ git(["config", "--get", "diffprism.gate-lines"], cwd) ?? "",
245
+ 10
246
+ );
247
+ if (Number.isFinite(configured) && configured > 0) {
248
+ return configured;
249
+ }
250
+ return DEFAULT_MIN_LINES;
251
+ }
252
+ function resolveHookPath(cwd) {
253
+ const configured = git(["config", "--get", "core.hooksPath"], cwd);
254
+ if (configured) {
255
+ return path.resolve(cwd, configured, "pre-commit");
256
+ }
257
+ const hooksDir = git(["rev-parse", "--git-path", "hooks"], cwd) ?? ".git/hooks";
258
+ return path.resolve(cwd, hooksDir, "pre-commit");
259
+ }
260
+ function git(args, cwd) {
261
+ try {
262
+ const out = execFileSync("git", args, {
263
+ cwd,
264
+ encoding: "utf8",
265
+ stdio: ["ignore", "pipe", "ignore"]
266
+ });
267
+ const trimmed = out.trim();
268
+ return trimmed === "" ? null : trimmed;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ function ensureTrailingNewline(text) {
274
+ return text.endsWith("\n") ? text : `${text}
275
+ `;
276
+ }
277
+ function message(err) {
278
+ return err instanceof Error ? err.message : String(err);
279
+ }
280
+ function fail(text) {
281
+ console.error(text);
282
+ process.exit(1);
283
+ }
284
+
27
285
  // cli/src/commands/review.ts
28
286
  async function review(ref, flags) {
29
287
  let diffRef;
@@ -34,7 +292,7 @@ async function review(ref, flags) {
34
292
  } else if (ref) {
35
293
  diffRef = ref;
36
294
  } else {
37
- diffRef = "working-copy";
295
+ diffRef = DEFAULT_DIFF_REF;
38
296
  }
39
297
  try {
40
298
  if (isPrRef(diffRef)) {
@@ -44,18 +302,32 @@ async function review(ref, flags) {
44
302
  }
45
303
  } catch (err) {
46
304
  const message2 = err instanceof Error ? err.message : String(err);
305
+ recordError("review", err);
47
306
  console.error(`Error: ${message2}`);
307
+ console.error(REPORT_HINT);
48
308
  process.exit(1);
49
309
  }
50
310
  }
51
311
  async function reviewLocalFlow(diffRef, flags) {
52
312
  const serverInfo = await ensureServer({ dev: flags.dev });
53
313
  console.error("Opening review in browser...");
54
- const { result } = await submitReviewToServer(serverInfo, diffRef, {
55
- title: flags.title,
56
- cwd: process.cwd(),
57
- diffRef
58
- });
314
+ let result;
315
+ try {
316
+ ({ result } = await submitReviewToServer(serverInfo, diffRef, {
317
+ title: flags.title,
318
+ cwd: process.cwd(),
319
+ diffRef
320
+ }));
321
+ } catch (err) {
322
+ if (err instanceof ReviewerAskedError) {
323
+ printQuestions(err.threads);
324
+ console.error(
325
+ `The reviewer asked something before deciding. Answer each question with the DiffPrism reply tool (session_id: ${err.sessionId}, annotation_id as listed), then run diffprism review again \u2014 the review stays open.`
326
+ );
327
+ process.exit(1);
328
+ }
329
+ throw err;
330
+ }
59
331
  console.log(JSON.stringify(result, null, 2));
60
332
  if (!result) {
61
333
  console.error("No review result was returned.");
@@ -100,8 +372,8 @@ async function serve() {
100
372
  }
101
373
 
102
374
  // cli/src/commands/setup.ts
103
- import fs from "fs";
104
- import path from "path";
375
+ import fs2 from "fs";
376
+ import path2 from "path";
105
377
  import os from "os";
106
378
  import readline from "readline";
107
379
 
@@ -113,102 +385,127 @@ description: Open current code changes in DiffPrism's browser-based review UI fo
113
385
 
114
386
  # DiffPrism Review
115
387
 
116
- You have 14 DiffPrism MCP tools available. Use them proactively \u2014 don't wait for the user to ask.
388
+ You have 14 DiffPrism MCP tools. Use the analysis tools proactively; open a human review only when asked.
389
+
390
+ ## How reviews work
391
+
392
+ - **One review per repo.** Opening a review for a repo that already has one updates it \u2014 new diff, same session, existing annotations kept. A git worktree is its own repo.
393
+ - **\`open_review\` waits for the decision.** It blocks until the reviewer approves, requests changes, or dismisses, and returns their \`ReviewResult\`. Pass \`wait: false\` to get the session id back immediately instead.
394
+ - **Scope defaults to the working copy everywhere.** \`open_review\`, \`get_diff\` and \`analyze_diff\` all review \`"working-copy"\` unless you pass \`diff_ref\` \u2014 the same default as \`diffprism review\` and the dashboard, so your self-review and the human review look at the same changes.
395
+ - **A decision stands while its diff is unchanged.** If your wait is cut short, open the same review again or re-run the same commit and you get the decision the reviewer already gave \u2014 not a second review request. Change the diff and it becomes a new question.
396
+ - **Tools act on the review for your repo.** Every tool that works on an open review takes \`session_id\` or \`repo_path\`, and otherwise uses the repo you are running in. If that is ambiguous the tool says so and lists the sessions \u2014 pass \`session_id\`. It never guesses.
117
397
 
118
398
  ## Workflow 1: Self-Review Before Human Review
119
399
 
120
400
  After making multi-file changes, **proactively self-review before requesting human review**:
121
401
 
122
- 1. Run \`mcp__diffprism__analyze_diff\` with \`diff_ref: "working-copy"\`
402
+ 1. Run \`mcp__diffprism__analyze_diff\` (it covers the working copy by default)
123
403
  2. Check the response for:
124
404
  - **Pattern flags** \u2014 console.logs left in, TODO comments, security issues (hardcoded secrets, SQL injection, XSS)
125
405
  - **Test coverage gaps** \u2014 new code paths without corresponding tests
126
406
  - **High complexity scores** \u2014 functions or files with elevated complexity
127
407
  3. **Fix any issues found** \u2014 remove debug logs, add missing tests, simplify complex code
128
- 4. Only then open a human review with \`mcp__diffprism__open_review\`
408
+ 4. Only then open a human review
129
409
 
130
410
  This should happen automatically after significant changes. You don't need the user to ask.
131
411
 
132
412
  ## Workflow 2: Annotated Human Review
133
413
 
134
- When opening a review, help the reviewer by flagging what matters:
135
-
136
414
  1. Call \`mcp__diffprism__open_review\` with:
137
- - \`diff_ref\`: \`"working-copy"\` (or what the user specified, e.g. \`"staged"\`, \`"HEAD~3..HEAD"\`)
415
+ - \`diff_ref\`: omit it for the working copy, or pass what the user asked for \u2014 see *Choosing a scope*
138
416
  - \`title\`: Brief summary of the changes
139
- - \`reasoning\`: Your reasoning about implementation decisions
140
- - \`annotations\`: Array of inline findings to pre-populate the review (see tool schema)
141
- 2. Use annotations to flag:
417
+ - \`reasoning\`: What you were trying to accomplish \u2014 this is how the reviewer tells sessions apart
418
+ - \`annotations\`: Findings to show when the review opens
419
+ 2. Annotate what matters:
142
420
  - Areas of uncertainty ("I chose approach X over Y because...")
143
421
  - Security-sensitive changes
144
422
  - Performance implications
145
- - Anything the reviewer should look at closely
146
- 3. After opening, use \`mcp__diffprism__flag_for_attention\` to highlight files that need careful review (e.g. auth logic, data migrations, public API changes)
147
- 4. Use \`mcp__diffprism__add_annotation\` to post additional findings about specific lines if you discover issues while the review is open
423
+ - Use \`type: "warning"\` for anything the reviewer must look at \u2014 warnings flag the session in the sidebar
424
+ 3. \`open_review\` returns the decision when the reviewer submits:
425
+ - **\`approved\`** / **\`approved_with_comments\`** \u2014 proceed. Read any comments or summary.
426
+ - **\`changes_requested\`** \u2014 read the \`summary\` and \`comments\`, make the fixes, and offer to re-review.
427
+ - **\`dismissed\`** \u2014 the reviewer closed it without deciding. Ask before continuing.
428
+ - If \`postReviewAction\` is \`"commit"\` \u2014 commit the changes. If \`"commit_and_pr"\` \u2014 commit and open a PR.
429
+ 4. If it returns \`status: "timed_out"\`, the reviewer is still reading. Keep waiting with \`mcp__diffprism__get_review_result\` (\`wait: true\`).
430
+ 5. If it returns \`status: "reviewer_asked"\`, the reviewer asked you something before deciding. Answer each thread with \`mcp__diffprism__reply\` (its \`id\` is the \`annotation_id\`) \u2014 and change the code if that's what they asked for. Then wait again with \`mcp__diffprism__get_review_result\` (\`wait: true\`), which can return \`reviewer_asked\` again.
148
431
 
149
- Handle the review result:
150
- - **\`approved\`** \u2014 Proceed with the task.
151
- - **\`changes_requested\`** \u2014 Read comments, make fixes, offer to re-review.
152
- - If \`postReviewAction\` is \`"commit"\` \u2014 commit the changes.
153
- - If \`postReviewAction\` is \`"commit_and_pr"\` \u2014 commit and open a PR.
432
+ **While a review is open, wait for it.** Don't ask the user whether they've finished, and don't move on to other work \u2014 their decision is the answer, and it arrives through the tool. Asking in the terminal splits the conversation in two and the decision gets lost between them.
154
433
 
155
- ## Workflow 3: PR Super Review
434
+ To add findings while a review is open, call \`mcp__diffprism__annotate\`.
156
435
 
157
- When the user opens a GitHub PR for review (via \`diffprism review <PR URL>\` or the DiffPrism UI), you become their AI-powered code reviewer. The diff is visible in the browser; you provide the intelligence.
436
+ ## Choosing a scope
158
437
 
159
- ### Getting oriented
160
- 1. Call \`mcp__diffprism__get_pr_context\` to understand the PR: title, author, branches, file list, briefing summary, and whether a local repo is connected.
438
+ | \`diff_ref\` | Shows | Use when |
439
+ |---|---|---|
440
+ | \`"working-copy"\` *(default)* | Everything uncommitted; staged and unstaged as separate groups | Almost always \u2014 "review my changes" |
441
+ | \`"staged"\` | Only what the next commit contains | Reviewing exactly what is about to be committed |
442
+ | \`"unstaged"\` | Only edits not yet staged | Rarely \u2014 reviewing work in progress beside a staged commit |
443
+ | \`"HEAD~3..HEAD"\`, \`"main..feature"\` | A range of commits | Reviewing work that is already committed |
161
444
 
162
- ### Investigating changes
163
- 2. Call \`mcp__diffprism__get_file_diff\` for specific files to see their hunks and triage category (critical/notable/mechanical).
164
- 3. Call \`mcp__diffprism__get_file_context\` to read full files from the local repo \u2014 this gives you surrounding code, not just diff hunks. Use this to understand how changed code fits into the broader file.
165
- 4. Call \`mcp__diffprism__get_user_focus\` to see what file/line the user is currently viewing in the browser. Proactively offer context about what they're looking at.
445
+ ## Commit gate
166
446
 
167
- ### Leaving findings
168
- 5. Call \`mcp__diffprism__add_review_comment\` to post findings directly to the browser UI. Comments appear as inline annotations on the diff in real-time. Use this to flag issues, suggest improvements, or answer the user's questions visually.
169
- 6. Call \`mcp__diffprism__get_review_comments\` to see what's already been noted before adding your own.
447
+ If the repo has the DiffPrism pre-commit gate installed (\`diffprism hook install\`), a \`git commit\` of a large enough change opens a review and **waits for a human**. It reviews **staged** changes only \u2014 unlike every other entry point \u2014 because a commit contains exactly the index; unstaged edits aren't part of what is being approved.
170
448
 
171
- ### Key principle
172
- The user sees the diff in the browser. You see it through MCP tools. Work together \u2014 they spot visual patterns, you analyze logic and context.
449
+ - Run \`git commit\` with a shell timeout long enough for someone to read the change \u2014 up to 600000 ms \u2014 not the short default.
450
+ - If the commit is interrupted, or reports no decision, the review is still open. Once the reviewer decides, run the **same** \`git commit\` again: their decision is picked up immediately. Don't change the staged files first \u2014 that makes it a new question.
451
+ - If it's blocked with changes requested, the reviewer's summary and comments are printed. Address them, stage, and commit again.
452
+ - If it's blocked because the reviewer asked something, their questions are printed with an \`annotation_id\` each. Answer each with \`mcp__diffprism__reply\`, then run the same \`git commit\` again \u2014 the review is still open.
173
453
 
174
- ## Tool Reference
454
+ ## Workflow 3: PR Review
175
455
 
176
- ### Review Lifecycle
177
- | Tool | Purpose |
178
- |------|---------|
179
- | \`open_review\` | Open browser review UI for local changes or a GitHub PR. |
180
- | \`get_review_result\` | Fetch result from a previous review. |
181
- | \`update_review_context\` | Push updated reasoning/description to a running review session. |
456
+ Pull requests are opened by the user \u2014 \`diffprism review <PR URL>\` or "Review PR" in the dashboard \u2014 not by \`open_review\`. You then work inside that review:
457
+
458
+ 1. \`mcp__diffprism__get_pr_context\` \u2014 title, author, branches, file list, briefing summary, and whether a local clone is connected.
459
+ 2. \`mcp__diffprism__get_file_diff\` \u2014 one file's hunks and triage category (critical/notable/mechanical).
460
+ 3. \`mcp__diffprism__get_file_context\` \u2014 the full file from the local clone, so you see surrounding code rather than just the hunks.
461
+ 4. \`mcp__diffprism__get_user_focus\` \u2014 what the user is looking at right now. Offer context about it.
462
+ 5. \`mcp__diffprism__get_review_comments\` \u2014 what has already been said, before you add to it.
463
+ 6. \`mcp__diffprism__annotate\` \u2014 post findings inline on the diff.
464
+
465
+ The reviewer can also ask you questions on lines of the PR. Hold that conversation in the dashboard, not the terminal:
466
+
467
+ 1. \`mcp__diffprism__wait_for_comments\` \u2014 blocks until the reviewer writes something you haven't answered, then returns those threads.
468
+ 2. \`mcp__diffprism__reply\` \u2014 answer each thread, passing its \`annotation_id\`.
469
+ 3. Wait again. On \`timed_out\`, nothing new was said \u2014 keep waiting until the user tells you to stop.
470
+
471
+ A PR review and a working-copy review can be open for the same clone at once. If a tool reports more than one session, pass the \`session_id\` of the one you mean.
182
472
 
183
- ### Headless Analysis
473
+ ## Tool Reference
474
+
475
+ ### Opening and deciding
184
476
  | Tool | Purpose |
185
477
  |------|---------|
186
- | \`analyze_diff\` | Returns analysis JSON (patterns, complexity, test gaps) without opening a browser. |
187
- | \`get_diff\` | Returns structured diff JSON (file-level and hunk-level changes). |
478
+ | \`open_review\` | Open a review of local changes and wait for the decision. |
479
+ | \`get_review_result\` | Check the decision on a review already open (after \`wait: false\` or a timeout). |
480
+ | \`update_review_context\` | Update reasoning, title, or description on an open review. |
188
481
 
189
- ### PR Super Review
482
+ ### Headless analysis
190
483
  | Tool | Purpose |
191
484
  |------|---------|
192
- | \`get_pr_context\` | High-level PR overview: metadata, briefing, file list, local repo status. |
193
- | \`get_file_diff\` | Diff hunks for a specific file with triage category. |
194
- | \`get_file_context\` | Full file content from local repo via \`git show\`. |
195
- | \`get_user_focus\` | What file/line the user is currently viewing in the browser UI. |
485
+ | \`analyze_diff\` | Analysis JSON (patterns, complexity, test gaps) without opening a browser. |
486
+ | \`get_diff\` | Structured diff JSON (file-level and hunk-level changes). |
196
487
 
197
- ### Annotation & Commenting
488
+ ### Working in an open review
198
489
  | Tool | Purpose |
199
490
  |------|---------|
200
- | \`add_review_comment\` | Post a comment that appears inline in the browser diff. |
201
- | \`get_review_comments\` | Read all comments and annotations on the session. |
202
- | \`add_annotation\` | Post a structured finding (finding/suggestion/question/warning). |
203
- | \`flag_for_attention\` | Mark files for human attention with warning annotations. |
204
- | \`get_review_state\` | Get current state of a review session including all annotations. |
491
+ | \`annotate\` | Post one or more findings. \`warning\` flags the session for attention. |
492
+ | \`get_review_comments\` | Every thread on the review; \`awaiting_reply\` narrows to threads waiting for an answer. |
493
+ | \`reply\` | Reply to a thread \u2014 answer the reviewer's question or follow up on a finding. |
494
+ | \`wait_for_comments\` | Block until the reviewer writes something you haven't answered. |
495
+ | \`get_review_state\` | Session status, attention and new-changes flags, and annotations. |
496
+ | \`get_user_focus\` | What the user is currently looking at. |
497
+ | \`get_pr_context\` | PR overview: metadata, briefing, file list, local clone status. |
498
+ | \`get_file_diff\` | Hunks for one file, with triage category. |
499
+ | \`get_file_context\` | Full file content from the local clone. |
205
500
 
206
501
  ## Rules
207
502
 
208
503
  - **Self-review is proactive** \u2014 run \`analyze_diff\` after significant changes without being asked.
209
- - **Human review requires explicit request** \u2014 only open \`open_review\` when the user asks (\`/review\`, "review my changes", or as part of a defined workflow like PR creation).
210
- - **Annotate generously** \u2014 the more context you provide in annotations, the faster the reviewer can make decisions.
211
- - **PR review is conversational** \u2014 when a PR is open, use the super review tools to answer questions and post findings without being asked to use specific tools.
504
+ - **Human review requires explicit request** \u2014 only call \`open_review\` when the user asks (\`/review\`, "review my changes", or as part of a defined workflow like PR creation).
505
+ - **Don't open a second review to check on the first** \u2014 use \`get_review_result\`.
506
+ - **Don't ask the user about a review that's open** \u2014 wait for the decision; it is their answer.
507
+ - **Annotate generously** \u2014 the more context you provide, the faster the reviewer can decide.
508
+ - **PR review is conversational** \u2014 when a PR is open, use the PR tools to answer questions and post findings without being asked to use specific tools.
212
509
  `;
213
510
 
214
511
  // cli/src/commands/setup.ts
@@ -219,33 +516,33 @@ var GITIGNORE_ENTRIES = [
219
516
  ".claude/skills/review/"
220
517
  ];
221
518
  function findGitRoot(from) {
222
- let dir = path.resolve(from);
519
+ let dir = path2.resolve(from);
223
520
  while (true) {
224
- if (fs.existsSync(path.join(dir, ".git"))) {
521
+ if (fs2.existsSync(path2.join(dir, ".git"))) {
225
522
  return dir;
226
523
  }
227
- const parent = path.dirname(dir);
524
+ const parent = path2.dirname(dir);
228
525
  if (parent === dir) return null;
229
526
  dir = parent;
230
527
  }
231
528
  }
232
529
  function readJsonFile(filePath) {
233
530
  try {
234
- const raw = fs.readFileSync(filePath, "utf-8");
531
+ const raw = fs2.readFileSync(filePath, "utf-8");
235
532
  return JSON.parse(raw);
236
533
  } catch {
237
534
  return {};
238
535
  }
239
536
  }
240
537
  function writeJsonFile(filePath, data) {
241
- const dir = path.dirname(filePath);
242
- if (!fs.existsSync(dir)) {
243
- fs.mkdirSync(dir, { recursive: true });
538
+ const dir = path2.dirname(filePath);
539
+ if (!fs2.existsSync(dir)) {
540
+ fs2.mkdirSync(dir, { recursive: true });
244
541
  }
245
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
542
+ fs2.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
246
543
  }
247
544
  function setupMcpJson(gitRoot, force) {
248
- const filePath = path.join(gitRoot, ".mcp.json");
545
+ const filePath = path2.join(gitRoot, ".mcp.json");
249
546
  const existing = readJsonFile(filePath);
250
547
  const servers = existing.mcpServers ?? {};
251
548
  if (servers.diffprism && !force) {
@@ -255,54 +552,47 @@ function setupMcpJson(gitRoot, force) {
255
552
  command: "npx",
256
553
  args: ["diffprism@latest", "serve"]
257
554
  };
258
- const action = fs.existsSync(filePath) ? "updated" : "created";
555
+ const action = fs2.existsSync(filePath) ? "updated" : "created";
259
556
  writeJsonFile(filePath, { ...existing, mcpServers: servers });
260
557
  return { action, filePath };
261
558
  }
262
559
  function setupClaudeSettings(baseDir, force) {
263
- const filePath = path.join(baseDir, ".claude", "settings.json");
560
+ const filePath = path2.join(baseDir, ".claude", "settings.json");
264
561
  const existing = readJsonFile(filePath);
265
562
  const permissions = existing.permissions ?? {};
266
563
  const allow = permissions.allow ?? [];
267
- const toolNames = [
268
- "mcp__diffprism__open_review",
269
- "mcp__diffprism__update_review_context",
270
- "mcp__diffprism__get_review_result",
271
- "mcp__diffprism__get_diff",
272
- "mcp__diffprism__analyze_diff",
273
- "mcp__diffprism__add_annotation",
274
- "mcp__diffprism__get_review_state",
275
- "mcp__diffprism__flag_for_attention",
276
- "mcp__diffprism__review_pr"
277
- ];
564
+ const toolNames = MCP_TOOL_NAMES.map(mcpToolPermission);
565
+ const retired = new Set(RETIRED_MCP_TOOL_NAMES.map(mcpToolPermission));
278
566
  const allPresent = toolNames.every((t) => allow.includes(t));
279
- if (allPresent && !force) {
567
+ const hasRetired = allow.some((t) => retired.has(t));
568
+ if (allPresent && !hasRetired && !force) {
280
569
  return { action: "skipped", filePath };
281
570
  }
571
+ const next = allow.filter((t) => !retired.has(t));
282
572
  for (const toolName of toolNames) {
283
- if (!allow.includes(toolName)) {
284
- allow.push(toolName);
573
+ if (!next.includes(toolName)) {
574
+ next.push(toolName);
285
575
  }
286
576
  }
287
- permissions.allow = allow;
288
- const action = fs.existsSync(filePath) ? "updated" : "created";
577
+ permissions.allow = next;
578
+ const action = fs2.existsSync(filePath) ? "updated" : "created";
289
579
  writeJsonFile(filePath, { ...existing, permissions });
290
580
  return { action, filePath };
291
581
  }
292
582
  function setupSkill(gitRoot, global, force) {
293
- const skillDir = global ? path.join(os.homedir(), ".claude", "skills", "review") : path.join(gitRoot, ".claude", "skills", "review");
294
- const filePath = path.join(skillDir, "SKILL.md");
295
- if (fs.existsSync(filePath)) {
296
- const existingContent = fs.readFileSync(filePath, "utf-8");
583
+ const skillDir = global ? path2.join(os.homedir(), ".claude", "skills", "review") : path2.join(gitRoot, ".claude", "skills", "review");
584
+ const filePath = path2.join(skillDir, "SKILL.md");
585
+ if (fs2.existsSync(filePath)) {
586
+ const existingContent = fs2.readFileSync(filePath, "utf-8");
297
587
  if (existingContent === skillContent) {
298
588
  return { action: "skipped", filePath };
299
589
  }
300
590
  }
301
- if (!fs.existsSync(skillDir)) {
302
- fs.mkdirSync(skillDir, { recursive: true });
591
+ if (!fs2.existsSync(skillDir)) {
592
+ fs2.mkdirSync(skillDir, { recursive: true });
303
593
  }
304
- const action = fs.existsSync(filePath) ? "updated" : "created";
305
- fs.writeFileSync(filePath, skillContent);
594
+ const action = fs2.existsSync(filePath) ? "updated" : "created";
595
+ fs2.writeFileSync(filePath, skillContent);
306
596
  return { action, filePath };
307
597
  }
308
598
  async function promptUser(question) {
@@ -318,9 +608,9 @@ async function promptUser(question) {
318
608
  });
319
609
  }
320
610
  async function setupGitignore(gitRoot) {
321
- const filePath = path.join(gitRoot, ".gitignore");
322
- if (fs.existsSync(filePath)) {
323
- const content = fs.readFileSync(filePath, "utf-8");
611
+ const filePath = path2.join(gitRoot, ".gitignore");
612
+ if (fs2.existsSync(filePath)) {
613
+ const content = fs2.readFileSync(filePath, "utf-8");
324
614
  const lines = content.split("\n").map((l) => l.trim());
325
615
  const missing = GITIGNORE_ENTRIES.filter((e) => !lines.includes(e));
326
616
  if (missing.length === 0) {
@@ -328,7 +618,7 @@ async function setupGitignore(gitRoot) {
328
618
  }
329
619
  const suffix = missing.map((e) => e + "\n").join("");
330
620
  const newContent = content.endsWith("\n") ? content + suffix : content + "\n" + suffix;
331
- fs.writeFileSync(filePath, newContent);
621
+ fs2.writeFileSync(filePath, newContent);
332
622
  return { action: "updated", filePath };
333
623
  }
334
624
  const confirmed = await promptUser(
@@ -340,7 +630,7 @@ async function setupGitignore(gitRoot) {
340
630
  );
341
631
  return { action: "skipped", filePath };
342
632
  }
343
- fs.writeFileSync(filePath, GITIGNORE_ENTRIES.map((e) => e + "\n").join(""));
633
+ fs2.writeFileSync(filePath, GITIGNORE_ENTRIES.map((e) => e + "\n").join(""));
344
634
  return { action: "created", filePath };
345
635
  }
346
636
  async function setup(flags) {
@@ -379,7 +669,7 @@ async function setupInteractive(flags) {
379
669
  }
380
670
  async function runDemo(dev) {
381
671
  console.log("");
382
- const { demo: demo2 } = await import("./demo-VPKUCZT3.js");
672
+ const { demo: demo2 } = await import("./demo-Z2XUXEV3.js");
383
673
  await demo2({ dev });
384
674
  }
385
675
  async function setupBatch(flags) {
@@ -438,27 +728,27 @@ function printSummary(result, baseDir) {
438
728
  if (result.created.length > 0) {
439
729
  console.log("Created:");
440
730
  for (const f of result.created) {
441
- console.log(` + ${path.relative(baseDir, f) || f}`);
731
+ console.log(` + ${path2.relative(baseDir, f) || f}`);
442
732
  }
443
733
  }
444
734
  if (result.updated.length > 0) {
445
735
  console.log("Updated:");
446
736
  for (const f of result.updated) {
447
- console.log(` ~ ${path.relative(baseDir, f) || f}`);
737
+ console.log(` ~ ${path2.relative(baseDir, f) || f}`);
448
738
  }
449
739
  }
450
740
  if (result.skipped.length > 0) {
451
741
  console.log("Skipped (already configured):");
452
742
  for (const f of result.skipped) {
453
- console.log(` - ${path.relative(baseDir, f) || f}`);
743
+ console.log(` - ${path2.relative(baseDir, f) || f}`);
454
744
  }
455
745
  }
456
746
  }
457
747
  function isGlobalSetupDone() {
458
748
  const home = os.homedir();
459
- const skillPath = path.join(home, ".claude", "skills", "review", "SKILL.md");
460
- const settingsPath = path.join(home, ".claude", "settings.json");
461
- if (!fs.existsSync(skillPath)) return false;
749
+ const skillPath = path2.join(home, ".claude", "skills", "review", "SKILL.md");
750
+ const settingsPath = path2.join(home, ".claude", "settings.json");
751
+ if (!fs2.existsSync(skillPath)) return false;
462
752
  const settings = readJsonFile(settingsPath);
463
753
  const permissions = settings.permissions ?? {};
464
754
  const allow = permissions.allow ?? [];
@@ -477,12 +767,12 @@ function isGlobalSetupDone() {
477
767
  }
478
768
 
479
769
  // cli/src/commands/teardown.ts
480
- import fs2 from "fs";
481
- import path2 from "path";
770
+ import fs3 from "fs";
771
+ import path3 from "path";
482
772
  import os2 from "os";
483
773
  function teardownMcpJson(gitRoot) {
484
- const filePath = path2.join(gitRoot, ".mcp.json");
485
- if (!fs2.existsSync(filePath)) {
774
+ const filePath = path3.join(gitRoot, ".mcp.json");
775
+ if (!fs3.existsSync(filePath)) {
486
776
  return { action: "skipped", filePath };
487
777
  }
488
778
  const existing = readJsonFile(filePath);
@@ -494,7 +784,7 @@ function teardownMcpJson(gitRoot) {
494
784
  if (Object.keys(servers).length === 0) {
495
785
  const { mcpServers: _, ...rest } = existing;
496
786
  if (Object.keys(rest).length === 0) {
497
- fs2.unlinkSync(filePath);
787
+ fs3.unlinkSync(filePath);
498
788
  } else {
499
789
  writeJsonFile(filePath, rest);
500
790
  }
@@ -504,23 +794,14 @@ function teardownMcpJson(gitRoot) {
504
794
  return { action: "removed", filePath };
505
795
  }
506
796
  function teardownClaudePermissions(baseDir) {
507
- const filePath = path2.join(baseDir, ".claude", "settings.json");
508
- if (!fs2.existsSync(filePath)) {
797
+ const filePath = path3.join(baseDir, ".claude", "settings.json");
798
+ if (!fs3.existsSync(filePath)) {
509
799
  return { action: "skipped", filePath };
510
800
  }
511
801
  const existing = readJsonFile(filePath);
512
802
  const permissions = existing.permissions ?? {};
513
803
  const allow = permissions.allow ?? [];
514
- const toolNames = [
515
- "mcp__diffprism__open_review",
516
- "mcp__diffprism__update_review_context",
517
- "mcp__diffprism__get_review_result",
518
- "mcp__diffprism__get_diff",
519
- "mcp__diffprism__analyze_diff",
520
- "mcp__diffprism__add_annotation",
521
- "mcp__diffprism__get_review_state",
522
- "mcp__diffprism__flag_for_attention"
523
- ];
804
+ const toolNames = [...MCP_TOOL_NAMES, ...RETIRED_MCP_TOOL_NAMES].map(mcpToolPermission);
524
805
  const filtered = allow.filter((t) => !toolNames.includes(t));
525
806
  if (filtered.length === allow.length) {
526
807
  return { action: "skipped", filePath };
@@ -539,40 +820,40 @@ function teardownClaudePermissions(baseDir) {
539
820
  return { action: "removed", filePath };
540
821
  }
541
822
  function cleanupSettingsFile(baseDir) {
542
- const filePath = path2.join(baseDir, ".claude", "settings.json");
543
- if (!fs2.existsSync(filePath)) return;
823
+ const filePath = path3.join(baseDir, ".claude", "settings.json");
824
+ if (!fs3.existsSync(filePath)) return;
544
825
  const existing = readJsonFile(filePath);
545
826
  if (Object.keys(existing).length === 0) {
546
- fs2.unlinkSync(filePath);
547
- tryRmdir(path2.join(baseDir, ".claude"));
827
+ fs3.unlinkSync(filePath);
828
+ tryRmdir(path3.join(baseDir, ".claude"));
548
829
  }
549
830
  }
550
831
  function tryRmdir(dirPath) {
551
832
  try {
552
- const entries = fs2.readdirSync(dirPath);
833
+ const entries = fs3.readdirSync(dirPath);
553
834
  if (entries.length === 0) {
554
- fs2.rmdirSync(dirPath);
835
+ fs3.rmdirSync(dirPath);
555
836
  }
556
837
  } catch {
557
838
  }
558
839
  }
559
840
  function teardownSkill(baseDir, global) {
560
- const skillDir = global ? path2.join(os2.homedir(), ".claude", "skills", "review") : path2.join(baseDir, ".claude", "skills", "review");
561
- const filePath = path2.join(skillDir, "SKILL.md");
562
- if (!fs2.existsSync(filePath)) {
841
+ const skillDir = global ? path3.join(os2.homedir(), ".claude", "skills", "review") : path3.join(baseDir, ".claude", "skills", "review");
842
+ const filePath = path3.join(skillDir, "SKILL.md");
843
+ if (!fs3.existsSync(filePath)) {
563
844
  return { action: "skipped", filePath };
564
845
  }
565
- fs2.unlinkSync(filePath);
846
+ fs3.unlinkSync(filePath);
566
847
  tryRmdir(skillDir);
567
- tryRmdir(path2.dirname(skillDir));
848
+ tryRmdir(path3.dirname(skillDir));
568
849
  return { action: "removed", filePath };
569
850
  }
570
851
  function teardownGitignore(gitRoot) {
571
- const filePath = path2.join(gitRoot, ".gitignore");
572
- if (!fs2.existsSync(filePath)) {
852
+ const filePath = path3.join(gitRoot, ".gitignore");
853
+ if (!fs3.existsSync(filePath)) {
573
854
  return { action: "skipped", filePath };
574
855
  }
575
- const content = fs2.readFileSync(filePath, "utf-8");
856
+ const content = fs3.readFileSync(filePath, "utf-8");
576
857
  const lines = content.split("\n");
577
858
  const entrySet = new Set(GITIGNORE_ENTRIES);
578
859
  const filtered = lines.filter((l) => !entrySet.has(l.trim()));
@@ -581,18 +862,18 @@ function teardownGitignore(gitRoot) {
581
862
  }
582
863
  const newContent = filtered.join("\n");
583
864
  if (newContent.trim() === "") {
584
- fs2.unlinkSync(filePath);
865
+ fs3.unlinkSync(filePath);
585
866
  } else {
586
- fs2.writeFileSync(filePath, newContent);
867
+ fs3.writeFileSync(filePath, newContent);
587
868
  }
588
869
  return { action: "removed", filePath };
589
870
  }
590
871
  function teardownDiffprismDir(gitRoot) {
591
- const dirPath = path2.join(gitRoot, ".diffprism");
592
- if (!fs2.existsSync(dirPath)) {
872
+ const dirPath = path3.join(gitRoot, ".diffprism");
873
+ if (!fs3.existsSync(dirPath)) {
593
874
  return { action: "skipped", filePath: dirPath };
594
875
  }
595
- fs2.rmSync(dirPath, { recursive: true });
876
+ fs3.rmSync(dirPath, { recursive: true });
596
877
  return { action: "removed", filePath: dirPath };
597
878
  }
598
879
  async function teardown(flags) {
@@ -650,21 +931,21 @@ function printTeardownSummary(result, baseDir) {
650
931
  if (result.removed.length > 0) {
651
932
  console.log("Removed:");
652
933
  for (const f of result.removed) {
653
- console.log(` - ${path2.relative(baseDir, f) || f}`);
934
+ console.log(` - ${path3.relative(baseDir, f) || f}`);
654
935
  }
655
936
  }
656
937
  if (result.skipped.length > 0) {
657
938
  console.log("Skipped (not found):");
658
939
  for (const f of result.skipped) {
659
- console.log(` . ${path2.relative(baseDir, f) || f}`);
940
+ console.log(` . ${path3.relative(baseDir, f) || f}`);
660
941
  }
661
942
  }
662
943
  }
663
944
 
664
945
  // cli/src/commands/server.ts
665
946
  import { spawn } from "child_process";
666
- import fs3 from "fs";
667
- import path3 from "path";
947
+ import fs4 from "fs";
948
+ import path4 from "path";
668
949
  import os3 from "os";
669
950
  async function server(flags) {
670
951
  if (flags.background) {
@@ -727,19 +1008,19 @@ async function spawnDaemon(flags) {
727
1008
  }
728
1009
  const args = process.argv.slice(1).filter((a) => a !== "--background");
729
1010
  args.push("--_daemon");
730
- const logDir = path3.join(os3.homedir(), ".diffprism");
731
- if (!fs3.existsSync(logDir)) {
732
- fs3.mkdirSync(logDir, { recursive: true });
1011
+ const logDir = path4.join(os3.homedir(), ".diffprism");
1012
+ if (!fs4.existsSync(logDir)) {
1013
+ fs4.mkdirSync(logDir, { recursive: true });
733
1014
  }
734
- const logPath = path3.join(logDir, "server.log");
735
- const logFd = fs3.openSync(logPath, "a");
1015
+ const logPath = path4.join(logDir, "server.log");
1016
+ const logFd = fs4.openSync(logPath, "a");
736
1017
  const child = spawn(process.execPath, args, {
737
1018
  detached: true,
738
1019
  stdio: ["ignore", logFd, logFd],
739
1020
  env: { ...process.env }
740
1021
  });
741
1022
  child.unref();
742
- fs3.closeSync(logFd);
1023
+ fs4.closeSync(logFd);
743
1024
  console.log("Starting DiffPrism server in background...");
744
1025
  const startTime = Date.now();
745
1026
  const timeoutMs = 15e3;
@@ -826,7 +1107,7 @@ async function fetchStatus(httpPort) {
826
1107
  return await response.json();
827
1108
  }
828
1109
  function printStatus(status, httpPort) {
829
- const version = true ? "0.49.0" : "0.0.0-dev";
1110
+ const version = currentVersion();
830
1111
  console.log(`
831
1112
  DiffPrism v${version}
832
1113
  `);
@@ -864,213 +1145,30 @@ async function defaultAction() {
864
1145
  }
865
1146
  }
866
1147
 
867
- // cli/src/commands/hook.ts
868
- import { execFileSync } from "child_process";
869
- import fs4 from "fs";
870
- import path4 from "path";
871
- var DEFAULT_MIN_LINES = 120;
872
- var MARKER_START = "# >>> diffprism >>>";
873
- var MARKER_END = "# <<< diffprism <<<";
874
- var HOOK_LINE = "diffprism hook pre-commit || exit 1";
875
- async function preCommitHook(flags = {}) {
876
- const cwd = process.cwd();
877
- const minLines = resolveMinLines(flags, cwd);
878
- let changedLines;
879
- try {
880
- const { diffSet } = getDiff("staged", { cwd });
881
- changedLines = diffSet.files.reduce(
882
- (total, file) => total + file.additions + file.deletions,
883
- 0
884
- );
885
- } catch (err) {
886
- fail(`Could not read the staged diff: ${message(err)}`);
887
- return;
888
- }
889
- if (changedLines === 0) {
890
- process.exit(0);
891
- }
892
- if (changedLines < minLines) {
893
- process.exit(0);
894
- }
895
- console.error(
896
- `${changedLines} staged lines (gate at ${minLines}) \u2014 opening DiffPrism review...`
897
- );
898
- let review2 = null;
899
- try {
900
- const serverInfo = await ensureServer({ dev: flags.dev });
901
- const { result } = await submitReviewToServer(serverInfo, "staged", {
902
- cwd,
903
- diffRef: "staged",
904
- title: "Pre-commit review"
905
- });
906
- review2 = result;
907
- } catch (err) {
908
- fail(`DiffPrism could not run the review: ${message(err)}`);
909
- return;
1148
+ // cli/src/commands/feedback.ts
1149
+ import open2 from "open";
1150
+ async function feedback(flags = {}) {
1151
+ const kind = flags.bug ? "bug" : "feedback";
1152
+ const error = flags.bug ? readLastError() : null;
1153
+ const url = buildFeedbackUrl({ kind, message: flags.message, error });
1154
+ if (flags.bug && error) {
1155
+ console.log(`Including the last error, from \`diffprism ${error.command}\` at ${error.at}.`);
910
1156
  }
911
- const decision = review2?.decision;
912
- if (decision === "approved" || decision === "approved_with_comments") {
913
- const summary = review2?.summary?.trim();
914
- const comments = review2?.comments ?? [];
915
- if (summary || comments.length > 0) {
916
- printFeedback(review2);
917
- }
918
- console.error(`Review: ${decision} \u2014 proceeding.`);
919
- process.exit(0);
920
- }
921
- if (decision === "changes_requested") {
922
- printFeedback(review2);
923
- fail("Commit blocked: the review requested changes.");
924
- return;
925
- }
926
- if (decision === "dismissed") {
927
- fail("Commit blocked: the review was dismissed without a decision.");
928
- return;
929
- }
930
- fail(
931
- `Commit blocked: no review decision was returned (got ${String(decision)}).`
932
- );
933
- }
934
- function printFeedback(review2) {
935
- const summary = review2?.summary?.trim();
936
- const comments = review2?.comments ?? [];
937
- console.error("");
938
- if (!summary && comments.length === 0) {
939
- console.error(" The review left no summary and no inline comments.");
940
- console.error(" Ask what needs changing \u2014 there is nothing here to act on.");
941
- console.error("");
942
- return;
943
- }
944
- if (summary) {
945
- for (const line of summary.split("\n")) {
946
- console.error(` ${line}`);
947
- }
948
- if (comments.length > 0) {
949
- console.error("");
950
- }
951
- }
952
- for (const c of comments) {
953
- console.error(` ${c.file}:${c.line} [${c.type}] ${c.body}`);
954
- }
955
- console.error("");
956
- }
957
- function installHook() {
958
- const hookPath = resolveHookPath(process.cwd());
959
- const existing = fs4.existsSync(hookPath) ? fs4.readFileSync(hookPath, "utf8") : "";
960
- if (existing.includes(MARKER_START)) {
961
- console.log(`Already installed in ${hookPath}`);
962
- return;
963
- }
964
- const block = `${MARKER_START}
965
- ${HOOK_LINE}
966
- ${MARKER_END}
967
- `;
968
- const base = existing === "" ? "#!/bin/sh\n" : ensureTrailingNewline(existing);
969
- const created = existing === "";
970
- fs4.mkdirSync(path4.dirname(hookPath), { recursive: true });
971
- fs4.writeFileSync(hookPath, `${base}
972
- ${block}`);
973
- fs4.chmodSync(hookPath, 493);
974
- console.log(`${created ? "Created" : "Updated"} ${hookPath}`);
975
1157
  console.log(
976
- `Staged changes of ${DEFAULT_MIN_LINES}+ lines now open a review before the commit lands.`
1158
+ flags.print ? url : `Opening a prefilled GitHub issue \u2014 review it, then submit if you're happy with it:
1159
+ ${url}`
977
1160
  );
978
- console.log("Tune with: git config diffprism.gate-lines <n>");
979
- console.log("Remove with: diffprism hook uninstall");
980
- }
981
- function uninstallHook() {
982
- const hookPath = resolveHookPath(process.cwd());
983
- if (!fs4.existsSync(hookPath)) {
984
- console.log("Nothing to remove \u2014 no hook file.");
985
- return;
986
- }
987
- const existing = fs4.readFileSync(hookPath, "utf8");
988
- if (!existing.includes(MARKER_START)) {
989
- console.log(`Nothing to remove \u2014 no diffprism block in ${hookPath}`);
990
- return;
991
- }
992
- const cleaned = removeMarkedBlock(existing);
993
- if (/^\s*(#![^\n]*)?\s*$/.test(cleaned)) {
994
- fs4.rmSync(hookPath);
995
- console.log(`Removed ${hookPath} (it contained nothing else).`);
996
- return;
997
- }
998
- fs4.writeFileSync(hookPath, cleaned);
999
- console.log(`Removed the diffprism block from ${hookPath}`);
1000
- }
1001
- function removeMarkedBlock(contents) {
1002
- const lines = contents.split("\n");
1003
- const out = [];
1004
- let inside = false;
1005
- for (const line of lines) {
1006
- if (line.trim() === MARKER_START) {
1007
- inside = true;
1008
- continue;
1009
- }
1010
- if (line.trim() === MARKER_END) {
1011
- inside = false;
1012
- continue;
1161
+ if (!flags.print) {
1162
+ try {
1163
+ await open2(url);
1164
+ } catch {
1013
1165
  }
1014
- if (!inside) {
1015
- out.push(line);
1016
- }
1017
- }
1018
- return ensureTrailingNewline(out.join("\n").replace(/\n{3,}/g, "\n\n"));
1019
- }
1020
- function resolveMinLines(flags, cwd) {
1021
- const fromFlag = flags.minLines ? Number.parseInt(flags.minLines, 10) : NaN;
1022
- if (Number.isFinite(fromFlag) && fromFlag > 0) {
1023
- return fromFlag;
1024
- }
1025
- const configured = Number.parseInt(
1026
- git(["config", "--get", "diffprism.gate-lines"], cwd) ?? "",
1027
- 10
1028
- );
1029
- if (Number.isFinite(configured) && configured > 0) {
1030
- return configured;
1031
1166
  }
1032
- return DEFAULT_MIN_LINES;
1033
- }
1034
- function resolveHookPath(cwd) {
1035
- const configured = git(["config", "--get", "core.hooksPath"], cwd);
1036
- if (configured) {
1037
- return path4.resolve(cwd, configured, "pre-commit");
1038
- }
1039
- const hooksDir = git(["rev-parse", "--git-path", "hooks"], cwd) ?? ".git/hooks";
1040
- return path4.resolve(cwd, hooksDir, "pre-commit");
1041
- }
1042
- function git(args, cwd) {
1043
- try {
1044
- const out = execFileSync("git", args, {
1045
- cwd,
1046
- encoding: "utf8",
1047
- stdio: ["ignore", "pipe", "ignore"]
1048
- });
1049
- const trimmed = out.trim();
1050
- return trimmed === "" ? null : trimmed;
1051
- } catch {
1052
- return null;
1053
- }
1054
- }
1055
- function ensureTrailingNewline(text) {
1056
- return text.endsWith("\n") ? text : `${text}
1057
- `;
1058
- }
1059
- function message(err) {
1060
- return err instanceof Error ? err.message : String(err);
1061
- }
1062
- function fail(text) {
1063
- console.error(text);
1064
- process.exit(1);
1065
1167
  }
1066
1168
 
1067
1169
  // cli/src/index.ts
1068
1170
  var program = new Command();
1069
- program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(
1070
- describeVersion(
1071
- true ? "0.49.0" : "0.0.0-dev"
1072
- )
1073
- );
1171
+ program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(describeVersion(currentVersion()));
1074
1172
  program.action(defaultAction);
1075
1173
  program.command("demo").description("Open a sample review to see DiffPrism in action").option("--dev", "Use Vite dev server").action(demo);
1076
1174
  program.command("review [ref]").description("Open a browser-based diff review (local git ref or GitHub PR ref like owner/repo#123)").option("--staged", "Review staged changes").option("--unstaged", "Review unstaged changes").option("-t, --title <title>", "Review title").option("--reasoning <text>", "Agent reasoning about the changes").option("--dev", "Use Vite dev server with HMR instead of static files").option("--post-to-github", "Automatically post review back to GitHub without prompting").action(review);
@@ -1085,6 +1183,7 @@ hookCmd.command("install").description("Add the diffprism gate to this repo's pr
1085
1183
  hookCmd.command("uninstall").description("Remove the diffprism gate from this repo's pre-commit hook").action(() => {
1086
1184
  uninstallHook();
1087
1185
  });
1186
+ program.command("feedback").description("Open a prefilled GitHub issue to share feedback or report a bug \u2014 you review it before anything is sent").option("--bug", "Report a bug, including the last error DiffPrism hit").option("-m, --message <text>", "Start the issue with this text").option("--print", "Print the issue URL instead of opening a browser").action((flags) => feedback(flags));
1088
1187
  program.command("serve").description("Start the MCP server for Claude Code integration").action(serve);
1089
1188
  program.command("setup").description("Configure DiffPrism for Claude Code integration").option("--global", "Configure globally (skill + permissions, no git repo required)").option("--force", "Overwrite existing configuration files").option("--dev", "Use Vite dev server").option("--no-demo", "Skip the demo review after setup").action((flags) => {
1090
1189
  setup(flags);