git-reviewer 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-264-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-271-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  When an agent writes the code, reading it becomes the bottleneck — and the tool
@@ -19,18 +19,34 @@ marked-up review back to the agent to act on.
19
19
  npm install -g git-reviewer
20
20
 
21
21
  cd ~/your-project
22
- reviewer # read the diff, comment on it
23
- reviewer export . --format prompt | claude -p "Apply this review."
22
+ reviewer | claude -p "Apply this review."
24
23
  ```
25
24
 
25
+ That is the whole loop. `reviewer` opens the review in your browser; when you
26
+ press **Submit Review**, the review is written to stdout and the command
27
+ exits — so whatever is on the other end of the pipe receives it and starts
28
+ work. Nothing to copy, no second command, no file to go and find.
29
+
30
+ Without a pipe, `reviewer` is just a review tool: it tells you where the
31
+ review was saved and keeps serving, because you may have more to read.
32
+ Everything it says to you goes to stderr either way, so only the review is
33
+ ever on stdout.
34
+
26
35
  ![A terminal installing git-reviewer and running it inside a project; the browser opens on the diff with the first changed file already showing; two review comments are written inline, one with a threaded follow-up; then reviewer export prints those same comments back for a coding agent to act on](docs/demo.gif)
27
36
 
28
37
  *The whole loop, unedited and in real time — [full resolution](docs/demo.mp4).*
29
38
 
30
- `reviewer` starts a local server and opens your browser on the repository you
31
- are standing in. Nothing is installed into the project, there is nothing to
32
- sign in to, and nothing leaves your machine. If you would rather not install
33
- it, `npx git-reviewer` works the same way.
39
+ Nothing is installed into the project, there is nothing to sign in to, and
40
+ nothing leaves your machine. If you would rather not install it,
41
+ `npx git-reviewer` works the same way.
42
+
43
+ The recording above shows the two-step form, which still works and is the one
44
+ to reach for when the review and the hand-off happen at different times:
45
+
46
+ ```bash
47
+ reviewer # read the diff, comment on it
48
+ reviewer export . --format prompt | claude -p "Apply this review."
49
+ ```
34
50
 
35
51
  Comments persist between sessions and survive the code moving underneath them —
36
52
  including the agent's own edits, which shift every line below the first change —
@@ -146,6 +162,9 @@ shown in full, read back out of `HEAD`:
146
162
  | `Escape` | Cancel input |
147
163
  | `↑` / `↓` | Move between files |
148
164
 
165
+ None of that is guessable, so the review pane says it too, in a **How to
166
+ comment** strip above the diff. Collapse it once and it stays collapsed.
167
+
149
168
  ## What it writes
150
169
 
151
170
  Two files, both plain text you can read without this app, both in a per-user
@@ -271,7 +290,7 @@ rather than a public issue.
271
290
 
272
291
  ```bash
273
292
  npm install # 3 dependencies, no build step, ~6MB
274
- npm test # 264 tests
293
+ npm test # 271 tests
275
294
  npm run test:watch
276
295
  npm run test:coverage
277
296
  ```
package/bin/reviewer.js CHANGED
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
5
5
  const path = require('path');
6
6
 
7
7
  const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
8
+ const { homeRelative } = require('../lib/paths');
8
9
  const { openInBrowser } = require('../lib/browser');
9
10
  const { formatPrompt } = require('../lib/agent');
10
11
  const { ReviewOwnershipError } = require('../lib/store');
@@ -22,6 +23,21 @@ const { version } = require('../package.json');
22
23
  * The `reviewer` command: start the local server and open it in a browser.
23
24
  */
24
25
 
26
+ /**
27
+ * Everything this command says to the person running it.
28
+ *
29
+ * stderr, not stdout, and that is the whole design. stdout carries the
30
+ * finished review so it can be piped into a coding agent; a banner or a
31
+ * progress line written there lands in the middle of it. Both streams go to
32
+ * the same terminal when nothing is piping, so this is invisible until it
33
+ * matters.
34
+ *
35
+ * @param {string} [line]
36
+ */
37
+ function note(line = '') {
38
+ process.stderr.write(`${line}\n`);
39
+ }
40
+
25
41
  /**
26
42
  * Listen on `port`, falling back to any free port if it is taken.
27
43
  *
@@ -29,16 +45,17 @@ const { version } = require('../package.json');
29
45
  * an EADDRINUSE stack trace.
30
46
  *
31
47
  * @param {number} port
48
+ * @param {object} [options] passed through to the server
32
49
  * @returns {Promise<import('http').Server>}
33
50
  */
34
- async function listen(port) {
51
+ async function listen(port, options = {}) {
35
52
  try {
36
- return await startServer({ port, host: DEFAULT_HOST, silent: true });
53
+ return await startServer({ ...options, port, host: DEFAULT_HOST, silent: true });
37
54
  } catch (error) {
38
55
  if (error.code !== 'EADDRINUSE') throw error;
39
56
 
40
- console.log(`Port ${port} is in use, picking another.`);
41
- return startServer({ port: 0, host: DEFAULT_HOST, silent: true });
57
+ note(`Port ${port} is in use, picking another.`);
58
+ return startServer({ ...options, port: 0, host: DEFAULT_HOST, silent: true });
42
59
  }
43
60
  }
44
61
 
@@ -112,11 +129,11 @@ async function main() {
112
129
  // can start against a half-populated directory. See #33.
113
130
  const adopted = adoptLegacyReviews();
114
131
  if (adopted > 0) {
115
- console.log(
132
+ note(
116
133
  ` Copied ${adopted} saved review${adopted === 1 ? '' : 's'} out of the ` +
117
134
  `install directory to\n ${reviewsDir()}\n so that upgrading or ` +
118
135
  'reinstalling this package can no longer delete them.\n' +
119
- ' The originals are left where they were.\n'
136
+ ' The originals are left where they were.'
120
137
  );
121
138
  }
122
139
 
@@ -144,23 +161,87 @@ async function main() {
144
161
 
145
162
  const repoPath = await repositoryToOpen(options.repoPath);
146
163
 
147
- const server = await listen(options.port ?? (Number(process.env.PORT) || DEFAULT_PORT));
148
- const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
149
-
150
- console.log(`\n Code Reviewer ${url}`);
151
- if (repoPath) console.log(` reviewing ${repoPath}`);
152
- console.log('\n Press Ctrl+C to stop.\n');
164
+ // Is anything reading our stdout? If so, the review goes there when it is
165
+ // submitted and the command finishes -- which is what turns the whole loop
166
+ // into one line:
167
+ //
168
+ // reviewer | claude -p "Apply this review."
169
+ //
170
+ // On a terminal there is nothing waiting, so submitting is not the end of
171
+ // anything: you may have more to review. It says where the review went and
172
+ // what to run next, and keeps serving.
173
+ const handingOff = !process.stdout.isTTY;
153
174
 
154
- if (options.open && !(await openInBrowser(url))) {
155
- console.log(' Could not open a browser — open the URL above yourself.\n');
156
- }
175
+ // One newline, now, before anything else happens.
176
+ //
177
+ // This looks pointless and is not. A reader on the other end of the pipe may
178
+ // give up waiting for the first byte long before there is a review to send —
179
+ // `claude -p` waits three seconds and then proceeds without stdin at all,
180
+ // printing a warning and exiting 0, so the whole thing fails silently and
181
+ // the agent is asked to apply a review it never received. Reviewing takes
182
+ // minutes, not seconds. A single byte at startup holds the stream open; the
183
+ // rest arrives whenever the review is submitted.
184
+ //
185
+ // Measured against claude 2.1.270: producer silent for 8s then sending the
186
+ // instruction, the instruction was ignored and the file left unchanged; the
187
+ // same producer emitting one newline first had it applied. It is a
188
+ // first-byte timeout, not a total one.
189
+ //
190
+ // A leading blank line costs nothing — the review is Markdown, and every
191
+ // other consumer of a pipe is indifferent to it.
192
+ if (handingOff) process.stdout.write('\n');
157
193
 
194
+ let handedOff = false;
158
195
  const stop = () => {
159
196
  server.close(() => process.exit(0));
160
197
  // Don't wait forever on a browser holding the connection open.
161
198
  setTimeout(() => process.exit(0), 2000).unref();
162
199
  };
163
200
 
201
+ /**
202
+ * A review has just been written. Say so, or hand it over.
203
+ *
204
+ * @param {{document: object, reviewPath: string}} submitted
205
+ */
206
+ const onReviewSubmitted = ({ document, reviewPath }) => {
207
+ const { comments, files } = document.summary;
208
+ const count = `${comments} comment${comments === 1 ? '' : 's'} on ` +
209
+ `${files} file${files === 1 ? '' : 's'}`;
210
+
211
+ if (!handingOff) {
212
+ note(`\n Review saved ${homeRelative(reviewPath)}`);
213
+ note(` ${count}\n`);
214
+ note(' Hand it to an agent with:');
215
+ note(` reviewer export ${repoPath ? homeRelative(repoPath) : '.'} --format prompt | claude -p "Apply this review."\n`);
216
+ return;
217
+ }
218
+
219
+ // Once is enough: the first submitted review is what the pipe is waiting
220
+ // for, and a second would arrive after the process has gone.
221
+ if (handedOff) return;
222
+ handedOff = true;
223
+
224
+ note(`\n ${count} — handing the review back to your terminal.\n`);
225
+ process.stdout.write(formatPrompt(document));
226
+ stop();
227
+ };
228
+
229
+ const server = await listen(
230
+ options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
231
+ { onReviewSubmitted, handoff: handingOff }
232
+ );
233
+ const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
234
+
235
+ note(`\n Code Reviewer ${url}`);
236
+ if (repoPath) note(` reviewing ${repoPath}`);
237
+ note(handingOff
238
+ ? '\n Submit the review in the browser and it will be written here.\n'
239
+ : '\n Press Ctrl+C to stop.\n');
240
+
241
+ if (options.open && !(await openInBrowser(url))) {
242
+ note(' Could not open a browser — open the URL above yourself.\n');
243
+ }
244
+
164
245
  process.on('SIGINT', stop);
165
246
  process.on('SIGTERM', stop);
166
247
  }
package/lib/cli.js CHANGED
@@ -18,6 +18,15 @@ Usage:
18
18
  Installed under both names: reviewer and git-reviewer. The second name also
19
19
  makes it reachable as a git subcommand: git reviewer
20
20
 
21
+ Handing a review to an agent:
22
+ reviewer | claude -p "Apply this review."
23
+
24
+ With something reading its output, reviewer writes the finished review there
25
+ the moment you submit it in the browser, and exits. On a terminal there is
26
+ nothing waiting, so it says where the review was saved and keeps serving.
27
+ Everything it tells you goes to stderr either way, so only the review is
28
+ ever on stdout.
29
+
21
30
  Arguments:
22
31
  repository Path to a git repository, or to any directory inside one.
23
32
  Defaults to the current directory. It is opened
@@ -38,6 +47,8 @@ Options:
38
47
 
39
48
  Examples:
40
49
  reviewer review the repository you are standing in
50
+ reviewer | claude -p "Apply this review."
51
+ review it, then hand it straight to an agent
41
52
  reviewer ~/work/api review another repository
42
53
  reviewer . --no-open print the URL, open it yourself
43
54
  reviewer export . print the review as JSON
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -1102,7 +1102,7 @@ async function submitReview() {
1102
1102
  throw new Error(data.error || 'Failed to submit review');
1103
1103
  }
1104
1104
 
1105
- showReviewModal(data.reviewContent, data.filename);
1105
+ showReviewModal(data);
1106
1106
  showStatus(`Review submitted successfully! ${data.totalComments} comments`, 'success');
1107
1107
 
1108
1108
  } catch (error) {
@@ -1110,9 +1110,38 @@ async function submitReview() {
1110
1110
  }
1111
1111
  }
1112
1112
 
1113
- // Show review modal with download and copy options
1114
- function showReviewModal(reviewContent, filename) {
1115
- // Create modal overlay
1113
+ /**
1114
+ * What happened when you pressed Submit.
1115
+ *
1116
+ * This used to offer a download and nothing else, which is a strange thing to
1117
+ * put in front of somebody who started this from a terminal: the review is
1118
+ * already a file, and they are more likely to want to know where it is than
1119
+ * to want a second copy of it in ~/Downloads.
1120
+ *
1121
+ * So it says where the review was written, and when something is waiting on
1122
+ * the other end of a pipe it says that instead — because in that case the
1123
+ * answer to "what do I do now" is "nothing, it has already gone".
1124
+ *
1125
+ * @param {{reviewContent: string, filename: string, reviewPath?: string,
1126
+ * handoff?: boolean, totalComments?: number}} result
1127
+ */
1128
+ function showReviewModal(result) {
1129
+ const { reviewContent, filename, reviewPath, handoff, totalComments } = result;
1130
+
1131
+ const count = totalComments === undefined
1132
+ ? ''
1133
+ : `${totalComments} comment${totalComments === 1 ? '' : 's'} · `;
1134
+
1135
+ const destination = handoff
1136
+ ? `<div class="review-destination handed-off">
1137
+ <strong>Handed back to your terminal.</strong>
1138
+ <span>${escapeHtml(count)}The command you ran has printed this review and exited. You can close this window.</span>
1139
+ </div>`
1140
+ : `<div class="review-destination">
1141
+ <strong>Saved</strong>
1142
+ <span class="review-path" title="${escapeHtml(reviewPath ?? '')}">${escapeHtml(reviewPath ?? filename)}</span>
1143
+ </div>`;
1144
+
1116
1145
  const modal = document.createElement('div');
1117
1146
  modal.className = 'review-modal';
1118
1147
  modal.innerHTML = `
@@ -1122,11 +1151,12 @@ function showReviewModal(reviewContent, filename) {
1122
1151
  <button class="close-modal" onclick="this.closest('.review-modal').remove()">×</button>
1123
1152
  </div>
1124
1153
  <div class="review-modal-body">
1154
+ ${destination}
1125
1155
  <pre class="review-text">${escapeHtml(reviewContent)}</pre>
1126
1156
  </div>
1127
1157
  <div class="review-modal-footer">
1128
1158
  <button data-filename="${escapeHtml(filename)}" onclick="downloadReview(this.dataset.filename, this.closest('.review-modal').querySelector('.review-text').textContent)">
1129
- Download ${escapeHtml(filename)}
1159
+ Download
1130
1160
  </button>
1131
1161
  <button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
1132
1162
  Copy to Clipboard
@@ -1594,6 +1624,8 @@ document.addEventListener('DOMContentLoaded', () => {
1594
1624
  }
1595
1625
  });
1596
1626
 
1627
+ restoreHowTo();
1628
+
1597
1629
  for (const id of ['recentList', 'browseList']) {
1598
1630
  const list = document.getElementById(id);
1599
1631
  list.addEventListener('click', onPickerActivate);
@@ -1850,3 +1882,54 @@ function onPickerActivate(event) {
1850
1882
  const into = event.target.closest('[data-into]');
1851
1883
  if (into) loadBrowse(into.dataset.into);
1852
1884
  }
1885
+
1886
+
1887
+ /* How to comment
1888
+ * ---------------------------------------------------------------------------
1889
+ * Open until it is dismissed, then remembered as dismissed. A hint that cannot
1890
+ * be turned off becomes furniture, and a hint that has to be found is not one.
1891
+ */
1892
+
1893
+ /** Where the dismissal is remembered. Per browser; it is a convenience, not state. */
1894
+ const HOW_TO_KEY = 'reviewer.howTo.collapsed';
1895
+
1896
+ /**
1897
+ * @returns {boolean} whether it has been collapsed before
1898
+ */
1899
+ function howToWasCollapsed() {
1900
+ try {
1901
+ return window.localStorage.getItem(HOW_TO_KEY) === 'true';
1902
+ } catch {
1903
+ // A private window, or site data blocked. Showing the hint again is a far
1904
+ // better failure than not rendering the panel at all.
1905
+ return false;
1906
+ }
1907
+ }
1908
+
1909
+ /**
1910
+ * @param {boolean} collapsed
1911
+ */
1912
+ function rememberHowTo(collapsed) {
1913
+ try {
1914
+ window.localStorage.setItem(HOW_TO_KEY, String(collapsed));
1915
+ } catch {
1916
+ // Nothing to do. It reopens next time, which is the harmless direction.
1917
+ }
1918
+ }
1919
+
1920
+ function toggleHowTo() {
1921
+ const panel = document.getElementById('howTo');
1922
+ const collapsed = panel.classList.toggle('collapsed');
1923
+
1924
+ document.getElementById('howToToggle').setAttribute('aria-expanded', String(!collapsed));
1925
+ rememberHowTo(collapsed);
1926
+ }
1927
+
1928
+ function restoreHowTo() {
1929
+ const panel = document.getElementById('howTo');
1930
+ if (!panel) return;
1931
+
1932
+ const collapsed = howToWasCollapsed();
1933
+ panel.classList.toggle('collapsed', collapsed);
1934
+ document.getElementById('howToToggle').setAttribute('aria-expanded', String(!collapsed));
1935
+ }
package/public/index.html CHANGED
@@ -101,6 +101,28 @@
101
101
  Full Context
102
102
  </button>
103
103
  </div>
104
+ <section id="howTo" class="how-to">
105
+ <button
106
+ id="howToToggle"
107
+ class="how-to-toggle"
108
+ onclick="toggleHowTo()"
109
+ aria-expanded="true"
110
+ aria-controls="howToBody">
111
+ <span class="how-to-chevron" aria-hidden="true">▾</span>
112
+ How to comment
113
+ </button>
114
+ <div id="howToBody" class="how-to-body">
115
+ <p><strong>Click a line number</strong> in the left column to comment on that line.
116
+ Removed lines work too — the most useful note is often about deleted code.</p>
117
+ <p><strong>Hold <kbd>⌘</kbd>/<kbd>Ctrl</kbd> and drag</strong> to select part of a line,
118
+ then click the line number. The selection is quoted in the comment.</p>
119
+ <p><kbd>⌘</kbd>/<kbd>Ctrl</kbd> + <kbd>Enter</kbd> saves ·
120
+ <kbd>Esc</kbd> cancels ·
121
+ <kbd>↑</kbd> <kbd>↓</kbd> move between files</p>
122
+ <p>Everything saves as you type. <strong>Submit Review</strong> when you are done.</p>
123
+ </div>
124
+ </section>
125
+
104
126
  <div id="codeViewer" class="code-viewer"></div>
105
127
  </main>
106
128
 
package/public/style.css CHANGED
@@ -1319,3 +1319,132 @@ button:disabled {
1319
1319
  max-height: calc(100vh - 112px);
1320
1320
  overflow-y: auto;
1321
1321
  }
1322
+
1323
+ /* Where the review went
1324
+ *
1325
+ * The modal used to offer a download and nothing else, which is an odd thing
1326
+ * to put in front of somebody who started this from a terminal. The review is
1327
+ * already a file; knowing where is more useful than a second copy of it.
1328
+ */
1329
+ .review-destination {
1330
+ display: flex;
1331
+ align-items: baseline;
1332
+ gap: 10px;
1333
+ margin-bottom: 14px;
1334
+ padding: 10px 12px;
1335
+ border: 1px solid #e1e4e8;
1336
+ border-radius: 6px;
1337
+ background: #f6f8fa;
1338
+ font-size: 13px;
1339
+ color: #24292e;
1340
+ }
1341
+
1342
+ .review-destination.handed-off {
1343
+ border-color: #b7e0c2;
1344
+ background: #eefcf1;
1345
+ }
1346
+
1347
+ .review-destination strong {
1348
+ flex: none;
1349
+ font-weight: 600;
1350
+ }
1351
+
1352
+ .review-destination span {
1353
+ min-width: 0;
1354
+ color: #57606a;
1355
+ }
1356
+
1357
+ .review-destination .review-path {
1358
+ /* Wrapped rather than truncated. There is room in a modal, and every way of
1359
+ * shortening a path hides the half somebody wanted: cut the front and the
1360
+ * directory goes, cut the back and the filename goes. */
1361
+ word-break: break-all;
1362
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1363
+ font-size: 12px;
1364
+ }
1365
+
1366
+ .review-destination {
1367
+ align-items: flex-start;
1368
+ }
1369
+
1370
+ /* How to comment
1371
+ *
1372
+ * Nothing on screen said that the column of line numbers was clickable, or
1373
+ * that holding Cmd and dragging selects a snippet to quote. Both are obvious
1374
+ * the second time and invisible the first, which is exactly what a hint is
1375
+ * for -- and exactly why it has to be dismissible rather than permanent.
1376
+ */
1377
+ .how-to {
1378
+ flex: none;
1379
+ border-bottom: 1px solid #e1e4e8;
1380
+ background: #f6f8fa;
1381
+ }
1382
+
1383
+ .how-to-toggle {
1384
+ display: flex;
1385
+ align-items: center;
1386
+ gap: 6px;
1387
+ width: 100%;
1388
+ padding: 8px 20px;
1389
+ border: none;
1390
+ background: transparent;
1391
+ font: inherit;
1392
+ font-size: 12px;
1393
+ font-weight: 600;
1394
+ color: #57606a;
1395
+ text-align: left;
1396
+ cursor: pointer;
1397
+ }
1398
+
1399
+ .how-to-toggle:hover {
1400
+ color: #24292e;
1401
+ }
1402
+
1403
+ .how-to-chevron {
1404
+ display: inline-block;
1405
+ font-size: 10px;
1406
+ transition: transform 0.15s ease;
1407
+ }
1408
+
1409
+ .how-to.collapsed .how-to-chevron {
1410
+ transform: rotate(-90deg);
1411
+ }
1412
+
1413
+ .how-to.collapsed .how-to-body {
1414
+ display: none;
1415
+ }
1416
+
1417
+ .how-to-body {
1418
+ padding: 0 20px 12px 20px;
1419
+ }
1420
+
1421
+ .how-to-body p {
1422
+ margin: 0 0 6px 0;
1423
+ font-size: 12.5px;
1424
+ line-height: 1.5;
1425
+ color: #57606a;
1426
+ }
1427
+
1428
+ .how-to-body p:last-child {
1429
+ margin-bottom: 0;
1430
+ }
1431
+
1432
+ .how-to-body strong {
1433
+ color: #24292e;
1434
+ font-weight: 600;
1435
+ }
1436
+
1437
+ .how-to-body kbd {
1438
+ display: inline-block;
1439
+ min-width: 18px;
1440
+ padding: 1px 5px;
1441
+ border: 1px solid #d1d5da;
1442
+ border-bottom-width: 2px;
1443
+ border-radius: 4px;
1444
+ background: white;
1445
+ font-family: inherit;
1446
+ font-size: 11px;
1447
+ line-height: 1.4;
1448
+ color: #24292e;
1449
+ text-align: center;
1450
+ }
package/server.js CHANGED
@@ -48,13 +48,22 @@ class FileNotFoundError extends Error {
48
48
  * @param {string} [options.reviewsDir] where comment and review files are written
49
49
  * @param {SessionStore} [options.sessions]
50
50
  * @param {(path: string) => import('simple-git').SimpleGit} [options.git] git factory, injectable for tests
51
+ * @param {(review: {document: object, reviewPath: string, documentPath: string,
52
+ * comments: object[]}) => void} [options.onReviewSubmitted] called after a
53
+ * review is written, so the process that started the server can act on it.
54
+ * This is what lets `reviewer` hand the finished review back to the terminal.
55
+ * @param {boolean} [options.handoff] whether the caller is going to do
56
+ * something with a submitted review. Reported to the page so it can say
57
+ * where the review went instead of only offering a download.
51
58
  * @returns {import('express').Express}
52
59
  */
53
60
  function createApp(options = {}) {
54
61
  const {
55
62
  reviewsDir = resolveReviewsDir(),
56
63
  sessions = new SessionStore(),
57
- git: gitFactory = simpleGit
64
+ git: gitFactory = simpleGit,
65
+ onReviewSubmitted = null,
66
+ handoff = false
58
67
  } = options;
59
68
 
60
69
  const app = express();
@@ -292,7 +301,10 @@ function createApp(options = {}) {
292
301
  }
293
302
 
294
303
  const repoId = sessions.create(root, mode);
295
- console.log(`Loaded repository: ${root} (mode: ${mode})`);
304
+ // stderr, not stdout. `reviewer | claude -p ...` puts the finished
305
+ // review on stdout, and anything else written there lands in the middle
306
+ // of it. Progress is for the person watching; stdout is for the pipe.
307
+ console.error(`Loaded repository: ${root} (mode: ${mode})`);
296
308
 
297
309
  try {
298
310
  await recordRecent(reviewsDir, root);
@@ -465,16 +477,39 @@ function createApp(options = {}) {
465
477
  `${JSON.stringify(document, null, 2)}\n`
466
478
  );
467
479
 
468
- console.log(`Review generated: ${filename} (${comments.length} comments)`);
480
+ console.error(`Review generated: ${filename} (${comments.length} comments)`);
481
+
482
+ const reviewPath = path.join(reviewsDir, filename);
469
483
 
470
484
  res.json({
471
485
  message: 'Review submitted successfully',
472
486
  reviewContent,
473
487
  filename,
474
488
  documentFilename,
489
+ reviewPath,
490
+ // The page has no other way to know whether anything is waiting for
491
+ // this on the other side, and "download it" is the wrong thing to say
492
+ // to someone whose terminal is about to be handed the same review.
493
+ handoff,
475
494
  review: document,
476
495
  totalComments: comments.length
477
496
  });
497
+
498
+ // After the response, so a slow or throwing consumer cannot make the
499
+ // browser think the submit failed. The review is already on disk by now
500
+ // and the caller is told where.
501
+ if (onReviewSubmitted) {
502
+ try {
503
+ onReviewSubmitted({
504
+ document,
505
+ reviewPath,
506
+ documentPath: path.join(reviewsDir, documentFilename),
507
+ comments
508
+ });
509
+ } catch (error) {
510
+ console.error('Review handoff failed:', error.message);
511
+ }
512
+ }
478
513
  } catch (error) {
479
514
  if (error instanceof ReviewOwnershipError) {
480
515
  return res.status(409).json({ error: error.message });
@@ -510,12 +545,14 @@ function startServer(options = {}) {
510
545
  silent = false,
511
546
  ...appOptions
512
547
  } = options;
548
+ // Everything else in `options` -- reviewsDir, sessions, git, handoff and
549
+ // onReviewSubmitted -- goes through to the app.
513
550
  const app = createApp(appOptions);
514
551
 
515
552
  return new Promise((resolve, reject) => {
516
553
  const server = app.listen(port, host, () => {
517
554
  if (!silent) {
518
- console.log(`Code Reviewer server running on http://${host}:${server.address().port}`);
555
+ console.error(`Code Reviewer server running on http://${host}:${server.address().port}`);
519
556
  }
520
557
  resolve(server);
521
558
  });