diffprism 0.49.0 → 1.0.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 +60 -20
- package/dist/bin.js +457 -358
- package/dist/{chunk-QGWYCEJN.js → chunk-3GMPE2ZR.js} +13 -0
- package/dist/{chunk-7UQM4WBZ.js → chunk-CJ6LV2XV.js} +2 -2
- package/dist/{chunk-EPUCA2N5.js → chunk-CU2BAL6Q.js} +704 -287
- package/dist/{chunk-24B33UN6.js → chunk-EPU4F7WT.js} +6 -56
- package/dist/{demo-VPKUCZT3.js → demo-X5LWQDVU.js} +3 -3
- package/dist/mcp-server.js +432 -963
- package/dist/{src-KF5HRJPX.js → src-DNNNQ2RD.js} +2 -2
- package/dist/{src-AMCPIYDZ.js → src-OZQ32NDN.js} +3 -1
- package/package.json +1 -1
- package/ui-dist/assets/index-BulxhOr3.css +1 -0
- package/ui-dist/assets/index-Zw97bHGn.js +345 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-DpPy4Hg4.css +0 -1
- package/ui-dist/assets/index-Dt04Ez08.js +0 -335
package/dist/bin.js
CHANGED
|
@@ -2,28 +2,286 @@
|
|
|
2
2
|
import {
|
|
3
3
|
isPrRef,
|
|
4
4
|
parsePrRef
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-EPU4F7WT.js";
|
|
6
6
|
import {
|
|
7
7
|
demo
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-CJ6LV2XV.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-
|
|
29
|
+
} from "./chunk-CU2BAL6Q.js";
|
|
18
30
|
import {
|
|
19
31
|
getDiff
|
|
20
|
-
} from "./chunk-
|
|
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 =
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
104
|
-
import
|
|
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
|
|
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\`
|
|
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
|
|
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\`:
|
|
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\`:
|
|
140
|
-
- \`annotations\`:
|
|
141
|
-
2.
|
|
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
|
-
-
|
|
146
|
-
3.
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
+
To add findings while a review is open, call \`mcp__diffprism__annotate\`.
|
|
156
435
|
|
|
157
|
-
|
|
436
|
+
## Choosing a scope
|
|
158
437
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
##
|
|
454
|
+
## Workflow 3: PR Review
|
|
175
455
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
473
|
+
## Tool Reference
|
|
474
|
+
|
|
475
|
+
### Opening and deciding
|
|
184
476
|
| Tool | Purpose |
|
|
185
477
|
|------|---------|
|
|
186
|
-
| \`
|
|
187
|
-
| \`
|
|
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
|
-
###
|
|
482
|
+
### Headless analysis
|
|
190
483
|
| Tool | Purpose |
|
|
191
484
|
|------|---------|
|
|
192
|
-
| \`
|
|
193
|
-
| \`
|
|
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
|
-
###
|
|
488
|
+
### Working in an open review
|
|
198
489
|
| Tool | Purpose |
|
|
199
490
|
|------|---------|
|
|
200
|
-
| \`
|
|
201
|
-
| \`get_review_comments\` |
|
|
202
|
-
| \`
|
|
203
|
-
| \`
|
|
204
|
-
| \`get_review_state\` |
|
|
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
|
|
210
|
-
- **
|
|
211
|
-
- **
|
|
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 =
|
|
519
|
+
let dir = path2.resolve(from);
|
|
223
520
|
while (true) {
|
|
224
|
-
if (
|
|
521
|
+
if (fs2.existsSync(path2.join(dir, ".git"))) {
|
|
225
522
|
return dir;
|
|
226
523
|
}
|
|
227
|
-
const parent =
|
|
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 =
|
|
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 =
|
|
242
|
-
if (!
|
|
243
|
-
|
|
538
|
+
const dir = path2.dirname(filePath);
|
|
539
|
+
if (!fs2.existsSync(dir)) {
|
|
540
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
244
541
|
}
|
|
245
|
-
|
|
542
|
+
fs2.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
|
|
246
543
|
}
|
|
247
544
|
function setupMcpJson(gitRoot, force) {
|
|
248
|
-
const filePath =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
284
|
-
|
|
573
|
+
if (!next.includes(toolName)) {
|
|
574
|
+
next.push(toolName);
|
|
285
575
|
}
|
|
286
576
|
}
|
|
287
|
-
permissions.allow =
|
|
288
|
-
const action =
|
|
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 ?
|
|
294
|
-
const filePath =
|
|
295
|
-
if (
|
|
296
|
-
const existingContent =
|
|
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 (!
|
|
302
|
-
|
|
591
|
+
if (!fs2.existsSync(skillDir)) {
|
|
592
|
+
fs2.mkdirSync(skillDir, { recursive: true });
|
|
303
593
|
}
|
|
304
|
-
const action =
|
|
305
|
-
|
|
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 =
|
|
322
|
-
if (
|
|
323
|
-
const content =
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
672
|
+
const { demo: demo2 } = await import("./demo-X5LWQDVU.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(` + ${
|
|
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(` ~ ${
|
|
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(` - ${
|
|
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 =
|
|
460
|
-
const settingsPath =
|
|
461
|
-
if (!
|
|
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
|
|
481
|
-
import
|
|
770
|
+
import fs3 from "fs";
|
|
771
|
+
import path3 from "path";
|
|
482
772
|
import os2 from "os";
|
|
483
773
|
function teardownMcpJson(gitRoot) {
|
|
484
|
-
const filePath =
|
|
485
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
508
|
-
if (!
|
|
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 =
|
|
543
|
-
if (!
|
|
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
|
-
|
|
547
|
-
tryRmdir(
|
|
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 =
|
|
833
|
+
const entries = fs3.readdirSync(dirPath);
|
|
553
834
|
if (entries.length === 0) {
|
|
554
|
-
|
|
835
|
+
fs3.rmdirSync(dirPath);
|
|
555
836
|
}
|
|
556
837
|
} catch {
|
|
557
838
|
}
|
|
558
839
|
}
|
|
559
840
|
function teardownSkill(baseDir, global) {
|
|
560
|
-
const skillDir = global ?
|
|
561
|
-
const filePath =
|
|
562
|
-
if (!
|
|
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
|
-
|
|
846
|
+
fs3.unlinkSync(filePath);
|
|
566
847
|
tryRmdir(skillDir);
|
|
567
|
-
tryRmdir(
|
|
848
|
+
tryRmdir(path3.dirname(skillDir));
|
|
568
849
|
return { action: "removed", filePath };
|
|
569
850
|
}
|
|
570
851
|
function teardownGitignore(gitRoot) {
|
|
571
|
-
const filePath =
|
|
572
|
-
if (!
|
|
852
|
+
const filePath = path3.join(gitRoot, ".gitignore");
|
|
853
|
+
if (!fs3.existsSync(filePath)) {
|
|
573
854
|
return { action: "skipped", filePath };
|
|
574
855
|
}
|
|
575
|
-
const content =
|
|
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
|
-
|
|
865
|
+
fs3.unlinkSync(filePath);
|
|
585
866
|
} else {
|
|
586
|
-
|
|
867
|
+
fs3.writeFileSync(filePath, newContent);
|
|
587
868
|
}
|
|
588
869
|
return { action: "removed", filePath };
|
|
589
870
|
}
|
|
590
871
|
function teardownDiffprismDir(gitRoot) {
|
|
591
|
-
const dirPath =
|
|
592
|
-
if (!
|
|
872
|
+
const dirPath = path3.join(gitRoot, ".diffprism");
|
|
873
|
+
if (!fs3.existsSync(dirPath)) {
|
|
593
874
|
return { action: "skipped", filePath: dirPath };
|
|
594
875
|
}
|
|
595
|
-
|
|
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(` - ${
|
|
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(` . ${
|
|
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
|
|
667
|
-
import
|
|
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 =
|
|
731
|
-
if (!
|
|
732
|
-
|
|
1011
|
+
const logDir = path4.join(os3.homedir(), ".diffprism");
|
|
1012
|
+
if (!fs4.existsSync(logDir)) {
|
|
1013
|
+
fs4.mkdirSync(logDir, { recursive: true });
|
|
733
1014
|
}
|
|
734
|
-
const logPath =
|
|
735
|
-
const logFd =
|
|
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
|
-
|
|
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 =
|
|
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/
|
|
868
|
-
import
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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);
|