getgloss 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -4,6 +4,17 @@
4
4
  import { Command } from "commander";
5
5
  import openBrowser from "open";
6
6
 
7
+ // src/shared/cleanup.ts
8
+ import { readdir, readFile, rm } from "fs/promises";
9
+
10
+ // src/shared/errors.ts
11
+ function formatError(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ function isFileNotFound(error) {
15
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
16
+ }
17
+
7
18
  // src/shared/paths.ts
8
19
  import { mkdir } from "fs/promises";
9
20
  import { homedir } from "os";
@@ -12,7 +23,7 @@ import path from "path";
12
23
  // package.json
13
24
  var package_default = {
14
25
  name: "getgloss",
15
- version: "0.7.2",
26
+ version: "0.8.1",
16
27
  description: "Local browser-based diff review for coding-agent loops.",
17
28
  type: "module",
18
29
  packageManager: "pnpm@10.33.2",
@@ -43,6 +54,8 @@ var package_default = {
43
54
  },
44
55
  dependencies: {
45
56
  "@hono/node-server": "1.19.14",
57
+ "@shikijs/langs": "4.1.0",
58
+ "@shikijs/themes": "4.1.0",
46
59
  "@tailwindcss/vite": "4.3.0",
47
60
  commander: "14.0.3",
48
61
  execa: "9.6.1",
@@ -52,6 +65,7 @@ var package_default = {
52
65
  open: "10.2.0",
53
66
  react: "19.2.6",
54
67
  "react-dom": "19.2.6",
68
+ shiki: "4.1.0",
55
69
  ulid: "3.0.2",
56
70
  zustand: "5.0.13"
57
71
  },
@@ -117,6 +131,27 @@ function globalReviewsDir() {
117
131
  function globalReviewDir(reviewId) {
118
132
  return path.join(globalReviewsDir(), reviewId);
119
133
  }
134
+ function globalReviewTurnsDir(reviewId) {
135
+ return path.join(globalReviewDir(reviewId), "turns");
136
+ }
137
+ function globalReviewTurnDir(reviewId, turnId) {
138
+ return path.join(globalReviewTurnsDir(reviewId), turnId);
139
+ }
140
+ function globalReviewTurnMetaFile(reviewId, turnId) {
141
+ return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
142
+ }
143
+ function globalReviewTurnDiffFile(reviewId, turnId) {
144
+ return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
145
+ }
146
+ function globalReviewTurnFeedbackFile(reviewId, turnId) {
147
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
148
+ }
149
+ function globalReviewTurnMarkdownFile(reviewId, turnId) {
150
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
151
+ }
152
+ function globalReviewTurnResolvedFile(reviewId, turnId) {
153
+ return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
154
+ }
120
155
  function globalReviewMetaFile(reviewId) {
121
156
  return path.join(globalReviewDir(reviewId), "meta.json");
122
157
  }
@@ -136,6 +171,427 @@ async function ensureDir(dir) {
136
171
  await mkdir(dir, { recursive: true });
137
172
  }
138
173
 
174
+ // src/shared/types.ts
175
+ var SIDES = ["L", "R"];
176
+ var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
177
+ var DIFF_LINE_TYPES = ["context", "add", "delete"];
178
+ var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
179
+ var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
180
+ var REVIEW_SCOPE_MODES = ["all", "single", "range"];
181
+ var RESOLUTION_STATUSES = ["partial", "resolved"];
182
+ var REVIEW_UPDATE_REASONS = [
183
+ "review-resolved",
184
+ "comment-resolved",
185
+ "comment-reopened",
186
+ "turn-created"
187
+ ];
188
+
189
+ // src/shared/validation.ts
190
+ function parseJson(raw, guard, label) {
191
+ const parsed = JSON.parse(raw);
192
+ return parseJsonValue(parsed, guard, label);
193
+ }
194
+ function parseJsonValue(value, guard, label) {
195
+ if (!guard(value)) {
196
+ throw new Error(`Invalid ${label}`);
197
+ }
198
+ return value;
199
+ }
200
+ function isServerInfo(value) {
201
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
202
+ }
203
+ function isHealthResponse(value) {
204
+ return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
205
+ }
206
+ function isClearReviewsResult(value) {
207
+ return isRecord(value) && isString(value.reviewsDir) && isString(value.cutoff) && isNumber(value.olderThanDays) && isBoolean(value.dryRun) && isArrayOf(value.candidates, isClearReviewEntry) && isArrayOf(value.deleted, isClearReviewEntry) && isArrayOf(value.skipped, isClearReviewSkipped) && isRecord(value.counts) && isNumber(value.counts.candidates) && isNumber(value.counts.deleted) && isNumber(value.counts.skipped);
208
+ }
209
+ function isCreateReviewResponse(value) {
210
+ return isRecord(value) && hasReviewRegistrationFields(value) && isOptional(value.turn, isReviewTurnSummary);
211
+ }
212
+ function isCreateReviewTurnResponse(value) {
213
+ return isRecord(value) && hasReviewRegistrationFields(value) && isReviewTurnSummary(value.turn) && isBoolean(value.reused);
214
+ }
215
+ function isListReviewsResponse(value) {
216
+ return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
217
+ }
218
+ function isOpenResult(value) {
219
+ return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isString(value.url) && isNumber(value.files) && isOptionalNumber(value.comments) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.artifactDir);
220
+ }
221
+ function isResolveResult(value) {
222
+ return isRecord(value) && value.ok === true && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.comments) && isString(value.path) && isResolutionBundle(value.resolution);
223
+ }
224
+ function isReviewRecord(value) {
225
+ return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
226
+ }
227
+ function isStoredReviewMeta(value) {
228
+ 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(
229
+ value.turns,
230
+ (turns) => isArrayOf(turns, isReviewTurnSummary)
231
+ ) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
232
+ }
233
+ function isReviewMeta(value) {
234
+ return isStoredReviewMeta(value) && isString(value.artifactDir);
235
+ }
236
+ function hasReviewRegistrationFields(value) {
237
+ return isReviewMeta(value.meta) && isString(value.url);
238
+ }
239
+ function isDiffPayload(value) {
240
+ 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(
241
+ value.commitDiffs,
242
+ (commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
243
+ ) && isString(value.capturedAt);
244
+ }
245
+ function isFeedbackBundle(value) {
246
+ 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);
247
+ }
248
+ function isResolutionBundle(value) {
249
+ 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);
250
+ }
251
+ function isReviewEvent(value) {
252
+ if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
253
+ return false;
254
+ }
255
+ switch (value.type) {
256
+ case "review.opened":
257
+ case "review.cancelled":
258
+ return true;
259
+ case "review.turn.created":
260
+ return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
261
+ case "review.submitted":
262
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
263
+ case "review.updated":
264
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
265
+ default:
266
+ return false;
267
+ }
268
+ }
269
+ function isReviewTurnMeta(value) {
270
+ 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);
271
+ }
272
+ function isReviewTurnSummary(value) {
273
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
274
+ return false;
275
+ }
276
+ return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
277
+ }
278
+ function isReviewTurn(value) {
279
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
280
+ return false;
281
+ }
282
+ return isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
283
+ }
284
+ function isClearReviewEntry(value) {
285
+ return isRecord(value) && isString(value.reviewId) && isReviewStatus(value.status) && isString(value.artifactDir) && isString(value.lastActivityAt);
286
+ }
287
+ function isClearReviewSkipped(value) {
288
+ return isRecord(value) && isString(value.reviewId) && isString(value.artifactDir) && isString(value.reason);
289
+ }
290
+ function isDiffScope(value) {
291
+ 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));
292
+ }
293
+ function isDiffRef(value) {
294
+ return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
295
+ }
296
+ function isBaseRef(value) {
297
+ return isRecord(value) && isString(value.ref) && isString(value.sha);
298
+ }
299
+ function isDiffStats(value) {
300
+ return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
301
+ }
302
+ function isDiffCommit(value) {
303
+ return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
304
+ }
305
+ function isCommitDiff(value) {
306
+ return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
307
+ }
308
+ function isReviewScope(value) {
309
+ if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
310
+ return false;
311
+ }
312
+ switch (value.mode) {
313
+ case "all":
314
+ return true;
315
+ case "single":
316
+ return isString(value.sha);
317
+ case "range":
318
+ return isString(value.fromSha) && isString(value.toSha);
319
+ }
320
+ }
321
+ function isDiffFile(value) {
322
+ 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);
323
+ }
324
+ function isDiffHunk(value) {
325
+ return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
326
+ }
327
+ function isDiffLine(value) {
328
+ return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
329
+ }
330
+ function isComment(value) {
331
+ 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);
332
+ }
333
+ function isResolvedComment(value) {
334
+ return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
335
+ }
336
+ function isResolutionCounts(value) {
337
+ return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
338
+ }
339
+ function isReviewStatus(value) {
340
+ return isOneOf(value, REVIEW_STATUSES);
341
+ }
342
+ function isResolutionStatus(value) {
343
+ return isOneOf(value, RESOLUTION_STATUSES);
344
+ }
345
+ function isReviewUpdateReason(value) {
346
+ return isOneOf(value, REVIEW_UPDATE_REASONS);
347
+ }
348
+ function isRecord(value) {
349
+ return typeof value === "object" && value !== null && !Array.isArray(value);
350
+ }
351
+ function isArrayOf(value, guard) {
352
+ return Array.isArray(value) && value.every(guard);
353
+ }
354
+ function isOptional(value, guard) {
355
+ return value === void 0 || guard(value);
356
+ }
357
+ function isString(value) {
358
+ return typeof value === "string";
359
+ }
360
+ function isOptionalString(value) {
361
+ return value === void 0 || isString(value);
362
+ }
363
+ function isNullableString(value) {
364
+ return value === null || isString(value);
365
+ }
366
+ function isNumber(value) {
367
+ return typeof value === "number" && Number.isFinite(value);
368
+ }
369
+ function isOptionalNumber(value) {
370
+ return value === void 0 || isNumber(value);
371
+ }
372
+ function isNullableNumber(value) {
373
+ return value === null || isNumber(value);
374
+ }
375
+ function isBoolean(value) {
376
+ return typeof value === "boolean";
377
+ }
378
+ function isOneOf(value, options) {
379
+ return typeof value === "string" && options.includes(value);
380
+ }
381
+
382
+ // src/shared/cleanup.ts
383
+ var DEFAULT_REVIEW_RETENTION_DAYS = 30;
384
+ var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
385
+ var millisecondsPerDay = 24 * 60 * 60 * 1e3;
386
+ async function clearReviewArtifacts(options = {}) {
387
+ const olderThanDays = normalizeRetentionDays(options.olderThanDays);
388
+ const dryRun = options.dryRun === true;
389
+ const now = options.now ?? /* @__PURE__ */ new Date();
390
+ const cutoff = new Date(now.getTime() - olderThanDays * millisecondsPerDay);
391
+ const reviewsDir = globalReviewsDir();
392
+ const candidates = [];
393
+ const deleted = [];
394
+ const skipped = [];
395
+ let entries;
396
+ try {
397
+ entries = await readdir(reviewsDir, { withFileTypes: true });
398
+ } catch (error) {
399
+ if (isFileNotFound(error)) {
400
+ return cleanupResult({
401
+ reviewsDir,
402
+ cutoff,
403
+ olderThanDays,
404
+ dryRun,
405
+ candidates,
406
+ deleted,
407
+ skipped
408
+ });
409
+ }
410
+ throw new Error(`Could not read reviews directory at ${reviewsDir}: ${formatError(error)}`, {
411
+ cause: error
412
+ });
413
+ }
414
+ for (const entry of entries) {
415
+ if (!entry.isDirectory()) {
416
+ continue;
417
+ }
418
+ const reviewId = entry.name;
419
+ const artifactDir = globalReviewDir(reviewId);
420
+ const candidate = await cleanupCandidate(reviewId, artifactDir, cutoff, skipped);
421
+ if (!candidate) {
422
+ continue;
423
+ }
424
+ candidates.push(candidate);
425
+ if (!dryRun) {
426
+ await rm(artifactDir, { recursive: true, force: true });
427
+ deleted.push(candidate);
428
+ }
429
+ }
430
+ return cleanupResult({ reviewsDir, cutoff, olderThanDays, dryRun, candidates, deleted, skipped });
431
+ }
432
+ function normalizeRetentionDays(value) {
433
+ const days = value ?? DEFAULT_REVIEW_RETENTION_DAYS;
434
+ if (!Number.isInteger(days) || days < 0) {
435
+ throw new Error("olderThanDays must be a non-negative integer");
436
+ }
437
+ return days;
438
+ }
439
+ async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
440
+ let raw;
441
+ try {
442
+ raw = await readFile(globalReviewMetaFile(reviewId), "utf8");
443
+ } catch (error) {
444
+ if (isFileNotFound(error)) {
445
+ skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
446
+ return null;
447
+ }
448
+ skipped.push({ reviewId, artifactDir, reason: `unreadable metadata: ${formatError(error)}` });
449
+ return null;
450
+ }
451
+ let meta;
452
+ try {
453
+ meta = parseJson(raw, isStoredReviewMeta, "review metadata");
454
+ } catch (error) {
455
+ skipped.push({ reviewId, artifactDir, reason: `invalid metadata: ${formatError(error)}` });
456
+ return null;
457
+ }
458
+ if (meta.id !== reviewId) {
459
+ skipped.push({ reviewId, artifactDir, reason: `metadata id mismatch: ${meta.id}` });
460
+ return null;
461
+ }
462
+ if (!clearableStatuses.has(meta.status)) {
463
+ return null;
464
+ }
465
+ const turnState = await persistedTurnCleanupState(reviewId, artifactDir, skipped);
466
+ if (turnState === "preserve") {
467
+ return null;
468
+ }
469
+ const lastActivityAt = latestTimestamp([
470
+ ...metadataTimestamps(meta),
471
+ ...turnState === "none" ? [] : turnState.timestamps
472
+ ]);
473
+ if (!lastActivityAt) {
474
+ skipped.push({ reviewId, artifactDir, reason: "missing valid activity timestamp" });
475
+ return null;
476
+ }
477
+ if (Date.parse(lastActivityAt) >= cutoff.getTime()) {
478
+ return null;
479
+ }
480
+ return {
481
+ reviewId,
482
+ status: meta.status,
483
+ artifactDir,
484
+ lastActivityAt
485
+ };
486
+ }
487
+ async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
488
+ let entries;
489
+ try {
490
+ entries = await readdir(globalReviewTurnsDir(reviewId), { withFileTypes: true });
491
+ } catch (error) {
492
+ if (isFileNotFound(error)) {
493
+ return "none";
494
+ }
495
+ skipped.push({
496
+ reviewId,
497
+ artifactDir,
498
+ reason: `unreadable turns directory: ${formatError(error)}`
499
+ });
500
+ return "preserve";
501
+ }
502
+ const turnDirs = entries.filter((entry) => entry.isDirectory());
503
+ if (turnDirs.length === 0) {
504
+ return "none";
505
+ }
506
+ const timestamps = [];
507
+ for (const entry of turnDirs) {
508
+ const turn = await readPersistedTurnMeta(reviewId, entry.name, artifactDir, skipped);
509
+ if (!turn) {
510
+ return "preserve";
511
+ }
512
+ if (turn.status === "pending" || !clearableStatuses.has(turn.status)) {
513
+ return "preserve";
514
+ }
515
+ timestamps.push(turn.createdAt, turn.submittedAt, turn.resolvedAt);
516
+ }
517
+ return { timestamps };
518
+ }
519
+ async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
520
+ let raw;
521
+ try {
522
+ raw = await readFile(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
523
+ } catch (error) {
524
+ skipped.push({
525
+ reviewId,
526
+ artifactDir,
527
+ reason: `${isFileNotFound(error) ? "missing" : "unreadable"} turn metadata for ${turnDirName}${isFileNotFound(error) ? "" : `: ${formatError(error)}`}`
528
+ });
529
+ return null;
530
+ }
531
+ try {
532
+ const turn = parseJson(raw, isReviewTurnMeta, "review turn metadata");
533
+ if (turn.id !== turnDirName) {
534
+ skipped.push({
535
+ reviewId,
536
+ artifactDir,
537
+ reason: `turn metadata id mismatch for ${turnDirName}: ${turn.id}`
538
+ });
539
+ return null;
540
+ }
541
+ return turn;
542
+ } catch (error) {
543
+ skipped.push({
544
+ reviewId,
545
+ artifactDir,
546
+ reason: `invalid turn metadata for ${turnDirName}: ${formatError(error)}`
547
+ });
548
+ return null;
549
+ }
550
+ }
551
+ function metadataTimestamps(meta) {
552
+ return [
553
+ meta.createdAt,
554
+ meta.submittedAt,
555
+ meta.resolvedAt,
556
+ ...(meta.turns ?? []).flatMap((turn) => [
557
+ turn.createdAt,
558
+ turn.capturedAt,
559
+ turn.submittedAt,
560
+ turn.resolvedAt
561
+ ])
562
+ ];
563
+ }
564
+ function latestTimestamp(timestamps) {
565
+ const latest = Math.max(
566
+ ...timestamps.map((timestamp) => timestamp ? Date.parse(timestamp) : Number.NaN).filter((timestamp) => Number.isFinite(timestamp))
567
+ );
568
+ return Number.isFinite(latest) ? new Date(latest).toISOString() : null;
569
+ }
570
+ function cleanupResult({
571
+ reviewsDir,
572
+ cutoff,
573
+ olderThanDays,
574
+ dryRun,
575
+ candidates,
576
+ deleted,
577
+ skipped
578
+ }) {
579
+ return {
580
+ reviewsDir,
581
+ cutoff: cutoff.toISOString(),
582
+ olderThanDays,
583
+ dryRun,
584
+ candidates,
585
+ deleted,
586
+ skipped,
587
+ counts: {
588
+ candidates: candidates.length,
589
+ deleted: deleted.length,
590
+ skipped: skipped.length
591
+ }
592
+ };
593
+ }
594
+
139
595
  // src/cli/git.ts
140
596
  import { execa } from "execa";
141
597
 
@@ -169,7 +625,7 @@ function languageForPath(filePath) {
169
625
  return languageByExtension[ext] ?? ext;
170
626
  }
171
627
 
172
- // src/cli/diff-parser.ts
628
+ // src/shared/diff-parser.ts
173
629
  var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
174
630
  function stripGitPath(input) {
175
631
  return input.replace(/^[ab]\//, "");
@@ -297,6 +753,18 @@ function parseUnifiedDiff(diffText) {
297
753
  return files;
298
754
  }
299
755
 
756
+ // src/shared/diff-stats.ts
757
+ function summarizeDiffFiles(files) {
758
+ return files.reduce(
759
+ (stats, file) => ({
760
+ files: stats.files + 1,
761
+ additions: stats.additions + file.additions,
762
+ deletions: stats.deletions + file.deletions
763
+ }),
764
+ { files: 0, additions: 0, deletions: 0 }
765
+ );
766
+ }
767
+
300
768
  // src/cli/git.ts
301
769
  var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
302
770
  async function git(args, cwd = process.cwd()) {
@@ -317,16 +785,6 @@ async function gitLenient(args, cwd) {
317
785
  async function getRepoRoot(cwd = process.cwd()) {
318
786
  return git(["rev-parse", "--show-toplevel"], cwd);
319
787
  }
320
- function summarize(files) {
321
- return files.reduce(
322
- (stats, file) => ({
323
- files: stats.files + 1,
324
- additions: stats.additions + file.additions,
325
- deletions: stats.deletions + file.deletions
326
- }),
327
- { files: 0, additions: 0, deletions: 0 }
328
- );
329
- }
330
788
  function buildPayload({
331
789
  repoRoot,
332
790
  branch,
@@ -335,7 +793,8 @@ function buildPayload({
335
793
  mode,
336
794
  requestedBase,
337
795
  comparison,
338
- fallbackReason
796
+ fallbackReason,
797
+ commitDiffs
339
798
  }) {
340
799
  const files = parseUnifiedDiff(rawDiff);
341
800
  return {
@@ -349,9 +808,10 @@ function buildPayload({
349
808
  comparison,
350
809
  fallbackReason
351
810
  },
352
- stats: summarize(files),
811
+ stats: summarizeDiffFiles(files),
353
812
  rawDiff,
354
813
  files,
814
+ ...commitDiffs ? { commitDiffs } : {},
355
815
  capturedAt: (/* @__PURE__ */ new Date()).toISOString()
356
816
  };
357
817
  }
@@ -407,6 +867,52 @@ async function resolveBranchBase(repoRoot) {
407
867
  }
408
868
  return null;
409
869
  }
870
+ async function captureCommitDiffs(baseSha, comparisonRef, repoRoot) {
871
+ const rawLog = await gitMaybe(
872
+ [
873
+ "log",
874
+ "--reverse",
875
+ "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%s%x1e",
876
+ `${baseSha}..${comparisonRef}`
877
+ ],
878
+ repoRoot
879
+ );
880
+ if (!rawLog) {
881
+ return [];
882
+ }
883
+ const commits = rawLog.split("").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
884
+ const [
885
+ sha = "",
886
+ shortSha2 = "",
887
+ authorName = "",
888
+ authorEmail = "",
889
+ authoredAt = "",
890
+ committedAt = "",
891
+ ...subjectParts
892
+ ] = entry.split("\0");
893
+ return {
894
+ sha,
895
+ shortSha: shortSha2,
896
+ subject: subjectParts.join("\0"),
897
+ authorName,
898
+ authorEmail,
899
+ authoredAt,
900
+ committedAt
901
+ };
902
+ }).filter((commit) => commit.sha && commit.shortSha);
903
+ const commitDiffs = [];
904
+ for (const commit of commits) {
905
+ const rawDiff = await git([...DIFF_ARGS, `${commit.sha}^`, commit.sha, "--"], repoRoot);
906
+ const files = parseUnifiedDiff(rawDiff);
907
+ commitDiffs.push({
908
+ commit,
909
+ stats: summarizeDiffFiles(files),
910
+ rawDiff,
911
+ files
912
+ });
913
+ }
914
+ return commitDiffs;
915
+ }
410
916
  async function captureDiff(baseRef, cwd = process.cwd()) {
411
917
  const repoRoot = await getRepoRoot(cwd);
412
918
  const [headSha, branch] = await Promise.all([
@@ -455,7 +961,10 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
455
961
  fallbackReason: "missing-branch-base"
456
962
  });
457
963
  }
458
- const rawDiff = await git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot);
964
+ const [rawDiff, commitDiffs] = await Promise.all([
965
+ git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot),
966
+ captureCommitDiffs(branchBase.mergeBaseSha, "HEAD", repoRoot)
967
+ ]);
459
968
  return buildPayload({
460
969
  repoRoot,
461
970
  branch,
@@ -464,7 +973,8 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
464
973
  mode: "branch",
465
974
  requestedBase: null,
466
975
  comparison: { ref: "HEAD", sha: headSha },
467
- fallbackReason: "working-tree-clean"
976
+ fallbackReason: "working-tree-clean",
977
+ commitDiffs
468
978
  });
469
979
  }
470
980
  async function assertGitAvailable() {
@@ -472,173 +982,47 @@ async function assertGitAvailable() {
472
982
  }
473
983
 
474
984
  // src/cli/lifecycle.ts
475
- import { spawn } from "child_process";
476
- import { existsSync, openSync } from "fs";
477
- import { rm } from "fs/promises";
985
+ import { execFile, spawn } from "child_process";
986
+ import { closeSync, existsSync, openSync } from "fs";
987
+ import { rm as rm3 } from "fs/promises";
988
+ import { userInfo } from "os";
478
989
  import { fileURLToPath } from "url";
990
+ import { promisify } from "util";
479
991
  import getPort from "get-port";
480
992
 
481
993
  // src/shared/server-info.ts
482
- import { readFile } from "fs/promises";
994
+ import { readFile as readFile2 } from "fs/promises";
483
995
 
484
996
  // src/shared/json.ts
485
- import { writeFile } from "fs/promises";
997
+ import { randomUUID } from "crypto";
998
+ import { rename, rm as rm2, writeFile } from "fs/promises";
999
+ import path3 from "path";
486
1000
  function serializeJson(value) {
487
1001
  return `${JSON.stringify(value, null, 2)}
488
1002
  `;
489
1003
  }
490
1004
  async function writeJsonFile(filePath, value) {
491
- await writeFile(filePath, serializeJson(value));
492
- }
493
-
494
- // src/shared/validation.ts
495
- var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
496
- var resolutionStatuses = ["partial", "resolved"];
497
- var reviewUpdateReasons = ["review-resolved", "comment-resolved", "comment-reopened"];
498
- var sides = ["L", "R"];
499
- var diffLineTypes = ["context", "add", "delete"];
500
- var diffScopeModes = ["working", "branch", "explicit"];
501
- var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
502
- function parseJson(raw, guard, label) {
503
- const parsed = JSON.parse(raw);
504
- return parseJsonValue(parsed, guard, label);
1005
+ await writeTextFile(filePath, serializeJson(value));
505
1006
  }
506
- function parseJsonValue(value, guard, label) {
507
- if (!guard(value)) {
508
- throw new Error(`Invalid ${label}`);
509
- }
510
- return value;
511
- }
512
- function isServerInfo(value) {
513
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
514
- }
515
- function isHealthResponse(value) {
516
- return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
517
- }
518
- function isCreateReviewResponse(value) {
519
- return isRecord(value) && isReviewMeta(value.meta) && isString(value.url);
520
- }
521
- function isListReviewsResponse(value) {
522
- return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
523
- }
524
- function isOpenResult(value) {
525
- return isRecord(value) && isString(value.reviewId) && isString(value.url) && isNumber(value.files) && isOptionalNumber(value.comments) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.artifactDir);
526
- }
527
- function isResolveResult(value) {
528
- return isRecord(value) && value.ok === true && isString(value.reviewId) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.comments) && isString(value.path) && isResolutionBundle(value.resolution);
529
- }
530
- function isReviewRecord(value) {
531
- return isRecord(value) && isReviewMeta(value.meta) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
532
- }
533
- function isStoredReviewMeta(value) {
534
- 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);
535
- }
536
- function isReviewMeta(value) {
537
- return isStoredReviewMeta(value) && isString(value.artifactDir);
538
- }
539
- function isDiffPayload(value) {
540
- 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);
541
- }
542
- function isFeedbackBundle(value) {
543
- return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
544
- }
545
- function isResolutionBundle(value) {
546
- return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
547
- }
548
- function isReviewEvent(value) {
549
- if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
550
- return false;
551
- }
552
- switch (value.type) {
553
- case "review.opened":
554
- case "review.cancelled":
555
- return true;
556
- case "review.submitted":
557
- return isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
558
- case "review.updated":
559
- return isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
560
- default:
561
- return false;
1007
+ async function writeTextFile(filePath, value) {
1008
+ const tempPath = path3.join(
1009
+ path3.dirname(filePath),
1010
+ `.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
1011
+ );
1012
+ try {
1013
+ await writeFile(tempPath, value);
1014
+ await rename(tempPath, filePath);
1015
+ } catch (error) {
1016
+ await rm2(tempPath, { force: true }).catch(() => void 0);
1017
+ throw error;
562
1018
  }
563
1019
  }
564
- function isDiffScope(value) {
565
- return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
566
- }
567
- function isDiffRef(value) {
568
- return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
569
- }
570
- function isBaseRef(value) {
571
- return isRecord(value) && isString(value.ref) && isString(value.sha);
572
- }
573
- function isDiffStats(value) {
574
- return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
575
- }
576
- function isDiffFile(value) {
577
- 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);
578
- }
579
- function isDiffHunk(value) {
580
- return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
581
- }
582
- function isDiffLine(value) {
583
- return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
584
- }
585
- function isComment(value) {
586
- 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);
587
- }
588
- function isResolvedComment(value) {
589
- return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
590
- }
591
- function isResolutionCounts(value) {
592
- return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
593
- }
594
- function isReviewStatus(value) {
595
- return isOneOf(value, reviewStatuses);
596
- }
597
- function isResolutionStatus(value) {
598
- return isOneOf(value, resolutionStatuses);
599
- }
600
- function isReviewUpdateReason(value) {
601
- return isOneOf(value, reviewUpdateReasons);
602
- }
603
- function isRecord(value) {
604
- return typeof value === "object" && value !== null && !Array.isArray(value);
605
- }
606
- function isArrayOf(value, guard) {
607
- return Array.isArray(value) && value.every(guard);
608
- }
609
- function isOptional(value, guard) {
610
- return value === void 0 || guard(value);
611
- }
612
- function isString(value) {
613
- return typeof value === "string";
614
- }
615
- function isOptionalString(value) {
616
- return value === void 0 || isString(value);
617
- }
618
- function isNullableString(value) {
619
- return value === null || isString(value);
620
- }
621
- function isNumber(value) {
622
- return typeof value === "number" && Number.isFinite(value);
623
- }
624
- function isOptionalNumber(value) {
625
- return value === void 0 || isNumber(value);
626
- }
627
- function isNullableNumber(value) {
628
- return value === null || isNumber(value);
629
- }
630
- function isBoolean(value) {
631
- return typeof value === "boolean";
632
- }
633
- function isOneOf(value, options) {
634
- return typeof value === "string" && options.includes(value);
635
- }
636
1020
 
637
1021
  // src/shared/server-info.ts
638
1022
  async function readServerInfo() {
639
1023
  let raw;
640
1024
  try {
641
- raw = await readFile(globalServerFile(), "utf8");
1025
+ raw = await readFile2(globalServerFile(), "utf8");
642
1026
  } catch (error) {
643
1027
  if (isFileNotFound(error)) {
644
1028
  return null;
@@ -659,12 +1043,6 @@ async function writeServerInfo(info) {
659
1043
  await ensureDir(globalStateDir());
660
1044
  await writeJsonFile(globalServerFile(), info);
661
1045
  }
662
- function isFileNotFound(error) {
663
- return error instanceof Error && "code" in error && error.code === "ENOENT";
664
- }
665
- function formatError(error) {
666
- return error instanceof Error ? error.message : String(error);
667
- }
668
1046
 
669
1047
  // src/cli/server-client.ts
670
1048
  var ServerClient = class {
@@ -678,17 +1056,33 @@ var ServerClient = class {
678
1056
  async createReview(diff) {
679
1057
  return this.post("/api/reviews", diff, isCreateReviewResponse, "create review response");
680
1058
  }
1059
+ async appendReviewTurn(reviewId, diff) {
1060
+ return this.post(
1061
+ `/api/reviews/${reviewId}/turns`,
1062
+ diff,
1063
+ isCreateReviewTurnResponse,
1064
+ "create review turn response"
1065
+ );
1066
+ }
681
1067
  async getReview(reviewId) {
682
1068
  return this.get(`/api/reviews/${reviewId}`, isReviewRecord, "review response");
683
1069
  }
684
1070
  async listReviews() {
685
1071
  return this.get("/api/reviews", isListReviewsResponse, "review list response");
686
1072
  }
1073
+ async clearReviews(request) {
1074
+ return this.post(
1075
+ "/api/maintenance/clear-reviews",
1076
+ request,
1077
+ isClearReviewsResult,
1078
+ "clear reviews response"
1079
+ );
1080
+ }
687
1081
  async getFeedback(reviewId) {
688
1082
  return this.get(`/api/reviews/${reviewId}/feedback`, isFeedbackBundle, "feedback response");
689
1083
  }
690
- async markResolved(reviewId, summary) {
691
- const request = { summary };
1084
+ async markResolved(reviewId, summary, turn) {
1085
+ const request = { summary, turn };
692
1086
  return this.post(
693
1087
  `/api/reviews/${reviewId}/resolved`,
694
1088
  request,
@@ -778,20 +1172,20 @@ var ServerClient = class {
778
1172
  }
779
1173
  }
780
1174
  }
781
- async get(path3, guard, label) {
782
- const response = await fetch(`${this.baseUrl}${path3}`);
1175
+ async get(path5, guard, label) {
1176
+ const response = await fetch(`${this.baseUrl}${path5}`);
783
1177
  return parseResponse(response, guard, label);
784
1178
  }
785
- async post(path3, body, guard, label) {
786
- const response = await fetch(`${this.baseUrl}${path3}`, {
1179
+ async post(path5, body, guard, label) {
1180
+ const response = await fetch(`${this.baseUrl}${path5}`, {
787
1181
  method: "POST",
788
1182
  headers: { "content-type": "application/json" },
789
1183
  body: JSON.stringify(body)
790
1184
  });
791
1185
  return parseResponse(response, guard, label);
792
1186
  }
793
- async delete(path3, guard, label) {
794
- const response = await fetch(`${this.baseUrl}${path3}`, { method: "DELETE" });
1187
+ async delete(path5, guard, label) {
1188
+ const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
795
1189
  return parseResponse(response, guard, label);
796
1190
  }
797
1191
  };
@@ -806,13 +1200,16 @@ function isPrematureWatchEnd(error) {
806
1200
  return error instanceof Error && error.message === "watch stream ended before completion";
807
1201
  }
808
1202
  function isAbortError(error) {
809
- return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
1203
+ return error instanceof Error && error.name === "AbortError";
810
1204
  }
811
1205
  async function sleep(milliseconds) {
812
1206
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
813
1207
  }
814
1208
 
815
1209
  // src/cli/lifecycle.ts
1210
+ var execFileAsync = promisify(execFile);
1211
+ var gracefulShutdownTimeoutMs = 2e3;
1212
+ var forceShutdownTimeoutMs = 1e3;
816
1213
  function serverUrl(info) {
817
1214
  return `http://localhost:${info.port}`;
818
1215
  }
@@ -839,9 +1236,23 @@ async function startServer(options = {}) {
839
1236
  if (existing && await isServerResponsive(existing)) {
840
1237
  return existing;
841
1238
  }
1239
+ if (existing) {
1240
+ await retireServer(existing);
1241
+ }
1242
+ const preferredPort = options.port ?? existing?.port ?? await getPort();
1243
+ try {
1244
+ return await launchServer(preferredPort);
1245
+ } catch (error) {
1246
+ if (options.port || !existing?.port) {
1247
+ throw error;
1248
+ }
1249
+ await removeServerInfoForPid(existing.pid);
1250
+ return launchServer(await getPort());
1251
+ }
1252
+ }
1253
+ async function launchServer(port) {
842
1254
  await ensureDir(globalStateDir());
843
1255
  await ensureDir(globalLogDir());
844
- const port = options.port ?? await getPort();
845
1256
  const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
846
1257
  if (!existsSync(daemonPath)) {
847
1258
  throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
@@ -856,6 +1267,7 @@ async function startServer(options = {}) {
856
1267
  },
857
1268
  stdio: ["ignore", logFd, logFd]
858
1269
  });
1270
+ closeSync(logFd);
859
1271
  child.unref();
860
1272
  const info = {
861
1273
  pid: child.pid ?? -1,
@@ -872,18 +1284,40 @@ async function startServer(options = {}) {
872
1284
  }
873
1285
  await new Promise((resolve) => setTimeout(resolve, 150));
874
1286
  }
1287
+ await terminatePid(info.pid);
1288
+ await removeServerInfoForPid(info.pid);
875
1289
  throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
876
1290
  }
877
- async function stopServer() {
1291
+ async function stopServer(options = {}) {
1292
+ if (options.all) {
1293
+ const info2 = await readServerInfo();
1294
+ const daemonPids = await listGlossDaemonPids();
1295
+ const stoppedPids = [];
1296
+ for (const pid of daemonPids) {
1297
+ if (await terminatePid(pid)) {
1298
+ stoppedPids.push(pid);
1299
+ }
1300
+ }
1301
+ await rm3(globalServerFile(), { force: true });
1302
+ return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
1303
+ }
878
1304
  const info = await readServerInfo();
879
1305
  if (!info) {
880
1306
  return { stopped: false, info: null };
881
1307
  }
882
- if (isPidAlive(info.pid)) {
883
- process.kill(info.pid, "SIGTERM");
1308
+ if (!isPidAlive(info.pid)) {
1309
+ await removeServerInfoForPid(info.pid);
1310
+ return { stopped: false, info };
1311
+ }
1312
+ if (!await isGlossDaemonPid(info.pid)) {
1313
+ await removeServerInfoForPid(info.pid);
1314
+ return { stopped: false, info };
884
1315
  }
885
- await rm(globalServerFile(), { force: true });
886
- return { stopped: true, info };
1316
+ const stopped = await terminatePid(info.pid);
1317
+ if (stopped) {
1318
+ await removeServerInfoForPid(info.pid);
1319
+ }
1320
+ return { stopped, info };
887
1321
  }
888
1322
  function isPidAlive(pid) {
889
1323
  if (pid <= 0) {
@@ -896,9 +1330,86 @@ function isPidAlive(pid) {
896
1330
  return false;
897
1331
  }
898
1332
  }
1333
+ async function retireServer(info) {
1334
+ if (isPidAlive(info.pid) && await isGlossDaemonPid(info.pid)) {
1335
+ await terminatePid(info.pid);
1336
+ }
1337
+ await removeServerInfoForPid(info.pid);
1338
+ }
1339
+ async function terminatePid(pid) {
1340
+ if (!isPidAlive(pid)) {
1341
+ return true;
1342
+ }
1343
+ try {
1344
+ process.kill(pid, "SIGTERM");
1345
+ } catch {
1346
+ return !isPidAlive(pid);
1347
+ }
1348
+ if (await waitForPidExit(pid, gracefulShutdownTimeoutMs)) {
1349
+ return true;
1350
+ }
1351
+ try {
1352
+ process.kill(pid, "SIGKILL");
1353
+ } catch {
1354
+ return !isPidAlive(pid);
1355
+ }
1356
+ return waitForPidExit(pid, forceShutdownTimeoutMs);
1357
+ }
1358
+ async function waitForPidExit(pid, timeoutMs) {
1359
+ const deadline = Date.now() + timeoutMs;
1360
+ while (Date.now() < deadline) {
1361
+ if (!isPidAlive(pid)) {
1362
+ return true;
1363
+ }
1364
+ await new Promise((resolve) => setTimeout(resolve, 50));
1365
+ }
1366
+ return !isPidAlive(pid);
1367
+ }
1368
+ async function removeServerInfoForPid(pid) {
1369
+ const current = await readServerInfo().catch(() => null);
1370
+ if (!current || current.pid === pid) {
1371
+ await rm3(globalServerFile(), { force: true });
1372
+ }
1373
+ }
1374
+ async function isGlossDaemonPid(pid) {
1375
+ const command = await readProcessCommand(pid);
1376
+ return command ? isGlossDaemonCommand(command) : false;
1377
+ }
1378
+ async function readProcessCommand(pid) {
1379
+ try {
1380
+ const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(pid), "-ww"]);
1381
+ return stdout.trim() || null;
1382
+ } catch {
1383
+ return null;
1384
+ }
1385
+ }
1386
+ async function listGlossDaemonPids() {
1387
+ let stdout;
1388
+ try {
1389
+ ({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
1390
+ } catch {
1391
+ return [];
1392
+ }
1393
+ const currentUser = userInfo().username;
1394
+ return parseGlossDaemonPids(stdout, currentUser, process.pid);
1395
+ }
1396
+ function parseGlossDaemonPids(stdout, currentUser, currentPid = process.pid) {
1397
+ return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
1398
+ pid: Number(match[1]),
1399
+ user: match[2],
1400
+ command: match[3]
1401
+ })).filter(
1402
+ ({ pid, user, command }) => pid !== currentPid && user === currentUser && isGlossDaemonCommand(command)
1403
+ ).map(({ pid }) => pid);
1404
+ }
1405
+ function isGlossDaemonCommand(command) {
1406
+ return /(?:^|\s)(?:\S*\/)?node\s+\S*dist\/server\/daemon\.js(?:\s|$)/.test(command);
1407
+ }
899
1408
 
900
1409
  // src/server/store.ts
901
- import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1410
+ import { createHash } from "crypto";
1411
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
1412
+ import path4 from "path";
902
1413
  import { ulid } from "ulid";
903
1414
 
904
1415
  // src/shared/comments.ts
@@ -928,6 +1439,60 @@ function resolutionCounts(feedback, resolvedComments = []) {
928
1439
  };
929
1440
  }
930
1441
 
1442
+ // src/shared/review-scope.ts
1443
+ var ALL_REVIEW_SCOPE = { mode: "all" };
1444
+ function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
1445
+ if (scope.mode === "all") {
1446
+ return ALL_REVIEW_SCOPE;
1447
+ }
1448
+ const commitDiffs = diff.commitDiffs ?? [];
1449
+ if (commitDiffs.length === 0) {
1450
+ throw new Error("Review scope requires a review with per-commit diffs");
1451
+ }
1452
+ if (scope.mode === "single") {
1453
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
1454
+ if (!commit) {
1455
+ throw new Error("Review scope must use commits from this review");
1456
+ }
1457
+ return { mode: "single", sha: commit.commit.sha };
1458
+ }
1459
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
1460
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
1461
+ if (fromIndex < 0 || toIndex < 0) {
1462
+ throw new Error("Review scope must use commits from this review");
1463
+ }
1464
+ if (fromIndex > toIndex) {
1465
+ throw new Error("Review scope range must be in review order");
1466
+ }
1467
+ return {
1468
+ mode: "range",
1469
+ fromSha: commitDiffs[fromIndex].commit.sha,
1470
+ toSha: commitDiffs[toIndex].commit.sha
1471
+ };
1472
+ }
1473
+ function sameReviewScope(left, right) {
1474
+ return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
1475
+ }
1476
+ function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
1477
+ if (scope.mode === "all") {
1478
+ return "All commits";
1479
+ }
1480
+ if (scope.mode === "single") {
1481
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
1482
+ return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
1483
+ }
1484
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
1485
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
1486
+ if (fromIndex >= 0 && toIndex >= fromIndex) {
1487
+ const count = toIndex - fromIndex + 1;
1488
+ return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
1489
+ }
1490
+ return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
1491
+ }
1492
+ function shortSha(sha) {
1493
+ return sha.slice(0, 7);
1494
+ }
1495
+
931
1496
  // src/shared/markdown.ts
932
1497
  function fenceFor(snippet) {
933
1498
  let fence = "```";
@@ -942,20 +1507,32 @@ function languageForSnippet(filePath, snippet) {
942
1507
  return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
943
1508
  }
944
1509
  function serializeFeedbackMarkdown(bundle) {
945
- const comments = [...bundle.comments].sort(compareCommentsByLocation);
946
- const files = [...new Set(comments.map((comment) => comment.filePath))];
1510
+ const comments = bundle.comments.toSorted(compareCommentsByLocation);
1511
+ const commentsByFile = /* @__PURE__ */ new Map();
1512
+ const files = [];
1513
+ for (const comment of comments) {
1514
+ const fileComments = commentsByFile.get(comment.filePath);
1515
+ if (fileComments) {
1516
+ fileComments.push(comment);
1517
+ } else {
1518
+ commentsByFile.set(comment.filePath, [comment]);
1519
+ files.push(comment.filePath);
1520
+ }
1521
+ }
947
1522
  const lines = [
948
1523
  `# Gloss feedback - ${bundle.timestamp}`,
949
1524
  `Review: ${bundle.reviewId}`,
1525
+ ...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
1526
+ ...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
950
1527
  `Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
951
1528
  `Files: ${files.length} Comments: ${comments.length}`,
952
1529
  ""
953
1530
  ];
954
1531
  for (const filePath of files) {
955
1532
  lines.push(`## ${filePath}`, "");
956
- for (const comment of comments.filter((item) => item.filePath === filePath)) {
1533
+ for (const comment of commentsByFile.get(filePath) ?? []) {
957
1534
  const snippet = comment.originalSnippet.trimEnd();
958
- const firstSnippetLine = snippet.split("\n").find((line) => line.trim().length > 0);
1535
+ const firstSnippetLine = firstNonEmptyLine(snippet);
959
1536
  const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
960
1537
  lines.push(heading, comment.body.trim(), "");
961
1538
  if (snippet) {
@@ -967,6 +1544,14 @@ function serializeFeedbackMarkdown(bundle) {
967
1544
  return `${lines.join("\n").trimEnd()}
968
1545
  `;
969
1546
  }
1547
+ function firstNonEmptyLine(text) {
1548
+ for (const line of text.split("\n")) {
1549
+ if (line.trim().length > 0) {
1550
+ return line;
1551
+ }
1552
+ }
1553
+ return void 0;
1554
+ }
970
1555
 
971
1556
  // src/shared/reviews.ts
972
1557
  function isResolvableReviewStatus(status) {
@@ -980,6 +1565,7 @@ var ReviewStore = class {
980
1565
  async create(diff) {
981
1566
  const id = ulid();
982
1567
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1568
+ const turn = createTurn(id, 1, diff, createdAt);
983
1569
  const meta = {
984
1570
  id,
985
1571
  cwd: diff.cwd,
@@ -987,108 +1573,197 @@ var ReviewStore = class {
987
1573
  branch: diff.branch,
988
1574
  status: "pending",
989
1575
  createdAt,
990
- artifactDir: globalReviewDir(id)
1576
+ artifactDir: globalReviewDir(id),
1577
+ activeTurnId: turn.id
991
1578
  };
992
- const record = { meta, diff };
1579
+ const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
993
1580
  this.reviews.set(id, record);
994
- await this.persistInitial(record);
1581
+ await this.persistInitial(record, turn);
995
1582
  this.emit({ type: "review.opened", reviewId: id });
996
1583
  return record;
997
1584
  }
1585
+ async appendTurn(id, diff) {
1586
+ const record = await this.get(id);
1587
+ if (!record) {
1588
+ throw new Error(`Review ${id} not found`);
1589
+ }
1590
+ if (record.meta.cwd !== diff.cwd) {
1591
+ throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
1592
+ }
1593
+ const latest = latestTurn(record);
1594
+ if (latest.status === "pending") {
1595
+ if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
1596
+ this.emit({
1597
+ type: "review.turn.created",
1598
+ reviewId: id,
1599
+ turnId: latest.id,
1600
+ turnIndex: latest.index,
1601
+ reused: true
1602
+ });
1603
+ return { record, turn: latest, reused: true };
1604
+ }
1605
+ throw new Error(`Review ${id} already has a pending turn`);
1606
+ }
1607
+ if (latest.status === "cancelled") {
1608
+ throw new Error(`Review ${id} is cancelled and cannot be continued`);
1609
+ }
1610
+ const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
1611
+ const nextRecord = normalizeRecord({
1612
+ ...record,
1613
+ meta: { ...record.meta, activeTurnId: turn.id },
1614
+ turns: [...record.turns, turn]
1615
+ });
1616
+ this.reviews.set(id, nextRecord);
1617
+ await this.persistInitial(nextRecord, turn);
1618
+ this.emit({
1619
+ type: "review.turn.created",
1620
+ reviewId: id,
1621
+ turnId: turn.id,
1622
+ turnIndex: turn.index,
1623
+ reused: false
1624
+ });
1625
+ return { record: nextRecord, turn, reused: false };
1626
+ }
998
1627
  async list() {
999
1628
  await this.loadAllReviews();
1000
- return [...this.reviews.values()].map((record) => record.meta).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1629
+ return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
1630
+ }
1631
+ async clearReviewArtifacts(options = {}) {
1632
+ const result = await clearReviewArtifacts(options);
1633
+ if (!result.dryRun) {
1634
+ for (const review of result.deleted) {
1635
+ this.reviews.delete(review.reviewId);
1636
+ }
1637
+ }
1638
+ return result;
1001
1639
  }
1002
1640
  async get(id) {
1003
1641
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
1004
1642
  }
1005
- async submit(id, comments) {
1643
+ async getTurn(id, turnId) {
1644
+ const record = await this.get(id);
1645
+ return record?.turns.find((turn) => turn.id === turnId) ?? null;
1646
+ }
1647
+ async submit(id, comments, reviewScope) {
1006
1648
  const record = await this.get(id);
1007
1649
  if (!record) {
1008
1650
  throw new Error(`Review ${id} not found`);
1009
1651
  }
1010
- if (record.meta.status !== "pending") {
1011
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
1652
+ const turn = activeTurn(record);
1653
+ const sortedComments = comments.toSorted(compareCommentsByLocation);
1654
+ const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
1655
+ if (turn.status !== "pending") {
1656
+ if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
1657
+ return {
1658
+ record,
1659
+ feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
1660
+ markdownPath: requiredPath(turn.markdownPath, "markdown path"),
1661
+ turn
1662
+ };
1663
+ }
1664
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
1012
1665
  }
1013
1666
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1667
+ const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
1668
+ const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
1014
1669
  const feedback = {
1015
1670
  version: 1,
1016
1671
  reviewId: id,
1672
+ turnId: turn.id,
1673
+ turnIndex: turn.index,
1017
1674
  timestamp,
1018
- base: record.diff.base,
1019
- branch: record.diff.branch,
1020
- comments: [...comments].sort(compareCommentsByLocation)
1675
+ base: turn.diff.base,
1676
+ branch: turn.diff.branch,
1677
+ reviewScope: normalizedReviewScope,
1678
+ comments: sortedComments
1021
1679
  };
1022
- record.feedback = feedback;
1023
- record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
1024
- this.reviews.set(id, record);
1025
- const artifactDir = globalReviewDir(id);
1026
- const feedbackPath = globalReviewFeedbackFile(id);
1027
- const markdownPath = globalReviewMarkdownFile(id);
1028
- record.meta = {
1029
- ...record.meta,
1030
- artifactDir,
1680
+ const nextTurn = {
1681
+ ...turn,
1682
+ status: "submitted",
1683
+ submittedAt: timestamp,
1031
1684
  feedbackPath,
1032
- markdownPath
1685
+ markdownPath,
1686
+ feedback
1033
1687
  };
1034
- await ensureDir(artifactDir);
1688
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1689
+ this.reviews.set(id, nextRecord);
1690
+ await ensureDir(globalReviewTurnDir(id, nextTurn.id));
1035
1691
  await Promise.all([
1036
- writeJsonFile(globalReviewMetaFile(id), record.meta),
1692
+ writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
1037
1693
  writeJsonFile(feedbackPath, feedback),
1038
- writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
1694
+ writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
1039
1695
  ]);
1696
+ await this.persistMeta(nextRecord);
1040
1697
  this.emit({
1041
1698
  type: "review.submitted",
1042
1699
  reviewId: id,
1700
+ turnId: nextTurn.id,
1701
+ turnIndex: nextTurn.index,
1043
1702
  counts: {
1044
1703
  files: countCommentFiles(feedback.comments),
1045
1704
  comments: feedback.comments.length
1046
1705
  }
1047
1706
  });
1048
- return { record, feedbackPath, markdownPath };
1707
+ return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
1049
1708
  }
1050
1709
  async feedback(id) {
1051
1710
  const record = await this.get(id);
1052
1711
  return record?.feedback ?? null;
1053
1712
  }
1054
- async markResolved(id, summary) {
1713
+ async markResolved(id, summary, turnSelector) {
1055
1714
  const record = await this.get(id);
1056
1715
  if (!record) {
1057
1716
  throw new Error(`Review ${id} not found`);
1058
1717
  }
1059
- this.assertResolvable(record, id);
1718
+ const turn = this.resolveTurnSelector(record, turnSelector);
1719
+ this.assertResolvable(turn, id);
1060
1720
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
1061
1721
  const existingById = new Map(
1062
- (record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
1722
+ (turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
1063
1723
  );
1064
1724
  const comments = this.sortResolvedComments(
1065
- (record.feedback?.comments ?? []).map((comment) => ({
1725
+ (turn.feedback?.comments ?? []).map((comment) => ({
1066
1726
  ...existingById.get(comment.id),
1067
1727
  commentId: comment.id,
1068
1728
  status: "resolved",
1069
1729
  resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
1070
1730
  })),
1071
- record
1731
+ turn
1072
1732
  );
1073
1733
  const resolution = {
1074
1734
  reviewId: id,
1735
+ turnId: turn.id,
1736
+ turnIndex: turn.index,
1075
1737
  status: "resolved",
1076
- summary: summary ?? record.resolution?.summary ?? null,
1738
+ summary: summary ?? turn.resolution?.summary ?? null,
1077
1739
  resolvedAt,
1078
1740
  comments
1079
1741
  };
1080
- record.meta = { ...record.meta, status: "resolved", resolvedAt };
1081
- return this.persistResolution(record, resolution, "review-resolved");
1742
+ const nextTurn = {
1743
+ ...turn,
1744
+ status: "resolved",
1745
+ resolvedAt
1746
+ };
1747
+ return this.persistResolution(record, nextTurn, resolution, "review-resolved");
1082
1748
  }
1083
1749
  async resolveComment(id, commentId, summary) {
1084
1750
  const record = await this.get(id);
1085
1751
  if (!record) {
1086
1752
  throw new Error(`Review ${id} not found`);
1087
1753
  }
1088
- this.assertResolvable(record, id);
1089
- this.assertCommentExists(record, commentId);
1754
+ const turn = this.findTurnForComment(record, commentId);
1755
+ if (!turn) {
1756
+ const currentTurn = activeTurn(record);
1757
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1758
+ throw new Error(
1759
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1760
+ );
1761
+ }
1762
+ throw new Error(`Comment ${commentId} not found`);
1763
+ }
1764
+ this.assertResolvable(turn, id);
1090
1765
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
1091
- const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
1766
+ const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
1092
1767
  const nextSummary = summary ?? previous?.summary;
1093
1768
  const nextComment = {
1094
1769
  commentId,
@@ -1098,46 +1773,59 @@ var ReviewStore = class {
1098
1773
  };
1099
1774
  const comments = this.sortResolvedComments(
1100
1775
  [
1101
- ...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1776
+ ...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1102
1777
  nextComment
1103
1778
  ],
1104
- record
1779
+ turn
1105
1780
  );
1106
- const counts = resolutionCounts(record.feedback, comments);
1781
+ const counts = resolutionCounts(turn.feedback, comments);
1107
1782
  const fullyResolved = counts.total === counts.resolved;
1108
1783
  const resolution = {
1109
1784
  reviewId: id,
1785
+ turnId: turn.id,
1786
+ turnIndex: turn.index,
1110
1787
  status: fullyResolved ? "resolved" : "partial",
1111
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1788
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
1112
1789
  resolvedAt: fullyResolved ? resolvedAt : null,
1113
1790
  comments
1114
1791
  };
1115
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
1116
- return this.persistResolution(record, resolution, "comment-resolved");
1792
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
1793
+ return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
1117
1794
  }
1118
1795
  async reopenComment(id, commentId) {
1119
1796
  const record = await this.get(id);
1120
1797
  if (!record) {
1121
1798
  throw new Error(`Review ${id} not found`);
1122
1799
  }
1123
- this.assertResolvable(record, id);
1124
- this.assertCommentExists(record, commentId);
1800
+ const turn = this.findTurnForComment(record, commentId);
1801
+ if (!turn) {
1802
+ const currentTurn = activeTurn(record);
1803
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1804
+ throw new Error(
1805
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1806
+ );
1807
+ }
1808
+ throw new Error(`Comment ${commentId} not found`);
1809
+ }
1810
+ this.assertResolvable(turn, id);
1125
1811
  const comments = this.sortResolvedComments(
1126
- (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1127
- record
1812
+ (turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1813
+ turn
1128
1814
  );
1129
- const counts = resolutionCounts(record.feedback, comments);
1815
+ const counts = resolutionCounts(turn.feedback, comments);
1130
1816
  const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
1131
1817
  const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
1132
1818
  const resolution = {
1133
1819
  reviewId: id,
1820
+ turnId: turn.id,
1821
+ turnIndex: turn.index,
1134
1822
  status: fullyResolved ? "resolved" : "partial",
1135
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1823
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
1136
1824
  resolvedAt,
1137
1825
  comments
1138
1826
  };
1139
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
1140
- return this.persistResolution(record, resolution, "comment-reopened");
1827
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
1828
+ return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
1141
1829
  }
1142
1830
  subscribe(reviewId, listener) {
1143
1831
  const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
@@ -1155,13 +1843,17 @@ var ReviewStore = class {
1155
1843
  listener(event);
1156
1844
  }
1157
1845
  }
1158
- async persistInitial(record) {
1159
- const dir = globalReviewDir(record.meta.id);
1160
- await ensureDir(dir);
1846
+ async persistInitial(record, turn) {
1847
+ await ensureDir(turn.artifactDir);
1161
1848
  await Promise.all([
1162
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
1163
- writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
1849
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
1850
+ writeJsonFile(turn.diffPath, turn.diff)
1164
1851
  ]);
1852
+ await this.persistMeta(record);
1853
+ }
1854
+ async persistMeta(record) {
1855
+ await ensureDir(globalReviewDir(record.meta.id));
1856
+ await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
1165
1857
  }
1166
1858
  async loadKnownReview(id) {
1167
1859
  const existing = this.reviews.get(id);
@@ -1173,98 +1865,229 @@ var ReviewStore = class {
1173
1865
  async loadAllReviews() {
1174
1866
  let entries;
1175
1867
  try {
1176
- entries = await readdir(globalReviewsDir(), { withFileTypes: true });
1868
+ entries = await readdir2(globalReviewsDir(), { withFileTypes: true });
1177
1869
  } catch (error) {
1178
- if (isFileNotFound2(error)) {
1870
+ if (isFileNotFound(error)) {
1179
1871
  return;
1180
1872
  }
1181
1873
  throw new Error(
1182
- `Could not read reviews directory at ${globalReviewsDir()}: ${formatError2(error)}`,
1874
+ `Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
1183
1875
  {
1184
1876
  cause: error
1185
1877
  }
1186
1878
  );
1187
1879
  }
1188
- await Promise.all(
1189
- entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
1190
- );
1880
+ const reviewLoads = [];
1881
+ for (const entry of entries) {
1882
+ if (entry.isDirectory()) {
1883
+ reviewLoads.push(this.loadReview(entry.name));
1884
+ }
1885
+ }
1886
+ await Promise.all(reviewLoads);
1191
1887
  }
1192
1888
  async loadReview(id) {
1193
1889
  const metaPath = globalReviewMetaFile(id);
1194
- const diffPath = globalReviewDiffFile(id);
1890
+ let metaRaw;
1891
+ try {
1892
+ metaRaw = await readFile3(metaPath, "utf8");
1893
+ } catch (error) {
1894
+ if (isFileNotFound(error)) {
1895
+ return this.loadReviewFromTurnsOnly(id);
1896
+ }
1897
+ throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1898
+ }
1899
+ const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1900
+ const persistedTurns = await this.loadPersistedTurns(id);
1901
+ const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
1902
+ const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
1903
+ if (turns.length === 0) {
1904
+ throw new Error(`Review ${id} has no recoverable turns`);
1905
+ }
1906
+ const latest = latestTurn({ turns });
1907
+ const record = normalizeRecord({
1908
+ meta: {
1909
+ ...storedMeta,
1910
+ artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
1911
+ activeTurnId: latest.id
1912
+ },
1913
+ turns,
1914
+ diff: latest.diff
1915
+ });
1916
+ this.reviews.set(id, record);
1917
+ return record;
1918
+ }
1919
+ async loadReviewFromTurnsOnly(id) {
1920
+ const turns = await this.loadPersistedTurns(id);
1921
+ if (turns.length === 0) {
1922
+ return null;
1923
+ }
1924
+ const latest = latestTurn({ turns });
1925
+ const record = normalizeRecord({
1926
+ meta: {
1927
+ id,
1928
+ cwd: latest.diff.cwd,
1929
+ base: latest.diff.base,
1930
+ branch: latest.diff.branch,
1931
+ status: latest.status,
1932
+ createdAt: turns[0]?.createdAt ?? latest.createdAt,
1933
+ artifactDir: globalReviewDir(id),
1934
+ activeTurnId: latest.id
1935
+ },
1936
+ turns,
1937
+ diff: latest.diff
1938
+ });
1939
+ this.reviews.set(id, record);
1940
+ await this.persistMeta(record);
1941
+ return record;
1942
+ }
1943
+ async loadPersistedTurns(id) {
1944
+ let entries;
1945
+ try {
1946
+ entries = await readdir2(globalReviewTurnsDir(id), { withFileTypes: true });
1947
+ } catch (error) {
1948
+ if (isFileNotFound(error)) {
1949
+ return [];
1950
+ }
1951
+ throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
1952
+ cause: error
1953
+ });
1954
+ }
1955
+ const turns = [];
1956
+ for (const entry of entries) {
1957
+ if (!entry.isDirectory()) {
1958
+ continue;
1959
+ }
1960
+ const turn = await this.loadPersistedTurn(id, entry.name);
1961
+ if (turn) {
1962
+ turns.push(turn);
1963
+ }
1964
+ }
1965
+ return turns.toSorted((a, b) => a.index - b.index);
1966
+ }
1967
+ async loadPersistedTurn(id, turnId) {
1968
+ const metaPath = globalReviewTurnMetaFile(id, turnId);
1969
+ const diffPath = globalReviewTurnDiffFile(id, turnId);
1195
1970
  let metaRaw;
1196
1971
  let diffRaw;
1197
1972
  try {
1198
1973
  [metaRaw, diffRaw] = await Promise.all([
1199
- readFile2(metaPath, "utf8"),
1200
- readFile2(diffPath, "utf8")
1974
+ readFile3(metaPath, "utf8"),
1975
+ readFile3(diffPath, "utf8")
1201
1976
  ]);
1202
1977
  } catch (error) {
1203
- if (isFileNotFound2(error)) {
1978
+ if (isFileNotFound(error)) {
1979
+ return null;
1980
+ }
1981
+ throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
1982
+ cause: error
1983
+ });
1984
+ }
1985
+ const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
1986
+ const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
1987
+ const [feedback, resolution] = await Promise.all([
1988
+ readOptionalJsonFile(
1989
+ globalReviewTurnFeedbackFile(id, turnId),
1990
+ isFeedbackBundle,
1991
+ "review feedback"
1992
+ ),
1993
+ readOptionalJsonFile(
1994
+ globalReviewTurnResolvedFile(id, turnId),
1995
+ isResolutionBundle,
1996
+ "review resolution"
1997
+ )
1998
+ ]);
1999
+ return reconcileTurn(meta, diff, feedback, resolution);
2000
+ }
2001
+ async loadLegacyTurn(id, storedMeta) {
2002
+ const diffPath = globalReviewDiffFile(id);
2003
+ let diffRaw;
2004
+ try {
2005
+ diffRaw = await readFile3(diffPath, "utf8");
2006
+ } catch (error) {
2007
+ if (isFileNotFound(error)) {
1204
2008
  return null;
1205
2009
  }
1206
- throw new Error(`Could not load review ${id}: ${formatError2(error)}`, { cause: error });
2010
+ throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
2011
+ cause: error
2012
+ });
1207
2013
  }
1208
- const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1209
2014
  const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
1210
- const feedback = await readOptionalJsonFile(
1211
- globalReviewFeedbackFile(id),
1212
- isFeedbackBundle,
1213
- "review feedback"
1214
- );
1215
- const resolution = await readOptionalJsonFile(
1216
- globalReviewResolvedFile(id),
1217
- isResolutionBundle,
1218
- "review resolution"
1219
- );
1220
- const record = {
1221
- meta: {
1222
- ...meta,
1223
- artifactDir: meta.artifactDir ?? globalReviewDir(id),
1224
- feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
1225
- markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
1226
- },
1227
- diff,
1228
- feedback,
1229
- resolution
2015
+ const [feedback, resolution] = await Promise.all([
2016
+ readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
2017
+ readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
2018
+ ]);
2019
+ const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
2020
+ const legacySummary = storedMeta.turns?.find(
2021
+ (turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
2022
+ ) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
2023
+ const meta = {
2024
+ id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
2025
+ index: legacySummary?.index ?? 1,
2026
+ status: legacySummary?.status ?? storedMeta.status,
2027
+ createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
2028
+ submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
2029
+ resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
2030
+ artifactDir: legacySummary?.artifactDir ?? artifactDir,
2031
+ diffPath,
2032
+ ...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
2033
+ ...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
1230
2034
  };
1231
- this.reviews.set(id, record);
1232
- return record;
2035
+ return reconcileTurn(meta, diff, feedback, resolution);
1233
2036
  }
1234
- assertResolvable(record, id) {
1235
- if (!isResolvableReviewStatus(record.meta.status)) {
1236
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
2037
+ assertResolvable(turn, id) {
2038
+ if (!isResolvableReviewStatus(turn.status)) {
2039
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
1237
2040
  }
1238
- if (!record.feedback) {
1239
- throw new Error(`Review ${id} has no submitted feedback`);
2041
+ if (!turn.feedback) {
2042
+ throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
1240
2043
  }
1241
2044
  }
1242
- assertCommentExists(record, commentId) {
1243
- if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
1244
- throw new Error(`Comment ${commentId} not found`);
2045
+ resolveTurnSelector(record, selector) {
2046
+ if (!selector) {
2047
+ return activeTurn(record);
1245
2048
  }
1246
- }
1247
- async persistResolution(record, resolution, reason) {
1248
- record.resolution = resolution;
1249
- this.reviews.set(record.meta.id, record);
1250
- const resolvedPath = globalReviewResolvedFile(record.meta.id);
1251
- await ensureDir(globalReviewDir(record.meta.id));
2049
+ const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
2050
+ if (!turn) {
2051
+ throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
2052
+ }
2053
+ return turn;
2054
+ }
2055
+ findTurnForComment(record, commentId) {
2056
+ return [...record.turns].reverse().find(
2057
+ (candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
2058
+ ) ?? null;
2059
+ }
2060
+ async persistResolution(record, turn, resolution, reason) {
2061
+ const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
2062
+ const nextTurn = {
2063
+ ...turn,
2064
+ resolvedPath,
2065
+ resolution
2066
+ };
2067
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
2068
+ this.reviews.set(record.meta.id, nextRecord);
2069
+ await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
1252
2070
  await Promise.all([
1253
2071
  writeJsonFile(resolvedPath, resolution),
1254
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
2072
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
1255
2073
  ]);
2074
+ await this.persistMeta(nextRecord);
1256
2075
  const result = {
1257
2076
  ok: true,
1258
2077
  reviewId: record.meta.id,
1259
- status: record.meta.status,
2078
+ turnId: nextTurn.id,
2079
+ turnIndex: nextTurn.index,
2080
+ status: nextTurn.status,
1260
2081
  resolutionStatus: resolution.status,
1261
- comments: resolutionCounts(record.feedback, resolution.comments),
2082
+ comments: resolutionCounts(nextTurn.feedback, resolution.comments),
1262
2083
  path: resolvedPath,
1263
2084
  resolution
1264
2085
  };
1265
2086
  this.emit({
1266
2087
  type: "review.updated",
1267
2088
  reviewId: record.meta.id,
2089
+ turnId: nextTurn.id,
2090
+ turnIndex: nextTurn.index,
1268
2091
  reason,
1269
2092
  status: result.status,
1270
2093
  resolutionStatus: result.resolutionStatus,
@@ -1272,24 +2095,134 @@ var ReviewStore = class {
1272
2095
  });
1273
2096
  return result;
1274
2097
  }
1275
- sortResolvedComments(comments, record) {
2098
+ sortResolvedComments(comments, turn) {
1276
2099
  const feedbackIndex = new Map(
1277
- record.feedback.comments.map((comment, index) => [comment.id, index])
1278
- );
1279
- return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
1280
- (a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
2100
+ turn.feedback.comments.map((comment, index) => [comment.id, index])
1281
2101
  );
2102
+ return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
2103
+ (entry) => entry.index !== void 0
2104
+ ).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
1282
2105
  }
1283
2106
  };
2107
+ function createTurn(reviewId, index, diff, createdAt) {
2108
+ const id = ulid();
2109
+ return {
2110
+ id,
2111
+ index,
2112
+ status: "pending",
2113
+ createdAt,
2114
+ artifactDir: globalReviewTurnDir(reviewId, id),
2115
+ diffPath: globalReviewTurnDiffFile(reviewId, id),
2116
+ diff
2117
+ };
2118
+ }
2119
+ function normalizeRecord(record) {
2120
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
2121
+ const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
2122
+ const meta = {
2123
+ ...record.meta,
2124
+ base: active.diff.base,
2125
+ branch: active.diff.branch,
2126
+ status: active.status,
2127
+ submittedAt: active.submittedAt,
2128
+ resolvedAt: active.resolvedAt,
2129
+ artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
2130
+ activeTurnId: active.id,
2131
+ turns: turns.map(turnSummary),
2132
+ feedbackPath: active.feedbackPath,
2133
+ markdownPath: active.markdownPath
2134
+ };
2135
+ return {
2136
+ meta,
2137
+ turns,
2138
+ diff: active.diff,
2139
+ ...active.feedback ? { feedback: active.feedback } : {},
2140
+ ...active.resolution ? { resolution: active.resolution } : {}
2141
+ };
2142
+ }
2143
+ function replaceTurn(record, nextTurn) {
2144
+ return {
2145
+ ...record,
2146
+ turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
2147
+ };
2148
+ }
2149
+ function activeTurn(record) {
2150
+ return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
2151
+ }
2152
+ function latestTurn(record) {
2153
+ return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
2154
+ }
2155
+ function turnMeta(turn) {
2156
+ return {
2157
+ id: turn.id,
2158
+ index: turn.index,
2159
+ status: turn.status,
2160
+ createdAt: turn.createdAt,
2161
+ submittedAt: turn.submittedAt,
2162
+ resolvedAt: turn.resolvedAt,
2163
+ artifactDir: turn.artifactDir,
2164
+ diffPath: turn.diffPath,
2165
+ feedbackPath: turn.feedbackPath,
2166
+ markdownPath: turn.markdownPath,
2167
+ resolvedPath: turn.resolvedPath
2168
+ };
2169
+ }
2170
+ function turnSummary(turn) {
2171
+ return {
2172
+ ...turnMeta(turn),
2173
+ capturedAt: turn.diff.capturedAt,
2174
+ stats: turn.diff.stats,
2175
+ comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
2176
+ };
2177
+ }
2178
+ function reconcileTurn(meta, diff, feedback, resolution) {
2179
+ const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
2180
+ return {
2181
+ ...meta,
2182
+ status,
2183
+ submittedAt: feedback?.timestamp ?? meta.submittedAt,
2184
+ resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
2185
+ feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
2186
+ markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
2187
+ resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
2188
+ diff,
2189
+ ...feedback ? { feedback } : {},
2190
+ ...resolution ? { resolution } : {}
2191
+ };
2192
+ }
2193
+ function mergeRecoveredTurns(legacyTurn, persistedTurns) {
2194
+ const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
2195
+ return turns.toSorted((a, b) => a.index - b.index);
2196
+ }
2197
+ function diffFingerprint(diff) {
2198
+ return createHash("sha256").update(
2199
+ JSON.stringify({
2200
+ base: diff.base,
2201
+ branch: diff.branch,
2202
+ cwd: diff.cwd,
2203
+ scope: diff.scope,
2204
+ rawDiff: diff.rawDiff
2205
+ })
2206
+ ).digest("hex");
2207
+ }
2208
+ function sameComments(left, right) {
2209
+ return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
2210
+ }
2211
+ function requiredPath(value, label) {
2212
+ if (!value) {
2213
+ throw new Error(`Submitted review is missing ${label}`);
2214
+ }
2215
+ return value;
2216
+ }
1284
2217
  async function readOptionalJsonFile(filePath, guard, label) {
1285
2218
  let raw;
1286
2219
  try {
1287
- raw = await readFile2(filePath, "utf8");
2220
+ raw = await readFile3(filePath, "utf8");
1288
2221
  } catch (error) {
1289
- if (isFileNotFound2(error)) {
2222
+ if (isFileNotFound(error)) {
1290
2223
  return void 0;
1291
2224
  }
1292
- throw new Error(`Could not read ${label} at ${filePath}: ${formatError2(error)}`, {
2225
+ throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
1293
2226
  cause: error
1294
2227
  });
1295
2228
  }
@@ -1299,15 +2232,9 @@ function parseJsonFile(raw, guard, label, filePath) {
1299
2232
  try {
1300
2233
  return parseJson(raw, guard, label);
1301
2234
  } catch (error) {
1302
- throw new Error(`Invalid ${label} at ${filePath}: ${formatError2(error)}`, { cause: error });
2235
+ throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
1303
2236
  }
1304
2237
  }
1305
- function isFileNotFound2(error) {
1306
- return error instanceof Error && "code" in error && error.code === "ENOENT";
1307
- }
1308
- function formatError2(error) {
1309
- return error instanceof Error ? error.message : String(error);
1310
- }
1311
2238
  var reviewStore = new ReviewStore();
1312
2239
 
1313
2240
  // src/cli/status.ts
@@ -1335,31 +2262,61 @@ function printPlain(value) {
1335
2262
  }
1336
2263
  var program = new Command();
1337
2264
  program.name("gloss").description("Local browser-based diff review for coding-agent loops.").version(packageVersion).option("--json", "print JSON for supported commands").option("--no-color", "disable color output");
1338
- program.command("open").description("Capture local changes and open them for review").option("--base <ref>", "explicit base git ref").option("--print-url", "print review URL").option("--no-open", "do not open a browser").option("--no-watch", "return immediately after registering the review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(
2265
+ program.command("open").description("Capture local changes and open them for review").option("--base <ref>", "explicit base git ref").option("--review <reviewId>", "append or resume a turn in an existing review").option("--print-url", "print review URL").option("--no-open", "do not open a browser").option("--no-watch", "return immediately after registering the review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(
1339
2266
  async (options) => {
1340
2267
  const globals = program.opts();
1341
- const info = await ensureServer();
1342
- const client = new ServerClient(serverUrl(info));
1343
- const diff = await captureDiff(options.base);
1344
- const { meta, url } = await client.createReview(diff);
2268
+ let info = await ensureServer();
2269
+ let client = new ServerClient(serverUrl(info));
2270
+ const inheritedBase = options.review && !options.base ? await baseForExistingReview(client, options.review) : null;
2271
+ const diff = await captureDiff(options.base ?? inheritedBase ?? void 0);
2272
+ const created = options.review ? await client.appendReviewTurn(options.review, diff) : await client.createReview(diff);
2273
+ const meta = created.meta;
2274
+ const turn = created.turn ?? meta.turns?.find((summary) => summary.id === meta.activeTurnId);
2275
+ if (!turn) {
2276
+ throw new Error(`Review ${meta.id} has no active turn`);
2277
+ }
2278
+ const reused = "reused" in created ? created.reused === true : false;
2279
+ let url = created.url;
2280
+ const shouldWatch = options.watch !== false;
1345
2281
  if (options.printUrl) {
1346
2282
  printPlain(url);
1347
2283
  }
1348
2284
  if (options.open !== false) {
1349
2285
  await openBrowser(url);
1350
2286
  }
1351
- if (options.watch === false) {
2287
+ if (!shouldWatch) {
1352
2288
  const result2 = {
1353
2289
  reviewId: meta.id,
2290
+ turnId: turn.id,
2291
+ turnIndex: turn.index,
1354
2292
  url,
1355
2293
  files: diff.files.length,
1356
2294
  scope: diff.scope.mode,
1357
- artifactDir: meta.artifactDir
2295
+ artifactDir: turn.artifactDir,
2296
+ reused
1358
2297
  };
1359
2298
  globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
1360
2299
  return;
1361
2300
  }
1362
- const event = await client.watchReview(meta.id, options.timeout);
2301
+ const watched = await watchReviewWithReconnect(
2302
+ meta.id,
2303
+ info,
2304
+ options.timeout,
2305
+ async (nextInfo) => {
2306
+ info = nextInfo;
2307
+ client = new ServerClient(serverUrl(info));
2308
+ url = `${serverUrl(info)}/review/${meta.id}`;
2309
+ if (options.printUrl) {
2310
+ printPlain(url);
2311
+ }
2312
+ if (options.open !== false) {
2313
+ await openBrowser(url);
2314
+ }
2315
+ }
2316
+ );
2317
+ info = watched.info;
2318
+ client = new ServerClient(serverUrl(info));
2319
+ const event = watched.event;
1363
2320
  if (event.type === "review.cancelled") {
1364
2321
  process.exitCode = 2;
1365
2322
  globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
@@ -1368,16 +2325,26 @@ program.command("open").description("Capture local changes and open them for rev
1368
2325
  if (event.type !== "review.submitted") {
1369
2326
  throw new Error(`Unexpected review event ${event.type}`);
1370
2327
  }
1371
- const feedback = await client.getFeedback(meta.id);
2328
+ const [feedback, submittedRecord] = await Promise.all([
2329
+ client.getFeedback(meta.id),
2330
+ client.getReview(meta.id)
2331
+ ]);
2332
+ const submittedTurn = submittedRecord.meta.turns?.find((summary) => summary.id === (event.turnId ?? turn.id)) ?? turn;
2333
+ if (!submittedTurn.feedbackPath || !submittedTurn.markdownPath) {
2334
+ throw new Error(`Review ${meta.id} turn ${submittedTurn.index} is missing feedback paths`);
2335
+ }
1372
2336
  const result = {
1373
2337
  reviewId: meta.id,
2338
+ turnId: submittedTurn.id,
2339
+ turnIndex: submittedTurn.index,
1374
2340
  url,
1375
2341
  files: event.counts.files,
1376
2342
  comments: event.counts.comments,
1377
- feedbackPath: globalReviewFeedbackFile(meta.id),
1378
- markdownPath: globalReviewMarkdownFile(meta.id),
1379
- artifactDir: globalReviewDir(meta.id),
1380
- feedback
2343
+ feedbackPath: submittedTurn.feedbackPath,
2344
+ markdownPath: submittedTurn.markdownPath,
2345
+ artifactDir: submittedTurn.artifactDir,
2346
+ feedback,
2347
+ reused
1381
2348
  };
1382
2349
  globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
1383
2350
  }
@@ -1385,8 +2352,12 @@ program.command("open").description("Capture local changes and open them for rev
1385
2352
  program.command("watch").argument("<reviewId>", "review id").description("Wait for review.submitted for an existing review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(async (reviewId, options) => {
1386
2353
  const globals = program.opts();
1387
2354
  const info = await ensureServer();
1388
- const client = new ServerClient(serverUrl(info));
1389
- const event = await client.watchReview(reviewId, options.timeout);
2355
+ const { event } = await watchReviewWithReconnect(
2356
+ reviewId,
2357
+ info,
2358
+ options.timeout,
2359
+ async () => void 0
2360
+ );
1390
2361
  globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
1391
2362
  });
1392
2363
  program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
@@ -1404,28 +2375,46 @@ program.command("status").description("Show server and active reviews").action(a
1404
2375
  responsive && info ? `Gloss server running at ${serverUrl(info)} with ${reviews.length} active review(s)` : "Gloss server is not running"
1405
2376
  );
1406
2377
  });
1407
- program.command("stop").description("Stop the managed background server").action(async () => {
2378
+ program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
1408
2379
  const globals = program.opts();
1409
- const result = await stopServer();
1410
- globals.json ? printJson(result) : printPlain(result.stopped ? "Gloss server stopped" : "Gloss server was not running");
2380
+ const result = await stopServer({ all: options.all });
2381
+ globals.json ? printJson(result) : printPlain(
2382
+ options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
2383
+ );
1411
2384
  });
1412
- program.command("resolve").argument("<reviewId>", "review id").description("Mark a submitted review or one feedback comment as resolved").option("--comment <commentId>", "resolve one submitted feedback comment").option("--summary <text>", "brief summary of the fixes applied").action(async (reviewId, options) => {
2385
+ program.command("clear").description("Delete old completed review artifacts").option(
2386
+ "--older-than <days>",
2387
+ "delete completed reviews older than this many days",
2388
+ parseOlderThanDays,
2389
+ DEFAULT_REVIEW_RETENTION_DAYS
2390
+ ).option("--dry-run", "print cleanup candidates without deleting them").action(async (options) => {
1413
2391
  const globals = program.opts();
1414
- const info = await ensureServer();
1415
- const client = new ServerClient(serverUrl(info));
1416
- const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary);
1417
- if (globals.json) {
1418
- printJson({
1419
- commentId: options.comment ?? null,
1420
- summary: options.summary ?? null,
1421
- ...result
1422
- });
1423
- return;
1424
- }
1425
- printPlain(
1426
- options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
1427
- );
2392
+ const result = await clearReviews({
2393
+ olderThanDays: options.olderThan,
2394
+ dryRun: options.dryRun === true
2395
+ });
2396
+ globals.json ? printJson(result) : printPlain(formatClearResult(result));
1428
2397
  });
2398
+ program.command("resolve").argument("<reviewId>", "review id").description("Mark a submitted review or one feedback comment as resolved").option("--comment <commentId>", "resolve one submitted feedback comment").option("--summary <text>", "brief summary of the fixes applied").option("--turn <idOrIndex>", "resolve a specific turn for whole-review resolution").action(
2399
+ async (reviewId, options) => {
2400
+ const globals = program.opts();
2401
+ const info = await ensureServer();
2402
+ const client = new ServerClient(serverUrl(info));
2403
+ const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary, options.turn);
2404
+ if (globals.json) {
2405
+ printJson({
2406
+ commentId: options.comment ?? null,
2407
+ summary: options.summary ?? null,
2408
+ turn: options.turn ?? null,
2409
+ ...result
2410
+ });
2411
+ return;
2412
+ }
2413
+ printPlain(
2414
+ options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
2415
+ );
2416
+ }
2417
+ );
1429
2418
  program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
1430
2419
  const globals = program.opts();
1431
2420
  const checks = [];
@@ -1455,6 +2444,21 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
1455
2444
  ok: info ? await isServerResponsive(info) : false,
1456
2445
  detail: info ? serverUrl(info) : "not started"
1457
2446
  });
2447
+ try {
2448
+ const daemonPids = await listGlossDaemonPids();
2449
+ const unmanagedDaemonPids = daemonPids.filter((pid) => pid !== info?.pid);
2450
+ checks.push({
2451
+ name: "daemon-processes",
2452
+ ok: unmanagedDaemonPids.length === 0,
2453
+ detail: daemonPids.length === 0 ? "none" : `${daemonPids.length} found${unmanagedDaemonPids.length > 0 ? `; unmanaged pids ${unmanagedDaemonPids.join(", ")}` : ""}`
2454
+ });
2455
+ } catch (error) {
2456
+ checks.push({
2457
+ name: "daemon-processes",
2458
+ ok: false,
2459
+ detail: error instanceof Error ? error.message : String(error)
2460
+ });
2461
+ }
1458
2462
  if (globals.json) {
1459
2463
  printJson({ checks });
1460
2464
  } else {
@@ -1465,6 +2469,66 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
1465
2469
  }
1466
2470
  }
1467
2471
  });
2472
+ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged) {
2473
+ const startedAt = Date.now();
2474
+ let info = initialInfo;
2475
+ while (true) {
2476
+ const remainingSeconds = timeoutSeconds && timeoutSeconds > 0 ? timeoutSeconds - (Date.now() - startedAt) / 1e3 : void 0;
2477
+ if (remainingSeconds !== void 0 && remainingSeconds <= 0) {
2478
+ throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
2479
+ }
2480
+ try {
2481
+ const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, remainingSeconds);
2482
+ return { event, info };
2483
+ } catch (error) {
2484
+ if (isWatchTimeout(error)) {
2485
+ throw error;
2486
+ }
2487
+ if (!isReconnectableWatchError(error)) {
2488
+ throw error;
2489
+ }
2490
+ await sleep2(500);
2491
+ const nextInfo = await ensureServer();
2492
+ if (nextInfo.port !== info.port) {
2493
+ await onServerChanged(nextInfo);
2494
+ }
2495
+ info = nextInfo;
2496
+ }
2497
+ }
2498
+ }
2499
+ async function baseForExistingReview(client, reviewId) {
2500
+ const record = await client.getReview(reviewId);
2501
+ return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
2502
+ }
2503
+ async function clearReviews(options) {
2504
+ const info = await readServerInfo();
2505
+ if (info && await isServerResponsive(info)) {
2506
+ return new ServerClient(serverUrl(info)).clearReviews(options);
2507
+ }
2508
+ return clearReviewArtifacts(options);
2509
+ }
2510
+ function parseOlderThanDays(value) {
2511
+ const days = Number(value);
2512
+ if (!Number.isInteger(days) || days < 0) {
2513
+ throw new Error("--older-than must be a non-negative integer");
2514
+ }
2515
+ return days;
2516
+ }
2517
+ function formatClearResult(result) {
2518
+ const action = result.dryRun ? "Would delete" : "Deleted";
2519
+ const count = result.dryRun ? result.counts.candidates : result.counts.deleted;
2520
+ const skipped = result.counts.skipped > 0 ? `; skipped ${result.counts.skipped} invalid review artifact(s)` : "";
2521
+ return `${action} ${count} review artifact(s) older than ${result.olderThanDays} day(s) from ${result.reviewsDir}${skipped}`;
2522
+ }
2523
+ function isWatchTimeout(error) {
2524
+ return error instanceof Error && /^watch timed out after/.test(error.message);
2525
+ }
2526
+ function isReconnectableWatchError(error) {
2527
+ return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
2528
+ }
2529
+ async function sleep2(milliseconds) {
2530
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
2531
+ }
1468
2532
  program.parseAsync(process.argv).catch((error) => {
1469
2533
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}
1470
2534
  `);