diffsplain 0.9.0 → 0.11.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.
Files changed (88) hide show
  1. package/README.md +21 -4
  2. package/dist/assets/{angular-html-m_m91ZYB.js → angular-html-CjYwnGyV.js} +1 -1
  3. package/dist/assets/{angular-ts-CO5Z--Rb.js → angular-ts-DSPf2G4O.js} +1 -1
  4. package/dist/assets/{apl-ogYYgLUu.js → apl-B9ytghJx.js} +1 -1
  5. package/dist/assets/{astro-DAfY3cNR.js → astro-BsGV0yBF.js} +1 -1
  6. package/dist/assets/{blade-E6RzsLUV.js → blade-Cc2eCYlt.js} +1 -1
  7. package/dist/assets/{c-kKdxhtHF.js → c-CWKyCnUx.js} +1 -1
  8. package/dist/assets/{cobol-3oDDyFNf.js → cobol-DkamOI1E.js} +1 -1
  9. package/dist/assets/{coffee-dsiILuWe.js → coffee-DyaEXePe.js} +1 -1
  10. package/dist/assets/{cpp-DYyNFq__.js → cpp-16-d2Eyo.js} +1 -1
  11. package/dist/assets/{crystal-B7dVp9TF.js → crystal-CavPPRhQ.js} +1 -1
  12. package/dist/assets/{css-DlxJ0aex.js → css-BME_wHJq.js} +1 -1
  13. package/dist/assets/{edge-BjEVphkC.js → edge-DeVi2hvK.js} +1 -1
  14. package/dist/assets/{elixir-0xTOtMcM.js → elixir-CCWgH_vc.js} +1 -1
  15. package/dist/assets/{elm-iQk_Wrxq.js → elm-CZ570J7x.js} +1 -1
  16. package/dist/assets/{erb-BO74ztiE.js → erb-CDPT5bea.js} +1 -1
  17. package/dist/assets/{git-rebase-BN667QRY.js → git-rebase-Dq5Z37DJ.js} +1 -1
  18. package/dist/assets/{glimmer-js-DTGUIHeS.js → glimmer-js-BoPVZK8p.js} +1 -1
  19. package/dist/assets/{glimmer-ts-BU3oQn2d.js → glimmer-ts-DazEWfg1.js} +1 -1
  20. package/dist/assets/{glsl-KLiqLJi2.js → glsl-DCUjYCZY.js} +1 -1
  21. package/dist/assets/{graphql-D_idcsoJ.js → graphql-DkCeSxy7.js} +1 -1
  22. package/dist/assets/{hack-BXlsgPIr.js → hack-tiY3l-W-.js} +1 -1
  23. package/dist/assets/{haml-fP0aUg4-.js → haml-DyQZrJg_.js} +1 -1
  24. package/dist/assets/{handlebars-C1ZEaot-.js → handlebars-Bvu5Rr0t.js} +1 -1
  25. package/dist/assets/{html-D04qIuup.js → html-C60VFgKW.js} +1 -1
  26. package/dist/assets/{html-derivative-C6ai-9QL.js → html-derivative-DB69gpnL.js} +1 -1
  27. package/dist/assets/{http-C7bz3ZvQ.js → http-DmS8hddd.js} +1 -1
  28. package/dist/assets/{hurl-jL6B6Ahr.js → hurl-Cdp6lmUx.js} +1 -1
  29. package/dist/assets/{index-BPaJGi-m.js → index-CUiLG0r9.js} +4 -4
  30. package/dist/assets/{index-VAyBpb3u.css → index-YnLIxcTm.css} +1 -1
  31. package/dist/assets/{java-BVzvLCON.js → java-CkOnwoVQ.js} +1 -1
  32. package/dist/assets/{javascript-UDbZHxCI.js → javascript-D2tF_Ylj.js} +1 -1
  33. package/dist/assets/{jinja-DB-QKo2A.js → jinja-CdWpLcff.js} +1 -1
  34. package/dist/assets/{jison-B1ZbR2FF.js → jison-BII58uuR.js} +1 -1
  35. package/dist/assets/{json-DV4WwJzT.js → json-BKlPRLrU.js} +1 -1
  36. package/dist/assets/{jsx-DS6_VTvW.js → jsx-Ba5UouxN.js} +1 -1
  37. package/dist/assets/{julia-C4tevET0.js → julia-CwdQF8Ff.js} +1 -1
  38. package/dist/assets/{just-CWMjefzB.js → just-Be3sr1-h.js} +1 -1
  39. package/dist/assets/{latex-tyEFf_Af.js → latex-CDF6t93N.js} +1 -1
  40. package/dist/assets/{liquid-C3d1X1rL.js → liquid-BE3LVHkw.js} +1 -1
  41. package/dist/assets/{lua-sXvrzLQY.js → lua-D_W8Ux9S.js} +1 -1
  42. package/dist/assets/{marko-CQweucif.js → marko-BeUfMD-U.js} +1 -1
  43. package/dist/assets/{mdc-TrxLWF7s.js → mdc-C8tRJlVU.js} +1 -1
  44. package/dist/assets/{nginx-ChE2nSNJ.js → nginx-CLPimMM6.js} +1 -1
  45. package/dist/assets/{nim-BY1CTRfx.js → nim-ikQhXKQf.js} +1 -1
  46. package/dist/assets/{perl-Cvj2PQQH.js → perl-C27cToFa.js} +1 -1
  47. package/dist/assets/{php-CGmHp8HW.js → php-Btdg-RP3.js} +1 -1
  48. package/dist/assets/{pug-DM4S0bRr.js → pug-Bk73ERXL.js} +1 -1
  49. package/dist/assets/{qml-DI89Al9K.js → qml-DhY7FDIl.js} +1 -1
  50. package/dist/assets/{r-Bs5_4yYh.js → r-CEwua7nj.js} +1 -1
  51. package/dist/assets/{razor-DimzayhZ.js → razor-sPO7Sh9d.js} +1 -1
  52. package/dist/assets/{regexp-BLACz0cx.js → regexp-D9DLFGXE.js} +1 -1
  53. package/dist/assets/{rst-DcWzxQh2.js → rst-YhauCnId.js} +1 -1
  54. package/dist/assets/{ruby-Dlat5E8d.js → ruby-DpMkRaoz.js} +1 -1
  55. package/dist/assets/{sas-CCDmMaBY.js → sas-0KdmKGRO.js} +1 -1
  56. package/dist/assets/{scss-BPiU2nu6.js → scss-CohvHWvq.js} +1 -1
  57. package/dist/assets/{shellscript-CKinVNrN.js → shellscript-C93Q8fLt.js} +1 -1
  58. package/dist/assets/{shellsession-Cea8Pu5d.js → shellsession-YCh2AQXu.js} +1 -1
  59. package/dist/assets/{soy--BRAPY4-.js → soy-CWVYdXUH.js} +1 -1
  60. package/dist/assets/{sql-DjKiCyWK.js → sql-CJNtax5I.js} +1 -1
  61. package/dist/assets/{stata-CDvo3J6y.js → stata-C7v41XAM.js} +1 -1
  62. package/dist/assets/{surrealql-C7b7E5sh.js → surrealql-CRUSOxDf.js} +1 -1
  63. package/dist/assets/{svelte-DO0tKtiK.js → svelte-Z0harRr1.js} +1 -1
  64. package/dist/assets/{templ-DhhmR_rv.js → templ-BplDouJX.js} +1 -1
  65. package/dist/assets/{tex-Cn_V-Gg4.js → tex-MsB6StN5.js} +1 -1
  66. package/dist/assets/{ts-tags-B6c-FTmA.js → ts-tags-BWx8aq5t.js} +1 -1
  67. package/dist/assets/{tsx-Cvtt2sJ-.js → tsx-DBT1gmNl.js} +1 -1
  68. package/dist/assets/{twig-B7ZbCUmB.js → twig-jz67rR3C.js} +1 -1
  69. package/dist/assets/{typescript-i3C6um2B.js → typescript-CzwGN36B.js} +1 -1
  70. package/dist/assets/{vue-html-D362KpK3.js → vue-html-Dhrmu7sO.js} +1 -1
  71. package/dist/assets/{vue-vine-D34cBbwO.js → vue-vine-BfQGXZs6.js} +1 -1
  72. package/dist/assets/{vue-DyPX0Vum.js → vue-xsrSiVMC.js} +1 -1
  73. package/dist/assets/{xml-DJEk7u4n.js → xml-W7cP4qeN.js} +1 -1
  74. package/dist/assets/{xsl-CoTtf90f.js → xsl-BlXf8qm_.js} +1 -1
  75. package/dist/assets/{yaml-Cv5epQ4-.js → yaml-BtNwxUT9.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +4 -2
  78. package/scripts/agent-config.mjs +184 -0
  79. package/scripts/agent-usage.mjs +141 -0
  80. package/scripts/build-diff-data.mjs +32 -12
  81. package/scripts/cli-args.mjs +36 -2
  82. package/scripts/coding-agents.mjs +265 -13
  83. package/scripts/generate-summaries.mjs +72 -13
  84. package/scripts/present.mjs +156 -58
  85. package/scripts/review-chat-context.mjs +6 -4
  86. package/scripts/review-chat-controller.mjs +142 -17
  87. package/scripts/review-chat-provider.mjs +40 -7
  88. package/scripts/serve-built.mjs +139 -7
@@ -15,12 +15,22 @@ import { fileURLToPath } from 'node:url';
15
15
  import {
16
16
  agentCommand,
17
17
  agentReadOnlyWarning,
18
+ assertFastCompatible,
18
19
  assertReasoningSupported,
20
+ codingAgentFromSelectionError,
19
21
  codingAgentAvailability,
20
22
  codingAgentBinary,
21
23
  parseAgentResponse,
24
+ parseAgentUsage,
22
25
  selectCodingAgent,
23
26
  } from './coding-agents.mjs';
27
+ import {
28
+ emptyUsageAccumulator,
29
+ formatReviewUsage,
30
+ recordUsage,
31
+ reviewUsage,
32
+ usageSummary,
33
+ } from './agent-usage.mjs';
24
34
  import {
25
35
  isBaseWorktreeTarget,
26
36
  reviewAccessMode,
@@ -75,6 +85,7 @@ const valueFlags = new Set([
75
85
  const booleanFlags = new Set([
76
86
  '--checkout',
77
87
  '--force',
88
+ '--fast',
78
89
  '--support-record',
79
90
  '--worktree',
80
91
  '--no-checkout-access',
@@ -135,10 +146,11 @@ Options:
135
146
  --cache-dir PATH Bare cache for fetched Git objects
136
147
  --agent NAME Use codex, claude, copilot, cursor, or opencode
137
148
  Cursor needs version 2026.08.11 or newer
138
- Without --agent, choose from usable agents in a terminal
149
+ Overrides the configured default for this run
139
150
  --codex-bin FILE Codex CLI path (default: codex)
140
151
  --model NAME Model passed to the coding agent
141
152
  --reasoning LEVEL Agent reasoning effort when supported
153
+ --fast Enable provider Fast mode for every agent call
142
154
  --batch-size COUNT Maximum files per agent pass (default: 12)
143
155
  --jobs COUNT Agent passes to run at once (default: 3)
144
156
  --support-record Print a safe record if this run fails
@@ -148,8 +160,9 @@ Options:
148
160
  --no-checkout-access
149
161
  Limit notes to the supplied snapshot
150
162
 
151
- Without --agent, an interactive terminal is required. In scripts, pass
152
- --agent NAME. Use diffsplain --no-agent for a plain review.`);
163
+ Without --agent or a configured default, an interactive terminal is required.
164
+ Use diffsplain config agent NAME to set a default. Use diffsplain --no-agent
165
+ for a plain review.`);
153
166
  process.exit(0);
154
167
  }
155
168
 
@@ -176,6 +189,7 @@ let selectedAgent;
176
189
  let agentBinary;
177
190
  const model = option('--model');
178
191
  const reasoning = option('--reasoning');
192
+ const fast = rawArgs.includes('--fast');
179
193
  const batchSizeValue = option('--batch-size') || '12';
180
194
  const reasoningLevels = new Set([
181
195
  'minimal',
@@ -375,6 +389,18 @@ const summariesPath = summaryPath({
375
389
  const ownershipPath = `${summariesPath}.lock`;
376
390
  let ownership;
377
391
  let ownershipHeartbeat;
392
+ let agentUsage = emptyUsageAccumulator();
393
+
394
+ function currentAgentUsage() {
395
+ return usageSummary(agentUsage);
396
+ }
397
+
398
+ function recordAgentUsage(usage) {
399
+ agentUsage = recordUsage(agentUsage, usage);
400
+ const emptyChatUsage = usageSummary(emptyUsageAccumulator());
401
+ console.log(formatReviewUsage(reviewUsage(currentAgentUsage(), emptyChatUsage)));
402
+ }
403
+
378
404
  function acquireOwnership() {
379
405
  ownership = acquireLease(ownershipPath);
380
406
  ownershipHeartbeat = setInterval(() => {
@@ -1037,8 +1063,13 @@ function publishSnapshot(
1037
1063
  ...(state.errors.length ? { errors: state.errors } : {}),
1038
1064
  ...(model ? { model } : {}),
1039
1065
  ...(reasoning ? { reasoning } : {}),
1066
+ fast,
1040
1067
  accessMode: accessMode.mode,
1041
1068
  },
1069
+ usage: reviewUsage(
1070
+ currentAgentUsage(),
1071
+ usageSummary(emptyUsageAccumulator()),
1072
+ ),
1042
1073
  };
1043
1074
  delete content.version;
1044
1075
  delete content.generatedAt;
@@ -1063,6 +1094,7 @@ function summariesForSnapshot(summaries, snapshot) {
1063
1094
  meta: {
1064
1095
  ...summaries.meta,
1065
1096
  reviewFingerprint: snapshot.notes.reviewFingerprint,
1097
+ usage: currentAgentUsage(),
1066
1098
  },
1067
1099
  };
1068
1100
  }
@@ -1206,14 +1238,20 @@ async function selectAgentForNotes() {
1206
1238
  signal: selectionAbortController.signal,
1207
1239
  },
1208
1240
  );
1209
- assertReasoningSupported(selectedAgent, reasoning);
1210
1241
  agentBinary = codingAgentBinary(selectedAgent, { codexBin });
1242
+ assertReasoningSupported(selectedAgent, reasoning);
1243
+ assertFastCompatible(selectedAgent, agentBinary, fast);
1211
1244
  supportRecorder?.setProvider(
1212
1245
  selectedAgent,
1213
1246
  safeCommandVersion(selectedAgent, agentBinary),
1214
1247
  );
1215
1248
  } catch (error) {
1216
- supportRecorder?.setProvider(requestedAgent || 'unknown');
1249
+ supportRecorder?.setProvider(
1250
+ codingAgentFromSelectionError(error) ||
1251
+ selectedAgent ||
1252
+ requestedAgent ||
1253
+ 'unknown',
1254
+ );
1217
1255
  if (error instanceof Error) error.exitCode = 2;
1218
1256
  throw error;
1219
1257
  }
@@ -1258,9 +1296,12 @@ function runAgent(invocation, input, { timeoutMs } = {}) {
1258
1296
  const timeout = timeoutMs
1259
1297
  ? setTimeout(() => {
1260
1298
  child.kill('SIGTERM');
1261
- rejectPromise(
1262
- new Error(`${selectedAgent} timed out`),
1299
+ const error = new Error(`${selectedAgent} timed out`);
1300
+ error.providerUsage = parseAgentUsage(
1301
+ selectedAgent,
1302
+ Buffer.concat(stdout).toString('utf8'),
1263
1303
  );
1304
+ rejectPromise(error);
1264
1305
  }, timeoutMs)
1265
1306
  : undefined;
1266
1307
  timeout?.unref();
@@ -1309,14 +1350,18 @@ function runAgent(invocation, input, { timeoutMs } = {}) {
1309
1350
  .filter((line) => line.trim() && line.length < 600)
1310
1351
  .slice(-8)
1311
1352
  .join('\n');
1312
- rejectPromise(
1313
- new Error(
1314
- `${selectedAgent} exited with status ${status ?? signal}${detail ? `\n${detail}` : ''}`,
1315
- ),
1353
+ const error = new Error(
1354
+ `${selectedAgent} exited with status ${status ?? signal}${detail ? `\n${detail}` : ''}`,
1316
1355
  );
1356
+ error.providerUsage = parseAgentUsage(selectedAgent, stdoutText);
1357
+ rejectPromise(error);
1317
1358
  return;
1318
1359
  }
1319
- resolvePromise({ stdout: stdoutText, stderr: stderrText });
1360
+ resolvePromise({
1361
+ stdout: stdoutText,
1362
+ stderr: stderrText,
1363
+ providerUsage: parseAgentUsage(selectedAgent, stdoutText),
1364
+ });
1320
1365
  });
1321
1366
  if (invocation.input === 'stdin') child.stdin.end(input);
1322
1367
  else child.stdin.end();
@@ -1326,8 +1371,16 @@ function runAgent(invocation, input, { timeoutMs } = {}) {
1326
1371
  async function requestAgent(invocation, input, normalize, snapshot) {
1327
1372
  supportRecorder?.addBytes('agentInput', Buffer.byteLength(input));
1328
1373
  return recordAsyncStage('agent', async () => {
1329
- const result = await runAgent(invocation, input);
1374
+ let result;
1375
+ try {
1376
+ result = await runAgent(invocation, input);
1377
+ } catch (error) {
1378
+ assertAgentReviewFresh(snapshot);
1379
+ recordAgentUsage(error?.providerUsage);
1380
+ throw error;
1381
+ }
1330
1382
  assertAgentReviewFresh(snapshot);
1383
+ recordAgentUsage(result.providerUsage);
1331
1384
  if (result.stderr.trim()) {
1332
1385
  console.error(
1333
1386
  `${selectedAgent} wrote diagnostic output:\n${result.stderr.trim()}`,
@@ -1560,6 +1613,7 @@ try {
1560
1613
  generatedAt: new Date().toISOString(),
1561
1614
  ...(model ? { model } : {}),
1562
1615
  ...(reasoning ? { reasoning } : {}),
1616
+ fast,
1563
1617
  accessMode: accessMode.mode,
1564
1618
  },
1565
1619
  };
@@ -1606,6 +1660,7 @@ try {
1606
1660
  generatedAt: new Date().toISOString(),
1607
1661
  ...(model ? { model } : {}),
1608
1662
  ...(reasoning ? { reasoning } : {}),
1663
+ fast,
1609
1664
  accessMode: accessMode.mode,
1610
1665
  },
1611
1666
  };
@@ -1687,6 +1742,7 @@ try {
1687
1742
  : 'complete',
1688
1743
  ...(model ? { model } : {}),
1689
1744
  ...(reasoning ? { reasoning } : {}),
1745
+ fast,
1690
1746
  accessMode: accessMode.mode,
1691
1747
  },
1692
1748
  },
@@ -1742,6 +1798,7 @@ try {
1742
1798
  binary: agentBinary,
1743
1799
  model,
1744
1800
  reasoning,
1801
+ fast,
1745
1802
  prompt: promptFor(batchPaths, { accessMode, includeChange: false }),
1746
1803
  schema: outputSchema(batchPaths, { includeChange: false }),
1747
1804
  schemaPath,
@@ -1868,6 +1925,7 @@ try {
1868
1925
  binary: agentBinary,
1869
1926
  model,
1870
1927
  reasoning,
1928
+ fast,
1871
1929
  prompt: promptFor([], { accessMode }),
1872
1930
  schema,
1873
1931
  schemaPath,
@@ -1938,6 +1996,7 @@ try {
1938
1996
  } catch (error) {
1939
1997
  if (!(error instanceof ReviewChangedError)) throw error;
1940
1998
  reviewChanged = true;
1999
+ agentUsage = emptyUsageAccumulator();
1941
2000
  console.log('The review changed while notes were generated. Rebuilding the snapshot.');
1942
2001
  }
1943
2002
  } while (reviewChanged && !interrupted);
@@ -17,9 +17,12 @@ import { dirname, join, resolve } from 'node:path';
17
17
  import { createInterface } from 'node:readline';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { helpText, parseCliArgs } from './cli-args.mjs';
20
+ import { applyAgentConfigOperation } from './agent-config.mjs';
20
21
  import {
21
22
  agentReadOnlyWarning,
23
+ assertFastCompatible,
22
24
  assertReasoningSupported,
25
+ codingAgentFromSelectionError,
23
26
  codingAgentAvailability,
24
27
  codingAgentBinary,
25
28
  selectCodingAgent,
@@ -31,6 +34,11 @@ import {
31
34
  } from './local-target.mjs';
32
35
  import { doctorReport } from './doctor.mjs';
33
36
  import { accessTokenDirectory } from './access-token.mjs';
37
+ import {
38
+ emptyUsageAccumulator,
39
+ reviewUsage,
40
+ usageSummary,
41
+ } from './agent-usage.mjs';
34
42
  import { cacheStatus, clearCache, formatCacheStatus, pruneCache } from './cache.mjs';
35
43
  import {
36
44
  agentFallbackRecordNeeded,
@@ -126,6 +134,22 @@ if (cli.version) {
126
134
  console.log(`diffsplain ${packageJson.version}`);
127
135
  process.exit(0);
128
136
  }
137
+ if (cli.config) {
138
+ try {
139
+ const result = applyAgentConfigOperation(cli.config);
140
+ if (result.kind === 'show') {
141
+ console.log(result.agent ?? 'No default coding agent is configured.');
142
+ } else if (result.kind === 'set') {
143
+ console.log(`Default coding agent set to "${result.agent}".`);
144
+ } else {
145
+ console.log('Default coding agent unset.');
146
+ }
147
+ process.exit(0);
148
+ } catch (error) {
149
+ console.error(error instanceof Error ? error.message : String(error));
150
+ process.exit(1);
151
+ }
152
+ }
129
153
  if (cli.doctor) {
130
154
  if (cli.doctor.deep) {
131
155
  console.error(
@@ -238,10 +262,11 @@ if (agentEnabled) {
238
262
  }),
239
263
  },
240
264
  );
241
- assertReasoningSupported(selectedAgent, cli.reasoning);
242
265
  selectedAgentBinary = codingAgentBinary(selectedAgent, {
243
266
  codexBin: cli.codexBin,
244
267
  });
268
+ assertReasoningSupported(selectedAgent, cli.reasoning);
269
+ assertFastCompatible(selectedAgent, selectedAgentBinary, cli.fast);
245
270
  recordSelectedProvider(selectedAgent, selectedAgentBinary);
246
271
  supportRecorder?.addStage(
247
272
  'agent',
@@ -263,7 +288,12 @@ if (agentEnabled) {
263
288
  agentArgs.push('--provider-read-only-warning-emitted');
264
289
  }
265
290
  } catch (error) {
266
- recordSelectedProvider(cli.agent || 'unknown');
291
+ recordSelectedProvider(
292
+ codingAgentFromSelectionError(error) ||
293
+ selectedAgent ||
294
+ cli.agent ||
295
+ 'unknown',
296
+ );
267
297
  supportRecorder?.addStage(
268
298
  'agent',
269
299
  performance.now() - selectionStarted,
@@ -338,9 +368,7 @@ writeFileSync(accessPath, access, { mode: 0o600 });
338
368
  chmodSync(accessPath, 0o600);
339
369
  if (agentEnabled) {
340
370
  feedArgs.push('--ignore-summary-watch');
341
- if (accessMode.mode === 'snapshot-only') {
342
- agentArgs.push('--snapshot', rawSnapshotPath);
343
- }
371
+ agentArgs.push('--snapshot', rawSnapshotPath);
344
372
  }
345
373
 
346
374
  const snapshotStarted = performance.now();
@@ -433,6 +461,7 @@ function chatAgentArguments() {
433
461
  ...chatAccessArguments(),
434
462
  ...optionalSiteArgument('--chat-model', cli.model),
435
463
  ...optionalSiteArgument('--chat-reasoning', cli.reasoning),
464
+ ...(cli.fast ? ['--chat-fast'] : []),
436
465
  ];
437
466
  }
438
467
 
@@ -506,59 +535,91 @@ function startSite() {
506
535
  child.on('error', handleSiteError);
507
536
  }
508
537
 
538
+ function savedReviewFingerprint(notes) {
539
+ if (!notes) return undefined;
540
+ if (notes.agentReviewFingerprint) return notes.agentReviewFingerprint;
541
+ return notes.reviewFingerprint;
542
+ }
543
+
544
+ function snapshotReviewFingerprint(snapshot) {
545
+ const savedFingerprint = savedReviewFingerprint(snapshot.notes);
546
+ if (savedFingerprint) return savedFingerprint;
547
+ const reviewData = {
548
+ repo: {
549
+ name: snapshot.repo.name,
550
+ base: snapshot.repo.base,
551
+ head: snapshot.repo.head,
552
+ branch: snapshot.repo.branch,
553
+ baseBranch: snapshot.repo.baseBranch,
554
+ remote: snapshot.repo.remote,
555
+ targetKind: snapshot.repo.target?.kind,
556
+ },
557
+ change: {
558
+ title: snapshot.change.title,
559
+ number: snapshot.change.number,
560
+ },
561
+ files: snapshot.files.map((file) => ({
562
+ path: file.path,
563
+ oldPath: file.oldPath,
564
+ status: file.status,
565
+ additions: file.additions,
566
+ deletions: file.deletions,
567
+ isBinary: file.isBinary,
568
+ patch: file.patch,
569
+ })),
570
+ };
571
+ return createHash('sha256')
572
+ .update(JSON.stringify(reviewData))
573
+ .digest('hex');
574
+ }
575
+
576
+ function noteMetadataMatches(notes, fingerprint) {
577
+ return [
578
+ notes.complete,
579
+ notes.fresh,
580
+ notes.generatedFor === fingerprint,
581
+ notes.accessMode === accessMode.mode,
582
+ ].every(Boolean);
583
+ }
584
+
585
+ function normalizedAgentSetting(value) {
586
+ return value ?? null;
587
+ }
588
+
589
+ function agentSettingsMatch(notes) {
590
+ return [
591
+ notes.agent === selectedAgent,
592
+ normalizedAgentSetting(notes.model) === normalizedAgentSetting(cli.model),
593
+ normalizedAgentSetting(notes.reasoning) ===
594
+ normalizedAgentSetting(cli.reasoning),
595
+ ].every(Boolean);
596
+ }
597
+
598
+ function emptyReview(files) {
599
+ return Array.isArray(files) && files.length === 0;
600
+ }
601
+
602
+ function hasCurrentAgentNotes(snapshot, fingerprint) {
603
+ const notes = snapshot.notes;
604
+ if (!savedReviewFingerprint(notes)) return false;
605
+ if (!noteMetadataMatches(notes, fingerprint)) return false;
606
+ if (emptyReview(snapshot.files)) return true;
607
+ return agentSettingsMatch(notes);
608
+ }
609
+
610
+ function snapshotStateFromSnapshot(snapshot) {
611
+ const fingerprint = snapshotReviewFingerprint(snapshot);
612
+ return {
613
+ fingerprint,
614
+ hasCurrentAgentNotes: hasCurrentAgentNotes(snapshot, fingerprint),
615
+ };
616
+ }
617
+
509
618
  function snapshotState() {
510
619
  try {
511
- const snapshot = JSON.parse(readFileSync(reviewSnapshotPath, 'utf8'));
512
- if (snapshot.notes?.reviewFingerprint) {
513
- const fingerprint =
514
- snapshot.notes.agentReviewFingerprint ||
515
- snapshot.notes.reviewFingerprint;
516
- const emptyReview =
517
- Array.isArray(snapshot.files) && snapshot.files.length === 0;
518
- return {
519
- fingerprint,
520
- hasCurrentAgentNotes:
521
- snapshot.notes.complete &&
522
- snapshot.notes.fresh &&
523
- snapshot.notes.generatedFor === fingerprint &&
524
- snapshot.notes.accessMode === accessMode.mode &&
525
- (emptyReview ||
526
- (snapshot.notes.agent === selectedAgent &&
527
- (snapshot.notes.model || null) === (cli.model || null) &&
528
- (snapshot.notes.reasoning || null) ===
529
- (cli.reasoning || null))),
530
- };
531
- }
532
- const reviewData = {
533
- repo: {
534
- name: snapshot.repo.name,
535
- base: snapshot.repo.base,
536
- head: snapshot.repo.head,
537
- branch: snapshot.repo.branch,
538
- baseBranch: snapshot.repo.baseBranch,
539
- remote: snapshot.repo.remote,
540
- targetKind: snapshot.repo.target?.kind,
541
- },
542
- change: {
543
- title: snapshot.change.title,
544
- number: snapshot.change.number,
545
- },
546
- files: snapshot.files.map((file) => ({
547
- path: file.path,
548
- oldPath: file.oldPath,
549
- status: file.status,
550
- additions: file.additions,
551
- deletions: file.deletions,
552
- isBinary: file.isBinary,
553
- patch: file.patch,
554
- })),
555
- };
556
- return {
557
- fingerprint: createHash('sha256')
558
- .update(JSON.stringify(reviewData))
559
- .digest('hex'),
560
- hasCurrentAgentNotes: false,
561
- };
620
+ return snapshotStateFromSnapshot(
621
+ JSON.parse(readFileSync(reviewSnapshotPath, 'utf8')),
622
+ );
562
623
  } catch {
563
624
  return undefined;
564
625
  }
@@ -699,12 +760,16 @@ function scheduleAgent(fingerprint) {
699
760
  if (agent) {
700
761
  if (selectedFingerprint !== agentFingerprint) {
701
762
  queuedFingerprint = selectedFingerprint;
763
+ console.log(
764
+ 'Review changed while notes were generated. Restarting agent notes...',
765
+ );
702
766
  agent.kill('SIGTERM');
703
767
  }
704
768
  return;
705
769
  }
706
770
  clearTimeout(agentTimer);
707
771
  const delay = agentFingerprint ? 300 : 0;
772
+ console.log('Preparing agent notes...');
708
773
  agentTimer = setTimeout(() => runAgent(selectedFingerprint), delay);
709
774
  }
710
775
 
@@ -720,11 +785,44 @@ function markSnapshotReady() {
720
785
  );
721
786
  }
722
787
 
788
+ function snapshotForPresentation(snapshot) {
789
+ const zeroUsage = usageSummary(emptyUsageAccumulator());
790
+ const hasCurrentNotes = snapshotStateFromSnapshot(snapshot)
791
+ .hasCurrentAgentNotes;
792
+ const current = {
793
+ ...snapshot,
794
+ usage: reviewUsage(zeroUsage, zeroUsage),
795
+ };
796
+ if (hasCurrentNotes && snapshot.notes?.fast === cli.fast) return current;
797
+ const content = {
798
+ ...current,
799
+ notes: {
800
+ ...snapshot.notes,
801
+ fast: cli.fast,
802
+ ...(hasCurrentNotes
803
+ ? {}
804
+ : { complete: false, status: 'generating' }),
805
+ },
806
+ };
807
+ delete content.version;
808
+ delete content.generatedAt;
809
+ return {
810
+ version: createHash('sha256')
811
+ .update(JSON.stringify(content))
812
+ .digest('hex')
813
+ .slice(0, 12),
814
+ generatedAt: new Date().toISOString(),
815
+ ...content,
816
+ };
817
+ }
818
+
723
819
  function seedPresentationSnapshot() {
724
- const raw = readFileSync(rawSnapshotPath);
820
+ const snapshot = snapshotForPresentation(
821
+ JSON.parse(readFileSync(rawSnapshotPath, 'utf8')),
822
+ );
725
823
  mkdirSync(dirname(outputPath), { recursive: true });
726
824
  const pendingOutput = `${outputPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
727
- writeFileSync(pendingOutput, raw);
825
+ writeFileSync(pendingOutput, `${JSON.stringify(snapshot, null, 2)}\n`);
728
826
  renameSync(pendingOutput, outputPath);
729
827
  }
730
828
 
@@ -307,8 +307,10 @@ export function makeInput({ kind, context, messages, question, accessMode }) {
307
307
  };
308
308
  }
309
309
 
310
- function providerResult(provider, input) {
311
- return typeof provider === 'function' ? provider(input) : provider.run(input);
310
+ function providerResult(provider, input, context) {
311
+ return typeof provider === 'function'
312
+ ? provider(input, context)
313
+ : provider.run(input, context);
312
314
  }
313
315
 
314
316
  function isExecution(value) {
@@ -324,8 +326,8 @@ function executionPromise(value) {
324
326
  return isExecution(value) ? value.promise : value;
325
327
  }
326
328
 
327
- export function providerExecution(provider, input) {
328
- const result = providerResult(provider, input);
329
+ export function providerExecution(provider, input, context) {
330
+ const result = providerResult(provider, input, context);
329
331
  return {
330
332
  promise: Promise.resolve(executionPromise(result)),
331
333
  cancel: cancellationFor(result),