getgloss 0.13.2 → 0.14.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
@@ -3,7 +3,7 @@
3
3
  // src/cli/index.ts
4
4
  import { randomUUID as randomUUID3 } from "crypto";
5
5
  import { constants } from "fs";
6
- import { access, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6
+ import { access, rm as rm5, writeFile as writeFile5 } from "fs/promises";
7
7
  import path7 from "path";
8
8
  import { Command } from "commander";
9
9
  import openBrowser from "open";
@@ -30,7 +30,7 @@ import path from "path";
30
30
  // package.json
31
31
  var package_default = {
32
32
  name: "getgloss",
33
- version: "0.13.2",
33
+ version: "0.14.0",
34
34
  description: "Local browser-based diff review for coding-agent loops.",
35
35
  type: "module",
36
36
  packageManager: "pnpm@10.33.2",
@@ -112,6 +112,7 @@ var package_default = {
112
112
 
113
113
  // src/shared/paths.ts
114
114
  var packageVersion = package_default.version;
115
+ var protocolVersion = 1;
115
116
  function expandHome(input) {
116
117
  if (input === "~") {
117
118
  return homedir();
@@ -127,6 +128,9 @@ function globalStateDir() {
127
128
  function globalServerFile() {
128
129
  return path.join(globalStateDir(), "server.json");
129
130
  }
131
+ function globalLastPortFile() {
132
+ return path.join(globalStateDir(), "last-port");
133
+ }
130
134
  function globalServerLockDir() {
131
135
  return path.join(globalStateDir(), "server.lock");
132
136
  }
@@ -178,6 +182,9 @@ function globalReviewMarkdownFile(reviewId) {
178
182
  function globalReviewResolvedFile(reviewId) {
179
183
  return path.join(globalReviewDir(reviewId), "resolved.json");
180
184
  }
185
+ function globalReviewEventsFile(reviewId) {
186
+ return path.join(globalReviewDir(reviewId), "events.jsonl");
187
+ }
181
188
  async function ensureDir(dir) {
182
189
  await mkdir(dir, { recursive: true });
183
190
  }
@@ -190,6 +197,8 @@ var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
190
197
  var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
191
198
  var REVIEW_SCOPE_MODES = ["all", "single", "range"];
192
199
  var RESOLUTION_STATUSES = ["partial", "resolved"];
200
+ var REVIEW_EVENT_ACTORS = ["human", "agent", "system"];
201
+ var AGENT_STATUSES = ["claimed", "working", "blocked", "completed", "failed"];
193
202
  var REVIEW_UPDATE_REASONS = [
194
203
  "review-resolved",
195
204
  "comment-resolved",
@@ -209,10 +218,10 @@ function parseJsonValue(value, guard, label) {
209
218
  return value;
210
219
  }
211
220
  function isServerInfo(value) {
212
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
221
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isOptionalNumber(value.protocolVersion) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
213
222
  }
214
223
  function isHealthResponse(value) {
215
- return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews) && isOptionalNumber(value.connections) && isOptionalString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
224
+ return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.protocolVersion) && isNumber(value.activeReviews) && isOptionalNumber(value.connections) && isOptionalString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
216
225
  }
217
226
  function isClearReviewsResult(value) {
218
227
  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);
@@ -232,8 +241,14 @@ function isOpenResult(value) {
232
241
  function isResolveResult(value) {
233
242
  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);
234
243
  }
244
+ function isAgentClaimResponse(value) {
245
+ return isRecord(value) && value.ok === true && isString(value.reviewId) && isString(value.turnId) && isNumber(value.turnIndex) && value.status === "claimed" && isString(value.feedbackPath) && isString(value.markdownPath) && isString(value.artifactDir) && isFeedbackBundle(value.feedback) && isOptional(value.resolution, isResolutionBundle) && isReviewEvent(value.event);
246
+ }
247
+ function isAgentNoteResponse(value) {
248
+ return isRecord(value) && value.ok === true && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isOptional(value.status, isAgentStatus) && isString(value.message) && isReviewEvent(value.event);
249
+ }
235
250
  function isReviewRecord(value) {
236
- return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
251
+ return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle) && isOptional(value.events, (events) => isArrayOf(events, isReviewEvent));
237
252
  }
238
253
  function isStoredReviewMeta(value) {
239
254
  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(
@@ -263,6 +278,9 @@ function isReviewEvent(value) {
263
278
  if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
264
279
  return false;
265
280
  }
281
+ if (!hasValidReviewEventEnvelope(value)) {
282
+ return false;
283
+ }
266
284
  switch (value.type) {
267
285
  case "review.opened":
268
286
  case "review.cancelled":
@@ -273,6 +291,10 @@ function isReviewEvent(value) {
273
291
  return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
274
292
  case "review.updated":
275
293
  return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
294
+ case "agent.claimed":
295
+ return isString(value.turnId) && isNumber(value.turnIndex) && value.status === "claimed" && isOptionalString(value.message);
296
+ case "agent.note":
297
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isOptional(value.status, isAgentStatus) && isString(value.message);
276
298
  default:
277
299
  return false;
278
300
  }
@@ -362,6 +384,15 @@ function isResolutionStatus(value) {
362
384
  function isReviewUpdateReason(value) {
363
385
  return isOneOf(value, REVIEW_UPDATE_REASONS);
364
386
  }
387
+ function isReviewEventActor(value) {
388
+ return isOneOf(value, REVIEW_EVENT_ACTORS);
389
+ }
390
+ function isAgentStatus(value) {
391
+ return isOneOf(value, AGENT_STATUSES);
392
+ }
393
+ function hasValidReviewEventEnvelope(value) {
394
+ return isOptionalString(value.id) && isOptionalNumber(value.seq) && isOptionalString(value.createdAt) && isOptional(value.actor, isReviewEventActor);
395
+ }
365
396
  function isRecord(value) {
366
397
  return typeof value === "object" && value !== null && !Array.isArray(value);
367
398
  }
@@ -1182,6 +1213,24 @@ var ServerClient = class {
1182
1213
  "resolve response"
1183
1214
  );
1184
1215
  }
1216
+ async claimReview(reviewId, message, turn) {
1217
+ const request = { message, turn };
1218
+ return this.post(
1219
+ `/api/reviews/${reviewId}/agent/claim`,
1220
+ request,
1221
+ isAgentClaimResponse,
1222
+ "agent claim response"
1223
+ );
1224
+ }
1225
+ async addAgentNote(reviewId, message, status, turn) {
1226
+ const request = { message, status, turn };
1227
+ return this.post(
1228
+ `/api/reviews/${reviewId}/agent/notes`,
1229
+ request,
1230
+ isAgentNoteResponse,
1231
+ "agent note response"
1232
+ );
1233
+ }
1185
1234
  async resolveComment(reviewId, commentId, summary) {
1186
1235
  const request = { summary };
1187
1236
  return this.post(
@@ -1207,7 +1256,8 @@ var ServerClient = class {
1207
1256
  "submit review response"
1208
1257
  );
1209
1258
  }
1210
- async watchReview(reviewId, timeoutSeconds) {
1259
+ async watchReview(reviewId, options = {}) {
1260
+ const { timeoutSeconds, turnId } = options;
1211
1261
  const deadline = timeoutSeconds && timeoutSeconds > 0 ? Date.now() + timeoutSeconds * 1e3 : null;
1212
1262
  while (true) {
1213
1263
  const remainingMs = deadline ? deadline - Date.now() : null;
@@ -1218,7 +1268,7 @@ var ServerClient = class {
1218
1268
  const timeoutMs = remainingMs === null ? null : Math.min(remainingMs, timedWatchAttemptMs);
1219
1269
  const timeout = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
1220
1270
  try {
1221
- return await this.readReviewEvents(reviewId, controller.signal);
1271
+ return await this.readReviewEvents(reviewId, controller.signal, turnId);
1222
1272
  } catch (error) {
1223
1273
  if (isAbortError(error)) {
1224
1274
  if (deadline && Date.now() < deadline) {
@@ -1237,7 +1287,7 @@ var ServerClient = class {
1237
1287
  }
1238
1288
  }
1239
1289
  }
1240
- async readReviewEvents(reviewId, signal) {
1290
+ async readReviewEvents(reviewId, signal, turnId) {
1241
1291
  const response = await fetch(`${this.baseUrl}/api/reviews/${reviewId}/events`, {
1242
1292
  signal
1243
1293
  });
@@ -1261,7 +1311,11 @@ var ServerClient = class {
1261
1311
  continue;
1262
1312
  }
1263
1313
  const event = parseJson(dataLine.slice(5).trim(), isReviewEvent, "review event");
1264
- if (event.type === "review.submitted" || event.type === "review.cancelled") {
1314
+ if (event.type === "review.cancelled") {
1315
+ await reader.cancel().catch(() => void 0);
1316
+ return event;
1317
+ }
1318
+ if (event.type === "review.submitted" && (!turnId || event.turnId === turnId)) {
1265
1319
  await reader.cancel().catch(() => void 0);
1266
1320
  return event;
1267
1321
  }
@@ -1317,7 +1371,13 @@ async function isServerResponsive(info) {
1317
1371
  }
1318
1372
  try {
1319
1373
  const health = await new ServerClient(serverUrl(info)).health();
1320
- return health.ok === true && health.version === packageVersion;
1374
+ if (health.ok !== true || health.version !== packageVersion || health.protocolVersion !== protocolVersion || health.stateDir !== info.stateDir) {
1375
+ return false;
1376
+ }
1377
+ if (info.daemonPath && health.daemonPath !== info.daemonPath) {
1378
+ return false;
1379
+ }
1380
+ return true;
1321
1381
  } catch {
1322
1382
  return false;
1323
1383
  }
@@ -1346,14 +1406,17 @@ async function startServerUnlocked(existing, options = {}) {
1346
1406
  if (existing) {
1347
1407
  await retireServer(existing);
1348
1408
  }
1349
- const preferredPort = options.port ?? existing?.port ?? await getPort();
1409
+ const rememberedPort = options.port ?? existing?.port ?? await readLastServerPort();
1410
+ const preferredPort = rememberedPort ?? await getPort();
1350
1411
  try {
1351
1412
  return await launchServer(preferredPort);
1352
1413
  } catch (error) {
1353
- if (options.port || !existing?.port) {
1414
+ if (options.port) {
1354
1415
  throw error;
1355
1416
  }
1356
- await removeServerInfoForPid(existing.pid);
1417
+ if (existing) {
1418
+ await removeServerInfoForPid(existing.pid);
1419
+ }
1357
1420
  return launchServer(await getPort());
1358
1421
  }
1359
1422
  }
@@ -1380,6 +1443,7 @@ async function launchServer(port) {
1380
1443
  pid: child.pid ?? -1,
1381
1444
  port,
1382
1445
  version: packageVersion,
1446
+ protocolVersion,
1383
1447
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1384
1448
  stateDir: globalStateDir(),
1385
1449
  cwd: process.cwd(),
@@ -1394,6 +1458,7 @@ async function launchServer(port) {
1394
1458
  const deadline = Date.now() + 8e3;
1395
1459
  while (Date.now() < deadline) {
1396
1460
  if (await isServerResponsive(info)) {
1461
+ await writeLastServerPort(port).catch(() => void 0);
1397
1462
  return info;
1398
1463
  }
1399
1464
  await new Promise((resolve) => setTimeout(resolve, 150));
@@ -1402,6 +1467,21 @@ async function launchServer(port) {
1402
1467
  await removeServerInfoForPid(info.pid);
1403
1468
  throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
1404
1469
  }
1470
+ async function readLastServerPort() {
1471
+ let raw;
1472
+ try {
1473
+ raw = await readFile3(globalLastPortFile(), "utf8");
1474
+ } catch {
1475
+ return null;
1476
+ }
1477
+ const port = Number(raw.trim());
1478
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
1479
+ }
1480
+ async function writeLastServerPort(port) {
1481
+ await ensureDir(globalStateDir());
1482
+ await writeFile3(globalLastPortFile(), `${port}
1483
+ `);
1484
+ }
1405
1485
  async function withServerLock(fn) {
1406
1486
  const release = await acquireServerLock();
1407
1487
  try {
@@ -1674,7 +1754,7 @@ async function sleep2(milliseconds) {
1674
1754
 
1675
1755
  // src/server/store.ts
1676
1756
  import { createHash } from "crypto";
1677
- import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
1757
+ import { appendFile, readdir as readdir2, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
1678
1758
  import path6 from "path";
1679
1759
  import { ulid } from "ulid";
1680
1760
 
@@ -1870,8 +1950,11 @@ var ReviewStore = class {
1870
1950
  const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
1871
1951
  this.reviews.set(id, record);
1872
1952
  await this.persistInitial(record, turn);
1873
- this.emit({ type: "review.opened", reviewId: id });
1874
- return record;
1953
+ const event = await this.appendReviewEvent(id, "system", {
1954
+ type: "review.opened",
1955
+ reviewId: id
1956
+ });
1957
+ return withEvents(record, [event]);
1875
1958
  }
1876
1959
  async appendTurn(id, diff) {
1877
1960
  const record = await this.get(id);
@@ -1884,14 +1967,18 @@ var ReviewStore = class {
1884
1967
  const latest = latestTurn(record);
1885
1968
  if (latest.status === "pending") {
1886
1969
  if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
1887
- this.emit({
1970
+ await this.appendReviewEvent(id, "system", {
1888
1971
  type: "review.turn.created",
1889
1972
  reviewId: id,
1890
1973
  turnId: latest.id,
1891
1974
  turnIndex: latest.index,
1892
1975
  reused: true
1893
1976
  });
1894
- return { record, turn: latest, reused: true };
1977
+ return {
1978
+ record: withEvents(record, await this.readEvents(id)),
1979
+ turn: latest,
1980
+ reused: true
1981
+ };
1895
1982
  }
1896
1983
  throw new Error(`Review ${id} already has a pending turn`);
1897
1984
  }
@@ -1906,14 +1993,18 @@ var ReviewStore = class {
1906
1993
  });
1907
1994
  this.reviews.set(id, nextRecord);
1908
1995
  await this.persistInitial(nextRecord, turn);
1909
- this.emit({
1996
+ await this.appendReviewEvent(id, "system", {
1910
1997
  type: "review.turn.created",
1911
1998
  reviewId: id,
1912
1999
  turnId: turn.id,
1913
2000
  turnIndex: turn.index,
1914
2001
  reused: false
1915
2002
  });
1916
- return { record: nextRecord, turn, reused: false };
2003
+ return {
2004
+ record: withEvents(nextRecord, await this.readEvents(id)),
2005
+ turn,
2006
+ reused: false
2007
+ };
1917
2008
  }
1918
2009
  async list() {
1919
2010
  await this.loadAllReviews();
@@ -1985,7 +2076,7 @@ var ReviewStore = class {
1985
2076
  writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
1986
2077
  ]);
1987
2078
  await this.persistMeta(nextRecord);
1988
- this.emit({
2079
+ await this.appendReviewEvent(id, "human", {
1989
2080
  type: "review.submitted",
1990
2081
  reviewId: id,
1991
2082
  turnId: nextTurn.id,
@@ -1995,12 +2086,86 @@ var ReviewStore = class {
1995
2086
  comments: feedback.comments.length
1996
2087
  }
1997
2088
  });
1998
- return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
2089
+ return {
2090
+ record: withEvents(nextRecord, await this.readEvents(id)),
2091
+ feedbackPath,
2092
+ markdownPath,
2093
+ turn: nextTurn
2094
+ };
1999
2095
  }
2000
2096
  async feedback(id) {
2001
2097
  const record = await this.get(id);
2002
2098
  return record?.feedback ?? null;
2003
2099
  }
2100
+ async events(id, afterSeq = 0) {
2101
+ const record = await this.get(id);
2102
+ if (!record) {
2103
+ return [];
2104
+ }
2105
+ return this.readEvents(id, afterSeq);
2106
+ }
2107
+ async claim(id, message, turnSelector) {
2108
+ const record = await this.get(id);
2109
+ if (!record) {
2110
+ throw new Error(`Review ${id} not found`);
2111
+ }
2112
+ const turn = turnSelector ? this.resolveTurnSelector(record, turnSelector) : [...record.turns].reverse().find((candidate) => candidate.status === "submitted");
2113
+ if (!turn) {
2114
+ throw new Error(`Review ${id} has no submitted unresolved turn to claim`);
2115
+ }
2116
+ if (turn.status !== "submitted") {
2117
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be claimed`);
2118
+ }
2119
+ this.assertResolvable(turn, id);
2120
+ if (!turn.feedbackPath || !turn.markdownPath) {
2121
+ throw new Error(`Review ${id} turn ${turn.index} is missing feedback paths`);
2122
+ }
2123
+ const event = await this.appendReviewEvent(id, "agent", {
2124
+ type: "agent.claimed",
2125
+ reviewId: id,
2126
+ turnId: turn.id,
2127
+ turnIndex: turn.index,
2128
+ status: "claimed",
2129
+ ...message ? { message } : {}
2130
+ });
2131
+ return {
2132
+ ok: true,
2133
+ reviewId: id,
2134
+ turnId: turn.id,
2135
+ turnIndex: turn.index,
2136
+ status: "claimed",
2137
+ feedbackPath: turn.feedbackPath,
2138
+ markdownPath: turn.markdownPath,
2139
+ artifactDir: turn.artifactDir,
2140
+ feedback: turn.feedback,
2141
+ ...turn.resolution ? { resolution: turn.resolution } : {},
2142
+ event
2143
+ };
2144
+ }
2145
+ async addAgentNote(id, message, status, turnSelector) {
2146
+ const record = await this.get(id);
2147
+ if (!record) {
2148
+ throw new Error(`Review ${id} not found`);
2149
+ }
2150
+ const turn = turnSelector ? this.resolveTurnSelector(record, turnSelector) : activeTurn(record);
2151
+ const event = await this.appendReviewEvent(id, "agent", {
2152
+ type: "agent.note",
2153
+ reviewId: id,
2154
+ turnId: turn.id,
2155
+ turnIndex: turn.index,
2156
+ ...status ? { status } : {},
2157
+ message
2158
+ });
2159
+ return {
2160
+ ok: true,
2161
+ reviewId: id,
2162
+ turnId: turn.id,
2163
+ turnIndex: turn.index,
2164
+ ...status ? { status } : {},
2165
+ message,
2166
+ event
2167
+ };
2168
+ }
2004
2169
  async markResolved(id, summary, turnSelector) {
2005
2170
  const record = await this.get(id);
2006
2171
  if (!record) {
@@ -2129,6 +2294,75 @@ var ReviewStore = class {
2129
2294
  }
2130
2295
  };
2131
2296
  }
2297
+ async appendReviewEvent(reviewId, actor, input) {
2298
+ const persistedEvents = await this.readPersistedEvents(reviewId);
2299
+ let existingEvents = persistedEvents ?? [];
2300
+ if (!persistedEvents && input.type !== "review.opened") {
2301
+ const record2 = this.reviews.get(reviewId) ?? await this.loadKnownReview(reviewId);
2302
+ existingEvents = record2 ? synthesizeReviewEvents(record2) : [];
2303
+ if (existingEvents.length > 0) {
2304
+ await ensureDir(globalReviewDir(reviewId));
2305
+ await writeFile4(
2306
+ globalReviewEventsFile(reviewId),
2307
+ `${existingEvents.map((event2) => JSON.stringify(event2)).join("\n")}
2308
+ `
2309
+ );
2310
+ }
2311
+ }
2312
+ const event = {
2313
+ ...input,
2314
+ id: ulid(),
2315
+ seq: nextEventSeq(existingEvents),
2316
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2317
+ actor
2318
+ };
2319
+ await ensureDir(globalReviewDir(reviewId));
2320
+ await appendFile(globalReviewEventsFile(reviewId), `${JSON.stringify(event)}
2321
+ `);
2322
+ const record = this.reviews.get(reviewId);
2323
+ if (record) {
2324
+ this.reviews.set(reviewId, withEvents(record, [...existingEvents, event]));
2325
+ }
2326
+ this.emit(event);
2327
+ return event;
2328
+ }
2329
+ async readEvents(reviewId, afterSeq = 0) {
2330
+ const persistedEvents = await this.readPersistedEvents(reviewId);
2331
+ if (!persistedEvents) {
2332
+ const record = this.reviews.get(reviewId) ?? await this.loadKnownReview(reviewId);
2333
+ if (!record) {
2334
+ return [];
2335
+ }
2336
+ const synthesized = synthesizeReviewEvents(record);
2337
+ if (synthesized.length > 0) {
2338
+ await ensureDir(globalReviewDir(reviewId));
2339
+ await writeFile4(
2340
+ globalReviewEventsFile(reviewId),
2341
+ `${synthesized.map((event) => JSON.stringify(event)).join("\n")}
2342
+ `
2343
+ );
2344
+ }
2345
+ this.reviews.set(reviewId, withEvents(record, synthesized));
2346
+ return synthesized.filter((event) => (event.seq ?? 0) > afterSeq);
2347
+ }
2348
+ return persistedEvents.filter((event) => (event.seq ?? 0) > afterSeq);
2349
+ }
2350
+ async readPersistedEvents(reviewId) {
2351
+ let raw;
2352
+ try {
2353
+ raw = await readFile4(globalReviewEventsFile(reviewId), "utf8");
2354
+ } catch (error) {
2355
+ if (isFileNotFound(error)) {
2356
+ return null;
2357
+ }
2358
+ throw new Error(`Could not read review events for ${reviewId}: ${formatError(error)}`, {
2359
+ cause: error
2360
+ });
2361
+ }
2362
+ return raw.split("\n").map((line) => line.trim()).filter(Boolean).map(
2363
+ (line) => parseJsonFile(line, isReviewEvent, "review event", globalReviewEventsFile(reviewId))
2364
+ ).filter((event) => event.reviewId === reviewId);
2365
+ }
2132
2366
  emit(event) {
2133
2367
  for (const listener of this.listeners.get(event.reviewId) ?? []) {
2134
2368
  listener(event);
@@ -2214,7 +2448,9 @@ var ReviewStore = class {
2214
2448
  diff: latest.diff
2215
2449
  });
2216
2450
  this.reviews.set(id, record);
2217
- return record;
2451
+ const withTimeline = withEvents(record, await this.readEvents(id));
2452
+ this.reviews.set(id, withTimeline);
2453
+ return withTimeline;
2218
2454
  }
2219
2455
  async loadReviewFromTurnsOnly(id) {
2220
2456
  const turns = await this.loadPersistedTurns(id);
@@ -2237,8 +2473,10 @@ var ReviewStore = class {
2237
2473
  diff: latest.diff
2238
2474
  });
2239
2475
  this.reviews.set(id, record);
2240
- await this.persistMeta(record);
2241
- return record;
2476
+ const withTimeline = withEvents(record, await this.readEvents(id));
2477
+ this.reviews.set(id, withTimeline);
2478
+ await this.persistMeta(withTimeline);
2479
+ return withTimeline;
2242
2480
  }
2243
2481
  async loadPersistedTurns(id) {
2244
2482
  let entries;
@@ -2383,7 +2621,7 @@ var ReviewStore = class {
2383
2621
  path: resolvedPath,
2384
2622
  resolution
2385
2623
  };
2386
- this.emit({
2624
+ await this.appendReviewEvent(record.meta.id, "agent", {
2387
2625
  type: "review.updated",
2388
2626
  reviewId: record.meta.id,
2389
2627
  turnId: nextTurn.id,
@@ -2475,6 +2713,88 @@ function turnSummary(turn) {
2475
2713
  comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
2476
2714
  };
2477
2715
  }
2716
+ function withEvents(record, events) {
2717
+ return { ...record, events: events.toSorted(compareReviewEvents) };
2718
+ }
2719
+ function nextEventSeq(events) {
2720
+ return Math.max(0, ...events.map((event) => event.seq ?? 0)) + 1;
2721
+ }
2722
+ function synthesizeReviewEvents(record) {
2723
+ let seq = 1;
2724
+ const next = (actor, input, createdAt) => ({
2725
+ ...input,
2726
+ id: ulid(),
2727
+ seq: seq++,
2728
+ createdAt,
2729
+ actor
2730
+ });
2731
+ const events = [
2732
+ next("system", { type: "review.opened", reviewId: record.meta.id }, record.meta.createdAt)
2733
+ ];
2734
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
2735
+ for (const turn of turns) {
2736
+ if (turn.index > 1) {
2737
+ events.push(
2738
+ next(
2739
+ "system",
2740
+ {
2741
+ type: "review.turn.created",
2742
+ reviewId: record.meta.id,
2743
+ turnId: turn.id,
2744
+ turnIndex: turn.index,
2745
+ reused: false
2746
+ },
2747
+ turn.createdAt
2748
+ )
2749
+ );
2750
+ }
2751
+ if (turn.feedback) {
2752
+ events.push(
2753
+ next(
2754
+ "human",
2755
+ {
2756
+ type: "review.submitted",
2757
+ reviewId: record.meta.id,
2758
+ turnId: turn.id,
2759
+ turnIndex: turn.index,
2760
+ counts: {
2761
+ files: countCommentFiles(turn.feedback.comments),
2762
+ comments: turn.feedback.comments.length
2763
+ }
2764
+ },
2765
+ turn.submittedAt ?? turn.feedback.timestamp
2766
+ )
2767
+ );
2768
+ }
2769
+ if (turn.feedback && turn.resolution) {
2770
+ const counts = resolutionCounts(turn.feedback, turn.resolution.comments);
2771
+ events.push(
2772
+ next(
2773
+ "agent",
2774
+ {
2775
+ type: "review.updated",
2776
+ reviewId: record.meta.id,
2777
+ turnId: turn.id,
2778
+ turnIndex: turn.index,
2779
+ reason: turn.resolution.status === "resolved" ? "review-resolved" : "comment-resolved",
2780
+ status: turn.status,
2781
+ resolutionStatus: turn.resolution.status,
2782
+ counts
2783
+ },
2784
+ turn.resolution.resolvedAt ?? turn.resolution.comments.at(-1)?.resolvedAt ?? turn.resolvedAt ?? turn.submittedAt ?? turn.createdAt
2785
+ )
2786
+ );
2787
+ }
2788
+ }
2789
+ return events.toSorted(compareReviewEvents);
2790
+ }
2791
+ function compareReviewEvents(left, right) {
2792
+ const seqDelta = (left.seq ?? 0) - (right.seq ?? 0);
2793
+ if (seqDelta !== 0) {
2794
+ return seqDelta;
2795
+ }
2796
+ return (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
2797
+ }
2478
2798
  function reconcileTurn(meta, diff, feedback, resolution) {
2479
2799
  const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
2480
2800
  return {
@@ -2612,7 +2932,8 @@ program.command("open").description("Capture local changes and open them for rev
2612
2932
  if (options.open !== false) {
2613
2933
  await openBrowser(url);
2614
2934
  }
2615
- }
2935
+ },
2936
+ { turnId: turn.id }
2616
2937
  );
2617
2938
  info = watched.info;
2618
2939
  client = new ServerClient(serverUrl(info));
@@ -2715,6 +3036,23 @@ program.command("resolve").argument("<reviewId>", "review id").description("Mark
2715
3036
  );
2716
3037
  }
2717
3038
  );
3039
+ program.command("claim").argument("<reviewId>", "review id").description("Claim the latest submitted unresolved review turn for agent work").option("--message <text>", "optional visible agent status message").option("--turn <idOrIndex>", "claim a specific submitted turn").action(async (reviewId, options) => {
3040
+ const globals = program.opts();
3041
+ const info = await ensureServer();
3042
+ const client = new ServerClient(serverUrl(info));
3043
+ const result = await client.claimReview(reviewId, options.message, options.turn);
3044
+ globals.json ? printJson(result) : printPlain(`Claimed review ${reviewId} turn ${result.turnIndex}`);
3045
+ });
3046
+ program.command("note").argument("<reviewId>", "review id").description("Post a visible agent progress note to a review").requiredOption("--message <text>", "agent note text").option("--status <status>", "agent status: claimed, working, blocked, completed, or failed").option("--turn <idOrIndex>", "attach the note to a specific turn").action(
3047
+ async (reviewId, options) => {
3048
+ const globals = program.opts();
3049
+ const status = parseAgentStatus(options.status);
3050
+ const info = await ensureServer();
3051
+ const client = new ServerClient(serverUrl(info));
3052
+ const result = await client.addAgentNote(reviewId, options.message, status, options.turn);
3053
+ globals.json ? printJson(result) : printPlain(`Added note to review ${reviewId}${status ? ` (${status})` : ""}`);
3054
+ }
3055
+ );
2718
3056
  program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
2719
3057
  const globals = program.opts();
2720
3058
  const checks = [];
@@ -2777,7 +3115,7 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
2777
3115
  }
2778
3116
  }
2779
3117
  });
2780
- async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged) {
3118
+ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged, options = {}) {
2781
3119
  const startedAt = Date.now();
2782
3120
  let info = initialInfo;
2783
3121
  while (true) {
@@ -2786,7 +3124,10 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
2786
3124
  throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
2787
3125
  }
2788
3126
  try {
2789
- const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, remainingSeconds);
3127
+ const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, {
3128
+ timeoutSeconds: remainingSeconds,
3129
+ turnId: options.turnId
3130
+ });
2790
3131
  return { event, info };
2791
3132
  } catch (error) {
2792
3133
  if (isWatchTimeout(error)) {
@@ -2814,7 +3155,7 @@ async function checkStateDirAccess() {
2814
3155
  try {
2815
3156
  await ensureDir(globalStateDir());
2816
3157
  await access(globalStateDir(), constants.R_OK | constants.W_OK | constants.X_OK);
2817
- await writeFile4(probePath, "");
3158
+ await writeFile5(probePath, "");
2818
3159
  await rm5(probePath, { force: true });
2819
3160
  return { name: "state-dir", ok: true, detail: stateDirDetail() };
2820
3161
  } catch (error) {
@@ -2862,6 +3203,15 @@ function parseOlderThanDays(value) {
2862
3203
  }
2863
3204
  return days;
2864
3205
  }
3206
+ function parseAgentStatus(value) {
3207
+ if (value === void 0) {
3208
+ return void 0;
3209
+ }
3210
+ if (value === "claimed" || value === "working" || value === "blocked" || value === "completed" || value === "failed") {
3211
+ return value;
3212
+ }
3213
+ throw new Error("--status must be one of claimed, working, blocked, completed, or failed");
3214
+ }
2865
3215
  function formatClearResult(result) {
2866
3216
  const action = result.dryRun ? "Would delete" : "Deleted";
2867
3217
  const count = result.dryRun ? result.counts.candidates : result.counts.deleted;