getgloss 0.8.0 → 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.8.0",
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",
@@ -150,14 +161,435 @@ function globalReviewDiffFile(reviewId) {
150
161
  function globalReviewFeedbackFile(reviewId) {
151
162
  return path.join(globalReviewDir(reviewId), "feedback.json");
152
163
  }
153
- function globalReviewMarkdownFile(reviewId) {
154
- return path.join(globalReviewDir(reviewId), "feedback.md");
164
+ function globalReviewMarkdownFile(reviewId) {
165
+ return path.join(globalReviewDir(reviewId), "feedback.md");
166
+ }
167
+ function globalReviewResolvedFile(reviewId) {
168
+ return path.join(globalReviewDir(reviewId), "resolved.json");
169
+ }
170
+ async function ensureDir(dir) {
171
+ await mkdir(dir, { recursive: true });
172
+ }
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
+ }
155
550
  }
156
- function globalReviewResolvedFile(reviewId) {
157
- return path.join(globalReviewDir(reviewId), "resolved.json");
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
+ ];
158
563
  }
159
- async function ensureDir(dir) {
160
- await mkdir(dir, { recursive: true });
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
+ };
161
593
  }
162
594
 
163
595
  // src/cli/git.ts
@@ -552,26 +984,18 @@ async function assertGitAvailable() {
552
984
  // src/cli/lifecycle.ts
553
985
  import { execFile, spawn } from "child_process";
554
986
  import { closeSync, existsSync, openSync } from "fs";
555
- import { rm as rm2 } from "fs/promises";
987
+ import { rm as rm3 } from "fs/promises";
556
988
  import { userInfo } from "os";
557
989
  import { fileURLToPath } from "url";
558
990
  import { promisify } from "util";
559
991
  import getPort from "get-port";
560
992
 
561
993
  // src/shared/server-info.ts
562
- import { readFile } from "fs/promises";
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
- }
994
+ import { readFile as readFile2 } from "fs/promises";
571
995
 
572
996
  // src/shared/json.ts
573
997
  import { randomUUID } from "crypto";
574
- import { rename, rm, writeFile } from "fs/promises";
998
+ import { rename, rm as rm2, writeFile } from "fs/promises";
575
999
  import path3 from "path";
576
1000
  function serializeJson(value) {
577
1001
  return `${JSON.stringify(value, null, 2)}
@@ -589,215 +1013,16 @@ async function writeTextFile(filePath, value) {
589
1013
  await writeFile(tempPath, value);
590
1014
  await rename(tempPath, filePath);
591
1015
  } catch (error) {
592
- await rm(tempPath, { force: true }).catch(() => void 0);
1016
+ await rm2(tempPath, { force: true }).catch(() => void 0);
593
1017
  throw error;
594
1018
  }
595
1019
  }
596
1020
 
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
-
612
- // src/shared/validation.ts
613
- function parseJson(raw, guard, label) {
614
- const parsed = JSON.parse(raw);
615
- return parseJsonValue(parsed, guard, label);
616
- }
617
- function parseJsonValue(value, guard, label) {
618
- if (!guard(value)) {
619
- throw new Error(`Invalid ${label}`);
620
- }
621
- return value;
622
- }
623
- function isServerInfo(value) {
624
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
625
- }
626
- function isHealthResponse(value) {
627
- return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
628
- }
629
- function isCreateReviewResponse(value) {
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);
634
- }
635
- function isListReviewsResponse(value) {
636
- return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
637
- }
638
- function isOpenResult(value) {
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);
640
- }
641
- function isResolveResult(value) {
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);
643
- }
644
- function isReviewRecord(value) {
645
- return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
646
- }
647
- function isStoredReviewMeta(value) {
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);
652
- }
653
- function isReviewMeta(value) {
654
- return isStoredReviewMeta(value) && isString(value.artifactDir);
655
- }
656
- function hasReviewRegistrationFields(value) {
657
- return isReviewMeta(value.meta) && isString(value.url);
658
- }
659
- function isDiffPayload(value) {
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);
664
- }
665
- function isFeedbackBundle(value) {
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);
667
- }
668
- function isResolutionBundle(value) {
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);
670
- }
671
- function isReviewEvent(value) {
672
- if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
673
- return false;
674
- }
675
- switch (value.type) {
676
- case "review.opened":
677
- case "review.cancelled":
678
- return true;
679
- case "review.turn.created":
680
- return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
681
- case "review.submitted":
682
- return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
683
- case "review.updated":
684
- return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
685
- default:
686
- return false;
687
- }
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
- }
704
- function isDiffScope(value) {
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));
706
- }
707
- function isDiffRef(value) {
708
- return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
709
- }
710
- function isBaseRef(value) {
711
- return isRecord(value) && isString(value.ref) && isString(value.sha);
712
- }
713
- function isDiffStats(value) {
714
- return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
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
- }
735
- function isDiffFile(value) {
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);
737
- }
738
- function isDiffHunk(value) {
739
- return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
740
- }
741
- function isDiffLine(value) {
742
- return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
743
- }
744
- function isComment(value) {
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);
746
- }
747
- function isResolvedComment(value) {
748
- return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
749
- }
750
- function isResolutionCounts(value) {
751
- return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
752
- }
753
- function isReviewStatus(value) {
754
- return isOneOf(value, REVIEW_STATUSES);
755
- }
756
- function isResolutionStatus(value) {
757
- return isOneOf(value, RESOLUTION_STATUSES);
758
- }
759
- function isReviewUpdateReason(value) {
760
- return isOneOf(value, REVIEW_UPDATE_REASONS);
761
- }
762
- function isRecord(value) {
763
- return typeof value === "object" && value !== null && !Array.isArray(value);
764
- }
765
- function isArrayOf(value, guard) {
766
- return Array.isArray(value) && value.every(guard);
767
- }
768
- function isOptional(value, guard) {
769
- return value === void 0 || guard(value);
770
- }
771
- function isString(value) {
772
- return typeof value === "string";
773
- }
774
- function isOptionalString(value) {
775
- return value === void 0 || isString(value);
776
- }
777
- function isNullableString(value) {
778
- return value === null || isString(value);
779
- }
780
- function isNumber(value) {
781
- return typeof value === "number" && Number.isFinite(value);
782
- }
783
- function isOptionalNumber(value) {
784
- return value === void 0 || isNumber(value);
785
- }
786
- function isNullableNumber(value) {
787
- return value === null || isNumber(value);
788
- }
789
- function isBoolean(value) {
790
- return typeof value === "boolean";
791
- }
792
- function isOneOf(value, options) {
793
- return typeof value === "string" && options.includes(value);
794
- }
795
-
796
1021
  // src/shared/server-info.ts
797
1022
  async function readServerInfo() {
798
1023
  let raw;
799
1024
  try {
800
- raw = await readFile(globalServerFile(), "utf8");
1025
+ raw = await readFile2(globalServerFile(), "utf8");
801
1026
  } catch (error) {
802
1027
  if (isFileNotFound(error)) {
803
1028
  return null;
@@ -845,6 +1070,14 @@ var ServerClient = class {
845
1070
  async listReviews() {
846
1071
  return this.get("/api/reviews", isListReviewsResponse, "review list response");
847
1072
  }
1073
+ async clearReviews(request) {
1074
+ return this.post(
1075
+ "/api/maintenance/clear-reviews",
1076
+ request,
1077
+ isClearReviewsResult,
1078
+ "clear reviews response"
1079
+ );
1080
+ }
848
1081
  async getFeedback(reviewId) {
849
1082
  return this.get(`/api/reviews/${reviewId}/feedback`, isFeedbackBundle, "feedback response");
850
1083
  }
@@ -1065,7 +1298,7 @@ async function stopServer(options = {}) {
1065
1298
  stoppedPids.push(pid);
1066
1299
  }
1067
1300
  }
1068
- await rm2(globalServerFile(), { force: true });
1301
+ await rm3(globalServerFile(), { force: true });
1069
1302
  return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
1070
1303
  }
1071
1304
  const info = await readServerInfo();
@@ -1135,7 +1368,7 @@ async function waitForPidExit(pid, timeoutMs) {
1135
1368
  async function removeServerInfoForPid(pid) {
1136
1369
  const current = await readServerInfo().catch(() => null);
1137
1370
  if (!current || current.pid === pid) {
1138
- await rm2(globalServerFile(), { force: true });
1371
+ await rm3(globalServerFile(), { force: true });
1139
1372
  }
1140
1373
  }
1141
1374
  async function isGlossDaemonPid(pid) {
@@ -1175,7 +1408,7 @@ function isGlossDaemonCommand(command) {
1175
1408
 
1176
1409
  // src/server/store.ts
1177
1410
  import { createHash } from "crypto";
1178
- import { readdir, readFile as readFile2 } from "fs/promises";
1411
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
1179
1412
  import path4 from "path";
1180
1413
  import { ulid } from "ulid";
1181
1414
 
@@ -1395,6 +1628,15 @@ var ReviewStore = class {
1395
1628
  await this.loadAllReviews();
1396
1629
  return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
1397
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;
1639
+ }
1398
1640
  async get(id) {
1399
1641
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
1400
1642
  }
@@ -1623,7 +1865,7 @@ var ReviewStore = class {
1623
1865
  async loadAllReviews() {
1624
1866
  let entries;
1625
1867
  try {
1626
- entries = await readdir(globalReviewsDir(), { withFileTypes: true });
1868
+ entries = await readdir2(globalReviewsDir(), { withFileTypes: true });
1627
1869
  } catch (error) {
1628
1870
  if (isFileNotFound(error)) {
1629
1871
  return;
@@ -1647,7 +1889,7 @@ var ReviewStore = class {
1647
1889
  const metaPath = globalReviewMetaFile(id);
1648
1890
  let metaRaw;
1649
1891
  try {
1650
- metaRaw = await readFile2(metaPath, "utf8");
1892
+ metaRaw = await readFile3(metaPath, "utf8");
1651
1893
  } catch (error) {
1652
1894
  if (isFileNotFound(error)) {
1653
1895
  return this.loadReviewFromTurnsOnly(id);
@@ -1701,7 +1943,7 @@ var ReviewStore = class {
1701
1943
  async loadPersistedTurns(id) {
1702
1944
  let entries;
1703
1945
  try {
1704
- entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
1946
+ entries = await readdir2(globalReviewTurnsDir(id), { withFileTypes: true });
1705
1947
  } catch (error) {
1706
1948
  if (isFileNotFound(error)) {
1707
1949
  return [];
@@ -1729,8 +1971,8 @@ var ReviewStore = class {
1729
1971
  let diffRaw;
1730
1972
  try {
1731
1973
  [metaRaw, diffRaw] = await Promise.all([
1732
- readFile2(metaPath, "utf8"),
1733
- readFile2(diffPath, "utf8")
1974
+ readFile3(metaPath, "utf8"),
1975
+ readFile3(diffPath, "utf8")
1734
1976
  ]);
1735
1977
  } catch (error) {
1736
1978
  if (isFileNotFound(error)) {
@@ -1760,7 +2002,7 @@ var ReviewStore = class {
1760
2002
  const diffPath = globalReviewDiffFile(id);
1761
2003
  let diffRaw;
1762
2004
  try {
1763
- diffRaw = await readFile2(diffPath, "utf8");
2005
+ diffRaw = await readFile3(diffPath, "utf8");
1764
2006
  } catch (error) {
1765
2007
  if (isFileNotFound(error)) {
1766
2008
  return null;
@@ -1975,7 +2217,7 @@ function requiredPath(value, label) {
1975
2217
  async function readOptionalJsonFile(filePath, guard, label) {
1976
2218
  let raw;
1977
2219
  try {
1978
- raw = await readFile2(filePath, "utf8");
2220
+ raw = await readFile3(filePath, "utf8");
1979
2221
  } catch (error) {
1980
2222
  if (isFileNotFound(error)) {
1981
2223
  return void 0;
@@ -2140,6 +2382,19 @@ program.command("stop").description("Stop the managed background server").option
2140
2382
  options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
2141
2383
  );
2142
2384
  });
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) => {
2391
+ const globals = program.opts();
2392
+ const result = await clearReviews({
2393
+ olderThanDays: options.olderThan,
2394
+ dryRun: options.dryRun === true
2395
+ });
2396
+ globals.json ? printJson(result) : printPlain(formatClearResult(result));
2397
+ });
2143
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(
2144
2399
  async (reviewId, options) => {
2145
2400
  const globals = program.opts();
@@ -2245,6 +2500,26 @@ async function baseForExistingReview(client, reviewId) {
2245
2500
  const record = await client.getReview(reviewId);
2246
2501
  return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
2247
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
+ }
2248
2523
  function isWatchTimeout(error) {
2249
2524
  return error instanceof Error && /^watch timed out after/.test(error.message);
2250
2525
  }