getgloss 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -12,7 +12,7 @@ import path from "path";
12
12
  // package.json
13
13
  var package_default = {
14
14
  name: "getgloss",
15
- version: "0.7.1",
15
+ version: "0.8.0",
16
16
  description: "Local browser-based diff review for coding-agent loops.",
17
17
  type: "module",
18
18
  packageManager: "pnpm@10.33.2",
@@ -43,6 +43,8 @@ var package_default = {
43
43
  },
44
44
  dependencies: {
45
45
  "@hono/node-server": "1.19.14",
46
+ "@shikijs/langs": "4.1.0",
47
+ "@shikijs/themes": "4.1.0",
46
48
  "@tailwindcss/vite": "4.3.0",
47
49
  commander: "14.0.3",
48
50
  execa: "9.6.1",
@@ -52,6 +54,7 @@ var package_default = {
52
54
  open: "10.2.0",
53
55
  react: "19.2.6",
54
56
  "react-dom": "19.2.6",
57
+ shiki: "4.1.0",
55
58
  ulid: "3.0.2",
56
59
  zustand: "5.0.13"
57
60
  },
@@ -117,6 +120,27 @@ function globalReviewsDir() {
117
120
  function globalReviewDir(reviewId) {
118
121
  return path.join(globalReviewsDir(), reviewId);
119
122
  }
123
+ function globalReviewTurnsDir(reviewId) {
124
+ return path.join(globalReviewDir(reviewId), "turns");
125
+ }
126
+ function globalReviewTurnDir(reviewId, turnId) {
127
+ return path.join(globalReviewTurnsDir(reviewId), turnId);
128
+ }
129
+ function globalReviewTurnMetaFile(reviewId, turnId) {
130
+ return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
131
+ }
132
+ function globalReviewTurnDiffFile(reviewId, turnId) {
133
+ return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
134
+ }
135
+ function globalReviewTurnFeedbackFile(reviewId, turnId) {
136
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
137
+ }
138
+ function globalReviewTurnMarkdownFile(reviewId, turnId) {
139
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
140
+ }
141
+ function globalReviewTurnResolvedFile(reviewId, turnId) {
142
+ return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
143
+ }
120
144
  function globalReviewMetaFile(reviewId) {
121
145
  return path.join(globalReviewDir(reviewId), "meta.json");
122
146
  }
@@ -169,7 +193,7 @@ function languageForPath(filePath) {
169
193
  return languageByExtension[ext] ?? ext;
170
194
  }
171
195
 
172
- // src/cli/diff-parser.ts
196
+ // src/shared/diff-parser.ts
173
197
  var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
174
198
  function stripGitPath(input) {
175
199
  return input.replace(/^[ab]\//, "");
@@ -297,6 +321,18 @@ function parseUnifiedDiff(diffText) {
297
321
  return files;
298
322
  }
299
323
 
324
+ // src/shared/diff-stats.ts
325
+ function summarizeDiffFiles(files) {
326
+ return files.reduce(
327
+ (stats, file) => ({
328
+ files: stats.files + 1,
329
+ additions: stats.additions + file.additions,
330
+ deletions: stats.deletions + file.deletions
331
+ }),
332
+ { files: 0, additions: 0, deletions: 0 }
333
+ );
334
+ }
335
+
300
336
  // src/cli/git.ts
301
337
  var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
302
338
  async function git(args, cwd = process.cwd()) {
@@ -317,16 +353,6 @@ async function gitLenient(args, cwd) {
317
353
  async function getRepoRoot(cwd = process.cwd()) {
318
354
  return git(["rev-parse", "--show-toplevel"], cwd);
319
355
  }
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
356
  function buildPayload({
331
357
  repoRoot,
332
358
  branch,
@@ -335,7 +361,8 @@ function buildPayload({
335
361
  mode,
336
362
  requestedBase,
337
363
  comparison,
338
- fallbackReason
364
+ fallbackReason,
365
+ commitDiffs
339
366
  }) {
340
367
  const files = parseUnifiedDiff(rawDiff);
341
368
  return {
@@ -349,9 +376,10 @@ function buildPayload({
349
376
  comparison,
350
377
  fallbackReason
351
378
  },
352
- stats: summarize(files),
379
+ stats: summarizeDiffFiles(files),
353
380
  rawDiff,
354
381
  files,
382
+ ...commitDiffs ? { commitDiffs } : {},
355
383
  capturedAt: (/* @__PURE__ */ new Date()).toISOString()
356
384
  };
357
385
  }
@@ -407,6 +435,52 @@ async function resolveBranchBase(repoRoot) {
407
435
  }
408
436
  return null;
409
437
  }
438
+ async function captureCommitDiffs(baseSha, comparisonRef, repoRoot) {
439
+ const rawLog = await gitMaybe(
440
+ [
441
+ "log",
442
+ "--reverse",
443
+ "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%s%x1e",
444
+ `${baseSha}..${comparisonRef}`
445
+ ],
446
+ repoRoot
447
+ );
448
+ if (!rawLog) {
449
+ return [];
450
+ }
451
+ const commits = rawLog.split("").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
452
+ const [
453
+ sha = "",
454
+ shortSha2 = "",
455
+ authorName = "",
456
+ authorEmail = "",
457
+ authoredAt = "",
458
+ committedAt = "",
459
+ ...subjectParts
460
+ ] = entry.split("\0");
461
+ return {
462
+ sha,
463
+ shortSha: shortSha2,
464
+ subject: subjectParts.join("\0"),
465
+ authorName,
466
+ authorEmail,
467
+ authoredAt,
468
+ committedAt
469
+ };
470
+ }).filter((commit) => commit.sha && commit.shortSha);
471
+ const commitDiffs = [];
472
+ for (const commit of commits) {
473
+ const rawDiff = await git([...DIFF_ARGS, `${commit.sha}^`, commit.sha, "--"], repoRoot);
474
+ const files = parseUnifiedDiff(rawDiff);
475
+ commitDiffs.push({
476
+ commit,
477
+ stats: summarizeDiffFiles(files),
478
+ rawDiff,
479
+ files
480
+ });
481
+ }
482
+ return commitDiffs;
483
+ }
410
484
  async function captureDiff(baseRef, cwd = process.cwd()) {
411
485
  const repoRoot = await getRepoRoot(cwd);
412
486
  const [headSha, branch] = await Promise.all([
@@ -455,7 +529,10 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
455
529
  fallbackReason: "missing-branch-base"
456
530
  });
457
531
  }
458
- const rawDiff = await git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot);
532
+ const [rawDiff, commitDiffs] = await Promise.all([
533
+ git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot),
534
+ captureCommitDiffs(branchBase.mergeBaseSha, "HEAD", repoRoot)
535
+ ]);
459
536
  return buildPayload({
460
537
  repoRoot,
461
538
  branch,
@@ -464,7 +541,8 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
464
541
  mode: "branch",
465
542
  requestedBase: null,
466
543
  comparison: { ref: "HEAD", sha: headSha },
467
- fallbackReason: "working-tree-clean"
544
+ fallbackReason: "working-tree-clean",
545
+ commitDiffs
468
546
  });
469
547
  }
470
548
  async function assertGitAvailable() {
@@ -472,33 +550,66 @@ async function assertGitAvailable() {
472
550
  }
473
551
 
474
552
  // src/cli/lifecycle.ts
475
- import { spawn } from "child_process";
476
- import { existsSync, openSync } from "fs";
477
- import { rm } from "fs/promises";
553
+ import { execFile, spawn } from "child_process";
554
+ import { closeSync, existsSync, openSync } from "fs";
555
+ import { rm as rm2 } from "fs/promises";
556
+ import { userInfo } from "os";
478
557
  import { fileURLToPath } from "url";
558
+ import { promisify } from "util";
479
559
  import getPort from "get-port";
480
560
 
481
561
  // src/shared/server-info.ts
482
562
  import { readFile } from "fs/promises";
483
563
 
564
+ // src/shared/errors.ts
565
+ function formatError(error) {
566
+ return error instanceof Error ? error.message : String(error);
567
+ }
568
+ function isFileNotFound(error) {
569
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
570
+ }
571
+
484
572
  // src/shared/json.ts
485
- import { writeFile } from "fs/promises";
573
+ import { randomUUID } from "crypto";
574
+ import { rename, rm, writeFile } from "fs/promises";
575
+ import path3 from "path";
486
576
  function serializeJson(value) {
487
577
  return `${JSON.stringify(value, null, 2)}
488
578
  `;
489
579
  }
490
580
  async function writeJsonFile(filePath, value) {
491
- await writeFile(filePath, serializeJson(value));
581
+ await writeTextFile(filePath, serializeJson(value));
582
+ }
583
+ async function writeTextFile(filePath, value) {
584
+ const tempPath = path3.join(
585
+ path3.dirname(filePath),
586
+ `.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
587
+ );
588
+ try {
589
+ await writeFile(tempPath, value);
590
+ await rename(tempPath, filePath);
591
+ } catch (error) {
592
+ await rm(tempPath, { force: true }).catch(() => void 0);
593
+ throw error;
594
+ }
492
595
  }
493
596
 
597
+ // src/shared/types.ts
598
+ var SIDES = ["L", "R"];
599
+ var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
600
+ var DIFF_LINE_TYPES = ["context", "add", "delete"];
601
+ var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
602
+ var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
603
+ var REVIEW_SCOPE_MODES = ["all", "single", "range"];
604
+ var RESOLUTION_STATUSES = ["partial", "resolved"];
605
+ var REVIEW_UPDATE_REASONS = [
606
+ "review-resolved",
607
+ "comment-resolved",
608
+ "comment-reopened",
609
+ "turn-created"
610
+ ];
611
+
494
612
  // 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
613
  function parseJson(raw, guard, label) {
503
614
  const parsed = JSON.parse(raw);
504
615
  return parseJsonValue(parsed, guard, label);
@@ -516,34 +627,46 @@ function isHealthResponse(value) {
516
627
  return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
517
628
  }
518
629
  function isCreateReviewResponse(value) {
519
- return isRecord(value) && isReviewMeta(value.meta) && isString(value.url);
630
+ return isRecord(value) && hasReviewRegistrationFields(value) && isOptional(value.turn, isReviewTurnSummary);
631
+ }
632
+ function isCreateReviewTurnResponse(value) {
633
+ return isRecord(value) && hasReviewRegistrationFields(value) && isReviewTurnSummary(value.turn) && isBoolean(value.reused);
520
634
  }
521
635
  function isListReviewsResponse(value) {
522
636
  return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
523
637
  }
524
638
  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);
639
+ 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);
526
640
  }
527
641
  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);
642
+ 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);
529
643
  }
530
644
  function isReviewRecord(value) {
531
- return isRecord(value) && isReviewMeta(value.meta) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
645
+ return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
532
646
  }
533
647
  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);
648
+ 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(
649
+ value.turns,
650
+ (turns) => isArrayOf(turns, isReviewTurnSummary)
651
+ ) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
535
652
  }
536
653
  function isReviewMeta(value) {
537
654
  return isStoredReviewMeta(value) && isString(value.artifactDir);
538
655
  }
656
+ function hasReviewRegistrationFields(value) {
657
+ return isReviewMeta(value.meta) && isString(value.url);
658
+ }
539
659
  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);
660
+ 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(
661
+ value.commitDiffs,
662
+ (commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
663
+ ) && isString(value.capturedAt);
541
664
  }
542
665
  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);
666
+ 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);
544
667
  }
545
668
  function isResolutionBundle(value) {
546
- return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
669
+ 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);
547
670
  }
548
671
  function isReviewEvent(value) {
549
672
  if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
@@ -553,16 +676,33 @@ function isReviewEvent(value) {
553
676
  case "review.opened":
554
677
  case "review.cancelled":
555
678
  return true;
679
+ case "review.turn.created":
680
+ return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
556
681
  case "review.submitted":
557
- return isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
682
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
558
683
  case "review.updated":
559
- return isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
684
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
560
685
  default:
561
686
  return false;
562
687
  }
563
688
  }
689
+ function isReviewTurnMeta(value) {
690
+ 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);
691
+ }
692
+ function isReviewTurnSummary(value) {
693
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
694
+ return false;
695
+ }
696
+ return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
697
+ }
698
+ function isReviewTurn(value) {
699
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
700
+ return false;
701
+ }
702
+ return isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
703
+ }
564
704
  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));
705
+ 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));
566
706
  }
567
707
  function isDiffRef(value) {
568
708
  return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
@@ -573,6 +713,25 @@ function isBaseRef(value) {
573
713
  function isDiffStats(value) {
574
714
  return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
575
715
  }
716
+ function isDiffCommit(value) {
717
+ return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
718
+ }
719
+ function isCommitDiff(value) {
720
+ return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
721
+ }
722
+ function isReviewScope(value) {
723
+ if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
724
+ return false;
725
+ }
726
+ switch (value.mode) {
727
+ case "all":
728
+ return true;
729
+ case "single":
730
+ return isString(value.sha);
731
+ case "range":
732
+ return isString(value.fromSha) && isString(value.toSha);
733
+ }
734
+ }
576
735
  function isDiffFile(value) {
577
736
  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
737
  }
@@ -580,10 +739,10 @@ function isDiffHunk(value) {
580
739
  return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
581
740
  }
582
741
  function isDiffLine(value) {
583
- return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
742
+ return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
584
743
  }
585
744
  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);
745
+ 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
746
  }
588
747
  function isResolvedComment(value) {
589
748
  return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
@@ -592,13 +751,13 @@ function isResolutionCounts(value) {
592
751
  return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
593
752
  }
594
753
  function isReviewStatus(value) {
595
- return isOneOf(value, reviewStatuses);
754
+ return isOneOf(value, REVIEW_STATUSES);
596
755
  }
597
756
  function isResolutionStatus(value) {
598
- return isOneOf(value, resolutionStatuses);
757
+ return isOneOf(value, RESOLUTION_STATUSES);
599
758
  }
600
759
  function isReviewUpdateReason(value) {
601
- return isOneOf(value, reviewUpdateReasons);
760
+ return isOneOf(value, REVIEW_UPDATE_REASONS);
602
761
  }
603
762
  function isRecord(value) {
604
763
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -659,12 +818,6 @@ async function writeServerInfo(info) {
659
818
  await ensureDir(globalStateDir());
660
819
  await writeJsonFile(globalServerFile(), info);
661
820
  }
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
821
 
669
822
  // src/cli/server-client.ts
670
823
  var ServerClient = class {
@@ -678,6 +831,14 @@ var ServerClient = class {
678
831
  async createReview(diff) {
679
832
  return this.post("/api/reviews", diff, isCreateReviewResponse, "create review response");
680
833
  }
834
+ async appendReviewTurn(reviewId, diff) {
835
+ return this.post(
836
+ `/api/reviews/${reviewId}/turns`,
837
+ diff,
838
+ isCreateReviewTurnResponse,
839
+ "create review turn response"
840
+ );
841
+ }
681
842
  async getReview(reviewId) {
682
843
  return this.get(`/api/reviews/${reviewId}`, isReviewRecord, "review response");
683
844
  }
@@ -687,8 +848,8 @@ var ServerClient = class {
687
848
  async getFeedback(reviewId) {
688
849
  return this.get(`/api/reviews/${reviewId}/feedback`, isFeedbackBundle, "feedback response");
689
850
  }
690
- async markResolved(reviewId, summary) {
691
- const request = { summary };
851
+ async markResolved(reviewId, summary, turn) {
852
+ const request = { summary, turn };
692
853
  return this.post(
693
854
  `/api/reviews/${reviewId}/resolved`,
694
855
  request,
@@ -778,20 +939,20 @@ var ServerClient = class {
778
939
  }
779
940
  }
780
941
  }
781
- async get(path3, guard, label) {
782
- const response = await fetch(`${this.baseUrl}${path3}`);
942
+ async get(path5, guard, label) {
943
+ const response = await fetch(`${this.baseUrl}${path5}`);
783
944
  return parseResponse(response, guard, label);
784
945
  }
785
- async post(path3, body, guard, label) {
786
- const response = await fetch(`${this.baseUrl}${path3}`, {
946
+ async post(path5, body, guard, label) {
947
+ const response = await fetch(`${this.baseUrl}${path5}`, {
787
948
  method: "POST",
788
949
  headers: { "content-type": "application/json" },
789
950
  body: JSON.stringify(body)
790
951
  });
791
952
  return parseResponse(response, guard, label);
792
953
  }
793
- async delete(path3, guard, label) {
794
- const response = await fetch(`${this.baseUrl}${path3}`, { method: "DELETE" });
954
+ async delete(path5, guard, label) {
955
+ const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
795
956
  return parseResponse(response, guard, label);
796
957
  }
797
958
  };
@@ -806,13 +967,16 @@ function isPrematureWatchEnd(error) {
806
967
  return error instanceof Error && error.message === "watch stream ended before completion";
807
968
  }
808
969
  function isAbortError(error) {
809
- return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
970
+ return error instanceof Error && error.name === "AbortError";
810
971
  }
811
972
  async function sleep(milliseconds) {
812
973
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
813
974
  }
814
975
 
815
976
  // src/cli/lifecycle.ts
977
+ var execFileAsync = promisify(execFile);
978
+ var gracefulShutdownTimeoutMs = 2e3;
979
+ var forceShutdownTimeoutMs = 1e3;
816
980
  function serverUrl(info) {
817
981
  return `http://localhost:${info.port}`;
818
982
  }
@@ -839,9 +1003,23 @@ async function startServer(options = {}) {
839
1003
  if (existing && await isServerResponsive(existing)) {
840
1004
  return existing;
841
1005
  }
1006
+ if (existing) {
1007
+ await retireServer(existing);
1008
+ }
1009
+ const preferredPort = options.port ?? existing?.port ?? await getPort();
1010
+ try {
1011
+ return await launchServer(preferredPort);
1012
+ } catch (error) {
1013
+ if (options.port || !existing?.port) {
1014
+ throw error;
1015
+ }
1016
+ await removeServerInfoForPid(existing.pid);
1017
+ return launchServer(await getPort());
1018
+ }
1019
+ }
1020
+ async function launchServer(port) {
842
1021
  await ensureDir(globalStateDir());
843
1022
  await ensureDir(globalLogDir());
844
- const port = options.port ?? await getPort();
845
1023
  const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
846
1024
  if (!existsSync(daemonPath)) {
847
1025
  throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
@@ -856,6 +1034,7 @@ async function startServer(options = {}) {
856
1034
  },
857
1035
  stdio: ["ignore", logFd, logFd]
858
1036
  });
1037
+ closeSync(logFd);
859
1038
  child.unref();
860
1039
  const info = {
861
1040
  pid: child.pid ?? -1,
@@ -872,18 +1051,40 @@ async function startServer(options = {}) {
872
1051
  }
873
1052
  await new Promise((resolve) => setTimeout(resolve, 150));
874
1053
  }
1054
+ await terminatePid(info.pid);
1055
+ await removeServerInfoForPid(info.pid);
875
1056
  throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
876
1057
  }
877
- async function stopServer() {
1058
+ async function stopServer(options = {}) {
1059
+ if (options.all) {
1060
+ const info2 = await readServerInfo();
1061
+ const daemonPids = await listGlossDaemonPids();
1062
+ const stoppedPids = [];
1063
+ for (const pid of daemonPids) {
1064
+ if (await terminatePid(pid)) {
1065
+ stoppedPids.push(pid);
1066
+ }
1067
+ }
1068
+ await rm2(globalServerFile(), { force: true });
1069
+ return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
1070
+ }
878
1071
  const info = await readServerInfo();
879
1072
  if (!info) {
880
1073
  return { stopped: false, info: null };
881
1074
  }
882
- if (isPidAlive(info.pid)) {
883
- process.kill(info.pid, "SIGTERM");
1075
+ if (!isPidAlive(info.pid)) {
1076
+ await removeServerInfoForPid(info.pid);
1077
+ return { stopped: false, info };
1078
+ }
1079
+ if (!await isGlossDaemonPid(info.pid)) {
1080
+ await removeServerInfoForPid(info.pid);
1081
+ return { stopped: false, info };
1082
+ }
1083
+ const stopped = await terminatePid(info.pid);
1084
+ if (stopped) {
1085
+ await removeServerInfoForPid(info.pid);
884
1086
  }
885
- await rm(globalServerFile(), { force: true });
886
- return { stopped: true, info };
1087
+ return { stopped, info };
887
1088
  }
888
1089
  function isPidAlive(pid) {
889
1090
  if (pid <= 0) {
@@ -896,9 +1097,86 @@ function isPidAlive(pid) {
896
1097
  return false;
897
1098
  }
898
1099
  }
1100
+ async function retireServer(info) {
1101
+ if (isPidAlive(info.pid) && await isGlossDaemonPid(info.pid)) {
1102
+ await terminatePid(info.pid);
1103
+ }
1104
+ await removeServerInfoForPid(info.pid);
1105
+ }
1106
+ async function terminatePid(pid) {
1107
+ if (!isPidAlive(pid)) {
1108
+ return true;
1109
+ }
1110
+ try {
1111
+ process.kill(pid, "SIGTERM");
1112
+ } catch {
1113
+ return !isPidAlive(pid);
1114
+ }
1115
+ if (await waitForPidExit(pid, gracefulShutdownTimeoutMs)) {
1116
+ return true;
1117
+ }
1118
+ try {
1119
+ process.kill(pid, "SIGKILL");
1120
+ } catch {
1121
+ return !isPidAlive(pid);
1122
+ }
1123
+ return waitForPidExit(pid, forceShutdownTimeoutMs);
1124
+ }
1125
+ async function waitForPidExit(pid, timeoutMs) {
1126
+ const deadline = Date.now() + timeoutMs;
1127
+ while (Date.now() < deadline) {
1128
+ if (!isPidAlive(pid)) {
1129
+ return true;
1130
+ }
1131
+ await new Promise((resolve) => setTimeout(resolve, 50));
1132
+ }
1133
+ return !isPidAlive(pid);
1134
+ }
1135
+ async function removeServerInfoForPid(pid) {
1136
+ const current = await readServerInfo().catch(() => null);
1137
+ if (!current || current.pid === pid) {
1138
+ await rm2(globalServerFile(), { force: true });
1139
+ }
1140
+ }
1141
+ async function isGlossDaemonPid(pid) {
1142
+ const command = await readProcessCommand(pid);
1143
+ return command ? isGlossDaemonCommand(command) : false;
1144
+ }
1145
+ async function readProcessCommand(pid) {
1146
+ try {
1147
+ const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(pid), "-ww"]);
1148
+ return stdout.trim() || null;
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+ async function listGlossDaemonPids() {
1154
+ let stdout;
1155
+ try {
1156
+ ({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
1157
+ } catch {
1158
+ return [];
1159
+ }
1160
+ const currentUser = userInfo().username;
1161
+ return parseGlossDaemonPids(stdout, currentUser, process.pid);
1162
+ }
1163
+ function parseGlossDaemonPids(stdout, currentUser, currentPid = process.pid) {
1164
+ return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
1165
+ pid: Number(match[1]),
1166
+ user: match[2],
1167
+ command: match[3]
1168
+ })).filter(
1169
+ ({ pid, user, command }) => pid !== currentPid && user === currentUser && isGlossDaemonCommand(command)
1170
+ ).map(({ pid }) => pid);
1171
+ }
1172
+ function isGlossDaemonCommand(command) {
1173
+ return /(?:^|\s)(?:\S*\/)?node\s+\S*dist\/server\/daemon\.js(?:\s|$)/.test(command);
1174
+ }
899
1175
 
900
1176
  // src/server/store.ts
901
- import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1177
+ import { createHash } from "crypto";
1178
+ import { readdir, readFile as readFile2 } from "fs/promises";
1179
+ import path4 from "path";
902
1180
  import { ulid } from "ulid";
903
1181
 
904
1182
  // src/shared/comments.ts
@@ -928,6 +1206,60 @@ function resolutionCounts(feedback, resolvedComments = []) {
928
1206
  };
929
1207
  }
930
1208
 
1209
+ // src/shared/review-scope.ts
1210
+ var ALL_REVIEW_SCOPE = { mode: "all" };
1211
+ function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
1212
+ if (scope.mode === "all") {
1213
+ return ALL_REVIEW_SCOPE;
1214
+ }
1215
+ const commitDiffs = diff.commitDiffs ?? [];
1216
+ if (commitDiffs.length === 0) {
1217
+ throw new Error("Review scope requires a review with per-commit diffs");
1218
+ }
1219
+ if (scope.mode === "single") {
1220
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
1221
+ if (!commit) {
1222
+ throw new Error("Review scope must use commits from this review");
1223
+ }
1224
+ return { mode: "single", sha: commit.commit.sha };
1225
+ }
1226
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
1227
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
1228
+ if (fromIndex < 0 || toIndex < 0) {
1229
+ throw new Error("Review scope must use commits from this review");
1230
+ }
1231
+ if (fromIndex > toIndex) {
1232
+ throw new Error("Review scope range must be in review order");
1233
+ }
1234
+ return {
1235
+ mode: "range",
1236
+ fromSha: commitDiffs[fromIndex].commit.sha,
1237
+ toSha: commitDiffs[toIndex].commit.sha
1238
+ };
1239
+ }
1240
+ function sameReviewScope(left, right) {
1241
+ return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
1242
+ }
1243
+ function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
1244
+ if (scope.mode === "all") {
1245
+ return "All commits";
1246
+ }
1247
+ if (scope.mode === "single") {
1248
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
1249
+ return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
1250
+ }
1251
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
1252
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
1253
+ if (fromIndex >= 0 && toIndex >= fromIndex) {
1254
+ const count = toIndex - fromIndex + 1;
1255
+ return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
1256
+ }
1257
+ return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
1258
+ }
1259
+ function shortSha(sha) {
1260
+ return sha.slice(0, 7);
1261
+ }
1262
+
931
1263
  // src/shared/markdown.ts
932
1264
  function fenceFor(snippet) {
933
1265
  let fence = "```";
@@ -942,20 +1274,32 @@ function languageForSnippet(filePath, snippet) {
942
1274
  return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
943
1275
  }
944
1276
  function serializeFeedbackMarkdown(bundle) {
945
- const comments = [...bundle.comments].sort(compareCommentsByLocation);
946
- const files = [...new Set(comments.map((comment) => comment.filePath))];
1277
+ const comments = bundle.comments.toSorted(compareCommentsByLocation);
1278
+ const commentsByFile = /* @__PURE__ */ new Map();
1279
+ const files = [];
1280
+ for (const comment of comments) {
1281
+ const fileComments = commentsByFile.get(comment.filePath);
1282
+ if (fileComments) {
1283
+ fileComments.push(comment);
1284
+ } else {
1285
+ commentsByFile.set(comment.filePath, [comment]);
1286
+ files.push(comment.filePath);
1287
+ }
1288
+ }
947
1289
  const lines = [
948
1290
  `# Gloss feedback - ${bundle.timestamp}`,
949
1291
  `Review: ${bundle.reviewId}`,
1292
+ ...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
1293
+ ...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
950
1294
  `Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
951
1295
  `Files: ${files.length} Comments: ${comments.length}`,
952
1296
  ""
953
1297
  ];
954
1298
  for (const filePath of files) {
955
1299
  lines.push(`## ${filePath}`, "");
956
- for (const comment of comments.filter((item) => item.filePath === filePath)) {
1300
+ for (const comment of commentsByFile.get(filePath) ?? []) {
957
1301
  const snippet = comment.originalSnippet.trimEnd();
958
- const firstSnippetLine = snippet.split("\n").find((line) => line.trim().length > 0);
1302
+ const firstSnippetLine = firstNonEmptyLine(snippet);
959
1303
  const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
960
1304
  lines.push(heading, comment.body.trim(), "");
961
1305
  if (snippet) {
@@ -967,6 +1311,14 @@ function serializeFeedbackMarkdown(bundle) {
967
1311
  return `${lines.join("\n").trimEnd()}
968
1312
  `;
969
1313
  }
1314
+ function firstNonEmptyLine(text) {
1315
+ for (const line of text.split("\n")) {
1316
+ if (line.trim().length > 0) {
1317
+ return line;
1318
+ }
1319
+ }
1320
+ return void 0;
1321
+ }
970
1322
 
971
1323
  // src/shared/reviews.ts
972
1324
  function isResolvableReviewStatus(status) {
@@ -980,6 +1332,7 @@ var ReviewStore = class {
980
1332
  async create(diff) {
981
1333
  const id = ulid();
982
1334
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1335
+ const turn = createTurn(id, 1, diff, createdAt);
983
1336
  const meta = {
984
1337
  id,
985
1338
  cwd: diff.cwd,
@@ -987,108 +1340,188 @@ var ReviewStore = class {
987
1340
  branch: diff.branch,
988
1341
  status: "pending",
989
1342
  createdAt,
990
- artifactDir: globalReviewDir(id)
1343
+ artifactDir: globalReviewDir(id),
1344
+ activeTurnId: turn.id
991
1345
  };
992
- const record = { meta, diff };
1346
+ const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
993
1347
  this.reviews.set(id, record);
994
- await this.persistInitial(record);
1348
+ await this.persistInitial(record, turn);
995
1349
  this.emit({ type: "review.opened", reviewId: id });
996
1350
  return record;
997
1351
  }
1352
+ async appendTurn(id, diff) {
1353
+ const record = await this.get(id);
1354
+ if (!record) {
1355
+ throw new Error(`Review ${id} not found`);
1356
+ }
1357
+ if (record.meta.cwd !== diff.cwd) {
1358
+ throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
1359
+ }
1360
+ const latest = latestTurn(record);
1361
+ if (latest.status === "pending") {
1362
+ if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
1363
+ this.emit({
1364
+ type: "review.turn.created",
1365
+ reviewId: id,
1366
+ turnId: latest.id,
1367
+ turnIndex: latest.index,
1368
+ reused: true
1369
+ });
1370
+ return { record, turn: latest, reused: true };
1371
+ }
1372
+ throw new Error(`Review ${id} already has a pending turn`);
1373
+ }
1374
+ if (latest.status === "cancelled") {
1375
+ throw new Error(`Review ${id} is cancelled and cannot be continued`);
1376
+ }
1377
+ const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
1378
+ const nextRecord = normalizeRecord({
1379
+ ...record,
1380
+ meta: { ...record.meta, activeTurnId: turn.id },
1381
+ turns: [...record.turns, turn]
1382
+ });
1383
+ this.reviews.set(id, nextRecord);
1384
+ await this.persistInitial(nextRecord, turn);
1385
+ this.emit({
1386
+ type: "review.turn.created",
1387
+ reviewId: id,
1388
+ turnId: turn.id,
1389
+ turnIndex: turn.index,
1390
+ reused: false
1391
+ });
1392
+ return { record: nextRecord, turn, reused: false };
1393
+ }
998
1394
  async list() {
999
1395
  await this.loadAllReviews();
1000
- return [...this.reviews.values()].map((record) => record.meta).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1396
+ return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
1001
1397
  }
1002
1398
  async get(id) {
1003
1399
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
1004
1400
  }
1005
- async submit(id, comments) {
1401
+ async getTurn(id, turnId) {
1402
+ const record = await this.get(id);
1403
+ return record?.turns.find((turn) => turn.id === turnId) ?? null;
1404
+ }
1405
+ async submit(id, comments, reviewScope) {
1006
1406
  const record = await this.get(id);
1007
1407
  if (!record) {
1008
1408
  throw new Error(`Review ${id} not found`);
1009
1409
  }
1010
- if (record.meta.status !== "pending") {
1011
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
1410
+ const turn = activeTurn(record);
1411
+ const sortedComments = comments.toSorted(compareCommentsByLocation);
1412
+ const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
1413
+ if (turn.status !== "pending") {
1414
+ if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
1415
+ return {
1416
+ record,
1417
+ feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
1418
+ markdownPath: requiredPath(turn.markdownPath, "markdown path"),
1419
+ turn
1420
+ };
1421
+ }
1422
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
1012
1423
  }
1013
1424
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1425
+ const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
1426
+ const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
1014
1427
  const feedback = {
1015
1428
  version: 1,
1016
1429
  reviewId: id,
1430
+ turnId: turn.id,
1431
+ turnIndex: turn.index,
1017
1432
  timestamp,
1018
- base: record.diff.base,
1019
- branch: record.diff.branch,
1020
- comments: [...comments].sort(compareCommentsByLocation)
1433
+ base: turn.diff.base,
1434
+ branch: turn.diff.branch,
1435
+ reviewScope: normalizedReviewScope,
1436
+ comments: sortedComments
1021
1437
  };
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,
1438
+ const nextTurn = {
1439
+ ...turn,
1440
+ status: "submitted",
1441
+ submittedAt: timestamp,
1031
1442
  feedbackPath,
1032
- markdownPath
1443
+ markdownPath,
1444
+ feedback
1033
1445
  };
1034
- await ensureDir(artifactDir);
1446
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1447
+ this.reviews.set(id, nextRecord);
1448
+ await ensureDir(globalReviewTurnDir(id, nextTurn.id));
1035
1449
  await Promise.all([
1036
- writeJsonFile(globalReviewMetaFile(id), record.meta),
1450
+ writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
1037
1451
  writeJsonFile(feedbackPath, feedback),
1038
- writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
1452
+ writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
1039
1453
  ]);
1454
+ await this.persistMeta(nextRecord);
1040
1455
  this.emit({
1041
1456
  type: "review.submitted",
1042
1457
  reviewId: id,
1458
+ turnId: nextTurn.id,
1459
+ turnIndex: nextTurn.index,
1043
1460
  counts: {
1044
1461
  files: countCommentFiles(feedback.comments),
1045
1462
  comments: feedback.comments.length
1046
1463
  }
1047
1464
  });
1048
- return { record, feedbackPath, markdownPath };
1465
+ return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
1049
1466
  }
1050
1467
  async feedback(id) {
1051
1468
  const record = await this.get(id);
1052
1469
  return record?.feedback ?? null;
1053
1470
  }
1054
- async markResolved(id, summary) {
1471
+ async markResolved(id, summary, turnSelector) {
1055
1472
  const record = await this.get(id);
1056
1473
  if (!record) {
1057
1474
  throw new Error(`Review ${id} not found`);
1058
1475
  }
1059
- this.assertResolvable(record, id);
1476
+ const turn = this.resolveTurnSelector(record, turnSelector);
1477
+ this.assertResolvable(turn, id);
1060
1478
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
1061
1479
  const existingById = new Map(
1062
- (record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
1480
+ (turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
1063
1481
  );
1064
1482
  const comments = this.sortResolvedComments(
1065
- (record.feedback?.comments ?? []).map((comment) => ({
1483
+ (turn.feedback?.comments ?? []).map((comment) => ({
1066
1484
  ...existingById.get(comment.id),
1067
1485
  commentId: comment.id,
1068
1486
  status: "resolved",
1069
1487
  resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
1070
1488
  })),
1071
- record
1489
+ turn
1072
1490
  );
1073
1491
  const resolution = {
1074
1492
  reviewId: id,
1493
+ turnId: turn.id,
1494
+ turnIndex: turn.index,
1075
1495
  status: "resolved",
1076
- summary: summary ?? record.resolution?.summary ?? null,
1496
+ summary: summary ?? turn.resolution?.summary ?? null,
1077
1497
  resolvedAt,
1078
1498
  comments
1079
1499
  };
1080
- record.meta = { ...record.meta, status: "resolved", resolvedAt };
1081
- return this.persistResolution(record, resolution, "review-resolved");
1500
+ const nextTurn = {
1501
+ ...turn,
1502
+ status: "resolved",
1503
+ resolvedAt
1504
+ };
1505
+ return this.persistResolution(record, nextTurn, resolution, "review-resolved");
1082
1506
  }
1083
1507
  async resolveComment(id, commentId, summary) {
1084
1508
  const record = await this.get(id);
1085
1509
  if (!record) {
1086
1510
  throw new Error(`Review ${id} not found`);
1087
1511
  }
1088
- this.assertResolvable(record, id);
1089
- this.assertCommentExists(record, commentId);
1512
+ const turn = this.findTurnForComment(record, commentId);
1513
+ if (!turn) {
1514
+ const currentTurn = activeTurn(record);
1515
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1516
+ throw new Error(
1517
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1518
+ );
1519
+ }
1520
+ throw new Error(`Comment ${commentId} not found`);
1521
+ }
1522
+ this.assertResolvable(turn, id);
1090
1523
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
1091
- const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
1524
+ const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
1092
1525
  const nextSummary = summary ?? previous?.summary;
1093
1526
  const nextComment = {
1094
1527
  commentId,
@@ -1098,46 +1531,59 @@ var ReviewStore = class {
1098
1531
  };
1099
1532
  const comments = this.sortResolvedComments(
1100
1533
  [
1101
- ...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1534
+ ...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1102
1535
  nextComment
1103
1536
  ],
1104
- record
1537
+ turn
1105
1538
  );
1106
- const counts = resolutionCounts(record.feedback, comments);
1539
+ const counts = resolutionCounts(turn.feedback, comments);
1107
1540
  const fullyResolved = counts.total === counts.resolved;
1108
1541
  const resolution = {
1109
1542
  reviewId: id,
1543
+ turnId: turn.id,
1544
+ turnIndex: turn.index,
1110
1545
  status: fullyResolved ? "resolved" : "partial",
1111
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1546
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
1112
1547
  resolvedAt: fullyResolved ? resolvedAt : null,
1113
1548
  comments
1114
1549
  };
1115
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
1116
- return this.persistResolution(record, resolution, "comment-resolved");
1550
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
1551
+ return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
1117
1552
  }
1118
1553
  async reopenComment(id, commentId) {
1119
1554
  const record = await this.get(id);
1120
1555
  if (!record) {
1121
1556
  throw new Error(`Review ${id} not found`);
1122
1557
  }
1123
- this.assertResolvable(record, id);
1124
- this.assertCommentExists(record, commentId);
1558
+ const turn = this.findTurnForComment(record, commentId);
1559
+ if (!turn) {
1560
+ const currentTurn = activeTurn(record);
1561
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1562
+ throw new Error(
1563
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1564
+ );
1565
+ }
1566
+ throw new Error(`Comment ${commentId} not found`);
1567
+ }
1568
+ this.assertResolvable(turn, id);
1125
1569
  const comments = this.sortResolvedComments(
1126
- (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1127
- record
1570
+ (turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1571
+ turn
1128
1572
  );
1129
- const counts = resolutionCounts(record.feedback, comments);
1573
+ const counts = resolutionCounts(turn.feedback, comments);
1130
1574
  const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
1131
1575
  const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
1132
1576
  const resolution = {
1133
1577
  reviewId: id,
1578
+ turnId: turn.id,
1579
+ turnIndex: turn.index,
1134
1580
  status: fullyResolved ? "resolved" : "partial",
1135
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1581
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
1136
1582
  resolvedAt,
1137
1583
  comments
1138
1584
  };
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");
1585
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
1586
+ return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
1141
1587
  }
1142
1588
  subscribe(reviewId, listener) {
1143
1589
  const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
@@ -1155,13 +1601,17 @@ var ReviewStore = class {
1155
1601
  listener(event);
1156
1602
  }
1157
1603
  }
1158
- async persistInitial(record) {
1159
- const dir = globalReviewDir(record.meta.id);
1160
- await ensureDir(dir);
1604
+ async persistInitial(record, turn) {
1605
+ await ensureDir(turn.artifactDir);
1161
1606
  await Promise.all([
1162
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
1163
- writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
1607
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
1608
+ writeJsonFile(turn.diffPath, turn.diff)
1164
1609
  ]);
1610
+ await this.persistMeta(record);
1611
+ }
1612
+ async persistMeta(record) {
1613
+ await ensureDir(globalReviewDir(record.meta.id));
1614
+ await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
1165
1615
  }
1166
1616
  async loadKnownReview(id) {
1167
1617
  const existing = this.reviews.get(id);
@@ -1175,23 +1625,106 @@ var ReviewStore = class {
1175
1625
  try {
1176
1626
  entries = await readdir(globalReviewsDir(), { withFileTypes: true });
1177
1627
  } catch (error) {
1178
- if (isFileNotFound2(error)) {
1628
+ if (isFileNotFound(error)) {
1179
1629
  return;
1180
1630
  }
1181
1631
  throw new Error(
1182
- `Could not read reviews directory at ${globalReviewsDir()}: ${formatError2(error)}`,
1632
+ `Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
1183
1633
  {
1184
1634
  cause: error
1185
1635
  }
1186
1636
  );
1187
1637
  }
1188
- await Promise.all(
1189
- entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
1190
- );
1638
+ const reviewLoads = [];
1639
+ for (const entry of entries) {
1640
+ if (entry.isDirectory()) {
1641
+ reviewLoads.push(this.loadReview(entry.name));
1642
+ }
1643
+ }
1644
+ await Promise.all(reviewLoads);
1191
1645
  }
1192
1646
  async loadReview(id) {
1193
1647
  const metaPath = globalReviewMetaFile(id);
1194
- const diffPath = globalReviewDiffFile(id);
1648
+ let metaRaw;
1649
+ try {
1650
+ metaRaw = await readFile2(metaPath, "utf8");
1651
+ } catch (error) {
1652
+ if (isFileNotFound(error)) {
1653
+ return this.loadReviewFromTurnsOnly(id);
1654
+ }
1655
+ throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1656
+ }
1657
+ const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1658
+ const persistedTurns = await this.loadPersistedTurns(id);
1659
+ const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
1660
+ const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
1661
+ if (turns.length === 0) {
1662
+ throw new Error(`Review ${id} has no recoverable turns`);
1663
+ }
1664
+ const latest = latestTurn({ turns });
1665
+ const record = normalizeRecord({
1666
+ meta: {
1667
+ ...storedMeta,
1668
+ artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
1669
+ activeTurnId: latest.id
1670
+ },
1671
+ turns,
1672
+ diff: latest.diff
1673
+ });
1674
+ this.reviews.set(id, record);
1675
+ return record;
1676
+ }
1677
+ async loadReviewFromTurnsOnly(id) {
1678
+ const turns = await this.loadPersistedTurns(id);
1679
+ if (turns.length === 0) {
1680
+ return null;
1681
+ }
1682
+ const latest = latestTurn({ turns });
1683
+ const record = normalizeRecord({
1684
+ meta: {
1685
+ id,
1686
+ cwd: latest.diff.cwd,
1687
+ base: latest.diff.base,
1688
+ branch: latest.diff.branch,
1689
+ status: latest.status,
1690
+ createdAt: turns[0]?.createdAt ?? latest.createdAt,
1691
+ artifactDir: globalReviewDir(id),
1692
+ activeTurnId: latest.id
1693
+ },
1694
+ turns,
1695
+ diff: latest.diff
1696
+ });
1697
+ this.reviews.set(id, record);
1698
+ await this.persistMeta(record);
1699
+ return record;
1700
+ }
1701
+ async loadPersistedTurns(id) {
1702
+ let entries;
1703
+ try {
1704
+ entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
1705
+ } catch (error) {
1706
+ if (isFileNotFound(error)) {
1707
+ return [];
1708
+ }
1709
+ throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
1710
+ cause: error
1711
+ });
1712
+ }
1713
+ const turns = [];
1714
+ for (const entry of entries) {
1715
+ if (!entry.isDirectory()) {
1716
+ continue;
1717
+ }
1718
+ const turn = await this.loadPersistedTurn(id, entry.name);
1719
+ if (turn) {
1720
+ turns.push(turn);
1721
+ }
1722
+ }
1723
+ return turns.toSorted((a, b) => a.index - b.index);
1724
+ }
1725
+ async loadPersistedTurn(id, turnId) {
1726
+ const metaPath = globalReviewTurnMetaFile(id, turnId);
1727
+ const diffPath = globalReviewTurnDiffFile(id, turnId);
1195
1728
  let metaRaw;
1196
1729
  let diffRaw;
1197
1730
  try {
@@ -1200,71 +1733,119 @@ var ReviewStore = class {
1200
1733
  readFile2(diffPath, "utf8")
1201
1734
  ]);
1202
1735
  } catch (error) {
1203
- if (isFileNotFound2(error)) {
1736
+ if (isFileNotFound(error)) {
1737
+ return null;
1738
+ }
1739
+ throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
1740
+ cause: error
1741
+ });
1742
+ }
1743
+ const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
1744
+ const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
1745
+ const [feedback, resolution] = await Promise.all([
1746
+ readOptionalJsonFile(
1747
+ globalReviewTurnFeedbackFile(id, turnId),
1748
+ isFeedbackBundle,
1749
+ "review feedback"
1750
+ ),
1751
+ readOptionalJsonFile(
1752
+ globalReviewTurnResolvedFile(id, turnId),
1753
+ isResolutionBundle,
1754
+ "review resolution"
1755
+ )
1756
+ ]);
1757
+ return reconcileTurn(meta, diff, feedback, resolution);
1758
+ }
1759
+ async loadLegacyTurn(id, storedMeta) {
1760
+ const diffPath = globalReviewDiffFile(id);
1761
+ let diffRaw;
1762
+ try {
1763
+ diffRaw = await readFile2(diffPath, "utf8");
1764
+ } catch (error) {
1765
+ if (isFileNotFound(error)) {
1204
1766
  return null;
1205
1767
  }
1206
- throw new Error(`Could not load review ${id}: ${formatError2(error)}`, { cause: error });
1768
+ throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
1769
+ cause: error
1770
+ });
1207
1771
  }
1208
- const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1209
1772
  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
1773
+ const [feedback, resolution] = await Promise.all([
1774
+ readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
1775
+ readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
1776
+ ]);
1777
+ const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
1778
+ const legacySummary = storedMeta.turns?.find(
1779
+ (turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
1780
+ ) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
1781
+ const meta = {
1782
+ id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
1783
+ index: legacySummary?.index ?? 1,
1784
+ status: legacySummary?.status ?? storedMeta.status,
1785
+ createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
1786
+ submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
1787
+ resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
1788
+ artifactDir: legacySummary?.artifactDir ?? artifactDir,
1789
+ diffPath,
1790
+ ...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
1791
+ ...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
1230
1792
  };
1231
- this.reviews.set(id, record);
1232
- return record;
1793
+ return reconcileTurn(meta, diff, feedback, resolution);
1233
1794
  }
1234
- assertResolvable(record, id) {
1235
- if (!isResolvableReviewStatus(record.meta.status)) {
1236
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
1795
+ assertResolvable(turn, id) {
1796
+ if (!isResolvableReviewStatus(turn.status)) {
1797
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
1237
1798
  }
1238
- if (!record.feedback) {
1239
- throw new Error(`Review ${id} has no submitted feedback`);
1799
+ if (!turn.feedback) {
1800
+ throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
1240
1801
  }
1241
1802
  }
1242
- assertCommentExists(record, commentId) {
1243
- if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
1244
- throw new Error(`Comment ${commentId} not found`);
1803
+ resolveTurnSelector(record, selector) {
1804
+ if (!selector) {
1805
+ return activeTurn(record);
1245
1806
  }
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));
1807
+ const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
1808
+ if (!turn) {
1809
+ throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
1810
+ }
1811
+ return turn;
1812
+ }
1813
+ findTurnForComment(record, commentId) {
1814
+ return [...record.turns].reverse().find(
1815
+ (candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
1816
+ ) ?? null;
1817
+ }
1818
+ async persistResolution(record, turn, resolution, reason) {
1819
+ const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
1820
+ const nextTurn = {
1821
+ ...turn,
1822
+ resolvedPath,
1823
+ resolution
1824
+ };
1825
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1826
+ this.reviews.set(record.meta.id, nextRecord);
1827
+ await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
1252
1828
  await Promise.all([
1253
1829
  writeJsonFile(resolvedPath, resolution),
1254
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
1830
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
1255
1831
  ]);
1832
+ await this.persistMeta(nextRecord);
1256
1833
  const result = {
1257
1834
  ok: true,
1258
1835
  reviewId: record.meta.id,
1259
- status: record.meta.status,
1836
+ turnId: nextTurn.id,
1837
+ turnIndex: nextTurn.index,
1838
+ status: nextTurn.status,
1260
1839
  resolutionStatus: resolution.status,
1261
- comments: resolutionCounts(record.feedback, resolution.comments),
1840
+ comments: resolutionCounts(nextTurn.feedback, resolution.comments),
1262
1841
  path: resolvedPath,
1263
1842
  resolution
1264
1843
  };
1265
1844
  this.emit({
1266
1845
  type: "review.updated",
1267
1846
  reviewId: record.meta.id,
1847
+ turnId: nextTurn.id,
1848
+ turnIndex: nextTurn.index,
1268
1849
  reason,
1269
1850
  status: result.status,
1270
1851
  resolutionStatus: result.resolutionStatus,
@@ -1272,24 +1853,134 @@ var ReviewStore = class {
1272
1853
  });
1273
1854
  return result;
1274
1855
  }
1275
- sortResolvedComments(comments, record) {
1856
+ sortResolvedComments(comments, turn) {
1276
1857
  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)
1858
+ turn.feedback.comments.map((comment, index) => [comment.id, index])
1281
1859
  );
1860
+ return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
1861
+ (entry) => entry.index !== void 0
1862
+ ).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
1282
1863
  }
1283
1864
  };
1865
+ function createTurn(reviewId, index, diff, createdAt) {
1866
+ const id = ulid();
1867
+ return {
1868
+ id,
1869
+ index,
1870
+ status: "pending",
1871
+ createdAt,
1872
+ artifactDir: globalReviewTurnDir(reviewId, id),
1873
+ diffPath: globalReviewTurnDiffFile(reviewId, id),
1874
+ diff
1875
+ };
1876
+ }
1877
+ function normalizeRecord(record) {
1878
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
1879
+ const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
1880
+ const meta = {
1881
+ ...record.meta,
1882
+ base: active.diff.base,
1883
+ branch: active.diff.branch,
1884
+ status: active.status,
1885
+ submittedAt: active.submittedAt,
1886
+ resolvedAt: active.resolvedAt,
1887
+ artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
1888
+ activeTurnId: active.id,
1889
+ turns: turns.map(turnSummary),
1890
+ feedbackPath: active.feedbackPath,
1891
+ markdownPath: active.markdownPath
1892
+ };
1893
+ return {
1894
+ meta,
1895
+ turns,
1896
+ diff: active.diff,
1897
+ ...active.feedback ? { feedback: active.feedback } : {},
1898
+ ...active.resolution ? { resolution: active.resolution } : {}
1899
+ };
1900
+ }
1901
+ function replaceTurn(record, nextTurn) {
1902
+ return {
1903
+ ...record,
1904
+ turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
1905
+ };
1906
+ }
1907
+ function activeTurn(record) {
1908
+ return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
1909
+ }
1910
+ function latestTurn(record) {
1911
+ return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
1912
+ }
1913
+ function turnMeta(turn) {
1914
+ return {
1915
+ id: turn.id,
1916
+ index: turn.index,
1917
+ status: turn.status,
1918
+ createdAt: turn.createdAt,
1919
+ submittedAt: turn.submittedAt,
1920
+ resolvedAt: turn.resolvedAt,
1921
+ artifactDir: turn.artifactDir,
1922
+ diffPath: turn.diffPath,
1923
+ feedbackPath: turn.feedbackPath,
1924
+ markdownPath: turn.markdownPath,
1925
+ resolvedPath: turn.resolvedPath
1926
+ };
1927
+ }
1928
+ function turnSummary(turn) {
1929
+ return {
1930
+ ...turnMeta(turn),
1931
+ capturedAt: turn.diff.capturedAt,
1932
+ stats: turn.diff.stats,
1933
+ comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
1934
+ };
1935
+ }
1936
+ function reconcileTurn(meta, diff, feedback, resolution) {
1937
+ const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
1938
+ return {
1939
+ ...meta,
1940
+ status,
1941
+ submittedAt: feedback?.timestamp ?? meta.submittedAt,
1942
+ resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1943
+ feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
1944
+ markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
1945
+ resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
1946
+ diff,
1947
+ ...feedback ? { feedback } : {},
1948
+ ...resolution ? { resolution } : {}
1949
+ };
1950
+ }
1951
+ function mergeRecoveredTurns(legacyTurn, persistedTurns) {
1952
+ const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
1953
+ return turns.toSorted((a, b) => a.index - b.index);
1954
+ }
1955
+ function diffFingerprint(diff) {
1956
+ return createHash("sha256").update(
1957
+ JSON.stringify({
1958
+ base: diff.base,
1959
+ branch: diff.branch,
1960
+ cwd: diff.cwd,
1961
+ scope: diff.scope,
1962
+ rawDiff: diff.rawDiff
1963
+ })
1964
+ ).digest("hex");
1965
+ }
1966
+ function sameComments(left, right) {
1967
+ return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
1968
+ }
1969
+ function requiredPath(value, label) {
1970
+ if (!value) {
1971
+ throw new Error(`Submitted review is missing ${label}`);
1972
+ }
1973
+ return value;
1974
+ }
1284
1975
  async function readOptionalJsonFile(filePath, guard, label) {
1285
1976
  let raw;
1286
1977
  try {
1287
1978
  raw = await readFile2(filePath, "utf8");
1288
1979
  } catch (error) {
1289
- if (isFileNotFound2(error)) {
1980
+ if (isFileNotFound(error)) {
1290
1981
  return void 0;
1291
1982
  }
1292
- throw new Error(`Could not read ${label} at ${filePath}: ${formatError2(error)}`, {
1983
+ throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
1293
1984
  cause: error
1294
1985
  });
1295
1986
  }
@@ -1299,15 +1990,9 @@ function parseJsonFile(raw, guard, label, filePath) {
1299
1990
  try {
1300
1991
  return parseJson(raw, guard, label);
1301
1992
  } catch (error) {
1302
- throw new Error(`Invalid ${label} at ${filePath}: ${formatError2(error)}`, { cause: error });
1993
+ throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
1303
1994
  }
1304
1995
  }
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
1996
  var reviewStore = new ReviewStore();
1312
1997
 
1313
1998
  // src/cli/status.ts
@@ -1335,31 +2020,61 @@ function printPlain(value) {
1335
2020
  }
1336
2021
  var program = new Command();
1337
2022
  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(
2023
+ 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
2024
  async (options) => {
1340
2025
  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);
2026
+ let info = await ensureServer();
2027
+ let client = new ServerClient(serverUrl(info));
2028
+ const inheritedBase = options.review && !options.base ? await baseForExistingReview(client, options.review) : null;
2029
+ const diff = await captureDiff(options.base ?? inheritedBase ?? void 0);
2030
+ const created = options.review ? await client.appendReviewTurn(options.review, diff) : await client.createReview(diff);
2031
+ const meta = created.meta;
2032
+ const turn = created.turn ?? meta.turns?.find((summary) => summary.id === meta.activeTurnId);
2033
+ if (!turn) {
2034
+ throw new Error(`Review ${meta.id} has no active turn`);
2035
+ }
2036
+ const reused = "reused" in created ? created.reused === true : false;
2037
+ let url = created.url;
2038
+ const shouldWatch = options.watch !== false;
1345
2039
  if (options.printUrl) {
1346
2040
  printPlain(url);
1347
2041
  }
1348
2042
  if (options.open !== false) {
1349
2043
  await openBrowser(url);
1350
2044
  }
1351
- if (options.watch === false) {
2045
+ if (!shouldWatch) {
1352
2046
  const result2 = {
1353
2047
  reviewId: meta.id,
2048
+ turnId: turn.id,
2049
+ turnIndex: turn.index,
1354
2050
  url,
1355
2051
  files: diff.files.length,
1356
2052
  scope: diff.scope.mode,
1357
- artifactDir: meta.artifactDir
2053
+ artifactDir: turn.artifactDir,
2054
+ reused
1358
2055
  };
1359
2056
  globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
1360
2057
  return;
1361
2058
  }
1362
- const event = await client.watchReview(meta.id, options.timeout);
2059
+ const watched = await watchReviewWithReconnect(
2060
+ meta.id,
2061
+ info,
2062
+ options.timeout,
2063
+ async (nextInfo) => {
2064
+ info = nextInfo;
2065
+ client = new ServerClient(serverUrl(info));
2066
+ url = `${serverUrl(info)}/review/${meta.id}`;
2067
+ if (options.printUrl) {
2068
+ printPlain(url);
2069
+ }
2070
+ if (options.open !== false) {
2071
+ await openBrowser(url);
2072
+ }
2073
+ }
2074
+ );
2075
+ info = watched.info;
2076
+ client = new ServerClient(serverUrl(info));
2077
+ const event = watched.event;
1363
2078
  if (event.type === "review.cancelled") {
1364
2079
  process.exitCode = 2;
1365
2080
  globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
@@ -1368,16 +2083,26 @@ program.command("open").description("Capture local changes and open them for rev
1368
2083
  if (event.type !== "review.submitted") {
1369
2084
  throw new Error(`Unexpected review event ${event.type}`);
1370
2085
  }
1371
- const feedback = await client.getFeedback(meta.id);
2086
+ const [feedback, submittedRecord] = await Promise.all([
2087
+ client.getFeedback(meta.id),
2088
+ client.getReview(meta.id)
2089
+ ]);
2090
+ const submittedTurn = submittedRecord.meta.turns?.find((summary) => summary.id === (event.turnId ?? turn.id)) ?? turn;
2091
+ if (!submittedTurn.feedbackPath || !submittedTurn.markdownPath) {
2092
+ throw new Error(`Review ${meta.id} turn ${submittedTurn.index} is missing feedback paths`);
2093
+ }
1372
2094
  const result = {
1373
2095
  reviewId: meta.id,
2096
+ turnId: submittedTurn.id,
2097
+ turnIndex: submittedTurn.index,
1374
2098
  url,
1375
2099
  files: event.counts.files,
1376
2100
  comments: event.counts.comments,
1377
- feedbackPath: globalReviewFeedbackFile(meta.id),
1378
- markdownPath: globalReviewMarkdownFile(meta.id),
1379
- artifactDir: globalReviewDir(meta.id),
1380
- feedback
2101
+ feedbackPath: submittedTurn.feedbackPath,
2102
+ markdownPath: submittedTurn.markdownPath,
2103
+ artifactDir: submittedTurn.artifactDir,
2104
+ feedback,
2105
+ reused
1381
2106
  };
1382
2107
  globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
1383
2108
  }
@@ -1385,8 +2110,12 @@ program.command("open").description("Capture local changes and open them for rev
1385
2110
  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
2111
  const globals = program.opts();
1387
2112
  const info = await ensureServer();
1388
- const client = new ServerClient(serverUrl(info));
1389
- const event = await client.watchReview(reviewId, options.timeout);
2113
+ const { event } = await watchReviewWithReconnect(
2114
+ reviewId,
2115
+ info,
2116
+ options.timeout,
2117
+ async () => void 0
2118
+ );
1390
2119
  globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
1391
2120
  });
1392
2121
  program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
@@ -1404,28 +2133,33 @@ program.command("status").description("Show server and active reviews").action(a
1404
2133
  responsive && info ? `Gloss server running at ${serverUrl(info)} with ${reviews.length} active review(s)` : "Gloss server is not running"
1405
2134
  );
1406
2135
  });
1407
- program.command("stop").description("Stop the managed background server").action(async () => {
1408
- 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");
1411
- });
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) => {
2136
+ program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
1413
2137
  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`
2138
+ const result = await stopServer({ all: options.all });
2139
+ globals.json ? printJson(result) : printPlain(
2140
+ options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
1427
2141
  );
1428
2142
  });
2143
+ 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(
2144
+ async (reviewId, options) => {
2145
+ const globals = program.opts();
2146
+ const info = await ensureServer();
2147
+ const client = new ServerClient(serverUrl(info));
2148
+ const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary, options.turn);
2149
+ if (globals.json) {
2150
+ printJson({
2151
+ commentId: options.comment ?? null,
2152
+ summary: options.summary ?? null,
2153
+ turn: options.turn ?? null,
2154
+ ...result
2155
+ });
2156
+ return;
2157
+ }
2158
+ printPlain(
2159
+ options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
2160
+ );
2161
+ }
2162
+ );
1429
2163
  program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
1430
2164
  const globals = program.opts();
1431
2165
  const checks = [];
@@ -1455,6 +2189,21 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
1455
2189
  ok: info ? await isServerResponsive(info) : false,
1456
2190
  detail: info ? serverUrl(info) : "not started"
1457
2191
  });
2192
+ try {
2193
+ const daemonPids = await listGlossDaemonPids();
2194
+ const unmanagedDaemonPids = daemonPids.filter((pid) => pid !== info?.pid);
2195
+ checks.push({
2196
+ name: "daemon-processes",
2197
+ ok: unmanagedDaemonPids.length === 0,
2198
+ detail: daemonPids.length === 0 ? "none" : `${daemonPids.length} found${unmanagedDaemonPids.length > 0 ? `; unmanaged pids ${unmanagedDaemonPids.join(", ")}` : ""}`
2199
+ });
2200
+ } catch (error) {
2201
+ checks.push({
2202
+ name: "daemon-processes",
2203
+ ok: false,
2204
+ detail: error instanceof Error ? error.message : String(error)
2205
+ });
2206
+ }
1458
2207
  if (globals.json) {
1459
2208
  printJson({ checks });
1460
2209
  } else {
@@ -1465,6 +2214,46 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
1465
2214
  }
1466
2215
  }
1467
2216
  });
2217
+ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged) {
2218
+ const startedAt = Date.now();
2219
+ let info = initialInfo;
2220
+ while (true) {
2221
+ const remainingSeconds = timeoutSeconds && timeoutSeconds > 0 ? timeoutSeconds - (Date.now() - startedAt) / 1e3 : void 0;
2222
+ if (remainingSeconds !== void 0 && remainingSeconds <= 0) {
2223
+ throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
2224
+ }
2225
+ try {
2226
+ const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, remainingSeconds);
2227
+ return { event, info };
2228
+ } catch (error) {
2229
+ if (isWatchTimeout(error)) {
2230
+ throw error;
2231
+ }
2232
+ if (!isReconnectableWatchError(error)) {
2233
+ throw error;
2234
+ }
2235
+ await sleep2(500);
2236
+ const nextInfo = await ensureServer();
2237
+ if (nextInfo.port !== info.port) {
2238
+ await onServerChanged(nextInfo);
2239
+ }
2240
+ info = nextInfo;
2241
+ }
2242
+ }
2243
+ }
2244
+ async function baseForExistingReview(client, reviewId) {
2245
+ const record = await client.getReview(reviewId);
2246
+ return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
2247
+ }
2248
+ function isWatchTimeout(error) {
2249
+ return error instanceof Error && /^watch timed out after/.test(error.message);
2250
+ }
2251
+ function isReconnectableWatchError(error) {
2252
+ return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
2253
+ }
2254
+ async function sleep2(milliseconds) {
2255
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
2256
+ }
1468
2257
  program.parseAsync(process.argv).catch((error) => {
1469
2258
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}
1470
2259
  `);