git-reviewer 2.14.1 → 2.16.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
@@ -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-379-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-500-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
@@ -48,6 +48,10 @@ Nothing is installed into the project, there is nothing to sign in to, and
48
48
  nothing leaves your machine. If you would rather not install it,
49
49
  `npx git-reviewer` works the same way.
50
50
 
51
+ You can also ask while you read. Connect the Claude Code session that wrote the
52
+ code, and it answers each comment in its thread as you write it; see
53
+ [Ask the session that wrote the code](#ask-the-session-that-wrote-the-code).
54
+
51
55
  When the review and the hand-off happen at different times, the two-step form
52
56
  still works and is the one to reach for:
53
57
 
@@ -79,7 +83,9 @@ git reviewer export [repository] [--format json|prompt]
79
83
  -p, --port <n> Port to listen on (default 4500; falls back to a free
80
84
  port if that one is taken)
81
85
  --no-open Print the URL instead of opening a browser
86
+ --no-intro Open the review without the first-run help
82
87
  --staged Review only staged changes (HEAD vs index)
88
+ --no-untracked Review tracked working-tree changes only
83
89
  -f, --format <f> Export format: json (default) or prompt
84
90
  -h, --help Show help
85
91
  -v, --version Show the version
@@ -103,6 +109,19 @@ changes. As with the default view, reload after changing what is staged. Saved c
103
109
  are shared between views and may refer to code outside the current comparison;
104
110
  check their anchors before applying them.
105
111
 
112
+ Wholly untracked directories with more than 200 files appear as one directory
113
+ row with a file count and no diff. Smaller directories stay reviewable file by
114
+ file. Tracked changes are always listed individually, and `.gitignore` still
115
+ applies. The scope note names any folded directories and their counts.
116
+ The headline counts individual files separately from folded folders and the
117
+ files inside them. Tracked changes appear first; reloading keeps your selection.
118
+
119
+ Use `git reviewer --no-untracked` to exclude all untracked files. If there are
120
+ no tracked working-tree changes, the view stays empty instead of loading the
121
+ last commit. Staged and commit comparisons keep their usual behavior.
122
+ Saved reviews and exports retain this tracked-only scope. The text review and
123
+ agent prompt explain the exclusion; older comments remain shared between views.
124
+
106
125
  ### When you are not standing in a repository
107
126
 
108
127
  The page opens on a picker rather than on an empty box:
@@ -330,6 +349,7 @@ rather than guess when an anchor has vanished.
330
349
  "comments": [
331
350
  {
332
351
  "id": "src/auth.js:10",
352
+ "key": "Xq3vB9aK",
333
353
  "file": "src/auth.js",
334
354
  "line": 10,
335
355
  "anchor": " if (scheme !== SCHEME || !token) return null;",
@@ -369,6 +389,54 @@ comment, finding the code a comment quotes rather than trusting its line
369
389
  number. Where an agent cannot leave a command running, it asks you to start
370
390
  `git reviewer` yourself and reads the result with `git reviewer export`.
371
391
 
392
+ ## Ask the session that wrote the code
393
+
394
+ The agent that wrote a change knows why it is the way it is: it read the code
395
+ around it, weighed the alternatives and chose one. reviewer can put your
396
+ comments to that session, and its answers appear in the thread, under the
397
+ comment, as they are written. You review in the browser and ask as you go:
398
+ "why throw here instead of repricing?" on one file, "which edge cases are not
399
+ tested?" on another, and each thread gets its answer from the agent that
400
+ wrote the code, in its own words.
401
+
402
+ **From the page.** Click **Ask Claude Code**, pick the session. Claude Code
403
+ keeps every session's transcript, and reviewer lists the ones that ran in
404
+ this repository. From then on every comment you write, and every reply you
405
+ add, goes to that session; threads that were already waiting go first. The
406
+ session is resumed in the background as a copy, so the original transcript
407
+ is left exactly as it was. The copy can read the repository and nothing else:
408
+ no edits, no shell, none of your MCP servers.
409
+
410
+ **Live, in your terminal.** To have the session itself answer, the one you
411
+ can watch and step into, connect it:
412
+
413
+ ```bash
414
+ git reviewer connect # the most recent session that worked here
415
+ git reviewer connect --list # choose one
416
+ git reviewer connect --session 9fef2341
417
+ ```
418
+
419
+ This resumes the session with reviewer attached as a Claude Code
420
+ [channel](https://code.claude.com/docs/en/channels). Every comment arrives in
421
+ the session as it is written, and it answers in the thread with the `reply`
422
+ tool, with your own permissions. Channels are a research preview, so Claude
423
+ Code asks you to confirm the development channel when it starts.
424
+
425
+ **Any agent.** Two commands are the whole protocol, so anything that can run a
426
+ command can take part:
427
+
428
+ ```bash
429
+ git reviewer watch # one JSON line per comment waiting for an answer
430
+ git reviewer reply <key> "Because the spec says inclusive."
431
+ git reviewer reply <key> - < answer.md # or the answer on stdin
432
+ ```
433
+
434
+ Every comment has a stable `key`. It is in the export, and in each line `watch`
435
+ prints. Answers are stored as follow-ups with a `by` field naming who wrote
436
+ them, so an agent's reply is never mistaken for your own note. They export
437
+ labelled as such too, so a review handed over afterwards carries the whole
438
+ conversation.
439
+
372
440
  ## HTTP API
373
441
 
374
442
  The UI is a client of this; nothing is hidden from you.
@@ -383,11 +451,61 @@ The UI is a client of this; nothing is hidden from you.
383
451
  | `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
384
452
  | `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
385
453
  | `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
386
- | `POST /api/save-comments` | Persist the current comments |
387
- | `GET /api/load-comments/:repoId` | Restore saved comments |
454
+ | `POST /api/save-comments` | Persist the current comments. Send back the `lastUpdated` you loaded and the save is conditional: if the file has moved on since, it answers `409` with the current `comments` and `lastUpdated` instead of overwriting them |
455
+ | `GET /api/load-comments/:repoId` | Restore saved comments, with `lastUpdated`, the version token a conditional save sends back |
388
456
  | `POST /api/submit-review` | Render the saved comments to a review file |
457
+ | `GET /api/events/:repoId` | Server-sent events for an open review: `comments` whenever the saved comments change, whoever changed them; `agents` as agents connect, leave or start on a thread; `partial` with an answer still being written |
458
+ | `GET /api/agent/sessions/:repoId` | The Claude Code sessions that ran in this repository, newest first |
459
+ | `POST /api/agent/connect` | `{repoId, sessionId, fork}`: have that session answer the review's comments in the background. `fork` defaults to `true`, a copy of the session |
460
+ | `POST /api/agent/disconnect` | `{repoId}`: stop the background session |
389
461
  | `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
390
462
 
463
+ ### POST request bodies
464
+
465
+ `POST /api/load-repo` opens a repository. `repoPath` is required; choose a comparison or staged changes:
466
+
467
+ ```json
468
+ {"repoPath":"/home/you/work/api","base":"main","head":"feature"}
469
+ ```
470
+
471
+ To review a commit range, use `"commits": {"from":"abc123","to":"def456"}` instead of `base`/`head`. To review staged changes, use `"staged": true` without comparison fields. `head` is optional when using `base`.
472
+
473
+ For tracked working-tree changes only, send `"untracked": false`. It defaults
474
+ to `true`. Folded directory entries carry `directory: true` and `fileCount`;
475
+ they have no file diff.
476
+
477
+ `POST /api/save-comments` persists comments for an open session:
478
+
479
+ ```json
480
+ {
481
+ "repoId": "<repoId returned by load-repo>",
482
+ "comments": [
483
+ {
484
+ "file": "src/app.js",
485
+ "line": 12,
486
+ "lineContent": "const value = 1;",
487
+ "text": "Consider renaming this variable.",
488
+ "selectedText": "const value",
489
+ "followUps": [{"text":"Please also update its uses.","timestamp":"2026-01-01T00:00:00.000Z"}]
490
+ }
491
+ ]
492
+ }
493
+ ```
494
+
495
+ Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, `followUps` and `key` are optional. A comment sent without a `key` gets one derived from its content, so the same list posted twice keeps its identities. Follow-ups take `key`, `timestamp`, and `by` for a reply from someone other than the reviewer. Follow-ups already saved for a comment are kept even when a save leaves them out, so a stale save cannot erase an agent's answer.
496
+
497
+ Add `"lastUpdated"` with the value `load-comments` returned (`null` for a review
498
+ not saved yet) to make the save conditional, as the page does. Without it the
499
+ save overwrites whatever is on disk, as it always has.
500
+
501
+ `POST /api/submit-review` renders the previously saved comments:
502
+
503
+ ```json
504
+ {"repoId":"<repoId returned by load-repo>"}
505
+ ```
506
+
507
+ The startup `--json` output does not include `repoId`: no session exists at startup. A client obtains its own `repoId` by calling `POST /api/load-repo`.
508
+
391
509
  A `repoId` is a random per-session handle, not a path. Requests that resolve
392
510
  outside the opened repository are refused, and a path that names nothing gets a
393
511
  404.
@@ -410,7 +528,7 @@ rather than a public issue.
410
528
 
411
529
  ```bash
412
530
  npm install # 3 dependencies, no build step, ~6MB
413
- npm test # 379 tests
531
+ npm test # 500 tests
414
532
  npm run test:watch
415
533
  npm run test:coverage
416
534
  ```
package/bin/reviewer.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('fs').promises;
5
5
  const path = require('path');
6
+ const { spawn } = require('child_process');
6
7
 
7
8
  const { parseArgs, buildUrl, UsageError, usage, invokedAs } = require('../lib/cli');
8
9
  const { homeRelative } = require('../lib/paths');
@@ -18,6 +19,14 @@ const {
18
19
  } = require('../lib/export');
19
20
  const { startServer, DEFAULT_PORT, DEFAULT_HOST } = require('../server');
20
21
  const { version } = require('../package.json');
22
+ const { updateSavedComments } = require('../lib/store');
23
+ const { watchComments } = require('../lib/watch');
24
+ const { announce } = require('../lib/presence');
25
+ const { mintKey } = require('../lib/comments');
26
+ const { appendReply, awaitingAnswer, newReviewerMessages, describeThread, lastAuthor } = require('../lib/threads');
27
+ const { listClaudeSessions } = require('../lib/claude-sessions');
28
+ const { runChannel } = require('../lib/channel');
29
+ const { resolveClaudeCommand } = require('../lib/claude-command');
21
30
 
22
31
  // What to call this command when it talks about itself: whatever the person
23
32
  // typed, so a hint can be pasted back as-is. See invokedAs in lib/cli.js.
@@ -92,6 +101,9 @@ async function exportReview(options) {
92
101
  error instanceof NoMatchingCommentsError ||
93
102
  error instanceof ReviewOwnershipError
94
103
  ) {
104
+ if (repoPath === given && error instanceof NoReviewError && !(await repoRoot(given))) {
105
+ throw new UsageError(`${given} is not inside a git repository, and no review is saved for it.`);
106
+ }
95
107
  throw new UsageError(error.message);
96
108
  }
97
109
  throw error;
@@ -104,6 +116,244 @@ async function exportReview(options) {
104
116
  );
105
117
  }
106
118
 
119
+ /**
120
+ * The root of the repository a command is about, or a usage error.
121
+ *
122
+ * @param {string|null} given the repository named on the command line
123
+ * @returns {Promise<string>}
124
+ */
125
+ async function requireRepository(given) {
126
+ const target = given ?? process.cwd();
127
+ const root = await repoRoot(target);
128
+ if (!root) throw new UsageError(`${target} is not inside a git repository.`);
129
+ return root;
130
+ }
131
+
132
+ /** How a session is shown in a list: its name, or what it was first asked. */
133
+ function sessionLabel(session) {
134
+ return session.title ?? session.firstPrompt ?? '(no prompt)';
135
+ }
136
+
137
+ /**
138
+ * `reviewer connect`: open the Claude Code session that worked here, with
139
+ * reviewer attached as a channel.
140
+ *
141
+ * Every comment the reviewer writes then arrives in that session as it is
142
+ * written, and the session answers in the thread with the `reply` tool. This
143
+ * is the session in a terminal, live: the person can watch it work and step
144
+ * in. During the channels research preview Claude Code asks once, at
145
+ * startup, to confirm a development channel; that is expected.
146
+ *
147
+ * @param {import('../lib/cli').CliOptions} options
148
+ */
149
+ async function connectCommand(options) {
150
+ const repoPath = await requireRepository(options.repoPath);
151
+ const sessions = await listClaudeSessions(repoPath, { limit: 50 });
152
+
153
+ if (options.list) {
154
+ if (sessions.length === 0) {
155
+ note(`No Claude Code session has worked in ${repoPath} yet.`);
156
+ return;
157
+ }
158
+ for (const session of sessions) {
159
+ process.stdout.write(`${session.id} ${session.updatedAt.slice(0, 16).replace('T', ' ')} ${sessionLabel(session)}\n`);
160
+ }
161
+ return;
162
+ }
163
+
164
+ let chosen = null;
165
+ if (options.session) {
166
+ const matches = sessions.filter(session => session.id.startsWith(options.session));
167
+ if (matches.length !== 1) {
168
+ throw new UsageError(matches.length === 0
169
+ ? `No Claude Code session ${options.session} has worked in ${repoPath}. ${NAME} connect --list shows them.`
170
+ : `${options.session} matches ${matches.length} sessions; give more of the id.`);
171
+ }
172
+ [chosen] = matches;
173
+ } else {
174
+ // The most recent session that is not one of reviewer's own copies:
175
+ // asking a copy made to answer an earlier review is asking second-hand.
176
+ chosen = sessions.find(session => !session.title?.startsWith('Review answers:')) ?? sessions[0] ?? null;
177
+ }
178
+
179
+ const title = chosen ? sessionLabel(chosen) : null;
180
+ const mcpConfig = {
181
+ mcpServers: {
182
+ reviewer: {
183
+ command: process.execPath,
184
+ args: [path.resolve(__filename), 'channel', repoPath],
185
+ ...(title ? { env: { REVIEWER_SESSION_TITLE: title } } : {})
186
+ }
187
+ }
188
+ };
189
+ const args = [
190
+ ...(chosen ? ['--resume', chosen.id] : []),
191
+ ...(chosen && options.fork ? ['--fork-session'] : []),
192
+ '--mcp-config', JSON.stringify(mcpConfig),
193
+ '--dangerously-load-development-channels', 'server:reviewer'
194
+ ];
195
+
196
+ note(chosen
197
+ ? `\n Resuming Claude Code session ${chosen.id}${options.fork ? ' as a copy' : ''}\n ${title}\n`
198
+ : `\n No Claude Code session has worked in ${repoPath} yet; starting a new one.\n`);
199
+ note(' Claude Code will ask you to confirm the reviewer channel (it is a research preview).');
200
+ note(` Then every comment you write in reviewer arrives here, and the answers appear under them.\n`);
201
+
202
+ const claude = process.env.REVIEWER_CLAUDE || 'claude';
203
+ // A session whose directory has since been removed is resumed from the
204
+ // repository root rather than failing to spawn at all.
205
+ const cwd = chosen ? await fs.access(chosen.cwd).then(() => chosen.cwd, () => repoPath) : repoPath;
206
+ const child = spawn(resolveClaudeCommand(claude), args, { cwd, stdio: 'inherit' });
207
+
208
+ // Ctrl+C belongs to Claude Code while it runs -- it interrupts a turn, it
209
+ // does not quit -- and the terminal delivers it to both processes. This one
210
+ // only waits, so it must not die of the same keypress and leave Claude Code
211
+ // running on a terminal the shell has already taken back. A request to
212
+ // stop that comes from outside is passed on instead.
213
+ const ignore = () => {};
214
+ const forward = signal => child.kill(signal);
215
+ process.on('SIGINT', ignore);
216
+ process.on('SIGQUIT', ignore);
217
+ process.on('SIGTERM', forward);
218
+ process.on('SIGHUP', forward);
219
+ try {
220
+ await new Promise((resolve, reject) => {
221
+ child.on('error', error => reject(['ENOENT', 'EINVAL'].includes(error.code)
222
+ ? new Error(`Could not run "${claude}". Is Claude Code installed and on your PATH? Set REVIEWER_CLAUDE to its path otherwise` +
223
+ `${process.platform === 'win32' ? ' (the full path of claude.exe)' : ''}.`)
224
+ : error));
225
+ child.on('exit', (code, signal) => {
226
+ process.exitCode = code ?? (signal ? 1 : 0);
227
+ resolve();
228
+ });
229
+ });
230
+ } finally {
231
+ process.off('SIGINT', ignore);
232
+ process.off('SIGQUIT', ignore);
233
+ process.off('SIGTERM', forward);
234
+ process.off('SIGHUP', forward);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * `reviewer channel`: the MCP server `connect` asks Claude Code to run.
240
+ *
241
+ * @param {import('../lib/cli').CliOptions} options
242
+ */
243
+ async function channelCommand(options) {
244
+ const repoPath = await requireRepository(options.repoPath);
245
+ await runChannel({
246
+ repoPath,
247
+ reviewsDir: reviewsDir(),
248
+ version,
249
+ title: process.env.REVIEWER_SESSION_TITLE || null
250
+ });
251
+ }
252
+
253
+ /**
254
+ * `reviewer reply <key> <text>`: answer in a thread from the command line.
255
+ *
256
+ * For an agent following a review with `reviewer watch`, and anything else
257
+ * that can run a command. Inside Claude Code the reply is signed `claude`
258
+ * and carries the session's id; elsewhere it is from `agent` unless `--as`
259
+ * says who.
260
+ *
261
+ * @param {import('../lib/cli').CliOptions} options
262
+ */
263
+ async function replyCommand(options) {
264
+ const repoPath = await requireRepository(options.repoPath);
265
+ if (options.textFromStdin) {
266
+ const chunks = [];
267
+ for await (const chunk of process.stdin) chunks.push(chunk);
268
+ options.text = Buffer.concat(chunks).toString('utf-8').trim();
269
+ if (options.text === '') throw new UsageError('reply - read nothing from stdin');
270
+ }
271
+ const session = process.env.CLAUDE_CODE_SESSION_ID || null;
272
+ const by = options.as ?? (session ? 'claude' : 'agent');
273
+
274
+ let thread = null;
275
+ await updateSavedComments(reviewsDir(), repoPath, current => {
276
+ thread = current.comments.find(comment => comment.key === options.key) ?? null;
277
+ if (!thread) return null;
278
+ return {
279
+ comments: appendReply(current.comments, options.key, {
280
+ key: mintKey(),
281
+ text: options.text,
282
+ timestamp: new Date().toISOString(),
283
+ by,
284
+ ...(session ? { session } : {})
285
+ })
286
+ };
287
+ });
288
+
289
+ // Loudly: a reply that went nowhere and exited 0 would look, to whatever
290
+ // ran it, exactly like one the reviewer can read.
291
+ if (!thread) {
292
+ console.error(`error: no comment with key ${options.key} in the review of ${repoPath}.`);
293
+ console.error(` ${NAME} export ${homeRelative(repoPath)} lists the comments and their keys.`);
294
+ process.exitCode = 1;
295
+ return;
296
+ }
297
+ note(`Replied on ${thread.file}:${thread.line} as ${by}.`);
298
+ }
299
+
300
+ /**
301
+ * `reviewer watch`: one JSON line per thing the reviewer says, as they say it.
302
+ *
303
+ * Threads already waiting for an answer come first, then each new comment
304
+ * and each reply the reviewer adds in a thread. Made for an agent that can
305
+ * follow a command's output -- Claude Code's Monitor tool turns each line
306
+ * into a message -- and answer with `reviewer reply`.
307
+ *
308
+ * @param {import('../lib/cli').CliOptions} options
309
+ */
310
+ async function watchCommand(options) {
311
+ const repoPath = await requireRepository(options.repoPath);
312
+ const dir = reviewsDir();
313
+ const session = process.env.CLAUDE_CODE_SESSION_ID || null;
314
+ const presence = announce(dir, repoPath, { kind: 'watch', agent: session ? 'claude' : 'agent', session, title: null });
315
+ const working = new Set();
316
+
317
+ const emit = comment => {
318
+ const followUps = (comment.followUps ?? []).filter(followUp => !followUp.by);
319
+ const latest = followUps.length > 0 ? followUps[followUps.length - 1].text : comment.text;
320
+ process.stdout.write(`${JSON.stringify({
321
+ key: comment.key,
322
+ file: comment.file,
323
+ line: comment.line,
324
+ text: latest,
325
+ thread: describeThread(comment)
326
+ })}\n`);
327
+ working.add(comment.key);
328
+ };
329
+
330
+ let known = null;
331
+ const watcher = watchComments(dir, repoPath, state => {
332
+ if (known === null) {
333
+ awaitingAnswer(state.comments).forEach(emit);
334
+ } else {
335
+ for (const { comment } of newReviewerMessages(known, state.comments)) emit(comment);
336
+ }
337
+ for (const key of working) {
338
+ const thread = state.comments.find(comment => comment.key === key);
339
+ if (!thread || lastAuthor(thread) !== null) working.delete(key);
340
+ }
341
+ presence.update({ working: [...working] });
342
+ known = state.comments;
343
+ });
344
+
345
+ note(`Watching the review of ${repoPath}. Reply with: ${NAME} reply <key> "<text>"`);
346
+
347
+ await new Promise(resolve => {
348
+ const finish = () => resolve();
349
+ process.once('SIGINT', finish);
350
+ process.once('SIGTERM', finish);
351
+ process.stdout.on('error', finish); // whatever was reading has gone
352
+ });
353
+ watcher.close();
354
+ await presence.close();
355
+ }
356
+
107
357
  /**
108
358
  * Which repository the page should open.
109
359
  *
@@ -162,6 +412,10 @@ async function main() {
162
412
  if (options.command === 'export') {
163
413
  return exportReview(options);
164
414
  }
415
+ if (options.command === 'connect') return connectCommand(options);
416
+ if (options.command === 'channel') return channelCommand(options);
417
+ if (options.command === 'reply') return replyCommand(options);
418
+ if (options.command === 'watch') return watchCommand(options);
165
419
 
166
420
  const repoPath = await repositoryToOpen(options.repoPath);
167
421
 
@@ -197,7 +451,11 @@ async function main() {
197
451
 
198
452
  let handedOff = false;
199
453
  const stop = () => {
200
- server.close(() => process.exit(0));
454
+ // A background Claude Code session answering comments stops with us:
455
+ // asked to straight away, and waited for before the process exits.
456
+ Promise.resolve(server.stopAgents?.()).catch(() => {}).then(() => {
457
+ server.close(() => process.exit(0));
458
+ });
201
459
  // Don't wait forever on a browser holding the connection open.
202
460
  setTimeout(() => process.exit(0), 2000).unref();
203
461
  };
@@ -250,13 +508,34 @@ async function main() {
250
508
  options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
251
509
  { onReviewSubmitted, handoff: handingOff, onIdle }
252
510
  );
253
- const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath, options.staged);
511
+ const port = server.address().port;
512
+ const url = buildUrl(
513
+ `http://${DEFAULT_HOST}:${port}`,
514
+ repoPath,
515
+ options.staged,
516
+ options.noIntro,
517
+ options.untracked
518
+ );
254
519
 
255
- note(`\n Code Reviewer ${url}`);
256
- if (repoPath) note(` reviewing ${repoPath}`);
257
- note(handingOff
258
- ? '\n Submit the review in the browser and it will be written here.\n'
259
- : '\n Close the tab when you are done, or press Ctrl+C.\n');
520
+ if (options.json) {
521
+ // One machine-readable line instead of the banner, so an agent can read
522
+ // the URL without a regex that breaks the day the banner is reworded.
523
+ // stderr, like everything else here: stdout is reserved for the review.
524
+ // No repoId: none exists yet. The page creates one via POST /api/load-repo.
525
+ note(JSON.stringify({
526
+ url,
527
+ port,
528
+ repository: repoPath,
529
+ pid: process.pid,
530
+ version
531
+ }));
532
+ } else {
533
+ note(`\n Code Reviewer ${url}`);
534
+ if (repoPath) note(` reviewing ${repoPath}`);
535
+ note(handingOff
536
+ ? '\n Submit the review in the browser and it will be written here.\n'
537
+ : '\n Close the tab when you are done, or press Ctrl+C.\n');
538
+ }
260
539
 
261
540
  if (options.open && !(await openInBrowser(url))) {
262
541
  note(' Could not open a browser — open the URL above yourself.\n');
package/lib/agent.js CHANGED
@@ -23,6 +23,7 @@ const SCHEMA = 'code-review/v1';
23
23
  * @property {string} generatedAt ISO 8601
24
24
  * @property {{path: string, name: string, head: string|null, branch: string|null}} repository
25
25
  * @property {'working'|'staged'|'lastCommit'|'range'|null} mode
26
+ * @property {boolean} [untracked] false when the working comparison excluded untracked files
26
27
  * @property {{comments: number, files: number}} summary
27
28
  * @property {AgentComment[]} comments
28
29
  */
@@ -30,12 +31,15 @@ const SCHEMA = 'code-review/v1';
30
31
  /**
31
32
  * @typedef {object} AgentComment
32
33
  * @property {string} id stable within a document
34
+ * @property {string} [key] stable across documents: the key the comment was
35
+ * saved with, which `reviewer reply` takes
33
36
  * @property {string} file repo-relative path
34
37
  * @property {number} line line number when the comment was written
35
38
  * @property {string|null} anchor the exact source line, for relocating the comment
36
39
  * @property {string} [selection] the snippet the reviewer highlighted
37
40
  * @property {string} body
38
- * @property {Array<{body: string, at?: string}>} [followUps]
41
+ * @property {Array<{body: string, at?: string, by?: string}>} [followUps]
42
+ * `by` names an agent that answered in the thread; absent means the reviewer
39
43
  */
40
44
 
41
45
  /**
@@ -48,9 +52,10 @@ const SCHEMA = 'code-review/v1';
48
52
  * @param {string|null} [args.head] the commit the review was written against
49
53
  * @param {string|null} [args.branch]
50
54
  * @param {string|null} [args.mode]
55
+ * @param {boolean} [args.untracked]
51
56
  * @returns {ReviewDocument}
52
57
  */
53
- function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null }) {
58
+ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null, untracked }) {
54
59
  const grouped = groupByFile(comments);
55
60
 
56
61
  return {
@@ -63,6 +68,9 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
63
68
  branch
64
69
  },
65
70
  mode,
71
+ // Older saves say nothing about untracked files. Only record an explicit
72
+ // exclusion in a working comparison; other scopes do not use this flag.
73
+ ...(mode === 'working' && untracked === false ? { untracked: false } : {}),
66
74
  // Omitted rather than null when there is no range, so `"range" in
67
75
  // document` is a meaningful test -- the same rule the optional comment
68
76
  // fields follow. A `working` review has no range and never will.
@@ -161,6 +169,10 @@ function toAgentComment(comment, id) {
161
169
  // Derived from file and line so it stays stable across exports, with a
162
170
  // suffix where that is not enough on its own. See `assignIds`.
163
171
  id,
172
+ // The saved key survives the code moving and the comment being edited,
173
+ // which `id` does not. Added beside it rather than replacing it, so
174
+ // nothing reading `id` today has to change.
175
+ ...(comment.key ? { key: comment.key } : {}),
164
176
  file: comment.file,
165
177
  line: comment.line,
166
178
  anchor: comment.lineContent ?? null,
@@ -173,6 +185,7 @@ function toAgentComment(comment, id) {
173
185
  entry.followUps = comment.followUps.map(followUp => {
174
186
  const item = { body: followUp.text };
175
187
  if (followUp.timestamp) item.at = followUp.timestamp;
188
+ if (followUp.by) item.by = followUp.by;
176
189
  return item;
177
190
  });
178
191
  }
@@ -198,10 +211,15 @@ function toAgentComment(comment, id) {
198
211
  * @param {ReviewDocument} document
199
212
  * @returns {string|null}
200
213
  */
201
- function describeScope({ repository, mode, range }) {
214
+ function describeScope({ repository, mode, range, untracked }) {
202
215
  const at = repository.head ? ` (${repository.head})` : '';
203
216
 
204
217
  if (mode === 'working') {
218
+ if (untracked === false) {
219
+ return `Comparison: tracked changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}. ` +
220
+ 'Untracked files are excluded from this comparison. Saved comments are shared between review views ' +
221
+ 'and may refer to changes outside this comparison; check their anchors before applying them.';
222
+ }
205
223
  return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
206
224
  }
207
225
 
@@ -312,8 +330,10 @@ function formatPrompt(document) {
312
330
 
313
331
  if (comment.followUps?.length > 0) {
314
332
  lines.push('Follow-ups:', '');
333
+ // An agent's answer is labelled as one. Unlabelled, "I'd keep it
334
+ // inclusive" would read as the reviewer's own instruction.
315
335
  comment.followUps.forEach((followUp, index) => {
316
- lines.push(`${index + 1}. ${followUp.body}`);
336
+ lines.push(`${index + 1}. ${followUp.by ? `(${followUp.by}) ` : ''}${followUp.body}`);
317
337
  });
318
338
  lines.push('');
319
339
  }
@@ -322,4 +342,4 @@ function formatPrompt(document) {
322
342
  return `${lines.join('\n')}`;
323
343
  }
324
344
 
325
- module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, SCHEMA };
345
+ module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, describeScope, SCHEMA };