diffprism 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,8 @@ diffprism hook install # Add the gate to this repo's pre-commit hoo
130
130
  diffprism hook uninstall # Remove it
131
131
  ```
132
132
 
133
+ If you ask the agent something while the commit waits, the commit stops and prints each question with the command that answers it (`diffprism reply --session <id> <annotation-id> "…"`). The agent answers, commits again, and the review picks up where it left off — no MCP server needed.
134
+
133
135
  It reviews **staged** changes only, where every other entry point defaults to the whole
134
136
  working copy: a commit contains exactly the index, so unstaged edits aren't part of what
135
137
  is being approved. By default a staged diff of **120+ changed lines** opens a review; anything smaller
@@ -187,6 +189,7 @@ diffprism server status # Check server status
187
189
  diffprism server stop # Stop the server
188
190
  diffprism hook install # Gate commits on a review
189
191
  diffprism hook uninstall # Remove the gate
192
+ diffprism reply --session <id> <annotation-id> "…" # Answer a reviewer's question, as the agent
190
193
  diffprism feedback # Share feedback as a prefilled GitHub issue
191
194
  diffprism feedback --bug # Report a bug, including the last error
192
195
  diffprism teardown # Remove configuration
@@ -215,6 +218,26 @@ pnpm run build
215
218
  pnpm cli review --staged # Run CLI from source
216
219
  ```
217
220
 
221
+ ### Dogfooding a checkout
222
+
223
+ To try unreleased changes in another project without publishing, point your global `diffprism` at a checkout:
224
+
225
+ ```bash
226
+ git clone https://github.com/CodeJonesW/diffprism.git diffprism-dogfood
227
+ cd diffprism-dogfood && pnpm install && pnpm build && npm link
228
+ diffprism --version # 1.2.0 (dev build — /path/to/diffprism-dogfood)
229
+ ```
230
+
231
+ Then, for each change you want to try:
232
+
233
+ ```bash
234
+ git fetch && git checkout <branch> && git pull && pnpm build
235
+ ```
236
+
237
+ The next `diffprism` command — a review, the commit gate — replaces the running server with the new build, unless a review is open in it. The dashboard picks up a rebuilt UI on reload. Go back to the release with `npm install -g diffprism@latest`.
238
+
239
+ A server is only ever replaced by a **newer** build. Claude Code keeps `diffprism serve` running on the build it started with until you restart it, so restart Claude Code to put its MCP tools on the new build too.
240
+
218
241
  ### Project Structure
219
242
 
220
243
  ```
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  } from "./chunk-EPU4F7WT.js";
6
6
  import {
7
7
  demo
8
- } from "./chunk-FWPE5EA6.js";
8
+ } from "./chunk-7YL3D42S.js";
9
9
  import {
10
10
  COMMIT_GATE_DIFF_REF,
11
11
  DEFAULT_DIFF_REF,
@@ -26,7 +26,7 @@ import {
26
26
  recordError,
27
27
  startGlobalServer,
28
28
  submitReviewToServer
29
- } from "./chunk-KRHZSUEK.js";
29
+ } from "./chunk-MSNGZDVF.js";
30
30
  import {
31
31
  getDiff
32
32
  } from "./chunk-3GMPE2ZR.js";
@@ -40,6 +40,55 @@ import { Command } from "commander";
40
40
  import { execFileSync } from "child_process";
41
41
  import fs from "fs";
42
42
  import path from "path";
43
+
44
+ // cli/src/commands/reply.ts
45
+ function replyCommandFor(sessionId, annotationId) {
46
+ return `diffprism reply --session ${sessionId} ${annotationId} "<your answer>"`;
47
+ }
48
+ async function reply(annotationId, words, flags) {
49
+ const body = words.join(" ").trim();
50
+ if (!body) {
51
+ fail("Nothing to say: pass the reply after the annotation id.");
52
+ return;
53
+ }
54
+ const serverInfo = await isServerAlive();
55
+ if (!serverInfo) {
56
+ fail("No DiffPrism server is running, so there is no open review to reply on.");
57
+ return;
58
+ }
59
+ let response;
60
+ try {
61
+ response = await fetch(
62
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${flags.session}/annotations/${annotationId}/replies`,
63
+ {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({ author: "agent", agent: flags.agent ?? "agent", body })
67
+ }
68
+ );
69
+ } catch (err) {
70
+ recordError("reply", err);
71
+ fail(`Could not reach the DiffPrism server: ${err instanceof Error ? err.message : String(err)}
72
+ ${REPORT_HINT}`);
73
+ return;
74
+ }
75
+ const data = await response.json().catch(() => ({}));
76
+ if (!response.ok) {
77
+ fail(`Reply not posted: ${data.error ?? `server returned ${response.status}`}`);
78
+ return;
79
+ }
80
+ const where = data.annotation ? ` on ${data.annotation.file}:${data.annotation.line}` : "";
81
+ console.log(`Replied${where}. It shows in the review now.`);
82
+ console.log(
83
+ "The review is still open. Go back to waiting for the decision now: run the same command that opened it again (`git commit` or `diffprism review`). It returns the decision, or the reviewer's next question. Don't ask them in the terminal."
84
+ );
85
+ }
86
+ function fail(message2) {
87
+ console.error(message2);
88
+ process.exit(1);
89
+ }
90
+
91
+ // cli/src/commands/hook.ts
43
92
  var DEFAULT_MIN_LINES = 120;
44
93
  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
94
  var MARKER_START = "# >>> diffprism >>>";
@@ -57,7 +106,7 @@ async function preCommitHook(flags = {}) {
57
106
  );
58
107
  } catch (err) {
59
108
  recordError("hook pre-commit", err);
60
- fail(`Could not read the staged diff: ${message(err)}
109
+ fail2(`Could not read the staged diff: ${message(err)}
61
110
  ${REPORT_HINT}`);
62
111
  return;
63
112
  }
@@ -88,18 +137,18 @@ ${REPORT_HINT}`);
88
137
  } catch (err) {
89
138
  stopWaiting();
90
139
  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.`
140
+ printQuestions(err.sessionId, err.threads);
141
+ fail2(
142
+ "Commit blocked: the reviewer asked you something before deciding. Answer each question with the command under it \u2014 change the code too if that's what they asked for \u2014 then run git commit again straight away to keep waiting. The review stays open, the reviewer may ask more, and the decision still comes. Don't stop to ask them in the terminal."
94
143
  );
95
144
  return;
96
145
  }
97
146
  if (err instanceof ReviewTimeoutError) {
98
- fail(`Commit blocked: no decision after ${Math.round(err.waitedMs / 1e3)}s. ${RETRY_ADVICE}`);
147
+ fail2(`Commit blocked: no decision after ${Math.round(err.waitedMs / 1e3)}s. ${RETRY_ADVICE}`);
99
148
  return;
100
149
  }
101
150
  recordError("hook pre-commit", err);
102
- fail(`DiffPrism could not run the review: ${message(err)}
151
+ fail2(`DiffPrism could not run the review: ${message(err)}
103
152
  ${REPORT_HINT}`);
104
153
  return;
105
154
  }
@@ -116,27 +165,28 @@ ${REPORT_HINT}`);
116
165
  }
117
166
  if (decision === "changes_requested") {
118
167
  printFeedback(review2);
119
- fail("Commit blocked: the review requested changes.");
168
+ fail2("Commit blocked: the review requested changes.");
120
169
  return;
121
170
  }
122
171
  if (decision === "dismissed") {
123
- fail("Commit blocked: the review was dismissed without a decision.");
172
+ fail2("Commit blocked: the review was dismissed without a decision.");
124
173
  return;
125
174
  }
126
- fail(
175
+ fail2(
127
176
  `Commit blocked: no review decision was returned (got ${String(decision)}).`
128
177
  );
129
178
  }
130
- function printQuestions(threads) {
179
+ function printQuestions(sessionId, threads) {
131
180
  console.error("");
132
181
  for (const t of threads) {
133
182
  const last = t.replies?.at(-1)?.body ?? t.body;
134
- console.error(` ${t.file}:${t.line} (annotation_id: ${t.id})`);
183
+ console.error(` ${t.file}:${t.line}`);
135
184
  for (const line of last.split("\n")) {
136
185
  console.error(` ${line}`);
137
186
  }
187
+ console.error(` Answer: ${replyCommandFor(sessionId, t.id)}`);
188
+ console.error("");
138
189
  }
139
- console.error("");
140
190
  }
141
191
  function printFeedback(review2) {
142
192
  const summary = review2?.summary?.trim();
@@ -277,7 +327,7 @@ function ensureTrailingNewline(text) {
277
327
  function message(err) {
278
328
  return err instanceof Error ? err.message : String(err);
279
329
  }
280
- function fail(text) {
330
+ function fail2(text) {
281
331
  console.error(text);
282
332
  process.exit(1);
283
333
  }
@@ -320,9 +370,9 @@ async function reviewLocalFlow(diffRef, flags) {
320
370
  }));
321
371
  } catch (err) {
322
372
  if (err instanceof ReviewerAskedError) {
323
- printQuestions(err.threads);
373
+ printQuestions(err.sessionId, err.threads);
324
374
  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.`
375
+ "The reviewer asked something before deciding. Answer each question with the command under it, then run diffprism review again straight away to keep waiting \u2014 the review stays open, and the reviewer may ask more. Don't stop to ask them in the terminal."
326
376
  );
327
377
  process.exit(1);
328
378
  }
@@ -449,7 +499,7 @@ If the repo has the DiffPrism pre-commit gate installed (\`diffprism hook instal
449
499
  - 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
500
  - 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
501
  - 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.
502
+ - If it's blocked because the reviewer asked something, each question is printed with the command that answers it \u2014 \`diffprism reply --session <id> <annotation-id> "<your answer>"\`. Run it for each, then run the same \`git commit\` again \u2014 the review is still open.
453
503
 
454
504
  ## Workflow 3: PR Review
455
505
 
@@ -669,7 +719,7 @@ async function setupInteractive(flags) {
669
719
  }
670
720
  async function runDemo(dev) {
671
721
  console.log("");
672
- const { demo: demo2 } = await import("./demo-Z2XUXEV3.js");
722
+ const { demo: demo2 } = await import("./demo-376MCAK4.js");
673
723
  await demo2({ dev });
674
724
  }
675
725
  async function setupBatch(flags) {
@@ -1184,6 +1234,7 @@ hookCmd.command("uninstall").description("Remove the diffprism gate from this re
1184
1234
  uninstallHook();
1185
1235
  });
1186
1236
  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));
1237
+ program.command("reply <annotation-id> <message...>").description("Answer a reviewer's question on an open review, as the agent").requiredOption("--session <id>", "The review the question is on").option("--agent <name>", "Name shown on the reply", "agent").action((annotationId, message2, flags) => reply(annotationId, message2, flags));
1187
1238
  program.command("serve").description("Start the MCP server for Claude Code integration").action(serve);
1188
1239
  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) => {
1189
1240
  setup(flags);
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ensureServer,
3
3
  submitReviewToServer
4
- } from "./chunk-KRHZSUEK.js";
4
+ } from "./chunk-MSNGZDVF.js";
5
5
  import {
6
6
  parseDiff
7
7
  } from "./chunk-3GMPE2ZR.js";
@@ -79,15 +79,13 @@ async function isServerAlive() {
79
79
  }
80
80
  }
81
81
 
82
- // packages/core/src/diff-scope.ts
83
- var DEFAULT_DIFF_REF = "working-copy";
84
- var COMMIT_GATE_DIFF_REF = "staged";
85
- var DIFF_REF_DESCRIPTION = 'Which changes to review. "working-copy" (the default): everything not yet committed, staged and unstaged shown as separate groups. "staged": only what the next commit would contain. "unstaged": only edits not yet staged. Or a ref range such as "HEAD~3..HEAD" or "main..feature".';
86
-
87
82
  // packages/core/src/build-info.ts
88
83
  import fs2 from "fs";
89
84
  import path2 from "path";
90
85
  import { fileURLToPath } from "url";
86
+ function builtAt() {
87
+ return true ? 1789607742599 : null;
88
+ }
91
89
  function getBuildInfo() {
92
90
  let dir = path2.dirname(fileURLToPath(import.meta.url));
93
91
  while (dir !== path2.dirname(dir)) {
@@ -112,6 +110,11 @@ function describeVersion(version) {
112
110
  return info.dev ? `${version} (dev build \u2014 ${info.root})` : version;
113
111
  }
114
112
 
113
+ // packages/core/src/diff-scope.ts
114
+ var DEFAULT_DIFF_REF = "working-copy";
115
+ var COMMIT_GATE_DIFF_REF = "staged";
116
+ var DIFF_REF_DESCRIPTION = 'Which changes to review. "working-copy" (the default): everything not yet committed, staged and unstaged shown as separate groups. "staged": only what the next commit would contain. "unstaged": only edits not yet staged. Or a ref range such as "HEAD~3..HEAD" or "main..feature".';
117
+
115
118
  // packages/core/src/feedback.ts
116
119
  import fs3 from "fs";
117
120
  import os2 from "os";
@@ -119,7 +122,7 @@ import path3 from "path";
119
122
  var ISSUES_NEW_URL = "https://github.com/CodeJonesW/diffprism/issues/new";
120
123
  var MAX_ERROR_CHARS = 1500;
121
124
  function currentVersion() {
122
- return true ? "1.1.0" : "0.0.0-dev";
125
+ return true ? "1.3.0" : "0.0.0-dev";
123
126
  }
124
127
  function describeEnvironment() {
125
128
  return {
@@ -226,7 +229,22 @@ import { fileURLToPath as fileURLToPath2 } from "url";
226
229
  async function ensureServer(options = {}) {
227
230
  const existing = await isServerAlive();
228
231
  if (existing) {
229
- return existing;
232
+ const decision = await decideOnRunningServer(existing, builtAt());
233
+ if (decision.action === "keep") {
234
+ return existing;
235
+ }
236
+ if (decision.action === "keep-busy") {
237
+ if (!options.silent) {
238
+ console.error(
239
+ `The DiffPrism server (PID ${existing.pid}) runs an older build, but ${decision.openReviews} review${decision.openReviews === 1 ? " is" : "s are"} open in it, so it stays up. Finish or close ${decision.openReviews === 1 ? "it" : "them"} and the next command starts the new build \u2014 or run \`diffprism server stop\`.`
240
+ );
241
+ }
242
+ return existing;
243
+ }
244
+ if (!options.silent) {
245
+ console.error(`Replacing the DiffPrism server (PID ${existing.pid}): it runs an older build than this command.`);
246
+ }
247
+ await stopServer(existing);
230
248
  }
231
249
  const spawnArgs = options.spawnCommand ?? buildDefaultSpawnCommand(options);
232
250
  const logDir = path4.join(os3.homedir(), ".diffprism");
@@ -256,6 +274,44 @@ async function ensureServer(options = {}) {
256
274
  `DiffPrism server failed to start within ${timeoutMs / 1e3}s. Check logs at ${logPath}`
257
275
  );
258
276
  }
277
+ async function decideOnRunningServer(server, ownBuiltAt) {
278
+ if (ownBuiltAt === null) return { action: "keep" };
279
+ if (server.builtAt !== void 0 && server.builtAt >= ownBuiltAt) return { action: "keep" };
280
+ const response = await fetch(`http://localhost:${server.httpPort}/api/reviews`);
281
+ if (!response.ok) {
282
+ throw new Error(`Could not list reviews on the running DiffPrism server: it returned ${response.status}`);
283
+ }
284
+ const { sessions: sessions2 } = await response.json();
285
+ const openReviews = sessions2.filter((s) => s.status === "pending" || s.status === "in_review").length;
286
+ return openReviews > 0 ? { action: "keep-busy", openReviews } : { action: "replace" };
287
+ }
288
+ async function stopServer(server, timeoutMs = 5e3) {
289
+ try {
290
+ process.kill(server.pid, "SIGTERM");
291
+ } catch {
292
+ }
293
+ const start = Date.now();
294
+ while (Date.now() - start < timeoutMs) {
295
+ if (!isProcessRunning(server.pid)) {
296
+ if (readServerFile()?.pid === server.pid) {
297
+ removeServerFile();
298
+ }
299
+ return;
300
+ }
301
+ await new Promise((resolve) => setTimeout(resolve, 100));
302
+ }
303
+ throw new Error(
304
+ `The old DiffPrism server (PID ${server.pid}) did not stop within ${timeoutMs / 1e3}s. Stop it with \`diffprism server stop\` and try again.`
305
+ );
306
+ }
307
+ function isProcessRunning(pid) {
308
+ try {
309
+ process.kill(pid, 0);
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
259
315
  function buildDefaultSpawnCommand(options) {
260
316
  const thisFile = fileURLToPath2(import.meta.url);
261
317
  const thisDir = path4.dirname(thisFile);
@@ -1913,6 +1969,10 @@ async function startGlobalServer(options = {}) {
1913
1969
  pid: process.pid,
1914
1970
  startedAt: Date.now()
1915
1971
  };
1972
+ const serverBuiltAt = builtAt();
1973
+ if (serverBuiltAt !== null) {
1974
+ serverInfo.builtAt = serverBuiltAt;
1975
+ }
1916
1976
  writeServerFile(serverInfo);
1917
1977
  if (!silent) {
1918
1978
  console.log(`
@@ -1993,11 +2053,11 @@ function mcpToolPermission(name) {
1993
2053
  export {
1994
2054
  readServerFile,
1995
2055
  isServerAlive,
2056
+ getBuildInfo,
2057
+ describeVersion,
1996
2058
  DEFAULT_DIFF_REF,
1997
2059
  COMMIT_GATE_DIFF_REF,
1998
2060
  DIFF_REF_DESCRIPTION,
1999
- getBuildInfo,
2000
- describeVersion,
2001
2061
  currentVersion,
2002
2062
  recordError,
2003
2063
  readLastError,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  demo
3
- } from "./chunk-FWPE5EA6.js";
4
- import "./chunk-KRHZSUEK.js";
3
+ } from "./chunk-7YL3D42S.js";
4
+ import "./chunk-MSNGZDVF.js";
5
5
  import "./chunk-3GMPE2ZR.js";
6
6
  import "./chunk-DHCVZGHE.js";
7
7
  import "./chunk-JSBRDJBE.js";
@@ -14,7 +14,7 @@ import {
14
14
  recordError,
15
15
  submitReviewToServer,
16
16
  waitForDecision
17
- } from "./chunk-KRHZSUEK.js";
17
+ } from "./chunk-MSNGZDVF.js";
18
18
  import {
19
19
  getDiff
20
20
  } from "./chunk-3GMPE2ZR.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffprism",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Local-first code review tool for agent-generated code changes",
6
6
  "bin": {