getgloss 0.3.0 → 0.4.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,18 +4,6 @@
4
4
  import { Command } from "commander";
5
5
  import openBrowser from "open";
6
6
 
7
- // src/mcp/index.ts
8
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
- import { z } from "zod/v4";
11
-
12
- // src/cli/lifecycle.ts
13
- import { spawn } from "child_process";
14
- import { existsSync, openSync } from "fs";
15
- import { readFile, rm, writeFile } from "fs/promises";
16
- import { fileURLToPath } from "url";
17
- import getPort from "get-port";
18
-
19
7
  // src/shared/paths.ts
20
8
  import { mkdir } from "fs/promises";
21
9
  import { homedir } from "os";
@@ -24,7 +12,7 @@ import path from "path";
24
12
  // package.json
25
13
  var package_default = {
26
14
  name: "getgloss",
27
- version: "0.3.0",
15
+ version: "0.4.1",
28
16
  description: "Local browser-based diff review for coding-agent loops.",
29
17
  type: "module",
30
18
  packageManager: "pnpm@10.33.2",
@@ -55,7 +43,6 @@ var package_default = {
55
43
  },
56
44
  dependencies: {
57
45
  "@hono/node-server": "^1.14.4",
58
- "@modelcontextprotocol/sdk": "^1.29.0",
59
46
  "@pierre/diffs": "^1.2.1",
60
47
  "@tailwindcss/vite": "^4.1.7",
61
48
  commander: "^14.0.0",
@@ -86,8 +73,7 @@ var package_default = {
86
73
  keywords: [
87
74
  "diff",
88
75
  "review",
89
- "coding-agents",
90
- "mcp"
76
+ "coding-agents"
91
77
  ],
92
78
  author: "Raj Joshi",
93
79
  license: "MIT",
@@ -152,265 +138,6 @@ async function ensureDir(dir) {
152
138
  await mkdir(dir, { recursive: true });
153
139
  }
154
140
 
155
- // src/cli/server-client.ts
156
- var ServerClient = class {
157
- constructor(baseUrl) {
158
- this.baseUrl = baseUrl;
159
- }
160
- baseUrl;
161
- async health() {
162
- return this.get("/api/health");
163
- }
164
- async createReview(diff) {
165
- return this.post("/api/reviews", diff);
166
- }
167
- async getReview(reviewId) {
168
- return this.get(`/api/reviews/${reviewId}`);
169
- }
170
- async listReviews() {
171
- return this.get("/api/reviews");
172
- }
173
- async getFeedback(reviewId) {
174
- return this.get(`/api/reviews/${reviewId}/feedback`);
175
- }
176
- async markResolved(reviewId, summary) {
177
- return this.post(`/api/reviews/${reviewId}/resolved`, { summary });
178
- }
179
- async submitReview(reviewId, comments) {
180
- return this.post(`/api/reviews/${reviewId}/submit`, { comments });
181
- }
182
- async watchReview(reviewId, timeoutSeconds) {
183
- const controller = new AbortController();
184
- const timeout = timeoutSeconds && timeoutSeconds > 0 ? setTimeout(() => controller.abort(), timeoutSeconds * 1e3) : null;
185
- try {
186
- const response = await fetch(`${this.baseUrl}/api/reviews/${reviewId}/events`, {
187
- signal: controller.signal
188
- });
189
- if (!response.ok || !response.body) {
190
- throw new Error(`watch failed: ${response.status} ${await response.text()}`);
191
- }
192
- const reader = response.body.getReader();
193
- const decoder = new TextDecoder();
194
- let buffer = "";
195
- while (true) {
196
- const { value, done } = await reader.read();
197
- if (done) {
198
- throw new Error("watch stream ended before completion");
199
- }
200
- buffer += decoder.decode(value, { stream: true });
201
- const events = buffer.split("\n\n");
202
- buffer = events.pop() ?? "";
203
- for (const eventChunk of events) {
204
- const dataLine = eventChunk.split("\n").find((line) => line.startsWith("data:"));
205
- if (!dataLine) {
206
- continue;
207
- }
208
- const event = JSON.parse(dataLine.slice(5).trim());
209
- if (event.type === "review.completed" || event.type === "review.cancelled") {
210
- return event;
211
- }
212
- }
213
- }
214
- } finally {
215
- if (timeout) {
216
- clearTimeout(timeout);
217
- }
218
- }
219
- }
220
- async get(path3) {
221
- const response = await fetch(`${this.baseUrl}${path3}`);
222
- return parseResponse(response);
223
- }
224
- async post(path3, body) {
225
- const response = await fetch(`${this.baseUrl}${path3}`, {
226
- method: "POST",
227
- headers: { "content-type": "application/json" },
228
- body: JSON.stringify(body)
229
- });
230
- return parseResponse(response);
231
- }
232
- };
233
- async function parseResponse(response) {
234
- if (!response.ok) {
235
- throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
236
- }
237
- return await response.json();
238
- }
239
-
240
- // src/cli/lifecycle.ts
241
- async function readServerInfo() {
242
- try {
243
- return JSON.parse(await readFile(globalServerFile(), "utf8"));
244
- } catch {
245
- return null;
246
- }
247
- }
248
- function serverUrl(info) {
249
- return `http://localhost:${info.port}`;
250
- }
251
- async function isServerResponsive(info) {
252
- if (!isPidAlive(info.pid)) {
253
- return false;
254
- }
255
- try {
256
- const health = await new ServerClient(serverUrl(info)).health();
257
- return health.ok === true;
258
- } catch {
259
- return false;
260
- }
261
- }
262
- async function ensureServer(options = {}) {
263
- const existing = await readServerInfo();
264
- if (existing && await isServerResponsive(existing)) {
265
- return existing;
266
- }
267
- return startServer(options);
268
- }
269
- async function startServer(options = {}) {
270
- const existing = await readServerInfo();
271
- if (existing && await isServerResponsive(existing)) {
272
- return existing;
273
- }
274
- await ensureDir(globalStateDir());
275
- await ensureDir(globalLogDir());
276
- const port = options.port ?? await getPort();
277
- const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
278
- if (!existsSync(daemonPath)) {
279
- throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
280
- }
281
- const logFd = openSync(globalServerLogFile(), "a");
282
- const child = spawn(process.execPath, [daemonPath], {
283
- detached: true,
284
- env: {
285
- ...process.env,
286
- GLOSS_PORT: String(port),
287
- GLOSS_STATE_DIR: globalStateDir()
288
- },
289
- stdio: ["ignore", logFd, logFd]
290
- });
291
- child.unref();
292
- const info = {
293
- pid: child.pid ?? -1,
294
- port,
295
- version: packageVersion,
296
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
297
- stateDir: globalStateDir()
298
- };
299
- await writeFile(globalServerFile(), `${JSON.stringify(info, null, 2)}
300
- `);
301
- const deadline = Date.now() + 8e3;
302
- while (Date.now() < deadline) {
303
- if (await isServerResponsive(info)) {
304
- return info;
305
- }
306
- await new Promise((resolve) => setTimeout(resolve, 150));
307
- }
308
- throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
309
- }
310
- async function stopServer() {
311
- const info = await readServerInfo();
312
- if (!info) {
313
- return { stopped: false, info: null };
314
- }
315
- if (isPidAlive(info.pid)) {
316
- process.kill(info.pid, "SIGTERM");
317
- }
318
- await rm(globalServerFile(), { force: true });
319
- return { stopped: true, info };
320
- }
321
- function isPidAlive(pid) {
322
- if (pid <= 0) {
323
- return false;
324
- }
325
- try {
326
- process.kill(pid, 0);
327
- return true;
328
- } catch {
329
- return false;
330
- }
331
- }
332
-
333
- // src/mcp/index.ts
334
- function textResult(value) {
335
- return {
336
- content: [
337
- {
338
- type: "text",
339
- text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
340
- }
341
- ]
342
- };
343
- }
344
- async function client() {
345
- const info = await ensureServer();
346
- return new ServerClient(serverUrl(info));
347
- }
348
- async function startMcpServer() {
349
- const server = new McpServer({
350
- name: "gloss",
351
- version: packageVersion
352
- });
353
- server.registerTool(
354
- "list_pending_reviews",
355
- {
356
- title: "List pending Gloss reviews",
357
- description: "List pending local Gloss review sessions."
358
- },
359
- async () => {
360
- const api = await client();
361
- const { reviews } = await api.listReviews();
362
- return textResult({ reviews: reviews.filter((review) => review.status === "pending") });
363
- }
364
- );
365
- server.registerTool(
366
- "get_review",
367
- {
368
- title: "Get Gloss review",
369
- description: "Fetch review metadata and diff payload.",
370
- inputSchema: { id: z.string() }
371
- },
372
- async ({ id }) => textResult(await (await client()).getReview(id))
373
- );
374
- server.registerTool(
375
- "watch_review",
376
- {
377
- title: "Watch Gloss review",
378
- description: "Block until a review completes, then return feedback.",
379
- inputSchema: {
380
- id: z.string(),
381
- timeout: z.number().optional()
382
- }
383
- },
384
- async ({ id, timeout }) => {
385
- const api = await client();
386
- await api.watchReview(id, timeout);
387
- return textResult(await api.getFeedback(id));
388
- }
389
- );
390
- server.registerTool(
391
- "get_review_feedback",
392
- {
393
- title: "Get Gloss review feedback",
394
- description: "Fetch completed review feedback.",
395
- inputSchema: { id: z.string() }
396
- },
397
- async ({ id }) => textResult(await (await client()).getFeedback(id))
398
- );
399
- server.registerTool(
400
- "mark_review_resolved",
401
- {
402
- title: "Mark Gloss review resolved",
403
- description: "Write a resolved marker for a completed review.",
404
- inputSchema: {
405
- id: z.string(),
406
- summary: z.string().optional()
407
- }
408
- },
409
- async ({ id, summary }) => textResult(await (await client()).markResolved(id, summary))
410
- );
411
- await server.connect(new StdioServerTransport());
412
- }
413
-
414
141
  // src/cli/git.ts
415
142
  import { execa } from "execa";
416
143
 
@@ -738,6 +465,228 @@ async function assertGitAvailable() {
738
465
  await execa("git", ["--version"]);
739
466
  }
740
467
 
468
+ // src/cli/lifecycle.ts
469
+ import { spawn } from "child_process";
470
+ import { existsSync, openSync } from "fs";
471
+ import { readFile, rm, writeFile } from "fs/promises";
472
+ import { fileURLToPath } from "url";
473
+ import getPort from "get-port";
474
+
475
+ // src/cli/server-client.ts
476
+ var ServerClient = class {
477
+ constructor(baseUrl) {
478
+ this.baseUrl = baseUrl;
479
+ }
480
+ baseUrl;
481
+ async health() {
482
+ return this.get("/api/health");
483
+ }
484
+ async createReview(diff) {
485
+ return this.post("/api/reviews", diff);
486
+ }
487
+ async getReview(reviewId) {
488
+ return this.get(`/api/reviews/${reviewId}`);
489
+ }
490
+ async listReviews() {
491
+ return this.get("/api/reviews");
492
+ }
493
+ async getFeedback(reviewId) {
494
+ return this.get(`/api/reviews/${reviewId}/feedback`);
495
+ }
496
+ async markResolved(reviewId, summary) {
497
+ return this.post(`/api/reviews/${reviewId}/resolved`, { summary });
498
+ }
499
+ async resolveComment(reviewId, commentId, summary) {
500
+ return this.post(`/api/reviews/${reviewId}/comments/${commentId}/resolved`, { summary });
501
+ }
502
+ async reopenComment(reviewId, commentId) {
503
+ return this.delete(`/api/reviews/${reviewId}/comments/${commentId}/resolved`);
504
+ }
505
+ async submitReview(reviewId, comments) {
506
+ return this.post(`/api/reviews/${reviewId}/submit`, { comments });
507
+ }
508
+ async watchReview(reviewId, timeoutSeconds) {
509
+ const deadline = timeoutSeconds && timeoutSeconds > 0 ? Date.now() + timeoutSeconds * 1e3 : null;
510
+ while (true) {
511
+ const remainingMs = deadline ? deadline - Date.now() : null;
512
+ if (remainingMs !== null && remainingMs <= 0) {
513
+ throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
514
+ }
515
+ const controller = new AbortController();
516
+ const timeout = remainingMs ? setTimeout(() => controller.abort(), remainingMs) : null;
517
+ try {
518
+ return await this.readReviewEvents(reviewId, controller.signal);
519
+ } catch (error) {
520
+ if (isAbortError(error)) {
521
+ throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
522
+ }
523
+ if (!isPrematureWatchEnd(error)) {
524
+ throw error;
525
+ }
526
+ await sleep(500);
527
+ } finally {
528
+ if (timeout) {
529
+ clearTimeout(timeout);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ async readReviewEvents(reviewId, signal) {
535
+ const response = await fetch(`${this.baseUrl}/api/reviews/${reviewId}/events`, {
536
+ signal
537
+ });
538
+ if (!response.ok || !response.body) {
539
+ throw new Error(`watch failed: ${response.status} ${await response.text()}`);
540
+ }
541
+ const reader = response.body.getReader();
542
+ const decoder = new TextDecoder();
543
+ let buffer = "";
544
+ while (true) {
545
+ const { value, done } = await reader.read();
546
+ if (done) {
547
+ throw new Error("watch stream ended before completion");
548
+ }
549
+ buffer += decoder.decode(value, { stream: true });
550
+ const events = buffer.split("\n\n");
551
+ buffer = events.pop() ?? "";
552
+ for (const eventChunk of events) {
553
+ const dataLine = eventChunk.split("\n").find((line) => line.startsWith("data:"));
554
+ if (!dataLine) {
555
+ continue;
556
+ }
557
+ const event = JSON.parse(dataLine.slice(5).trim());
558
+ if (event.type === "review.submitted" || event.type === "review.cancelled") {
559
+ return event;
560
+ }
561
+ }
562
+ }
563
+ }
564
+ async get(path3) {
565
+ const response = await fetch(`${this.baseUrl}${path3}`);
566
+ return parseResponse(response);
567
+ }
568
+ async post(path3, body) {
569
+ const response = await fetch(`${this.baseUrl}${path3}`, {
570
+ method: "POST",
571
+ headers: { "content-type": "application/json" },
572
+ body: JSON.stringify(body)
573
+ });
574
+ return parseResponse(response);
575
+ }
576
+ async delete(path3) {
577
+ const response = await fetch(`${this.baseUrl}${path3}`, { method: "DELETE" });
578
+ return parseResponse(response);
579
+ }
580
+ };
581
+ async function parseResponse(response) {
582
+ if (!response.ok) {
583
+ throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
584
+ }
585
+ return await response.json();
586
+ }
587
+ function isPrematureWatchEnd(error) {
588
+ return error instanceof Error && error.message === "watch stream ended before completion";
589
+ }
590
+ function isAbortError(error) {
591
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
592
+ }
593
+ async function sleep(milliseconds) {
594
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
595
+ }
596
+
597
+ // src/cli/lifecycle.ts
598
+ async function readServerInfo() {
599
+ try {
600
+ return JSON.parse(await readFile(globalServerFile(), "utf8"));
601
+ } catch {
602
+ return null;
603
+ }
604
+ }
605
+ function serverUrl(info) {
606
+ return `http://localhost:${info.port}`;
607
+ }
608
+ async function isServerResponsive(info) {
609
+ if (!isPidAlive(info.pid)) {
610
+ return false;
611
+ }
612
+ try {
613
+ const health = await new ServerClient(serverUrl(info)).health();
614
+ return health.ok === true;
615
+ } catch {
616
+ return false;
617
+ }
618
+ }
619
+ async function ensureServer(options = {}) {
620
+ const existing = await readServerInfo();
621
+ if (existing && await isServerResponsive(existing)) {
622
+ return existing;
623
+ }
624
+ return startServer(options);
625
+ }
626
+ async function startServer(options = {}) {
627
+ const existing = await readServerInfo();
628
+ if (existing && await isServerResponsive(existing)) {
629
+ return existing;
630
+ }
631
+ await ensureDir(globalStateDir());
632
+ await ensureDir(globalLogDir());
633
+ const port = options.port ?? await getPort();
634
+ const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
635
+ if (!existsSync(daemonPath)) {
636
+ throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
637
+ }
638
+ const logFd = openSync(globalServerLogFile(), "a");
639
+ const child = spawn(process.execPath, [daemonPath], {
640
+ detached: true,
641
+ env: {
642
+ ...process.env,
643
+ GLOSS_PORT: String(port),
644
+ GLOSS_STATE_DIR: globalStateDir()
645
+ },
646
+ stdio: ["ignore", logFd, logFd]
647
+ });
648
+ child.unref();
649
+ const info = {
650
+ pid: child.pid ?? -1,
651
+ port,
652
+ version: packageVersion,
653
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
654
+ stateDir: globalStateDir()
655
+ };
656
+ await writeFile(globalServerFile(), `${JSON.stringify(info, null, 2)}
657
+ `);
658
+ const deadline = Date.now() + 8e3;
659
+ while (Date.now() < deadline) {
660
+ if (await isServerResponsive(info)) {
661
+ return info;
662
+ }
663
+ await new Promise((resolve) => setTimeout(resolve, 150));
664
+ }
665
+ throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
666
+ }
667
+ async function stopServer() {
668
+ const info = await readServerInfo();
669
+ if (!info) {
670
+ return { stopped: false, info: null };
671
+ }
672
+ if (isPidAlive(info.pid)) {
673
+ process.kill(info.pid, "SIGTERM");
674
+ }
675
+ await rm(globalServerFile(), { force: true });
676
+ return { stopped: true, info };
677
+ }
678
+ function isPidAlive(pid) {
679
+ if (pid <= 0) {
680
+ return false;
681
+ }
682
+ try {
683
+ process.kill(pid, 0);
684
+ return true;
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+
741
690
  // src/server/store.ts
742
691
  import { readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
743
692
  import { ulid } from "ulid";
@@ -850,6 +799,9 @@ var ReviewStore = class {
850
799
  if (!record) {
851
800
  throw new Error(`Review ${id} not found`);
852
801
  }
802
+ if (record.meta.status !== "pending") {
803
+ throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
804
+ }
853
805
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
854
806
  const feedback = {
855
807
  version: 1,
@@ -862,7 +814,7 @@ var ReviewStore = class {
862
814
  )
863
815
  };
864
816
  record.feedback = feedback;
865
- record.meta = { ...record.meta, status: "completed", completedAt: timestamp };
817
+ record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
866
818
  this.reviews.set(id, record);
867
819
  const artifactDir = globalReviewDir(id);
868
820
  const feedbackPath = globalReviewFeedbackFile(id);
@@ -882,7 +834,7 @@ var ReviewStore = class {
882
834
  writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
883
835
  ]);
884
836
  this.emit({
885
- type: "review.completed",
837
+ type: "review.submitted",
886
838
  reviewId: id,
887
839
  counts: {
888
840
  files: new Set(feedback.comments.map((comment) => comment.filePath)).size,
@@ -900,19 +852,88 @@ var ReviewStore = class {
900
852
  if (!record) {
901
853
  throw new Error(`Review ${id} not found`);
902
854
  }
855
+ this.assertResolvable(record, id);
903
856
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
904
- const resolvedPath = globalReviewResolvedFile(id);
857
+ const existingById = new Map(
858
+ (record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
859
+ );
860
+ const comments = this.sortResolvedComments(
861
+ (record.feedback?.comments ?? []).map((comment) => ({
862
+ ...existingById.get(comment.id),
863
+ commentId: comment.id,
864
+ status: "resolved",
865
+ resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
866
+ })),
867
+ record
868
+ );
869
+ const resolution = {
870
+ reviewId: id,
871
+ status: "resolved",
872
+ summary: summary ?? record.resolution?.summary ?? null,
873
+ resolvedAt,
874
+ comments
875
+ };
905
876
  record.meta = { ...record.meta, status: "resolved", resolvedAt };
906
- this.reviews.set(id, record);
907
- await ensureDir(globalReviewDir(id));
908
- await writeFile2(
909
- resolvedPath,
910
- `${JSON.stringify({ reviewId: id, summary: summary ?? null, resolvedAt }, null, 2)}
911
- `
877
+ return this.persistResolution(record, resolution);
878
+ }
879
+ async resolveComment(id, commentId, summary) {
880
+ const record = await this.get(id);
881
+ if (!record) {
882
+ throw new Error(`Review ${id} not found`);
883
+ }
884
+ this.assertResolvable(record, id);
885
+ this.assertCommentExists(record, commentId);
886
+ const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
887
+ const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
888
+ const nextSummary = summary ?? previous?.summary;
889
+ const nextComment = {
890
+ commentId,
891
+ status: "resolved",
892
+ ...nextSummary ? { summary: nextSummary } : {},
893
+ resolvedAt
894
+ };
895
+ const comments = this.sortResolvedComments(
896
+ [
897
+ ...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
898
+ nextComment
899
+ ],
900
+ record
912
901
  );
913
- await writeFile2(globalReviewMetaFile(id), `${JSON.stringify(record.meta, null, 2)}
914
- `);
915
- return resolvedPath;
902
+ const counts = this.resolutionCounts(record, comments);
903
+ const fullyResolved = counts.total === counts.resolved;
904
+ const resolution = {
905
+ reviewId: id,
906
+ status: fullyResolved ? "resolved" : "partial",
907
+ summary: fullyResolved ? record.resolution?.summary ?? null : null,
908
+ resolvedAt: fullyResolved ? resolvedAt : null,
909
+ comments
910
+ };
911
+ record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
912
+ return this.persistResolution(record, resolution);
913
+ }
914
+ async reopenComment(id, commentId) {
915
+ const record = await this.get(id);
916
+ if (!record) {
917
+ throw new Error(`Review ${id} not found`);
918
+ }
919
+ this.assertResolvable(record, id);
920
+ this.assertCommentExists(record, commentId);
921
+ const comments = this.sortResolvedComments(
922
+ (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
923
+ record
924
+ );
925
+ const counts = this.resolutionCounts(record, comments);
926
+ const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
927
+ const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
928
+ const resolution = {
929
+ reviewId: id,
930
+ status: fullyResolved ? "resolved" : "partial",
931
+ summary: fullyResolved ? record.resolution?.summary ?? null : null,
932
+ resolvedAt,
933
+ comments
934
+ };
935
+ record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
936
+ return this.persistResolution(record, resolution);
916
937
  }
917
938
  subscribe(reviewId, listener) {
918
939
  const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
@@ -967,6 +988,7 @@ var ReviewStore = class {
967
988
  const meta = JSON.parse(metaRaw);
968
989
  const diff = JSON.parse(diffRaw);
969
990
  let feedback;
991
+ let resolution;
970
992
  try {
971
993
  feedback = JSON.parse(
972
994
  await readFile2(globalReviewFeedbackFile(id), "utf8")
@@ -974,6 +996,13 @@ var ReviewStore = class {
974
996
  } catch {
975
997
  feedback = void 0;
976
998
  }
999
+ try {
1000
+ resolution = JSON.parse(
1001
+ await readFile2(globalReviewResolvedFile(id), "utf8")
1002
+ );
1003
+ } catch {
1004
+ resolution = void 0;
1005
+ }
977
1006
  const record = {
978
1007
  meta: {
979
1008
  ...meta,
@@ -982,7 +1011,8 @@ var ReviewStore = class {
982
1011
  markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
983
1012
  },
984
1013
  diff,
985
- feedback
1014
+ feedback,
1015
+ resolution
986
1016
  };
987
1017
  this.reviews.set(id, record);
988
1018
  return record;
@@ -990,6 +1020,60 @@ var ReviewStore = class {
990
1020
  return null;
991
1021
  }
992
1022
  }
1023
+ assertResolvable(record, id) {
1024
+ if (record.meta.status !== "submitted" && record.meta.status !== "resolved") {
1025
+ throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
1026
+ }
1027
+ if (!record.feedback) {
1028
+ throw new Error(`Review ${id} has no submitted feedback`);
1029
+ }
1030
+ }
1031
+ assertCommentExists(record, commentId) {
1032
+ if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
1033
+ throw new Error(`Comment ${commentId} not found`);
1034
+ }
1035
+ }
1036
+ async persistResolution(record, resolution) {
1037
+ record.resolution = resolution;
1038
+ this.reviews.set(record.meta.id, record);
1039
+ const resolvedPath = globalReviewResolvedFile(record.meta.id);
1040
+ await ensureDir(globalReviewDir(record.meta.id));
1041
+ await Promise.all([
1042
+ writeFile2(resolvedPath, `${JSON.stringify(resolution, null, 2)}
1043
+ `),
1044
+ writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
1045
+ `)
1046
+ ]);
1047
+ return {
1048
+ ok: true,
1049
+ reviewId: record.meta.id,
1050
+ status: record.meta.status,
1051
+ resolutionStatus: resolution.status,
1052
+ comments: this.resolutionCounts(record, resolution.comments),
1053
+ path: resolvedPath,
1054
+ resolution
1055
+ };
1056
+ }
1057
+ sortResolvedComments(comments, record) {
1058
+ const feedbackIndex = new Map(
1059
+ record.feedback.comments.map((comment, index) => [comment.id, index])
1060
+ );
1061
+ return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
1062
+ (a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
1063
+ );
1064
+ }
1065
+ resolutionCounts(record, comments) {
1066
+ const total = record.feedback.comments.length;
1067
+ const resolvedIds = new Set(comments.map((comment) => comment.commentId));
1068
+ const resolved = record.feedback.comments.filter(
1069
+ (comment) => resolvedIds.has(comment.id)
1070
+ ).length;
1071
+ return {
1072
+ total,
1073
+ resolved,
1074
+ open: total - resolved
1075
+ };
1076
+ }
993
1077
  };
994
1078
  var reviewStore = new ReviewStore();
995
1079
 
@@ -1022,9 +1106,9 @@ program.command("open").description("Capture local changes and open them for rev
1022
1106
  async (options) => {
1023
1107
  const globals = program.opts();
1024
1108
  const info = await ensureServer();
1025
- const client2 = new ServerClient(serverUrl(info));
1109
+ const client = new ServerClient(serverUrl(info));
1026
1110
  const diff = await captureDiff(options.base);
1027
- const { meta, url } = await client2.createReview(diff);
1111
+ const { meta, url } = await client.createReview(diff);
1028
1112
  if (options.printUrl) {
1029
1113
  printPlain(url);
1030
1114
  }
@@ -1042,16 +1126,16 @@ program.command("open").description("Capture local changes and open them for rev
1042
1126
  globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
1043
1127
  return;
1044
1128
  }
1045
- const event = await client2.watchReview(meta.id, options.timeout);
1129
+ const event = await client.watchReview(meta.id, options.timeout);
1046
1130
  if (event.type === "review.cancelled") {
1047
1131
  process.exitCode = 2;
1048
1132
  globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
1049
1133
  return;
1050
1134
  }
1051
- if (event.type !== "review.completed") {
1135
+ if (event.type !== "review.submitted") {
1052
1136
  throw new Error(`Unexpected review event ${event.type}`);
1053
1137
  }
1054
- const feedback = await client2.getFeedback(meta.id);
1138
+ const feedback = await client.getFeedback(meta.id);
1055
1139
  const result = {
1056
1140
  reviewId: meta.id,
1057
1141
  url,
@@ -1062,14 +1146,14 @@ program.command("open").description("Capture local changes and open them for rev
1062
1146
  artifactDir: globalReviewDir(meta.id),
1063
1147
  feedback
1064
1148
  };
1065
- globals.json ? printJson(result) : printPlain(`Review ${meta.id} completed with ${event.counts.comments} comments`);
1149
+ globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
1066
1150
  }
1067
1151
  );
1068
- program.command("watch").argument("<reviewId>", "review id").description("Wait for review.completed for an existing review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(async (reviewId, options) => {
1152
+ 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) => {
1069
1153
  const globals = program.opts();
1070
1154
  const info = await ensureServer();
1071
- const client2 = new ServerClient(serverUrl(info));
1072
- const event = await client2.watchReview(reviewId, options.timeout);
1155
+ const client = new ServerClient(serverUrl(info));
1156
+ const event = await client.watchReview(reviewId, options.timeout);
1073
1157
  globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
1074
1158
  });
1075
1159
  program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
@@ -1092,8 +1176,22 @@ program.command("stop").description("Stop the managed background server").option
1092
1176
  const result = await stopServer();
1093
1177
  globals.json ? printJson(result) : printPlain(result.stopped ? "Gloss server stopped" : "Gloss server was not running");
1094
1178
  });
1095
- program.command("mcp").description("Start the experimental stdio MCP server").action(async () => {
1096
- await startMcpServer();
1179
+ 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) => {
1180
+ const globals = program.opts();
1181
+ const info = await ensureServer();
1182
+ const client = new ServerClient(serverUrl(info));
1183
+ const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary);
1184
+ if (globals.json) {
1185
+ printJson({
1186
+ commentId: options.comment ?? null,
1187
+ summary: options.summary ?? null,
1188
+ ...result
1189
+ });
1190
+ return;
1191
+ }
1192
+ printPlain(
1193
+ options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
1194
+ );
1097
1195
  });
1098
1196
  program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
1099
1197
  const globals = program.opts();