getgloss 0.7.2 → 0.8.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.
@@ -1,4 +1,5 @@
1
1
  // src/server/daemon.ts
2
+ import { rm as rm2 } from "fs/promises";
2
3
  import { serve } from "@hono/node-server";
3
4
 
4
5
  // src/shared/paths.ts
@@ -9,7 +10,7 @@ import path from "path";
9
10
  // package.json
10
11
  var package_default = {
11
12
  name: "getgloss",
12
- version: "0.7.2",
13
+ version: "0.8.0",
13
14
  description: "Local browser-based diff review for coding-agent loops.",
14
15
  type: "module",
15
16
  packageManager: "pnpm@10.33.2",
@@ -40,6 +41,8 @@ var package_default = {
40
41
  },
41
42
  dependencies: {
42
43
  "@hono/node-server": "1.19.14",
44
+ "@shikijs/langs": "4.1.0",
45
+ "@shikijs/themes": "4.1.0",
43
46
  "@tailwindcss/vite": "4.3.0",
44
47
  commander: "14.0.3",
45
48
  execa: "9.6.1",
@@ -49,6 +52,7 @@ var package_default = {
49
52
  open: "10.2.0",
50
53
  react: "19.2.6",
51
54
  "react-dom": "19.2.6",
55
+ shiki: "4.1.0",
52
56
  ulid: "3.0.2",
53
57
  zustand: "5.0.13"
54
58
  },
@@ -108,6 +112,27 @@ function globalReviewsDir() {
108
112
  function globalReviewDir(reviewId) {
109
113
  return path.join(globalReviewsDir(), reviewId);
110
114
  }
115
+ function globalReviewTurnsDir(reviewId) {
116
+ return path.join(globalReviewDir(reviewId), "turns");
117
+ }
118
+ function globalReviewTurnDir(reviewId, turnId) {
119
+ return path.join(globalReviewTurnsDir(reviewId), turnId);
120
+ }
121
+ function globalReviewTurnMetaFile(reviewId, turnId) {
122
+ return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
123
+ }
124
+ function globalReviewTurnDiffFile(reviewId, turnId) {
125
+ return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
126
+ }
127
+ function globalReviewTurnFeedbackFile(reviewId, turnId) {
128
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
129
+ }
130
+ function globalReviewTurnMarkdownFile(reviewId, turnId) {
131
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
132
+ }
133
+ function globalReviewTurnResolvedFile(reviewId, turnId) {
134
+ return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
135
+ }
111
136
  function globalReviewMetaFile(reviewId) {
112
137
  return path.join(globalReviewDir(reviewId), "meta.json");
113
138
  }
@@ -130,23 +155,49 @@ async function ensureDir(dir) {
130
155
  // src/shared/server-info.ts
131
156
  import { readFile } from "fs/promises";
132
157
 
158
+ // src/shared/errors.ts
159
+ function formatError(error) {
160
+ return error instanceof Error ? error.message : String(error);
161
+ }
162
+ function isFileNotFound(error) {
163
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
164
+ }
165
+
133
166
  // src/shared/json.ts
134
- import { writeFile } from "fs/promises";
167
+ import { randomUUID } from "crypto";
168
+ import { rename, rm, writeFile } from "fs/promises";
169
+ import path2 from "path";
135
170
  function serializeJson(value) {
136
171
  return `${JSON.stringify(value, null, 2)}
137
172
  `;
138
173
  }
139
174
  async function writeJsonFile(filePath, value) {
140
- await writeFile(filePath, serializeJson(value));
175
+ await writeTextFile(filePath, serializeJson(value));
141
176
  }
177
+ async function writeTextFile(filePath, value) {
178
+ const tempPath = path2.join(
179
+ path2.dirname(filePath),
180
+ `.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
181
+ );
182
+ try {
183
+ await writeFile(tempPath, value);
184
+ await rename(tempPath, filePath);
185
+ } catch (error) {
186
+ await rm(tempPath, { force: true }).catch(() => void 0);
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ // src/shared/types.ts
192
+ var SIDES = ["L", "R"];
193
+ var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
194
+ var DIFF_LINE_TYPES = ["context", "add", "delete"];
195
+ var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
196
+ var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
197
+ var REVIEW_SCOPE_MODES = ["all", "single", "range"];
198
+ var RESOLUTION_STATUSES = ["partial", "resolved"];
142
199
 
143
200
  // src/shared/validation.ts
144
- var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
145
- var resolutionStatuses = ["partial", "resolved"];
146
- var sides = ["L", "R"];
147
- var diffLineTypes = ["context", "add", "delete"];
148
- var diffScopeModes = ["working", "branch", "explicit"];
149
- var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
150
201
  function parseJson(raw, guard, label) {
151
202
  const parsed = JSON.parse(raw);
152
203
  return parseJsonValue(parsed, guard, label);
@@ -157,26 +208,50 @@ function parseJsonValue(value, guard, label) {
157
208
  }
158
209
  return value;
159
210
  }
211
+ function isServerInfo(value) {
212
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
213
+ }
214
+ function isOpenFileRequest(value) {
215
+ return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId);
216
+ }
217
+ function isCommitRangeDiffRequest(value) {
218
+ return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
219
+ }
160
220
  function isSubmitReviewRequest(value) {
161
- return isRecord(value) && isArrayOf(value.comments, isComment);
221
+ return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
162
222
  }
163
223
  function isResolutionRequest(value) {
164
- return isRecord(value) && isOptionalString(value.summary);
224
+ return isRecord(value) && isOptionalString(value.summary) && isOptionalString(value.turn);
165
225
  }
166
226
  function isStoredReviewMeta(value) {
167
- return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
227
+ return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.activeTurnId) && isOptional(
228
+ value.turns,
229
+ (turns) => isArrayOf(turns, isReviewTurnSummary)
230
+ ) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
168
231
  }
169
232
  function isDiffPayload(value) {
170
- return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isString(value.capturedAt);
233
+ return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isOptional(
234
+ value.commitDiffs,
235
+ (commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
236
+ ) && isString(value.capturedAt);
171
237
  }
172
238
  function isFeedbackBundle(value) {
173
- return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
239
+ return isRecord(value) && value.version === 1 && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isOptional(value.reviewScope, isReviewScope) && isArrayOf(value.comments, isComment);
174
240
  }
175
241
  function isResolutionBundle(value) {
176
- return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
242
+ return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
243
+ }
244
+ function isReviewTurnMeta(value) {
245
+ return isRecord(value) && isString(value.id) && isNumber(value.index) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isString(value.artifactDir) && isString(value.diffPath) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.resolvedPath);
246
+ }
247
+ function isReviewTurnSummary(value) {
248
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
249
+ return false;
250
+ }
251
+ return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
177
252
  }
178
253
  function isDiffScope(value) {
179
- return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
254
+ return isRecord(value) && isOneOf(value.mode, DIFF_SCOPE_MODES) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, DIFF_FALLBACK_REASONS));
180
255
  }
181
256
  function isDiffRef(value) {
182
257
  return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
@@ -187,6 +262,25 @@ function isBaseRef(value) {
187
262
  function isDiffStats(value) {
188
263
  return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
189
264
  }
265
+ function isDiffCommit(value) {
266
+ return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
267
+ }
268
+ function isCommitDiff(value) {
269
+ return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
270
+ }
271
+ function isReviewScope(value) {
272
+ if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
273
+ return false;
274
+ }
275
+ switch (value.mode) {
276
+ case "all":
277
+ return true;
278
+ case "single":
279
+ return isString(value.sha);
280
+ case "range":
281
+ return isString(value.fromSha) && isString(value.toSha);
282
+ }
283
+ }
190
284
  function isDiffFile(value) {
191
285
  return isRecord(value) && isString(value.path) && isNullableString(value.oldPath) && isNumber(value.additions) && isNumber(value.deletions) && isBoolean(value.isBinary) && isBoolean(value.isDeleted) && isBoolean(value.isNew) && isBoolean(value.isRenamed) && isNullableString(value.language) && isArrayOf(value.hunks, isDiffHunk);
192
286
  }
@@ -194,19 +288,22 @@ function isDiffHunk(value) {
194
288
  return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
195
289
  }
196
290
  function isDiffLine(value) {
197
- return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
291
+ return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
198
292
  }
199
293
  function isComment(value) {
200
- return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, sides) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
294
+ return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, SIDES) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
201
295
  }
202
296
  function isResolvedComment(value) {
203
297
  return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
204
298
  }
299
+ function isResolutionCounts(value) {
300
+ return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
301
+ }
205
302
  function isReviewStatus(value) {
206
- return isOneOf(value, reviewStatuses);
303
+ return isOneOf(value, REVIEW_STATUSES);
207
304
  }
208
305
  function isResolutionStatus(value) {
209
- return isOneOf(value, resolutionStatuses);
306
+ return isOneOf(value, RESOLUTION_STATUSES);
210
307
  }
211
308
  function isRecord(value) {
212
309
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -214,6 +311,9 @@ function isRecord(value) {
214
311
  function isArrayOf(value, guard) {
215
312
  return Array.isArray(value) && value.every(guard);
216
313
  }
314
+ function isOptional(value, guard) {
315
+ return value === void 0 || guard(value);
316
+ }
217
317
  function isString(value) {
218
318
  return typeof value === "string";
219
319
  }
@@ -226,6 +326,9 @@ function isNullableString(value) {
226
326
  function isNumber(value) {
227
327
  return typeof value === "number" && Number.isFinite(value);
228
328
  }
329
+ function isOptionalNumber(value) {
330
+ return value === void 0 || isNumber(value);
331
+ }
229
332
  function isNullableNumber(value) {
230
333
  return value === null || isNumber(value);
231
334
  }
@@ -237,14 +340,34 @@ function isOneOf(value, options) {
237
340
  }
238
341
 
239
342
  // src/shared/server-info.ts
343
+ async function readServerInfo() {
344
+ let raw;
345
+ try {
346
+ raw = await readFile(globalServerFile(), "utf8");
347
+ } catch (error) {
348
+ if (isFileNotFound(error)) {
349
+ return null;
350
+ }
351
+ throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
352
+ cause: error
353
+ });
354
+ }
355
+ try {
356
+ return parseJson(raw, isServerInfo, "server info");
357
+ } catch (error) {
358
+ throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
359
+ cause: error
360
+ });
361
+ }
362
+ }
240
363
  async function writeServerInfo(info) {
241
364
  await ensureDir(globalStateDir());
242
365
  await writeJsonFile(globalServerFile(), info);
243
366
  }
244
367
 
245
368
  // src/server/index.ts
246
- import { readFile as readFile3 } from "fs/promises";
247
- import path3 from "path";
369
+ import { readFile as readFile3, realpath, stat } from "fs/promises";
370
+ import path5 from "path";
248
371
  import { fileURLToPath } from "url";
249
372
  import { Hono } from "hono";
250
373
  import { streamSSE } from "hono/streaming";
@@ -276,17 +399,11 @@ function resolutionCounts(feedback, resolvedComments = []) {
276
399
  };
277
400
  }
278
401
 
279
- // src/shared/reviews.ts
280
- function isResolvableReviewStatus(status) {
281
- return status === "submitted" || status === "resolved";
282
- }
283
-
284
- // src/server/store.ts
285
- import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
286
- import { ulid } from "ulid";
402
+ // src/shared/git-diff.ts
403
+ import { execa } from "execa";
287
404
 
288
405
  // src/shared/language.ts
289
- import path2 from "path";
406
+ import path3 from "path";
290
407
  var languageByExtension = {
291
408
  cjs: "js",
292
409
  css: "css",
@@ -308,13 +425,240 @@ var languageByExtension = {
308
425
  yml: "yaml"
309
426
  };
310
427
  function languageForPath(filePath) {
311
- const ext = path2.extname(filePath).slice(1).toLowerCase();
428
+ const ext = path3.extname(filePath).slice(1).toLowerCase();
312
429
  if (!ext) {
313
430
  return null;
314
431
  }
315
432
  return languageByExtension[ext] ?? ext;
316
433
  }
317
434
 
435
+ // src/shared/diff-parser.ts
436
+ var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
437
+ function stripGitPath(input) {
438
+ return input.replace(/^[ab]\//, "");
439
+ }
440
+ function emptyFile() {
441
+ return {
442
+ path: "",
443
+ oldPath: null,
444
+ additions: 0,
445
+ deletions: 0,
446
+ isBinary: false,
447
+ isDeleted: false,
448
+ isNew: false,
449
+ isRenamed: false,
450
+ language: null,
451
+ hunks: []
452
+ };
453
+ }
454
+ function parseUnifiedDiff(diffText) {
455
+ const files = [];
456
+ let current = null;
457
+ let currentHunk = null;
458
+ let oldCursor = 0;
459
+ let newCursor = 0;
460
+ const finalizeFile = () => {
461
+ if (current?.path) {
462
+ current.language = languageForPath(current.path);
463
+ files.push(current);
464
+ }
465
+ };
466
+ for (const line of diffText.split("\n")) {
467
+ if (line.startsWith("diff --git ")) {
468
+ finalizeFile();
469
+ current = emptyFile();
470
+ currentHunk = null;
471
+ oldCursor = 0;
472
+ newCursor = 0;
473
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
474
+ if (match) {
475
+ current.oldPath = match[1];
476
+ current.path = match[2];
477
+ }
478
+ continue;
479
+ }
480
+ if (!current) {
481
+ continue;
482
+ }
483
+ if (line.startsWith("new file mode")) {
484
+ current.isNew = true;
485
+ continue;
486
+ }
487
+ if (line.startsWith("deleted file mode")) {
488
+ current.isDeleted = true;
489
+ continue;
490
+ }
491
+ if (line.startsWith("rename from ")) {
492
+ current.oldPath = line.slice("rename from ".length);
493
+ current.isRenamed = true;
494
+ continue;
495
+ }
496
+ if (line.startsWith("rename to ")) {
497
+ current.path = line.slice("rename to ".length);
498
+ current.isRenamed = true;
499
+ continue;
500
+ }
501
+ if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) {
502
+ current.isBinary = true;
503
+ continue;
504
+ }
505
+ if (line.startsWith("--- ")) {
506
+ const oldPath = line.slice(4).trim();
507
+ current.oldPath = oldPath === "/dev/null" ? null : stripGitPath(oldPath);
508
+ continue;
509
+ }
510
+ if (line.startsWith("+++ ")) {
511
+ const newPath = line.slice(4).trim();
512
+ current.path = newPath === "/dev/null" ? current.oldPath ?? current.path : stripGitPath(newPath);
513
+ continue;
514
+ }
515
+ const hunkMatch = hunkHeaderPattern.exec(line);
516
+ if (hunkMatch) {
517
+ const oldStart = Number(hunkMatch[1]);
518
+ const oldLines = Number(hunkMatch[2] ?? "1");
519
+ const newStart = Number(hunkMatch[3]);
520
+ const newLines = Number(hunkMatch[4] ?? "1");
521
+ currentHunk = {
522
+ oldStart,
523
+ oldLines,
524
+ newStart,
525
+ newLines,
526
+ header: hunkMatch[5]?.trim() ?? "",
527
+ lines: []
528
+ };
529
+ current.hunks.push(currentHunk);
530
+ oldCursor = oldStart;
531
+ newCursor = newStart;
532
+ continue;
533
+ }
534
+ if (!currentHunk) {
535
+ continue;
536
+ }
537
+ const marker = line[0];
538
+ const content = line.slice(1);
539
+ let diffLine = null;
540
+ if (marker === "+") {
541
+ diffLine = { type: "add", oldLine: null, newLine: newCursor, content };
542
+ current.additions += 1;
543
+ newCursor += 1;
544
+ } else if (marker === "-") {
545
+ diffLine = { type: "delete", oldLine: oldCursor, newLine: null, content };
546
+ current.deletions += 1;
547
+ oldCursor += 1;
548
+ } else if (marker === " ") {
549
+ diffLine = { type: "context", oldLine: oldCursor, newLine: newCursor, content };
550
+ oldCursor += 1;
551
+ newCursor += 1;
552
+ } else if (line.startsWith("\")) {
553
+ continue;
554
+ }
555
+ if (diffLine) {
556
+ currentHunk.lines.push(diffLine);
557
+ }
558
+ }
559
+ finalizeFile();
560
+ return files;
561
+ }
562
+
563
+ // src/shared/diff-stats.ts
564
+ function summarizeDiffFiles(files) {
565
+ return files.reduce(
566
+ (stats, file) => ({
567
+ files: stats.files + 1,
568
+ additions: stats.additions + file.additions,
569
+ deletions: stats.deletions + file.deletions
570
+ }),
571
+ { files: 0, additions: 0, deletions: 0 }
572
+ );
573
+ }
574
+
575
+ // src/shared/git-diff.ts
576
+ var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
577
+ async function git(args, cwd) {
578
+ const result = await execa("git", args, { cwd });
579
+ return result.stdout.trimEnd();
580
+ }
581
+ async function captureCommitRangeDiff(fromSha, toSha, repoRoot) {
582
+ const rawDiff = await git([...DIFF_ARGS, `${fromSha}^`, toSha, "--"], repoRoot);
583
+ const files = parseUnifiedDiff(rawDiff);
584
+ return {
585
+ stats: summarizeDiffFiles(files),
586
+ rawDiff,
587
+ files
588
+ };
589
+ }
590
+
591
+ // src/shared/reviews.ts
592
+ function isResolvableReviewStatus(status) {
593
+ return status === "submitted" || status === "resolved";
594
+ }
595
+
596
+ // src/server/local-open.ts
597
+ import open from "open";
598
+ async function openLocalPath(filePath) {
599
+ await open(filePath, { wait: false });
600
+ }
601
+
602
+ // src/server/store.ts
603
+ import { createHash } from "crypto";
604
+ import { readdir, readFile as readFile2 } from "fs/promises";
605
+ import path4 from "path";
606
+ import { ulid } from "ulid";
607
+
608
+ // src/shared/review-scope.ts
609
+ var ALL_REVIEW_SCOPE = { mode: "all" };
610
+ function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
611
+ if (scope.mode === "all") {
612
+ return ALL_REVIEW_SCOPE;
613
+ }
614
+ const commitDiffs = diff.commitDiffs ?? [];
615
+ if (commitDiffs.length === 0) {
616
+ throw new Error("Review scope requires a review with per-commit diffs");
617
+ }
618
+ if (scope.mode === "single") {
619
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
620
+ if (!commit) {
621
+ throw new Error("Review scope must use commits from this review");
622
+ }
623
+ return { mode: "single", sha: commit.commit.sha };
624
+ }
625
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
626
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
627
+ if (fromIndex < 0 || toIndex < 0) {
628
+ throw new Error("Review scope must use commits from this review");
629
+ }
630
+ if (fromIndex > toIndex) {
631
+ throw new Error("Review scope range must be in review order");
632
+ }
633
+ return {
634
+ mode: "range",
635
+ fromSha: commitDiffs[fromIndex].commit.sha,
636
+ toSha: commitDiffs[toIndex].commit.sha
637
+ };
638
+ }
639
+ function sameReviewScope(left, right) {
640
+ return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
641
+ }
642
+ function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
643
+ if (scope.mode === "all") {
644
+ return "All commits";
645
+ }
646
+ if (scope.mode === "single") {
647
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
648
+ return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
649
+ }
650
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
651
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
652
+ if (fromIndex >= 0 && toIndex >= fromIndex) {
653
+ const count = toIndex - fromIndex + 1;
654
+ return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
655
+ }
656
+ return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
657
+ }
658
+ function shortSha(sha) {
659
+ return sha.slice(0, 7);
660
+ }
661
+
318
662
  // src/shared/markdown.ts
319
663
  function fenceFor(snippet) {
320
664
  let fence = "```";
@@ -329,20 +673,32 @@ function languageForSnippet(filePath, snippet) {
329
673
  return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
330
674
  }
331
675
  function serializeFeedbackMarkdown(bundle) {
332
- const comments = [...bundle.comments].sort(compareCommentsByLocation);
333
- const files = [...new Set(comments.map((comment) => comment.filePath))];
676
+ const comments = bundle.comments.toSorted(compareCommentsByLocation);
677
+ const commentsByFile = /* @__PURE__ */ new Map();
678
+ const files = [];
679
+ for (const comment of comments) {
680
+ const fileComments = commentsByFile.get(comment.filePath);
681
+ if (fileComments) {
682
+ fileComments.push(comment);
683
+ } else {
684
+ commentsByFile.set(comment.filePath, [comment]);
685
+ files.push(comment.filePath);
686
+ }
687
+ }
334
688
  const lines = [
335
689
  `# Gloss feedback - ${bundle.timestamp}`,
336
690
  `Review: ${bundle.reviewId}`,
691
+ ...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
692
+ ...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
337
693
  `Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
338
694
  `Files: ${files.length} Comments: ${comments.length}`,
339
695
  ""
340
696
  ];
341
697
  for (const filePath of files) {
342
698
  lines.push(`## ${filePath}`, "");
343
- for (const comment of comments.filter((item) => item.filePath === filePath)) {
699
+ for (const comment of commentsByFile.get(filePath) ?? []) {
344
700
  const snippet = comment.originalSnippet.trimEnd();
345
- const firstSnippetLine = snippet.split("\n").find((line) => line.trim().length > 0);
701
+ const firstSnippetLine = firstNonEmptyLine(snippet);
346
702
  const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
347
703
  lines.push(heading, comment.body.trim(), "");
348
704
  if (snippet) {
@@ -354,6 +710,14 @@ function serializeFeedbackMarkdown(bundle) {
354
710
  return `${lines.join("\n").trimEnd()}
355
711
  `;
356
712
  }
713
+ function firstNonEmptyLine(text) {
714
+ for (const line of text.split("\n")) {
715
+ if (line.trim().length > 0) {
716
+ return line;
717
+ }
718
+ }
719
+ return void 0;
720
+ }
357
721
 
358
722
  // src/server/store.ts
359
723
  var ReviewStore = class {
@@ -362,6 +726,7 @@ var ReviewStore = class {
362
726
  async create(diff) {
363
727
  const id = ulid();
364
728
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
729
+ const turn = createTurn(id, 1, diff, createdAt);
365
730
  const meta = {
366
731
  id,
367
732
  cwd: diff.cwd,
@@ -369,108 +734,188 @@ var ReviewStore = class {
369
734
  branch: diff.branch,
370
735
  status: "pending",
371
736
  createdAt,
372
- artifactDir: globalReviewDir(id)
737
+ artifactDir: globalReviewDir(id),
738
+ activeTurnId: turn.id
373
739
  };
374
- const record = { meta, diff };
740
+ const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
375
741
  this.reviews.set(id, record);
376
- await this.persistInitial(record);
742
+ await this.persistInitial(record, turn);
377
743
  this.emit({ type: "review.opened", reviewId: id });
378
744
  return record;
379
745
  }
746
+ async appendTurn(id, diff) {
747
+ const record = await this.get(id);
748
+ if (!record) {
749
+ throw new Error(`Review ${id} not found`);
750
+ }
751
+ if (record.meta.cwd !== diff.cwd) {
752
+ throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
753
+ }
754
+ const latest = latestTurn(record);
755
+ if (latest.status === "pending") {
756
+ if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
757
+ this.emit({
758
+ type: "review.turn.created",
759
+ reviewId: id,
760
+ turnId: latest.id,
761
+ turnIndex: latest.index,
762
+ reused: true
763
+ });
764
+ return { record, turn: latest, reused: true };
765
+ }
766
+ throw new Error(`Review ${id} already has a pending turn`);
767
+ }
768
+ if (latest.status === "cancelled") {
769
+ throw new Error(`Review ${id} is cancelled and cannot be continued`);
770
+ }
771
+ const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
772
+ const nextRecord = normalizeRecord({
773
+ ...record,
774
+ meta: { ...record.meta, activeTurnId: turn.id },
775
+ turns: [...record.turns, turn]
776
+ });
777
+ this.reviews.set(id, nextRecord);
778
+ await this.persistInitial(nextRecord, turn);
779
+ this.emit({
780
+ type: "review.turn.created",
781
+ reviewId: id,
782
+ turnId: turn.id,
783
+ turnIndex: turn.index,
784
+ reused: false
785
+ });
786
+ return { record: nextRecord, turn, reused: false };
787
+ }
380
788
  async list() {
381
789
  await this.loadAllReviews();
382
- return [...this.reviews.values()].map((record) => record.meta).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
790
+ return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
383
791
  }
384
792
  async get(id) {
385
793
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
386
794
  }
387
- async submit(id, comments) {
795
+ async getTurn(id, turnId) {
796
+ const record = await this.get(id);
797
+ return record?.turns.find((turn) => turn.id === turnId) ?? null;
798
+ }
799
+ async submit(id, comments, reviewScope) {
388
800
  const record = await this.get(id);
389
801
  if (!record) {
390
802
  throw new Error(`Review ${id} not found`);
391
803
  }
392
- if (record.meta.status !== "pending") {
393
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
804
+ const turn = activeTurn(record);
805
+ const sortedComments = comments.toSorted(compareCommentsByLocation);
806
+ const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
807
+ if (turn.status !== "pending") {
808
+ if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
809
+ return {
810
+ record,
811
+ feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
812
+ markdownPath: requiredPath(turn.markdownPath, "markdown path"),
813
+ turn
814
+ };
815
+ }
816
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
394
817
  }
395
818
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
819
+ const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
820
+ const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
396
821
  const feedback = {
397
822
  version: 1,
398
823
  reviewId: id,
824
+ turnId: turn.id,
825
+ turnIndex: turn.index,
399
826
  timestamp,
400
- base: record.diff.base,
401
- branch: record.diff.branch,
402
- comments: [...comments].sort(compareCommentsByLocation)
827
+ base: turn.diff.base,
828
+ branch: turn.diff.branch,
829
+ reviewScope: normalizedReviewScope,
830
+ comments: sortedComments
403
831
  };
404
- record.feedback = feedback;
405
- record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
406
- this.reviews.set(id, record);
407
- const artifactDir = globalReviewDir(id);
408
- const feedbackPath = globalReviewFeedbackFile(id);
409
- const markdownPath = globalReviewMarkdownFile(id);
410
- record.meta = {
411
- ...record.meta,
412
- artifactDir,
832
+ const nextTurn = {
833
+ ...turn,
834
+ status: "submitted",
835
+ submittedAt: timestamp,
413
836
  feedbackPath,
414
- markdownPath
837
+ markdownPath,
838
+ feedback
415
839
  };
416
- await ensureDir(artifactDir);
840
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
841
+ this.reviews.set(id, nextRecord);
842
+ await ensureDir(globalReviewTurnDir(id, nextTurn.id));
417
843
  await Promise.all([
418
- writeJsonFile(globalReviewMetaFile(id), record.meta),
844
+ writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
419
845
  writeJsonFile(feedbackPath, feedback),
420
- writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
846
+ writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
421
847
  ]);
848
+ await this.persistMeta(nextRecord);
422
849
  this.emit({
423
850
  type: "review.submitted",
424
851
  reviewId: id,
852
+ turnId: nextTurn.id,
853
+ turnIndex: nextTurn.index,
425
854
  counts: {
426
855
  files: countCommentFiles(feedback.comments),
427
856
  comments: feedback.comments.length
428
857
  }
429
858
  });
430
- return { record, feedbackPath, markdownPath };
859
+ return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
431
860
  }
432
861
  async feedback(id) {
433
862
  const record = await this.get(id);
434
863
  return record?.feedback ?? null;
435
864
  }
436
- async markResolved(id, summary) {
865
+ async markResolved(id, summary, turnSelector) {
437
866
  const record = await this.get(id);
438
867
  if (!record) {
439
868
  throw new Error(`Review ${id} not found`);
440
869
  }
441
- this.assertResolvable(record, id);
870
+ const turn = this.resolveTurnSelector(record, turnSelector);
871
+ this.assertResolvable(turn, id);
442
872
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
443
873
  const existingById = new Map(
444
- (record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
874
+ (turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
445
875
  );
446
876
  const comments = this.sortResolvedComments(
447
- (record.feedback?.comments ?? []).map((comment) => ({
877
+ (turn.feedback?.comments ?? []).map((comment) => ({
448
878
  ...existingById.get(comment.id),
449
879
  commentId: comment.id,
450
880
  status: "resolved",
451
881
  resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
452
882
  })),
453
- record
883
+ turn
454
884
  );
455
885
  const resolution = {
456
886
  reviewId: id,
887
+ turnId: turn.id,
888
+ turnIndex: turn.index,
457
889
  status: "resolved",
458
- summary: summary ?? record.resolution?.summary ?? null,
890
+ summary: summary ?? turn.resolution?.summary ?? null,
459
891
  resolvedAt,
460
892
  comments
461
893
  };
462
- record.meta = { ...record.meta, status: "resolved", resolvedAt };
463
- return this.persistResolution(record, resolution, "review-resolved");
894
+ const nextTurn = {
895
+ ...turn,
896
+ status: "resolved",
897
+ resolvedAt
898
+ };
899
+ return this.persistResolution(record, nextTurn, resolution, "review-resolved");
464
900
  }
465
901
  async resolveComment(id, commentId, summary) {
466
902
  const record = await this.get(id);
467
903
  if (!record) {
468
904
  throw new Error(`Review ${id} not found`);
469
905
  }
470
- this.assertResolvable(record, id);
471
- this.assertCommentExists(record, commentId);
906
+ const turn = this.findTurnForComment(record, commentId);
907
+ if (!turn) {
908
+ const currentTurn = activeTurn(record);
909
+ if (!isResolvableReviewStatus(currentTurn.status)) {
910
+ throw new Error(
911
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
912
+ );
913
+ }
914
+ throw new Error(`Comment ${commentId} not found`);
915
+ }
916
+ this.assertResolvable(turn, id);
472
917
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
473
- const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
918
+ const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
474
919
  const nextSummary = summary ?? previous?.summary;
475
920
  const nextComment = {
476
921
  commentId,
@@ -480,46 +925,59 @@ var ReviewStore = class {
480
925
  };
481
926
  const comments = this.sortResolvedComments(
482
927
  [
483
- ...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
928
+ ...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
484
929
  nextComment
485
930
  ],
486
- record
931
+ turn
487
932
  );
488
- const counts = resolutionCounts(record.feedback, comments);
933
+ const counts = resolutionCounts(turn.feedback, comments);
489
934
  const fullyResolved = counts.total === counts.resolved;
490
935
  const resolution = {
491
936
  reviewId: id,
937
+ turnId: turn.id,
938
+ turnIndex: turn.index,
492
939
  status: fullyResolved ? "resolved" : "partial",
493
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
940
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
494
941
  resolvedAt: fullyResolved ? resolvedAt : null,
495
942
  comments
496
943
  };
497
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
498
- return this.persistResolution(record, resolution, "comment-resolved");
944
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
945
+ return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
499
946
  }
500
947
  async reopenComment(id, commentId) {
501
948
  const record = await this.get(id);
502
949
  if (!record) {
503
950
  throw new Error(`Review ${id} not found`);
504
951
  }
505
- this.assertResolvable(record, id);
506
- this.assertCommentExists(record, commentId);
952
+ const turn = this.findTurnForComment(record, commentId);
953
+ if (!turn) {
954
+ const currentTurn = activeTurn(record);
955
+ if (!isResolvableReviewStatus(currentTurn.status)) {
956
+ throw new Error(
957
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
958
+ );
959
+ }
960
+ throw new Error(`Comment ${commentId} not found`);
961
+ }
962
+ this.assertResolvable(turn, id);
507
963
  const comments = this.sortResolvedComments(
508
- (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
509
- record
964
+ (turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
965
+ turn
510
966
  );
511
- const counts = resolutionCounts(record.feedback, comments);
967
+ const counts = resolutionCounts(turn.feedback, comments);
512
968
  const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
513
969
  const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
514
970
  const resolution = {
515
971
  reviewId: id,
972
+ turnId: turn.id,
973
+ turnIndex: turn.index,
516
974
  status: fullyResolved ? "resolved" : "partial",
517
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
975
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
518
976
  resolvedAt,
519
977
  comments
520
978
  };
521
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
522
- return this.persistResolution(record, resolution, "comment-reopened");
979
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
980
+ return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
523
981
  }
524
982
  subscribe(reviewId, listener) {
525
983
  const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
@@ -537,13 +995,17 @@ var ReviewStore = class {
537
995
  listener(event);
538
996
  }
539
997
  }
540
- async persistInitial(record) {
541
- const dir = globalReviewDir(record.meta.id);
542
- await ensureDir(dir);
998
+ async persistInitial(record, turn) {
999
+ await ensureDir(turn.artifactDir);
543
1000
  await Promise.all([
544
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
545
- writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
1001
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
1002
+ writeJsonFile(turn.diffPath, turn.diff)
546
1003
  ]);
1004
+ await this.persistMeta(record);
1005
+ }
1006
+ async persistMeta(record) {
1007
+ await ensureDir(globalReviewDir(record.meta.id));
1008
+ await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
547
1009
  }
548
1010
  async loadKnownReview(id) {
549
1011
  const existing = this.reviews.get(id);
@@ -567,13 +1029,96 @@ var ReviewStore = class {
567
1029
  }
568
1030
  );
569
1031
  }
570
- await Promise.all(
571
- entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
572
- );
1032
+ const reviewLoads = [];
1033
+ for (const entry of entries) {
1034
+ if (entry.isDirectory()) {
1035
+ reviewLoads.push(this.loadReview(entry.name));
1036
+ }
1037
+ }
1038
+ await Promise.all(reviewLoads);
573
1039
  }
574
1040
  async loadReview(id) {
575
1041
  const metaPath = globalReviewMetaFile(id);
576
- const diffPath = globalReviewDiffFile(id);
1042
+ let metaRaw;
1043
+ try {
1044
+ metaRaw = await readFile2(metaPath, "utf8");
1045
+ } catch (error) {
1046
+ if (isFileNotFound(error)) {
1047
+ return this.loadReviewFromTurnsOnly(id);
1048
+ }
1049
+ throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1050
+ }
1051
+ const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1052
+ const persistedTurns = await this.loadPersistedTurns(id);
1053
+ const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
1054
+ const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
1055
+ if (turns.length === 0) {
1056
+ throw new Error(`Review ${id} has no recoverable turns`);
1057
+ }
1058
+ const latest = latestTurn({ turns });
1059
+ const record = normalizeRecord({
1060
+ meta: {
1061
+ ...storedMeta,
1062
+ artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
1063
+ activeTurnId: latest.id
1064
+ },
1065
+ turns,
1066
+ diff: latest.diff
1067
+ });
1068
+ this.reviews.set(id, record);
1069
+ return record;
1070
+ }
1071
+ async loadReviewFromTurnsOnly(id) {
1072
+ const turns = await this.loadPersistedTurns(id);
1073
+ if (turns.length === 0) {
1074
+ return null;
1075
+ }
1076
+ const latest = latestTurn({ turns });
1077
+ const record = normalizeRecord({
1078
+ meta: {
1079
+ id,
1080
+ cwd: latest.diff.cwd,
1081
+ base: latest.diff.base,
1082
+ branch: latest.diff.branch,
1083
+ status: latest.status,
1084
+ createdAt: turns[0]?.createdAt ?? latest.createdAt,
1085
+ artifactDir: globalReviewDir(id),
1086
+ activeTurnId: latest.id
1087
+ },
1088
+ turns,
1089
+ diff: latest.diff
1090
+ });
1091
+ this.reviews.set(id, record);
1092
+ await this.persistMeta(record);
1093
+ return record;
1094
+ }
1095
+ async loadPersistedTurns(id) {
1096
+ let entries;
1097
+ try {
1098
+ entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
1099
+ } catch (error) {
1100
+ if (isFileNotFound(error)) {
1101
+ return [];
1102
+ }
1103
+ throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
1104
+ cause: error
1105
+ });
1106
+ }
1107
+ const turns = [];
1108
+ for (const entry of entries) {
1109
+ if (!entry.isDirectory()) {
1110
+ continue;
1111
+ }
1112
+ const turn = await this.loadPersistedTurn(id, entry.name);
1113
+ if (turn) {
1114
+ turns.push(turn);
1115
+ }
1116
+ }
1117
+ return turns.toSorted((a, b) => a.index - b.index);
1118
+ }
1119
+ async loadPersistedTurn(id, turnId) {
1120
+ const metaPath = globalReviewTurnMetaFile(id, turnId);
1121
+ const diffPath = globalReviewTurnDiffFile(id, turnId);
577
1122
  let metaRaw;
578
1123
  let diffRaw;
579
1124
  try {
@@ -585,68 +1130,116 @@ var ReviewStore = class {
585
1130
  if (isFileNotFound(error)) {
586
1131
  return null;
587
1132
  }
588
- throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1133
+ throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
1134
+ cause: error
1135
+ });
1136
+ }
1137
+ const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
1138
+ const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
1139
+ const [feedback, resolution] = await Promise.all([
1140
+ readOptionalJsonFile(
1141
+ globalReviewTurnFeedbackFile(id, turnId),
1142
+ isFeedbackBundle,
1143
+ "review feedback"
1144
+ ),
1145
+ readOptionalJsonFile(
1146
+ globalReviewTurnResolvedFile(id, turnId),
1147
+ isResolutionBundle,
1148
+ "review resolution"
1149
+ )
1150
+ ]);
1151
+ return reconcileTurn(meta, diff, feedback, resolution);
1152
+ }
1153
+ async loadLegacyTurn(id, storedMeta) {
1154
+ const diffPath = globalReviewDiffFile(id);
1155
+ let diffRaw;
1156
+ try {
1157
+ diffRaw = await readFile2(diffPath, "utf8");
1158
+ } catch (error) {
1159
+ if (isFileNotFound(error)) {
1160
+ return null;
1161
+ }
1162
+ throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
1163
+ cause: error
1164
+ });
589
1165
  }
590
- const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
591
1166
  const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
592
- const feedback = await readOptionalJsonFile(
593
- globalReviewFeedbackFile(id),
594
- isFeedbackBundle,
595
- "review feedback"
596
- );
597
- const resolution = await readOptionalJsonFile(
598
- globalReviewResolvedFile(id),
599
- isResolutionBundle,
600
- "review resolution"
601
- );
602
- const record = {
603
- meta: {
604
- ...meta,
605
- artifactDir: meta.artifactDir ?? globalReviewDir(id),
606
- feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
607
- markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
608
- },
609
- diff,
610
- feedback,
611
- resolution
1167
+ const [feedback, resolution] = await Promise.all([
1168
+ readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
1169
+ readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
1170
+ ]);
1171
+ const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
1172
+ const legacySummary = storedMeta.turns?.find(
1173
+ (turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
1174
+ ) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
1175
+ const meta = {
1176
+ id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
1177
+ index: legacySummary?.index ?? 1,
1178
+ status: legacySummary?.status ?? storedMeta.status,
1179
+ createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
1180
+ submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
1181
+ resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
1182
+ artifactDir: legacySummary?.artifactDir ?? artifactDir,
1183
+ diffPath,
1184
+ ...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
1185
+ ...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
612
1186
  };
613
- this.reviews.set(id, record);
614
- return record;
1187
+ return reconcileTurn(meta, diff, feedback, resolution);
615
1188
  }
616
- assertResolvable(record, id) {
617
- if (!isResolvableReviewStatus(record.meta.status)) {
618
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
1189
+ assertResolvable(turn, id) {
1190
+ if (!isResolvableReviewStatus(turn.status)) {
1191
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
619
1192
  }
620
- if (!record.feedback) {
621
- throw new Error(`Review ${id} has no submitted feedback`);
1193
+ if (!turn.feedback) {
1194
+ throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
622
1195
  }
623
1196
  }
624
- assertCommentExists(record, commentId) {
625
- if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
626
- throw new Error(`Comment ${commentId} not found`);
1197
+ resolveTurnSelector(record, selector) {
1198
+ if (!selector) {
1199
+ return activeTurn(record);
627
1200
  }
1201
+ const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
1202
+ if (!turn) {
1203
+ throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
1204
+ }
1205
+ return turn;
628
1206
  }
629
- async persistResolution(record, resolution, reason) {
630
- record.resolution = resolution;
631
- this.reviews.set(record.meta.id, record);
632
- const resolvedPath = globalReviewResolvedFile(record.meta.id);
633
- await ensureDir(globalReviewDir(record.meta.id));
1207
+ findTurnForComment(record, commentId) {
1208
+ return [...record.turns].reverse().find(
1209
+ (candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
1210
+ ) ?? null;
1211
+ }
1212
+ async persistResolution(record, turn, resolution, reason) {
1213
+ const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
1214
+ const nextTurn = {
1215
+ ...turn,
1216
+ resolvedPath,
1217
+ resolution
1218
+ };
1219
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1220
+ this.reviews.set(record.meta.id, nextRecord);
1221
+ await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
634
1222
  await Promise.all([
635
1223
  writeJsonFile(resolvedPath, resolution),
636
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
1224
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
637
1225
  ]);
1226
+ await this.persistMeta(nextRecord);
638
1227
  const result = {
639
1228
  ok: true,
640
1229
  reviewId: record.meta.id,
641
- status: record.meta.status,
1230
+ turnId: nextTurn.id,
1231
+ turnIndex: nextTurn.index,
1232
+ status: nextTurn.status,
642
1233
  resolutionStatus: resolution.status,
643
- comments: resolutionCounts(record.feedback, resolution.comments),
1234
+ comments: resolutionCounts(nextTurn.feedback, resolution.comments),
644
1235
  path: resolvedPath,
645
1236
  resolution
646
1237
  };
647
1238
  this.emit({
648
1239
  type: "review.updated",
649
1240
  reviewId: record.meta.id,
1241
+ turnId: nextTurn.id,
1242
+ turnIndex: nextTurn.index,
650
1243
  reason,
651
1244
  status: result.status,
652
1245
  resolutionStatus: result.resolutionStatus,
@@ -654,15 +1247,125 @@ var ReviewStore = class {
654
1247
  });
655
1248
  return result;
656
1249
  }
657
- sortResolvedComments(comments, record) {
1250
+ sortResolvedComments(comments, turn) {
658
1251
  const feedbackIndex = new Map(
659
- record.feedback.comments.map((comment, index) => [comment.id, index])
660
- );
661
- return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
662
- (a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
1252
+ turn.feedback.comments.map((comment, index) => [comment.id, index])
663
1253
  );
1254
+ return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
1255
+ (entry) => entry.index !== void 0
1256
+ ).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
664
1257
  }
665
1258
  };
1259
+ function createTurn(reviewId, index, diff, createdAt) {
1260
+ const id = ulid();
1261
+ return {
1262
+ id,
1263
+ index,
1264
+ status: "pending",
1265
+ createdAt,
1266
+ artifactDir: globalReviewTurnDir(reviewId, id),
1267
+ diffPath: globalReviewTurnDiffFile(reviewId, id),
1268
+ diff
1269
+ };
1270
+ }
1271
+ function normalizeRecord(record) {
1272
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
1273
+ const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
1274
+ const meta = {
1275
+ ...record.meta,
1276
+ base: active.diff.base,
1277
+ branch: active.diff.branch,
1278
+ status: active.status,
1279
+ submittedAt: active.submittedAt,
1280
+ resolvedAt: active.resolvedAt,
1281
+ artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
1282
+ activeTurnId: active.id,
1283
+ turns: turns.map(turnSummary),
1284
+ feedbackPath: active.feedbackPath,
1285
+ markdownPath: active.markdownPath
1286
+ };
1287
+ return {
1288
+ meta,
1289
+ turns,
1290
+ diff: active.diff,
1291
+ ...active.feedback ? { feedback: active.feedback } : {},
1292
+ ...active.resolution ? { resolution: active.resolution } : {}
1293
+ };
1294
+ }
1295
+ function replaceTurn(record, nextTurn) {
1296
+ return {
1297
+ ...record,
1298
+ turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
1299
+ };
1300
+ }
1301
+ function activeTurn(record) {
1302
+ return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
1303
+ }
1304
+ function latestTurn(record) {
1305
+ return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
1306
+ }
1307
+ function turnMeta(turn) {
1308
+ return {
1309
+ id: turn.id,
1310
+ index: turn.index,
1311
+ status: turn.status,
1312
+ createdAt: turn.createdAt,
1313
+ submittedAt: turn.submittedAt,
1314
+ resolvedAt: turn.resolvedAt,
1315
+ artifactDir: turn.artifactDir,
1316
+ diffPath: turn.diffPath,
1317
+ feedbackPath: turn.feedbackPath,
1318
+ markdownPath: turn.markdownPath,
1319
+ resolvedPath: turn.resolvedPath
1320
+ };
1321
+ }
1322
+ function turnSummary(turn) {
1323
+ return {
1324
+ ...turnMeta(turn),
1325
+ capturedAt: turn.diff.capturedAt,
1326
+ stats: turn.diff.stats,
1327
+ comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
1328
+ };
1329
+ }
1330
+ function reconcileTurn(meta, diff, feedback, resolution) {
1331
+ const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
1332
+ return {
1333
+ ...meta,
1334
+ status,
1335
+ submittedAt: feedback?.timestamp ?? meta.submittedAt,
1336
+ resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1337
+ feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
1338
+ markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
1339
+ resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
1340
+ diff,
1341
+ ...feedback ? { feedback } : {},
1342
+ ...resolution ? { resolution } : {}
1343
+ };
1344
+ }
1345
+ function mergeRecoveredTurns(legacyTurn, persistedTurns) {
1346
+ const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
1347
+ return turns.toSorted((a, b) => a.index - b.index);
1348
+ }
1349
+ function diffFingerprint(diff) {
1350
+ return createHash("sha256").update(
1351
+ JSON.stringify({
1352
+ base: diff.base,
1353
+ branch: diff.branch,
1354
+ cwd: diff.cwd,
1355
+ scope: diff.scope,
1356
+ rawDiff: diff.rawDiff
1357
+ })
1358
+ ).digest("hex");
1359
+ }
1360
+ function sameComments(left, right) {
1361
+ return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
1362
+ }
1363
+ function requiredPath(value, label) {
1364
+ if (!value) {
1365
+ throw new Error(`Submitted review is missing ${label}`);
1366
+ }
1367
+ return value;
1368
+ }
666
1369
  async function readOptionalJsonFile(filePath, guard, label) {
667
1370
  let raw;
668
1371
  try {
@@ -684,12 +1387,6 @@ function parseJsonFile(raw, guard, label, filePath) {
684
1387
  throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
685
1388
  }
686
1389
  }
687
- function isFileNotFound(error) {
688
- return error instanceof Error && "code" in error && error.code === "ENOENT";
689
- }
690
- function formatError(error) {
691
- return error instanceof Error ? error.message : String(error);
692
- }
693
1390
  var reviewStore = new ReviewStore();
694
1391
 
695
1392
  // src/server/index.ts
@@ -705,7 +1402,7 @@ var mimeTypes = {
705
1402
  ".sh": "text/x-shellscript; charset=utf-8",
706
1403
  ".svg": "image/svg+xml"
707
1404
  };
708
- function createApp(origin2) {
1405
+ function createApp(origin2, options = {}) {
709
1406
  const app = new Hono();
710
1407
  app.get("/api/health", async (c) => {
711
1408
  const reviews = await reviewStore.list();
@@ -729,10 +1426,36 @@ function createApp(origin2) {
729
1426
  const record = await reviewStore.create(diff);
730
1427
  const response = {
731
1428
  meta: record.meta,
1429
+ turn: activeTurnSummary(record.meta),
732
1430
  url: `${origin2}/review/${record.meta.id}`
733
1431
  };
1432
+ options.onReviewActivity?.();
734
1433
  return c.json(response, 201);
735
1434
  });
1435
+ app.post("/api/reviews/:id/turns", async (c) => {
1436
+ const id = c.req.param("id");
1437
+ const existing = await reviewStore.get(id);
1438
+ if (!existing) {
1439
+ return c.json({ error: "review not found" }, 404);
1440
+ }
1441
+ const parsed = await readJsonBody(c, isDiffPayload, "review diff");
1442
+ if (!parsed.ok) {
1443
+ return parsed.response;
1444
+ }
1445
+ try {
1446
+ const { record, turn, reused } = await reviewStore.appendTurn(id, parsed.body);
1447
+ const response = {
1448
+ meta: record.meta,
1449
+ turn: turnSummary2(record.meta, turn.id),
1450
+ url: `${origin2}/review/${id}`,
1451
+ reused
1452
+ };
1453
+ options.onReviewActivity?.();
1454
+ return c.json(response);
1455
+ } catch (error) {
1456
+ return c.json({ error: formatError(error) }, 409);
1457
+ }
1458
+ });
736
1459
  app.get("/api/reviews/:id", async (c) => {
737
1460
  const record = await reviewStore.get(c.req.param("id"));
738
1461
  if (!record) {
@@ -740,6 +1463,13 @@ function createApp(origin2) {
740
1463
  }
741
1464
  return c.json(record);
742
1465
  });
1466
+ app.get("/api/reviews/:id/turns/:turnId", async (c) => {
1467
+ const turn = await reviewStore.getTurn(c.req.param("id"), c.req.param("turnId"));
1468
+ if (!turn) {
1469
+ return c.json({ error: "turn not found" }, 404);
1470
+ }
1471
+ return c.json(turn);
1472
+ });
743
1473
  app.get("/api/reviews/:id/feedback", async (c) => {
744
1474
  const feedback = await reviewStore.feedback(c.req.param("id"));
745
1475
  if (!feedback) {
@@ -758,6 +1488,7 @@ function createApp(origin2) {
758
1488
  let pending = Promise.resolve();
759
1489
  let cleanup = null;
760
1490
  let close = null;
1491
+ let unregisterEventStream = null;
761
1492
  const closedPromise = new Promise((resolve) => {
762
1493
  close = () => {
763
1494
  if (closed) {
@@ -768,6 +1499,7 @@ function createApp(origin2) {
768
1499
  resolve();
769
1500
  };
770
1501
  });
1502
+ unregisterEventStream = options.registerEventStream?.(() => close?.()) ?? null;
771
1503
  const send = (event) => {
772
1504
  pending = pending.then(() => stream.writeSSE({ data: JSON.stringify(event) })).then(() => {
773
1505
  if (event.type === "review.cancelled") {
@@ -788,6 +1520,8 @@ function createApp(origin2) {
788
1520
  cleanup = () => {
789
1521
  clearInterval(heartbeat);
790
1522
  unsubscribe();
1523
+ unregisterEventStream?.();
1524
+ unregisterEventStream = null;
791
1525
  };
792
1526
  stream.onAbort(() => close?.());
793
1527
  send({ type: "review.opened", reviewId: id });
@@ -795,6 +1529,8 @@ function createApp(origin2) {
795
1529
  send({
796
1530
  type: "review.submitted",
797
1531
  reviewId: id,
1532
+ turnId: record.meta.activeTurnId,
1533
+ turnIndex: record.meta.turns?.find((turn) => turn.id === record.meta.activeTurnId)?.index,
798
1534
  counts: {
799
1535
  files: countCommentFiles(record.feedback.comments),
800
1536
  comments: record.feedback.comments.length
@@ -810,44 +1546,151 @@ function createApp(origin2) {
810
1546
  if (!existing) {
811
1547
  return c.json({ error: "review not found" }, 404);
812
1548
  }
813
- if (existing.meta.status !== "pending") {
814
- return c.json({ error: `review is ${existing.meta.status} and cannot be submitted` }, 409);
815
- }
816
1549
  const parsed = await readJsonBody(c, isSubmitReviewRequest, "submit review request");
817
1550
  if (!parsed.ok) {
818
1551
  return parsed.response;
819
1552
  }
820
1553
  const body = parsed.body;
821
- const { record, feedbackPath, markdownPath } = await reviewStore.submit(id, body.comments);
1554
+ let submitted;
1555
+ try {
1556
+ submitted = await reviewStore.submit(id, body.comments, body.reviewScope);
1557
+ } catch (error) {
1558
+ return c.json({ error: formatError(error) }, 409);
1559
+ }
1560
+ const { feedbackPath, markdownPath, turn } = submitted;
822
1561
  const response = {
823
1562
  reviewId: id,
1563
+ turnId: turn.id,
1564
+ turnIndex: turn.index,
824
1565
  url: `${origin2}/review/${id}`,
825
- files: record.diff.files.length,
1566
+ files: turn.diff.files.length,
826
1567
  comments: body.comments.length,
827
- artifactDir: record.meta.artifactDir,
1568
+ artifactDir: turn.artifactDir,
828
1569
  feedbackPath,
829
1570
  markdownPath
830
1571
  };
1572
+ options.onReviewActivity?.();
831
1573
  return c.json(response);
832
1574
  });
833
- app.post("/api/reviews/:id/resolved", async (c) => {
1575
+ app.post("/api/reviews/:id/commits/range", async (c) => {
834
1576
  const id = c.req.param("id");
835
1577
  const existing = await reviewStore.get(id);
836
1578
  if (!existing) {
837
1579
  return c.json({ error: "review not found" }, 404);
838
1580
  }
839
- if (!isResolvableReviewStatus(existing.meta.status)) {
840
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
1581
+ const parsed = await readJsonBody(c, isCommitRangeDiffRequest, "commit range diff request");
1582
+ if (!parsed.ok) {
1583
+ return parsed.response;
1584
+ }
1585
+ const requestedTurnId = parsed.body.turnId;
1586
+ const turn = requestedTurnId ? await reviewStore.getTurn(id, requestedTurnId) : null;
1587
+ if (requestedTurnId && !turn) {
1588
+ return c.json({ error: "turn not found" }, 404);
1589
+ }
1590
+ const diffPayload = turn?.diff ?? existing.diff;
1591
+ const commitDiffs = diffPayload.commitDiffs ?? [];
1592
+ if (commitDiffs.length === 0) {
1593
+ return c.json({ error: "commit ranges are only available for branch reviews" }, 409);
1594
+ }
1595
+ const { fromSha, toSha } = parsed.body;
1596
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === fromSha);
1597
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === toSha);
1598
+ if (fromIndex < 0 || toIndex < 0) {
1599
+ return c.json({ error: "commit range must use commits from this review" }, 404);
1600
+ }
1601
+ if (fromIndex > toIndex) {
1602
+ return c.json({ error: "fromSha must come before or match toSha" }, 400);
841
1603
  }
842
- if (!existing.feedback) {
843
- return c.json({ error: "submitted feedback not found" }, 409);
1604
+ const diff = fromSha === toSha ? commitDiffs[fromIndex] : await captureCommitRangeDiff(fromSha, toSha, diffPayload.cwd);
1605
+ const response = {
1606
+ fromSha,
1607
+ toSha,
1608
+ stats: diff.stats,
1609
+ rawDiff: diff.rawDiff,
1610
+ files: diff.files
1611
+ };
1612
+ return c.json(response);
1613
+ });
1614
+ app.post("/api/reviews/:id/files/open", async (c) => {
1615
+ const id = c.req.param("id");
1616
+ const existing = await reviewStore.get(id);
1617
+ if (!existing) {
1618
+ return c.json({ error: "review not found" }, 404);
1619
+ }
1620
+ const parsed = await readJsonBody(c, isOpenFileRequest, "open file request");
1621
+ if (!parsed.ok) {
1622
+ return parsed.response;
1623
+ }
1624
+ const { filePath, turnId } = parsed.body;
1625
+ if (!filePath || filePath.includes("\0") || path5.isAbsolute(filePath)) {
1626
+ return c.json({ error: "filePath must be a repo-relative path" }, 400);
1627
+ }
1628
+ const repoRoot = path5.resolve(existing.diff.cwd);
1629
+ const requestedAbsolutePath = path5.resolve(repoRoot, filePath);
1630
+ if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
1631
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
1632
+ }
1633
+ const turn = turnId ? await reviewStore.getTurn(id, turnId) : null;
1634
+ if (turnId && !turn) {
1635
+ return c.json({ error: "turn not found" }, 404);
1636
+ }
1637
+ const diffPayload = turn?.diff ?? existing.diff;
1638
+ const reviewFiles = [
1639
+ ...diffPayload.files,
1640
+ ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
1641
+ ].filter((file) => file.path === filePath);
1642
+ if (reviewFiles.length === 0) {
1643
+ return c.json({ error: "file is not part of this review" }, 404);
1644
+ }
1645
+ if (reviewFiles.every((file) => file.isDeleted)) {
1646
+ return c.json({ error: "deleted files cannot be opened locally" }, 409);
1647
+ }
1648
+ let realRepoRoot;
1649
+ let realFilePath;
1650
+ try {
1651
+ [realRepoRoot, realFilePath] = await Promise.all([
1652
+ realpath(repoRoot),
1653
+ realpath(requestedAbsolutePath)
1654
+ ]);
1655
+ } catch (error) {
1656
+ if (isFileNotFound(error)) {
1657
+ return c.json({ error: "file no longer exists on disk" }, 404);
1658
+ }
1659
+ throw error;
1660
+ }
1661
+ if (!isPathWithin(realRepoRoot, realFilePath)) {
1662
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
1663
+ }
1664
+ const fileStats = await stat(realFilePath);
1665
+ if (!fileStats.isFile()) {
1666
+ return c.json({ error: "path is not a file" }, 409);
1667
+ }
1668
+ try {
1669
+ await openLocalPath(realFilePath);
1670
+ } catch (error) {
1671
+ return c.json({ error: `could not open file: ${formatError(error)}` }, 500);
1672
+ }
1673
+ const response = { ok: true, path: realFilePath };
1674
+ return c.json(response);
1675
+ });
1676
+ app.post("/api/reviews/:id/resolved", async (c) => {
1677
+ const id = c.req.param("id");
1678
+ const existing = await reviewStore.get(id);
1679
+ if (!existing) {
1680
+ return c.json({ error: "review not found" }, 404);
844
1681
  }
845
1682
  const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
846
1683
  if (!parsed.ok) {
847
1684
  return parsed.response;
848
1685
  }
849
1686
  const body = parsed.body;
850
- return c.json(await reviewStore.markResolved(id, body.summary));
1687
+ try {
1688
+ const result = await reviewStore.markResolved(id, body.summary, body.turn);
1689
+ options.onReviewActivity?.();
1690
+ return c.json(result);
1691
+ } catch (error) {
1692
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
1693
+ }
851
1694
  });
852
1695
  app.post("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
853
1696
  const id = c.req.param("id");
@@ -856,18 +1699,18 @@ function createApp(origin2) {
856
1699
  if (!existing) {
857
1700
  return c.json({ error: "review not found" }, 404);
858
1701
  }
859
- if (!isResolvableReviewStatus(existing.meta.status)) {
860
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
861
- }
862
- if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
863
- return c.json({ error: "comment not found" }, 404);
864
- }
865
1702
  const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
866
1703
  if (!parsed.ok) {
867
1704
  return parsed.response;
868
1705
  }
869
1706
  const body = parsed.body;
870
- return c.json(await reviewStore.resolveComment(id, commentId, body.summary));
1707
+ try {
1708
+ const result = await reviewStore.resolveComment(id, commentId, body.summary);
1709
+ options.onReviewActivity?.();
1710
+ return c.json(result);
1711
+ } catch (error) {
1712
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
1713
+ }
871
1714
  });
872
1715
  app.delete("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
873
1716
  const id = c.req.param("id");
@@ -876,13 +1719,13 @@ function createApp(origin2) {
876
1719
  if (!existing) {
877
1720
  return c.json({ error: "review not found" }, 404);
878
1721
  }
879
- if (!isResolvableReviewStatus(existing.meta.status)) {
880
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
881
- }
882
- if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
883
- return c.json({ error: "comment not found" }, 404);
1722
+ try {
1723
+ const result = await reviewStore.reopenComment(id, commentId);
1724
+ options.onReviewActivity?.();
1725
+ return c.json(result);
1726
+ } catch (error) {
1727
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
884
1728
  }
885
- return c.json(await reviewStore.reopenComment(id, commentId));
886
1729
  });
887
1730
  app.get("/logo.svg", serveRootFile("logo.svg", mimeTypes[".svg"]));
888
1731
  app.get("/logo-mark.svg", serveRootFile("logo-mark.svg", mimeTypes[".svg"]));
@@ -899,37 +1742,46 @@ function createApp(origin2) {
899
1742
  }
900
1743
  async function serveAsset(c) {
901
1744
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
902
- const normalized = path3.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
903
- const assetPath = path3.join(webRoot, "assets", normalized);
1745
+ const normalized = path5.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
1746
+ const assetPath = path5.join(webRoot, "assets", normalized);
904
1747
  try {
905
1748
  const body = await readFile3(assetPath);
906
1749
  return new Response(body, {
907
1750
  headers: {
908
- "content-type": mimeTypes[path3.extname(assetPath)] ?? "application/octet-stream"
1751
+ "content-type": mimeTypes[path5.extname(assetPath)] ?? "application/octet-stream"
909
1752
  }
910
1753
  });
911
- } catch {
1754
+ } catch (error) {
1755
+ if (!isFileNotFound(error)) {
1756
+ throw error;
1757
+ }
912
1758
  return new Response("Not found", { status: 404 });
913
1759
  }
914
1760
  }
915
1761
  async function serveIndex() {
916
1762
  try {
917
- const body = await readFile3(path3.join(webRoot, "index.html"));
1763
+ const body = await readFile3(path5.join(webRoot, "index.html"));
918
1764
  return new Response(body, {
919
1765
  headers: { "content-type": "text/html; charset=utf-8" }
920
1766
  });
921
- } catch {
1767
+ } catch (error) {
1768
+ if (!isFileNotFound(error)) {
1769
+ throw error;
1770
+ }
922
1771
  return new Response("Gloss web assets are missing. Run pnpm build.", { status: 500 });
923
1772
  }
924
1773
  }
925
1774
  function serveRootFile(fileName, contentType) {
926
1775
  return async () => {
927
1776
  try {
928
- const body = await readFile3(path3.join(webRoot, fileName));
1777
+ const body = await readFile3(path5.join(webRoot, fileName));
929
1778
  return new Response(body, {
930
1779
  headers: { "content-type": contentType }
931
1780
  });
932
- } catch {
1781
+ } catch (error) {
1782
+ if (!isFileNotFound(error)) {
1783
+ throw error;
1784
+ }
933
1785
  return new Response(`${fileName} is missing. Run pnpm build.`, { status: 404 });
934
1786
  }
935
1787
  };
@@ -941,7 +1793,7 @@ async function readJsonBody(c, guard, label) {
941
1793
  } catch (error) {
942
1794
  return {
943
1795
  ok: false,
944
- response: c.json({ error: `invalid JSON body: ${formatError2(error)}` }, 400)
1796
+ response: c.json({ error: `invalid JSON body: ${formatError(error)}` }, 400)
945
1797
  };
946
1798
  }
947
1799
  try {
@@ -949,22 +1801,53 @@ async function readJsonBody(c, guard, label) {
949
1801
  } catch (error) {
950
1802
  return {
951
1803
  ok: false,
952
- response: c.json({ error: formatError2(error) }, 400)
1804
+ response: c.json({ error: formatError(error) }, 400)
953
1805
  };
954
1806
  }
955
1807
  }
956
- function formatError2(error) {
957
- return error instanceof Error ? error.message : String(error);
1808
+ function isPathWithin(parentPath, childPath) {
1809
+ const relative = path5.relative(parentPath, childPath);
1810
+ return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
1811
+ }
1812
+ function activeTurnSummary(meta) {
1813
+ if (!meta.activeTurnId) {
1814
+ throw new Error(`Review ${meta.id} has no active turn`);
1815
+ }
1816
+ return turnSummary2(meta, meta.activeTurnId);
1817
+ }
1818
+ function turnSummary2(meta, turnId) {
1819
+ const summary = meta.turns?.find((turn) => turn.id === turnId);
1820
+ if (!summary) {
1821
+ throw new Error(`Review ${meta.id} is missing turn ${turnId}`);
1822
+ }
1823
+ return summary;
1824
+ }
1825
+ function statusForStoreError(error) {
1826
+ return /not found/i.test(formatError(error)) ? 404 : 409;
958
1827
  }
959
1828
 
960
1829
  // src/server/daemon.ts
961
1830
  var port = Number(process.env.GLOSS_PORT ?? "0");
1831
+ var idleTimeoutMs = Number(process.env.GLOSS_IDLE_TIMEOUT_MS ?? "120000");
962
1832
  if (!port) {
963
1833
  throw new Error("GLOSS_PORT is required");
964
1834
  }
965
1835
  var origin = `http://localhost:${port}`;
1836
+ var eventStreams = /* @__PURE__ */ new Set();
1837
+ var idleTimer = null;
1838
+ var shuttingDown = false;
966
1839
  var server = serve({
967
- fetch: createApp(origin).fetch,
1840
+ fetch: createApp(origin, {
1841
+ onReviewActivity: () => {
1842
+ void scheduleIdleShutdown();
1843
+ },
1844
+ registerEventStream: (close) => {
1845
+ eventStreams.add(close);
1846
+ return () => {
1847
+ eventStreams.delete(close);
1848
+ };
1849
+ }
1850
+ }).fetch,
968
1851
  port
969
1852
  });
970
1853
  await writeServerInfo({
@@ -974,9 +1857,64 @@ await writeServerInfo({
974
1857
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
975
1858
  stateDir: globalStateDir()
976
1859
  });
977
- process.on("SIGTERM", () => {
978
- server.close(() => {
979
- process.exit(0);
1860
+ await scheduleIdleShutdown();
1861
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
1862
+ process.on(signal, () => {
1863
+ void shutdown(0);
980
1864
  });
981
- });
1865
+ }
1866
+ async function scheduleIdleShutdown() {
1867
+ if (shuttingDown || idleTimeoutMs <= 0) {
1868
+ return;
1869
+ }
1870
+ const activeReviews = await countActiveReviews();
1871
+ if (activeReviews > 0) {
1872
+ if (idleTimer) {
1873
+ clearTimeout(idleTimer);
1874
+ idleTimer = null;
1875
+ }
1876
+ return;
1877
+ }
1878
+ if (!idleTimer) {
1879
+ idleTimer = setTimeout(() => {
1880
+ idleTimer = null;
1881
+ void shutdownIfIdle();
1882
+ }, idleTimeoutMs);
1883
+ }
1884
+ }
1885
+ async function shutdownIfIdle() {
1886
+ if (await countActiveReviews() > 0) {
1887
+ await scheduleIdleShutdown();
1888
+ return;
1889
+ }
1890
+ await shutdown(0);
1891
+ }
1892
+ async function countActiveReviews() {
1893
+ const reviews = await reviewStore.list();
1894
+ return reviews.filter((review) => review.status === "pending").length;
1895
+ }
1896
+ async function shutdown(exitCode) {
1897
+ if (shuttingDown) {
1898
+ return;
1899
+ }
1900
+ shuttingDown = true;
1901
+ if (idleTimer) {
1902
+ clearTimeout(idleTimer);
1903
+ idleTimer = null;
1904
+ }
1905
+ for (const close of [...eventStreams]) {
1906
+ close();
1907
+ }
1908
+ await new Promise((resolve) => {
1909
+ server.close(() => resolve());
1910
+ });
1911
+ await removeCurrentServerInfo();
1912
+ process.exit(exitCode);
1913
+ }
1914
+ async function removeCurrentServerInfo() {
1915
+ const info = await readServerInfo().catch(() => null);
1916
+ if (!info || info.pid === process.pid) {
1917
+ await rm2(globalServerFile(), { force: true });
1918
+ }
1919
+ }
982
1920
  //# sourceMappingURL=daemon.js.map