diffsplain 0.5.0 → 0.6.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 (91) hide show
  1. package/README.md +17 -12
  2. package/dist/assets/{angular-html-CoQle5-T.js → angular-html-vBvPnd6a.js} +1 -1
  3. package/dist/assets/{angular-ts-BB_A1Lpy.js → angular-ts-CDzF1Z6a.js} +1 -1
  4. package/dist/assets/{apl-B7_4_FNq.js → apl-Bm9bxvP7.js} +1 -1
  5. package/dist/assets/{astro-CXVo4VE4.js → astro-RNrNWCYO.js} +1 -1
  6. package/dist/assets/{blade-BVX41dq7.js → blade-On-NBYUv.js} +1 -1
  7. package/dist/assets/{c-Dhyi8Nd2.js → c-hRmhFgD0.js} +1 -1
  8. package/dist/assets/{cobol-CU5oLeZj.js → cobol-CIqc3k2t.js} +1 -1
  9. package/dist/assets/{coffee-D_FU6gC_.js → coffee-Cd5EyvvS.js} +1 -1
  10. package/dist/assets/{cpp-CxoYLSi3.js → cpp-zXFAAp_S.js} +1 -1
  11. package/dist/assets/{crystal-BdgqBN3z.js → crystal-DRYY4cSw.js} +1 -1
  12. package/dist/assets/{css-wLk1vVwJ.js → css-C4op_MsD.js} +1 -1
  13. package/dist/assets/{edge-DMylWoGl.js → edge-d3rJkErf.js} +1 -1
  14. package/dist/assets/{elixir-Djmgto_Z.js → elixir-DPqlJhYM.js} +1 -1
  15. package/dist/assets/{elm-D7B-eBLA.js → elm-DgU37I8a.js} +1 -1
  16. package/dist/assets/{erb-g1sso1GW.js → erb-CZVB_ntC.js} +1 -1
  17. package/dist/assets/{git-rebase-DEAhxutZ.js → git-rebase-hUkltHZG.js} +1 -1
  18. package/dist/assets/{glimmer-js-CHvGFGMp.js → glimmer-js-5XOT9GQ8.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DvhL705p.js → glimmer-ts-DT7D0Kfq.js} +1 -1
  20. package/dist/assets/{glsl-C8mS_t8I.js → glsl-Br4_Eh5O.js} +1 -1
  21. package/dist/assets/{graphql-DW2pnvqi.js → graphql--BmZ_AYP.js} +1 -1
  22. package/dist/assets/{hack-DYc1d3he.js → hack-CMdqGDoA.js} +1 -1
  23. package/dist/assets/{haml-_5OmzH31.js → haml-DLEOHGmg.js} +1 -1
  24. package/dist/assets/{handlebars-rN-CaXUf.js → handlebars-BfUftoV0.js} +1 -1
  25. package/dist/assets/{html-derivative-BovOubry.js → html-derivative-D5cZE6C4.js} +1 -1
  26. package/dist/assets/{html-DQnaJUjl.js → html-uOgx2iYC.js} +1 -1
  27. package/dist/assets/{http-BKTgwOYc.js → http-cW9c66J_.js} +1 -1
  28. package/dist/assets/{hurl-ChS3_IgD.js → hurl-BztURO2n.js} +1 -1
  29. package/dist/assets/{index-BLtRBc2n.css → index-C2p_1iAk.css} +1 -1
  30. package/dist/assets/{index-CEglkSs-.js → index-DZFM4-3L.js} +11 -11
  31. package/dist/assets/{java-Dgnyk--z.js → java-Cy4mnKaj.js} +1 -1
  32. package/dist/assets/{javascript-BfnrwkRN.js → javascript-_Z02t_9Q.js} +1 -1
  33. package/dist/assets/{jinja-CD2AIvOi.js → jinja-BppBm5gt.js} +1 -1
  34. package/dist/assets/{jison-DmKNw3Pl.js → jison-Dzns436M.js} +1 -1
  35. package/dist/assets/{json-CKB5a8aj.js → json-BYkfLOry.js} +1 -1
  36. package/dist/assets/{jsx-jJXrDRBM.js → jsx-DtAm8NUb.js} +1 -1
  37. package/dist/assets/{julia-G8vVBW-t.js → julia-CLIMRuh0.js} +1 -1
  38. package/dist/assets/{just-DMXLrLZL.js → just-DMg47an6.js} +1 -1
  39. package/dist/assets/{latex-DS3xuxld.js → latex-D05ztCOx.js} +1 -1
  40. package/dist/assets/{liquid-COQnD_C4.js → liquid-DpImQR4t.js} +1 -1
  41. package/dist/assets/{lua-BwMKwosd.js → lua--ZHUYQUU.js} +1 -1
  42. package/dist/assets/{marko-cQCK_WIk.js → marko-Dh3Xi08i.js} +1 -1
  43. package/dist/assets/{mdc-DQUjY13N.js → mdc-Br6Pt78u.js} +1 -1
  44. package/dist/assets/{nginx-C4Kv3DJi.js → nginx-CwSU6U6d.js} +1 -1
  45. package/dist/assets/{nim-ChzLzuYD.js → nim-CBbTwyeq.js} +1 -1
  46. package/dist/assets/{perl-z5xUiWAY.js → perl-D3jQu3LN.js} +1 -1
  47. package/dist/assets/{php-BHqm-Xeh.js → php-BMeXBzlU.js} +1 -1
  48. package/dist/assets/{pug-C9C_Llft.js → pug-BGdY1g5E.js} +1 -1
  49. package/dist/assets/{qml-dtpuwXJZ.js → qml-DsT5biza.js} +1 -1
  50. package/dist/assets/{r-BicQMs_c.js → r-CbS9PfN5.js} +1 -1
  51. package/dist/assets/{razor-D5h00YTZ.js → razor-Bwm8roHi.js} +1 -1
  52. package/dist/assets/{regexp-CTa8S86v.js → regexp-Bc_9DyAu.js} +1 -1
  53. package/dist/assets/{rst-Bg-b-7HK.js → rst-Cj1Aii91.js} +1 -1
  54. package/dist/assets/{ruby-BiWYm056.js → ruby-BPMtFZnc.js} +1 -1
  55. package/dist/assets/{sas-bJTdfgUz.js → sas-CoOau1t2.js} +1 -1
  56. package/dist/assets/{scss-0qkNyHr1.js → scss-CvfaI808.js} +1 -1
  57. package/dist/assets/{shellscript-3sDkDE9z.js → shellscript-B0C-TdLE.js} +1 -1
  58. package/dist/assets/{shellsession-D4EBhEmz.js → shellsession-UhmLiqcA.js} +1 -1
  59. package/dist/assets/{soy-DG9hUwLL.js → soy-D419u0RL.js} +1 -1
  60. package/dist/assets/{sql-B0UoKHkx.js → sql-Do9k0rRn.js} +1 -1
  61. package/dist/assets/{stata-CsQbuXQp.js → stata-x47J6BC4.js} +1 -1
  62. package/dist/assets/{surrealql-D63VLxos.js → surrealql-BERQ6pHu.js} +1 -1
  63. package/dist/assets/{svelte-BApdnAma.js → svelte-dRWkyqKW.js} +1 -1
  64. package/dist/assets/{templ-B64pN6Lz.js → templ-CTq-KoWN.js} +1 -1
  65. package/dist/assets/{tex-DR2UVIPF.js → tex-CdRGjyYI.js} +1 -1
  66. package/dist/assets/{ts-tags-IwhomvQv.js → ts-tags-CgaMqyVb.js} +1 -1
  67. package/dist/assets/{tsx-DROdxqzS.js → tsx-cpBQKcPG.js} +1 -1
  68. package/dist/assets/{twig-BtbUqpR4.js → twig-vbs_PM4-.js} +1 -1
  69. package/dist/assets/{typescript-CHpSjYZL.js → typescript-Cc--nwIo.js} +1 -1
  70. package/dist/assets/{vue-DCvEu_ug.js → vue-CgHwdBmB.js} +1 -1
  71. package/dist/assets/{vue-html-BgL4Rf-L.js → vue-html-BHfoMw47.js} +1 -1
  72. package/dist/assets/{vue-vine-CRNdhPTn.js → vue-vine-_lVRp_NK.js} +1 -1
  73. package/dist/assets/{xml-CkfPxZuz.js → xml-G4NVmTde.js} +1 -1
  74. package/dist/assets/{xsl-CteaoFSV.js → xsl-s0so2M1i.js} +1 -1
  75. package/dist/assets/{yaml-CUr3TDRN.js → yaml-CpsmW05t.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +49 -23
  78. package/scripts/access-token.mjs +21 -0
  79. package/scripts/build-diff-data.mjs +260 -39
  80. package/scripts/cache.mjs +301 -0
  81. package/scripts/cli-args.mjs +206 -57
  82. package/scripts/coding-agents.mjs +279 -162
  83. package/scripts/dev.mjs +218 -0
  84. package/scripts/doctor.mjs +265 -46
  85. package/scripts/generate-summaries.mjs +760 -260
  86. package/scripts/mock-agent.mjs +165 -0
  87. package/scripts/present.mjs +394 -85
  88. package/scripts/presenter-runtime.mjs +148 -0
  89. package/scripts/serve-built.mjs +343 -71
  90. package/scripts/summary-path.mjs +24 -7
  91. package/scripts/support-record.mjs +242 -0
@@ -3,10 +3,9 @@
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
5
  import {
6
- mkdirSync,
6
+ existsSync,
7
7
  mkdtempSync,
8
8
  readFileSync,
9
- renameSync,
10
9
  rmSync,
11
10
  writeFileSync,
12
11
  } from 'node:fs';
@@ -15,12 +14,25 @@ import { dirname, relative, resolve } from 'node:path';
15
14
  import { fileURLToPath } from 'node:url';
16
15
  import {
17
16
  agentCommand,
17
+ assertReasoningSupported,
18
18
  codingAgentBinary,
19
19
  commandAvailable,
20
20
  parseAgentResponse,
21
21
  selectCodingAgent,
22
22
  } from './coding-agents.mjs';
23
23
  import { summaryPath } from './summary-path.mjs';
24
+ import {
25
+ acquireLease,
26
+ publishLeaseFile,
27
+ refreshLease,
28
+ releaseLease,
29
+ } from './cache.mjs';
30
+ import {
31
+ createSupportRecorder,
32
+ formatSupportRecord,
33
+ safeCommandVersion,
34
+ writeSupportRecord,
35
+ } from './support-record.mjs';
24
36
 
25
37
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26
38
  const callerDirectory = process.cwd();
@@ -43,8 +55,14 @@ const valueFlags = new Set([
43
55
  '--batch-size',
44
56
  '--jobs',
45
57
  '--snapshot',
58
+ '--support-record-file',
59
+ ]);
60
+ const booleanFlags = new Set([
61
+ '--checkout',
62
+ '--force',
63
+ '--support-record',
64
+ '--worktree',
46
65
  ]);
47
- const booleanFlags = new Set(['--checkout', '--force', '--worktree']);
48
66
 
49
67
  function fail(message) {
50
68
  console.error(message);
@@ -86,12 +104,16 @@ Options:
86
104
  --summaries FILE Agent note file
87
105
  --output FILE Rebuilt Diffsplain JSON
88
106
  --cache-dir PATH Bare cache for fetched Git objects
89
- --agent NAME Use codex, claude, copilot, cursor, or opencode
107
+ --agent NAME Use codex, claude, copilot, or opencode
108
+ Cursor is disabled because it cannot meet the review boundary
90
109
  --codex-bin FILE Codex CLI path (default: codex)
91
110
  --model NAME Model passed to the coding agent
92
111
  --reasoning LEVEL Agent reasoning effort when supported
93
112
  --batch-size COUNT Maximum files per agent pass (default: 12)
94
113
  --jobs COUNT Agent passes to run at once (default: 3)
114
+ --support-record Print a safe record if this run fails
115
+ --support-record-file FILE
116
+ Write a safe record if this run fails
95
117
  --force Regenerate all notes instead of using cached notes`);
96
118
  process.exit(0);
97
119
  }
@@ -101,18 +123,22 @@ const outputPath = resolve(
101
123
  callerDirectory,
102
124
  option('--output') || resolve(root, '.cache/diff-data.json'),
103
125
  );
126
+ const printSupportRecord = rawArgs.includes('--support-record');
127
+ const supportRecordFile = option('--support-record-file');
128
+ if (printSupportRecord && supportRecordFile) {
129
+ fail('Pass either --support-record or --support-record-file, not both');
130
+ }
131
+ const supportRecordPath = supportRecordFile
132
+ ? resolve(callerDirectory, supportRecordFile)
133
+ : undefined;
104
134
  const codexBin = option('--codex-bin') || process.env.CODEX_BIN;
135
+ const requestedAgent = option('--agent');
136
+ const supportRecorder =
137
+ printSupportRecord || supportRecordPath
138
+ ? createSupportRecorder()
139
+ : undefined;
105
140
  let selectedAgent;
106
- try {
107
- selectedAgent = await selectCodingAgent(
108
- option('--agent'),
109
- (agent) =>
110
- commandAvailable(codingAgentBinary(agent, { codexBin })),
111
- );
112
- } catch (error) {
113
- fail(error.message);
114
- }
115
- const agentBinary = codingAgentBinary(selectedAgent, { codexBin });
141
+ let agentBinary;
116
142
  const model = option('--model');
117
143
  const reasoning = option('--reasoning');
118
144
  const batchSizeValue = option('--batch-size') || '12';
@@ -126,11 +152,27 @@ const reasoningLevels = new Set([
126
152
  if (reasoning && !reasoningLevels.has(reasoning)) {
127
153
  fail('--reasoning must be minimal, low, medium, high, or xhigh');
128
154
  }
155
+ if (requestedAgent) {
156
+ try {
157
+ await selectCodingAgent(requestedAgent, async () => true);
158
+ assertReasoningSupported(requestedAgent, reasoning);
159
+ } catch (error) {
160
+ fail(error instanceof Error ? error.message : String(error));
161
+ }
162
+ }
129
163
  if (!/^[1-9]\d*$/.test(batchSizeValue) || Number(batchSizeValue) > 50) {
130
164
  fail('--batch-size must be a number from 1 to 50');
131
165
  }
132
166
  const batchSize = Number(batchSizeValue);
133
167
  const batchByteLimit = 180_000;
168
+ const softFileByteLimit = 180_000;
169
+ const hardInputByteLimit = 2_000_000;
170
+ const priorNoteByteLimit = 250_000;
171
+ const titleCodePointLimit = 160;
172
+ const proseCodePointLimit = 1_200;
173
+ const detailItemLimit = 4;
174
+ const riskItemLimit = 3;
175
+ const listItemCodePointLimit = 500;
134
176
  const jobsValue = option('--jobs') || '3';
135
177
  if (!/^[1-9]\d*$/.test(jobsValue) || Number(jobsValue) > 8) {
136
178
  fail('--jobs must be a number from 1 to 8');
@@ -148,10 +190,37 @@ const snapshotPath = option('--snapshot');
148
190
  const activeAgentProcesses = new Set();
149
191
  let interrupted = false;
150
192
 
151
- process.once('SIGTERM', () => {
193
+ function recordSyncStage(name, action) {
194
+ const finishStage = supportRecorder?.startStage(name);
195
+ try {
196
+ const result = action();
197
+ finishStage?.();
198
+ return result;
199
+ } catch (error) {
200
+ finishStage?.('failed');
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ async function recordAsyncStage(name, action) {
206
+ const finishStage = supportRecorder?.startStage(name);
207
+ try {
208
+ const result = await action();
209
+ finishStage?.();
210
+ return result;
211
+ } catch (error) {
212
+ finishStage?.('failed');
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ function interrupt() {
152
218
  interrupted = true;
153
219
  for (const child of activeAgentProcesses) child.kill('SIGTERM');
154
- });
220
+ }
221
+
222
+ process.once('SIGINT', interrupt);
223
+ process.once('SIGTERM', interrupt);
155
224
 
156
225
  if (range && (base || head)) {
157
226
  fail('--range cannot be used with --base or --head');
@@ -192,8 +261,21 @@ const summariesPath = summaryPath({
192
261
  head: selectedHead,
193
262
  remote,
194
263
  });
264
+ const ownershipPath = `${summariesPath}.lock`;
265
+ let ownership;
266
+ let ownershipHeartbeat;
267
+ function acquireOwnership() {
268
+ ownership = acquireLease(ownershipPath);
269
+ ownershipHeartbeat = setInterval(() => {
270
+ try { refreshLease(ownership); } catch { clearInterval(ownershipHeartbeat); }
271
+ }, 30_000);
272
+ ownershipHeartbeat.unref();
273
+ }
195
274
  if (selectedBase) targetArgs.push('--base', selectedBase);
196
275
  if (selectedHead) targetArgs.push('--head', selectedHead);
276
+ if (supportRecordPath) {
277
+ targetArgs.push('--exclude-output', supportRecordPath);
278
+ }
197
279
  const cacheDirectory = option('--cache-dir');
198
280
  if (cacheDirectory) {
199
281
  targetArgs.push('--cache-dir', resolve(callerDirectory, cacheDirectory));
@@ -233,15 +315,17 @@ function pathInsideRepo(file) {
233
315
  }
234
316
 
235
317
  function cleanSnapshot(snapshot) {
318
+ const summaryFile = pathInsideRepo(summariesPath);
236
319
  const excluded = new Set(
237
- [pathInsideRepo(summariesPath), pathInsideRepo(outputPath)].filter(Boolean),
320
+ [summaryFile, summaryFile && `${summaryFile}.lock`, pathInsideRepo(outputPath)].filter(Boolean),
238
321
  );
239
322
  const files = snapshot.files
240
323
  .filter((file) => !excluded.has(file.path))
241
324
  .map((file) => {
242
325
  const fullPatch = typeof file.patch === 'string' ? file.patch : '';
243
- const useSnippet = fullPatch.length > 180_000;
244
- return {
326
+ const useSnippet =
327
+ Buffer.byteLength(fullPatch) > softFileByteLimit;
328
+ const summaryFile = {
245
329
  path: file.path,
246
330
  ...(file.oldPath ? { oldPath: file.oldPath } : {}),
247
331
  status: file.status,
@@ -251,6 +335,16 @@ function cleanSnapshot(snapshot) {
251
335
  patch: useSnippet ? file.snippet : fullPatch,
252
336
  patchIsExcerpt: useSnippet,
253
337
  };
338
+ const inputBytes = Buffer.byteLength(JSON.stringify(summaryFile));
339
+ return {
340
+ ...summaryFile,
341
+ ...(inputBytes > hardInputByteLimit
342
+ ? {
343
+ summaryFailure:
344
+ `The file input is ${inputBytes} bytes after using its patch excerpt; the hard limit is ${hardInputByteLimit} bytes.`,
345
+ }
346
+ : {}),
347
+ };
254
348
  });
255
349
 
256
350
  return {
@@ -273,11 +367,35 @@ function cleanSnapshot(snapshot) {
273
367
  };
274
368
  }
275
369
 
370
+ // fallow-ignore-next-line complexity
371
+ function compactExistingFileNotes(snapshot, existingFiles, selected, baseBytes) {
372
+ const existingFileNotes = {};
373
+ const propertyBytes = Buffer.byteLength(',"existingFileNotes":');
374
+ const maximumBytes = Math.min(
375
+ priorNoteByteLimit,
376
+ hardInputByteLimit - baseBytes - propertyBytes,
377
+ );
378
+ let contextBytes = Buffer.byteLength('{}');
379
+ let hasEntries = false;
380
+ for (const file of snapshot.files) {
381
+ const path = file.path;
382
+ const note = existingFiles[path];
383
+ if (selected.has(path) || !note) continue;
384
+ const compactNote = { title: note.title, what: note.what, risks: note.risks };
385
+ const entryBytes = Buffer.byteLength(
386
+ `${hasEntries ? ',' : ''}${JSON.stringify(path)}:${JSON.stringify(compactNote)}`,
387
+ );
388
+ if (contextBytes + entryBytes <= maximumBytes) {
389
+ existingFileNotes[path] = compactNote;
390
+ contextBytes += entryBytes;
391
+ hasEntries = true;
392
+ }
393
+ }
394
+ return existingFileNotes;
395
+ }
396
+
276
397
  function batchInput(snapshot, rawSnapshot, paths, existingFiles) {
277
398
  const selected = new Set(paths);
278
- const existingFileNotes = Object.fromEntries(
279
- Object.entries(existingFiles).filter(([path]) => !selected.has(path)),
280
- );
281
399
  const result = {
282
400
  repo: snapshot.repo,
283
401
  change: snapshot.change,
@@ -291,37 +409,59 @@ function batchInput(snapshot, rawSnapshot, paths, existingFiles) {
291
409
  })),
292
410
  files: snapshot.files
293
411
  .filter((file) => selected.has(file.path))
294
- .map((file) => ({ ...file })),
295
- ...(Object.keys(existingFileNotes).length ? { existingFileNotes } : {}),
412
+ .map(({ summaryFailure: _summaryFailure, ...file }) => file),
296
413
  };
297
414
 
298
- let encoded = JSON.stringify(result);
299
- if (Buffer.byteLength(encoded) > 2_000_000) {
415
+ let baseInput = JSON.stringify(result);
416
+ if (Buffer.byteLength(baseInput) > hardInputByteLimit) {
300
417
  for (const file of result.files) {
301
418
  const source = rawSnapshot.files.find((item) => item.path === file.path);
302
419
  file.patch = source?.snippet || '';
303
420
  file.patchIsExcerpt = true;
304
421
  }
305
- encoded = JSON.stringify(result);
422
+ baseInput = JSON.stringify(result);
306
423
  }
307
- if (Buffer.byteLength(encoded) > 2_000_000) {
424
+ const baseBytes = Buffer.byteLength(baseInput);
425
+ if (baseBytes > hardInputByteLimit) {
308
426
  throw new Error(
309
- `The batch containing ${paths.join(', ')} is too large to summarize`,
427
+ `The agent input containing ${paths.join(', ')} is larger than the ${hardInputByteLimit}-byte hard limit`,
310
428
  );
311
429
  }
312
- return encoded;
430
+ const existingFileNotes = compactExistingFileNotes(
431
+ snapshot,
432
+ existingFiles,
433
+ selected,
434
+ baseBytes,
435
+ );
436
+ return Object.keys(existingFileNotes).length
437
+ ? `${baseInput.slice(0, -1)},"existingFileNotes":${JSON.stringify(existingFileNotes)}}`
438
+ : baseInput;
313
439
  }
314
440
 
315
- const text = { type: 'string' };
316
- const list = { type: 'array', items: text };
441
+ function textSchema(maxLength) {
442
+ return { type: 'string', minLength: 1, maxLength };
443
+ }
444
+
445
+ function listSchema(maxItems) {
446
+ return {
447
+ type: 'array',
448
+ maxItems,
449
+ items: textSchema(listItemCodePointLimit),
450
+ };
451
+ }
452
+
453
+ const title = textSchema(titleCodePointLimit);
454
+ const prose = textSchema(proseCodePointLimit);
455
+ const details = listSchema(detailItemLimit);
456
+ const risks = listSchema(riskItemLimit);
317
457
  const fileNote = {
318
458
  type: 'object',
319
459
  properties: {
320
- title: text,
321
- what: text,
322
- why: text,
323
- details: list,
324
- risks: list,
460
+ title,
461
+ what: prose,
462
+ why: prose,
463
+ details,
464
+ risks,
325
465
  },
326
466
  required: ['title', 'what', 'why', 'details', 'risks'],
327
467
  additionalProperties: false,
@@ -333,11 +473,11 @@ function outputSchema(paths, { includeChange = true } = {}) {
333
473
  properties.change = {
334
474
  type: 'object',
335
475
  properties: {
336
- title: text,
337
- summary: text,
338
- why: text,
339
- highlights: list,
340
- risks: list,
476
+ title,
477
+ summary: prose,
478
+ why: prose,
479
+ highlights: details,
480
+ risks,
341
481
  },
342
482
  required: ['title', 'summary', 'why', 'highlights', 'risks'],
343
483
  additionalProperties: false,
@@ -392,18 +532,34 @@ risks to at most three concrete items. Use an empty list when there is no useful
392
532
  detail or risk. For binary files, describe only the change shown by the metadata.`;
393
533
  }
394
534
 
395
- function normalizedText(value, field) {
535
+ function normalizedText(value, field, limit) {
396
536
  if (typeof value !== 'string' || !value.trim()) {
397
537
  throw new Error(`${field} must be a non-empty string`);
398
538
  }
399
- return value.trim();
539
+ const text = value.trim();
540
+ if (Array.from(text).length > limit) {
541
+ throw new Error(`${field} must be at most ${limit} Unicode code points`);
542
+ }
543
+ return text;
400
544
  }
401
545
 
402
- function normalizedList(value, field) {
546
+ // fallow-ignore-next-line complexity
547
+ function normalizedList(value, field, maxItems) {
403
548
  if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
404
549
  throw new Error(`${field} must be a list of strings`);
405
550
  }
406
- return value.map((item) => item.trim()).filter(Boolean);
551
+ if (value.length > maxItems) {
552
+ throw new Error(`${field} must contain at most ${maxItems} items`);
553
+ }
554
+ const list = value.map((item) => item.trim()).filter(Boolean);
555
+ for (const item of list) {
556
+ if (Array.from(item).length > listItemCodePointLimit) {
557
+ throw new Error(
558
+ `${field} items must be at most ${listItemCodePointLimit} Unicode code points`,
559
+ );
560
+ }
561
+ }
562
+ return list;
407
563
  }
408
564
 
409
565
  function exactFields(value, fields, label) {
@@ -425,104 +581,229 @@ function exactFields(value, fields, label) {
425
581
  }
426
582
  }
427
583
 
428
- function normalizeResponse(
429
- value,
430
- paths,
431
- { includeChange = true } = {},
432
- ) {
433
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
584
+ function isObject(value) {
585
+ return Boolean(value) &&
586
+ typeof value === 'object' &&
587
+ !Array.isArray(value);
588
+ }
589
+
590
+ function responseObject(value) {
591
+ if (!isObject(value)) {
434
592
  throw new Error('Agent response must be an object');
435
593
  }
436
- const allowed = new Set(['change', 'files']);
437
- if (Object.keys(value).some((field) => !allowed.has(field))) {
594
+ return value;
595
+ }
596
+
597
+ function unsupportedResponseFields(value) {
598
+ return Object.keys(value).filter(
599
+ (field) => !['change', 'files'].includes(field),
600
+ );
601
+ }
602
+
603
+ function normalizeChangeResponse(value) {
604
+ const response = responseObject(value);
605
+ if (unsupportedResponseFields(response).length) {
438
606
  throw new Error('Agent response has unsupported fields');
439
607
  }
440
- if (includeChange) {
441
- exactFields(
442
- value.change,
443
- ['title', 'summary', 'why', 'highlights', 'risks'],
444
- 'Change note',
445
- );
608
+ exactFields(
609
+ response.change,
610
+ ['title', 'summary', 'why', 'highlights', 'risks'],
611
+ 'Change note',
612
+ );
613
+ return {
614
+ title: normalizedText(response.change.title, 'change.title', titleCodePointLimit),
615
+ summary: normalizedText(response.change.summary, 'change.summary', proseCodePointLimit),
616
+ why: normalizedText(response.change.why, 'change.why', proseCodePointLimit),
617
+ highlights: normalizedList(
618
+ response.change.highlights,
619
+ 'change.highlights', detailItemLimit,
620
+ ),
621
+ risks: normalizedList(response.change.risks, 'change.risks', riskItemLimit),
622
+ };
623
+ }
624
+
625
+ function arrayFileEntry(note) {
626
+ if (!isObject(note)) {
627
+ return { error: 'Agent response has a malformed file note' };
628
+ }
629
+ const path = typeof note.path === 'string' ? note.path.trim() : '';
630
+ return path
631
+ ? { entry: { path, note, arrayForm: true } }
632
+ : { error: 'Agent response has a file note without a path' };
633
+ }
634
+
635
+ function arrayFileEntries(notes) {
636
+ const result = { entries: [], errors: [] };
637
+ for (const note of notes) {
638
+ const item = arrayFileEntry(note);
639
+ if (item.entry) result.entries.push(item.entry);
640
+ if (item.error) result.errors.push(item.error);
446
641
  }
447
- let fileValues = {};
448
- if (!paths.length) {
642
+ return result;
643
+ }
644
+
645
+ function fileResponseEntries(value) {
646
+ const response = responseObject(value);
647
+ const errors = unsupportedResponseFields(response).map(
648
+ (field) => `Agent response has unsupported field: ${field}`,
649
+ );
650
+ if (Array.isArray(response.files)) {
651
+ const arrayResult = arrayFileEntries(response.files);
449
652
  return {
450
- ...(includeChange
451
- ? {
452
- change: {
453
- title: normalizedText(value.change.title, 'change.title'),
454
- summary: normalizedText(
455
- value.change.summary,
456
- 'change.summary',
457
- ),
458
- why: normalizedText(value.change.why, 'change.why'),
459
- highlights: normalizedList(
460
- value.change.highlights,
461
- 'change.highlights',
462
- ),
463
- risks: normalizedList(value.change.risks, 'change.risks'),
464
- },
465
- }
466
- : {}),
467
- files: {},
653
+ entries: arrayResult.entries,
654
+ errors: [...errors, ...arrayResult.errors],
468
655
  };
469
656
  }
470
- if (Array.isArray(value.files)) {
471
- fileValues = {};
472
- for (const note of value.files) {
473
- exactFields(
474
- note,
475
- ['path', 'title', 'what', 'why', 'details', 'risks'],
476
- 'File note',
477
- );
478
- const path = normalizedText(note.path, 'files.path');
479
- if (fileValues[path]) throw new Error(`Duplicate file note: ${path}`);
480
- fileValues[path] = note;
657
+ if (!isObject(response.files)) {
658
+ throw new Error('Agent response has no file notes object');
659
+ }
660
+ return {
661
+ entries: Object.entries(response.files).map(([path, note]) => ({
662
+ path,
663
+ note,
664
+ arrayForm: false,
665
+ })),
666
+ errors,
667
+ };
668
+ }
669
+
670
+ function normalizeFileNote(note, path, arrayForm) {
671
+ exactFields(
672
+ note,
673
+ [
674
+ ...(arrayForm ? ['path'] : []),
675
+ 'title',
676
+ 'what',
677
+ 'why',
678
+ 'details',
679
+ 'risks',
680
+ ],
681
+ path,
682
+ );
683
+ return {
684
+ title: normalizedText(note.title, `${path}.title`, titleCodePointLimit),
685
+ what: normalizedText(note.what, `${path}.what`, proseCodePointLimit),
686
+ why: normalizedText(note.why, `${path}.why`, proseCodePointLimit),
687
+ details: normalizedList(note.details, `${path}.details`, detailItemLimit),
688
+ risks: normalizedList(note.risks, `${path}.risks`, riskItemLimit),
689
+ };
690
+ }
691
+
692
+ function indexFileEntries(entries, expected) {
693
+ const byPath = new Map();
694
+ const failedFiles = [];
695
+ for (const entry of entries) {
696
+ if (!expected.has(entry.path)) {
697
+ failedFiles.push({
698
+ path: entry.path,
699
+ reason: 'Agent output included a file outside this batch.',
700
+ });
701
+ continue;
481
702
  }
482
- } else {
483
- fileValues = value.files;
703
+ const values = byPath.get(entry.path) || [];
704
+ byPath.set(entry.path, [...values, entry]);
705
+ }
706
+ return { byPath, failedFiles };
707
+ }
708
+
709
+ function normalizeFileEntry(path, values) {
710
+ if (values.length !== 1) {
711
+ return {
712
+ failure: {
713
+ path,
714
+ reason: values.length
715
+ ? 'Agent output repeated this file.'
716
+ : 'Agent output omitted this file.',
717
+ },
718
+ };
719
+ }
720
+ try {
721
+ return {
722
+ note: normalizeFileNote(
723
+ values[0].note,
724
+ path,
725
+ values[0].arrayForm,
726
+ ),
727
+ };
728
+ } catch (error) {
729
+ return {
730
+ failure: {
731
+ path,
732
+ reason: error instanceof Error ? error.message : String(error),
733
+ },
734
+ };
484
735
  }
485
- exactFields(fileValues, paths, 'File notes');
736
+ }
486
737
 
738
+ function normalizeFileResponse(value, paths) {
739
+ const expected = new Set(paths);
487
740
  const files = {};
741
+ const { entries, errors } = fileResponseEntries(value);
742
+ const indexed = indexFileEntries(entries, expected);
488
743
  for (const path of paths) {
489
- const note = fileValues[path];
490
- if (!Array.isArray(value.files)) {
491
- exactFields(note, ['title', 'what', 'why', 'details', 'risks'], path);
492
- }
493
- files[path] = {
494
- title: normalizedText(note.title, `${path}.title`),
495
- what: normalizedText(note.what, `${path}.what`),
496
- why: normalizedText(note.why, `${path}.why`),
497
- details: normalizedList(note.details, `${path}.details`),
498
- risks: normalizedList(note.risks, `${path}.risks`),
499
- };
744
+ const result = normalizeFileEntry(
745
+ path,
746
+ indexed.byPath.get(path) || [],
747
+ );
748
+ if (result.note) files[path] = result.note;
749
+ if (result.failure) indexed.failedFiles.push(result.failure);
500
750
  }
751
+ return { files, failedFiles: indexed.failedFiles, errors };
752
+ }
753
+
754
+ function writeJsonAtomic(file, value, options) {
755
+ publishLeaseFile(
756
+ ownership,
757
+ file,
758
+ `${JSON.stringify(value, null, 2)}\n`,
759
+ options,
760
+ );
761
+ }
501
762
 
763
+ function metadataList(value) {
764
+ return Array.isArray(value) ? value : [];
765
+ }
766
+
767
+ function summaryFailureState(snapshot, summaries) {
768
+ const failedFiles = metadataList(summaries.meta?.failedFiles);
769
+ const errors = metadataList(summaries.meta?.errors);
770
+ const emptyReviewComplete =
771
+ snapshot.files.length === 0 &&
772
+ summaries.meta?.status === 'complete' &&
773
+ summaries.meta?.reviewFingerprint ===
774
+ snapshot.notes?.reviewFingerprint;
775
+ const complete = [
776
+ failedFiles.length === 0,
777
+ errors.length === 0,
778
+ emptyReviewComplete ||
779
+ (completeChangeNote(summaries.change) &&
780
+ snapshot.files.every((file) =>
781
+ completeFileNote(summaries.files?.[file.path]),
782
+ )),
783
+ ].every(Boolean);
784
+ return { failedFiles, errors, complete };
785
+ }
786
+
787
+ function snapshotFileWithNote(file, summaries, failureByPath) {
788
+ const nextFile = { ...file };
789
+ delete nextFile.noteFailure;
790
+ const note = summaries.files?.[file.path];
791
+ if (completeFileNote(note)) {
792
+ return { ...nextFile, summary: note, noteReady: true };
793
+ }
794
+ const failure = failureByPath.get(file.path);
502
795
  return {
503
- ...(includeChange
504
- ? {
505
- change: {
506
- title: normalizedText(value.change.title, 'change.title'),
507
- summary: normalizedText(value.change.summary, 'change.summary'),
508
- why: normalizedText(value.change.why, 'change.why'),
509
- highlights: normalizedList(
510
- value.change.highlights,
511
- 'change.highlights',
512
- ),
513
- risks: normalizedList(value.change.risks, 'change.risks'),
514
- },
515
- }
516
- : {}),
517
- files,
796
+ ...nextFile,
797
+ noteReady: false,
798
+ ...(failure ? { noteFailure: failure } : {}),
518
799
  };
519
800
  }
520
801
 
521
- function writeJsonAtomic(file, value) {
522
- mkdirSync(dirname(file), { recursive: true });
523
- const temporary = `${file}.${process.pid}.tmp`;
524
- writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
525
- renameSync(temporary, file);
802
+ function notesWithoutFailures(notes = {}) {
803
+ const nextNotes = { ...notes };
804
+ delete nextNotes.failedFiles;
805
+ delete nextNotes.errors;
806
+ return nextNotes;
526
807
  }
527
808
 
528
809
  function publishSnapshot(snapshot, summaries) {
@@ -536,17 +817,18 @@ function publishSnapshot(snapshot, summaries) {
536
817
  throw new Error('The diff changed while agent notes were being written');
537
818
  }
538
819
 
539
- const complete =
540
- completeChangeNote(summaries.change) &&
541
- snapshot.files.every((file) =>
542
- completeFileNote(summaries.files?.[file.path]),
543
- );
544
- const files = snapshot.files.map((file) => {
545
- const note = summaries.files?.[file.path];
546
- return completeFileNote(note)
547
- ? { ...file, summary: note, noteReady: true }
548
- : { ...file, noteReady: false };
549
- });
820
+ const lockPath = pathInsideRepo(summariesPath);
821
+ const publishedFiles = snapshot.files.filter(
822
+ (file) => file.path !== `${lockPath}.lock`,
823
+ );
824
+ const publishedSnapshot = { ...snapshot, files: publishedFiles };
825
+ const state = summaryFailureState(publishedSnapshot, summaries);
826
+ const failureByPath = new Map(
827
+ state.failedFiles.map((failure) => [failure.path, failure.reason]),
828
+ );
829
+ const files = publishedFiles.map((file) =>
830
+ snapshotFileWithNote(file, summaries, failureByPath),
831
+ );
550
832
  const content = {
551
833
  ...snapshot,
552
834
  ...(completeChangeNote(summaries.change)
@@ -554,15 +836,20 @@ function publishSnapshot(snapshot, summaries) {
554
836
  : {}),
555
837
  files,
556
838
  notes: {
557
- ...snapshot.notes,
839
+ ...notesWithoutFailures(snapshot.notes),
840
+ agent: selectedAgent,
558
841
  generatedFor: reviewFingerprint,
559
842
  fresh: true,
560
- complete,
561
- status: complete
843
+ complete: state.complete,
844
+ status: state.complete
562
845
  ? 'complete'
563
846
  : summaries.meta?.status || 'generating',
564
847
  completedFiles: files.filter((file) => file.noteReady).length,
565
848
  totalFiles: files.length,
849
+ ...(state.failedFiles.length
850
+ ? { failedFiles: state.failedFiles }
851
+ : {}),
852
+ ...(state.errors.length ? { errors: state.errors } : {}),
566
853
  ...(model ? { model } : {}),
567
854
  ...(reasoning ? { reasoning } : {}),
568
855
  },
@@ -573,11 +860,15 @@ function publishSnapshot(snapshot, summaries) {
573
860
  .update(JSON.stringify(content))
574
861
  .digest('hex')
575
862
  .slice(0, 12);
576
- writeJsonAtomic(outputPath, {
577
- version,
578
- generatedAt: new Date().toISOString(),
579
- ...content,
580
- });
863
+ writeJsonAtomic(
864
+ outputPath,
865
+ {
866
+ version,
867
+ generatedAt: new Date().toISOString(),
868
+ ...content,
869
+ },
870
+ { privateFile: false },
871
+ );
581
872
  }
582
873
 
583
874
  function publish(snapshot, summaries) {
@@ -588,6 +879,30 @@ function publish(snapshot, summaries) {
588
879
  }
589
880
  }
590
881
 
882
+ function storeProgress(snapshot, summaries) {
883
+ recordSyncStage('publish', () => {
884
+ writeJsonAtomic(summariesPath, summaries);
885
+ publish(snapshot, summaries);
886
+ });
887
+ }
888
+
889
+ function emitFailedSupportRecord(code = 1) {
890
+ if (!supportRecorder) return;
891
+ const record = supportRecorder.failure(code);
892
+ try {
893
+ if (supportRecordPath) {
894
+ writeSupportRecord(supportRecordPath, record);
895
+ console.error(`Wrote support record to ${supportRecordPath}`);
896
+ } else {
897
+ process.stderr.write(
898
+ `Diffsplain support record:\n${formatSupportRecord(record)}`,
899
+ );
900
+ }
901
+ } catch {
902
+ console.error('Could not write the support record.');
903
+ }
904
+ }
905
+
591
906
  function readJson(file, fallback) {
592
907
  try {
593
908
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -596,14 +911,73 @@ function readJson(file, fallback) {
596
911
  }
597
912
  }
598
913
 
914
+ function readSummaryState(file) {
915
+ try {
916
+ const value = JSON.parse(readFileSync(file, 'utf8'));
917
+ const valid =
918
+ value &&
919
+ typeof value === 'object' &&
920
+ !Array.isArray(value);
921
+ return valid
922
+ ? { value, damaged: false }
923
+ : { value: {}, damaged: true };
924
+ } catch {
925
+ return { value: {}, damaged: existsSync(file) };
926
+ }
927
+ }
928
+
929
+ async function selectAgentForNotes() {
930
+ try {
931
+ selectedAgent = await selectCodingAgent(
932
+ requestedAgent,
933
+ (agent) => commandAvailable(codingAgentBinary(agent, { codexBin })),
934
+ );
935
+ assertReasoningSupported(selectedAgent, reasoning);
936
+ agentBinary = codingAgentBinary(selectedAgent, { codexBin });
937
+ supportRecorder?.setProvider(
938
+ selectedAgent,
939
+ safeCommandVersion(selectedAgent, agentBinary),
940
+ );
941
+ } catch (error) {
942
+ supportRecorder?.setProvider(requestedAgent || 'unknown');
943
+ if (error instanceof Error) error.exitCode = 2;
944
+ throw error;
945
+ }
946
+ }
947
+
948
+ function addFailures(summaries, failedFiles = [], errors = []) {
949
+ const priorFailedFiles = metadataList(summaries.meta?.failedFiles);
950
+ const priorErrors = metadataList(summaries.meta?.errors);
951
+ const uniqueFailures = new Map(
952
+ [...priorFailedFiles, ...failedFiles].map((failure) => [
953
+ `${failure.path}\0${failure.reason}`,
954
+ failure,
955
+ ]),
956
+ );
957
+ const nextErrors = [...new Set([...priorErrors, ...errors])];
958
+ return {
959
+ ...summaries,
960
+ meta: {
961
+ ...summaries.meta,
962
+ ...(uniqueFailures.size
963
+ ? { failedFiles: [...uniqueFailures.values()] }
964
+ : {}),
965
+ ...(nextErrors.length ? { errors: nextErrors } : {}),
966
+ },
967
+ };
968
+ }
969
+
970
+ function failureReason(error) {
971
+ const message = error instanceof Error ? error.message : String(error);
972
+ return message.split('\n').find((line) => line.trim())?.trim() ||
973
+ 'Agent note generation failed.';
974
+ }
975
+
599
976
  function runAgent(invocation, input) {
600
977
  return new Promise((resolvePromise, rejectPromise) => {
601
978
  const child = spawn(invocation.command, invocation.args, {
602
979
  cwd: invocation.cwd || root,
603
- env: {
604
- ...process.env,
605
- ...invocation.env,
606
- },
980
+ env: invocation.env || process.env,
607
981
  stdio: ['pipe', 'pipe', 'pipe'],
608
982
  });
609
983
  activeAgentProcesses.add(child);
@@ -613,6 +987,7 @@ function runAgent(invocation, input) {
613
987
  const maxBuffer = 10 * 1024 * 1024;
614
988
  const collect = (chunks, chunk) => {
615
989
  outputBytes += chunk.length;
990
+ supportRecorder?.addBytes('agentOutput', chunk.length);
616
991
  if (outputBytes > maxBuffer) {
617
992
  child.kill('SIGTERM');
618
993
  rejectPromise(new Error(`${selectedAgent} returned too much output`));
@@ -622,6 +997,9 @@ function runAgent(invocation, input) {
622
997
  };
623
998
  child.stdout.on('data', (chunk) => collect(stdout, chunk));
624
999
  child.stderr.on('data', (chunk) => collect(stderr, chunk));
1000
+ child.stdin.on('error', (error) => {
1001
+ if (error.code !== 'EPIPE') rejectPromise(error);
1002
+ });
625
1003
  child.once('error', (error) => {
626
1004
  activeAgentProcesses.delete(child);
627
1005
  rejectPromise(error);
@@ -653,13 +1031,26 @@ function runAgent(invocation, input) {
653
1031
  );
654
1032
  return;
655
1033
  }
656
- resolvePromise(stdoutText);
1034
+ resolvePromise({ stdout: stdoutText, stderr: stderrText });
657
1035
  });
658
1036
  if (invocation.input === 'stdin') child.stdin.end(input);
659
1037
  else child.stdin.end();
660
1038
  });
661
1039
  }
662
1040
 
1041
+ async function requestAgent(invocation, input, normalize) {
1042
+ supportRecorder?.addBytes('agentInput', Buffer.byteLength(input));
1043
+ return recordAsyncStage('agent', async () => {
1044
+ const result = await runAgent(invocation, input);
1045
+ if (result.stderr.trim()) {
1046
+ console.error(
1047
+ `${selectedAgent} wrote diagnostic output:\n${result.stderr.trim()}`,
1048
+ );
1049
+ }
1050
+ return normalize(result.stdout);
1051
+ });
1052
+ }
1053
+
663
1054
  function completeText(value) {
664
1055
  return typeof value === 'string' && Boolean(value.trim());
665
1056
  }
@@ -711,6 +1102,18 @@ function fileFingerprint(file) {
711
1102
  .digest('hex');
712
1103
  }
713
1104
 
1105
+ function generationSettingsMatch(meta, generationSettings) {
1106
+ if (!meta || typeof meta !== 'object' || typeof meta.agent !== 'string') {
1107
+ return false;
1108
+ }
1109
+ const previousSettings = {
1110
+ agent: meta.agent,
1111
+ model: Object.hasOwn(meta, 'model') ? meta.model : null,
1112
+ reasoning: Object.hasOwn(meta, 'reasoning') ? meta.reasoning : null,
1113
+ };
1114
+ return JSON.stringify(previousSettings) === JSON.stringify(generationSettings);
1115
+ }
1116
+
714
1117
  const temporaryDirectory = mkdtempSync(
715
1118
  resolve(tmpdir(), 'diffsplain-agent-'),
716
1119
  );
@@ -718,22 +1121,36 @@ let workingSummaries;
718
1121
  let workingSnapshot;
719
1122
 
720
1123
  try {
721
- const rawSnapshotPath = resolve(temporaryDirectory, 'diff-data.json');
722
- if (!snapshotPath) runBuilder(rawSnapshotPath, true);
723
- const rawSnapshot = JSON.parse(
724
- readFileSync(snapshotPath || rawSnapshotPath, 'utf8'),
725
- );
726
- const snapshot = cleanSnapshot(rawSnapshot);
1124
+ recordSyncStage('cache', acquireOwnership);
1125
+ const { rawSnapshot, snapshot } = recordSyncStage('snapshot', () => {
1126
+ const rawSnapshotPath = resolve(temporaryDirectory, 'diff-data.json');
1127
+ if (!snapshotPath) runBuilder(rawSnapshotPath, true);
1128
+ const rawSnapshotText = readFileSync(
1129
+ snapshotPath || rawSnapshotPath,
1130
+ 'utf8',
1131
+ );
1132
+ supportRecorder?.addBytes(
1133
+ 'snapshot',
1134
+ Buffer.byteLength(rawSnapshotText),
1135
+ );
1136
+ const value = JSON.parse(rawSnapshotText);
1137
+ return { rawSnapshot: value, snapshot: cleanSnapshot(value) };
1138
+ });
727
1139
  const paths = snapshot.files.map((file) => file.path);
728
- const previousSummaries = readJson(summariesPath, {});
1140
+ const previousState = readSummaryState(summariesPath);
1141
+ const previousSummaries = previousState.value;
1142
+ if (previousState.damaged) {
1143
+ console.error(
1144
+ `Saved notes at ${summariesPath} are damaged. Rebuilding them from the current review.`,
1145
+ );
1146
+ }
729
1147
  if (paths.length === 0) {
1148
+ if (requestedAgent) await selectAgentForNotes();
730
1149
  workingSnapshot = rawSnapshot;
731
1150
  workingSummaries = {
732
- ...(completeChangeNote(previousSummaries.change)
733
- ? { change: previousSummaries.change }
734
- : {}),
735
1151
  files: {},
736
1152
  meta: {
1153
+ agent: selectedAgent,
737
1154
  reviewFingerprint: rawSnapshot.notes.reviewFingerprint,
738
1155
  fileFingerprints: {},
739
1156
  status: 'complete',
@@ -742,10 +1159,15 @@ try {
742
1159
  ...(reasoning ? { reasoning } : {}),
743
1160
  },
744
1161
  };
745
- writeJsonAtomic(summariesPath, workingSummaries);
746
- publish(rawSnapshot, workingSummaries);
1162
+ storeProgress(rawSnapshot, workingSummaries);
747
1163
  console.log('No changed files to summarize.');
748
1164
  } else {
1165
+ await selectAgentForNotes();
1166
+ const generationSettings = {
1167
+ agent: selectedAgent,
1168
+ model: model || null,
1169
+ reasoning: reasoning || null,
1170
+ };
749
1171
  const startedAt = new Date().toISOString();
750
1172
  const previousFiles =
751
1173
  previousSummaries.files &&
@@ -765,45 +1187,67 @@ try {
765
1187
  const fileFingerprints = Object.fromEntries(
766
1188
  paths.map((path) => [path, fileFingerprint(rawFiles.get(path))]),
767
1189
  );
1190
+ const settingsMatch = generationSettingsMatch(
1191
+ previousSummaries.meta,
1192
+ generationSettings,
1193
+ );
1194
+ const summaryFiles = new Map(
1195
+ snapshot.files.map((file) => [file.path, file]),
1196
+ );
768
1197
  const reusableFiles = {};
769
1198
  const changedPaths = [];
1199
+ const inputFailures = [];
770
1200
  for (const path of paths) {
771
1201
  if (
772
1202
  !force &&
1203
+ settingsMatch &&
773
1204
  previousFingerprints[path] === fileFingerprints[path] &&
774
1205
  completeFileNote(previousFiles[path])
775
1206
  ) {
776
1207
  reusableFiles[path] = previousFiles[path];
1208
+ } else if (summaryFiles.get(path)?.summaryFailure) {
1209
+ inputFailures.push({
1210
+ path,
1211
+ reason: summaryFiles.get(path).summaryFailure,
1212
+ });
777
1213
  } else {
778
1214
  changedPaths.push(path);
779
1215
  }
780
1216
  }
781
1217
  const changeNeedsRefresh =
782
1218
  force ||
1219
+ !settingsMatch ||
783
1220
  previousSummaries.meta?.reviewFingerprint !==
784
1221
  rawSnapshot.notes.reviewFingerprint ||
785
1222
  !completeChangeNote(previousSummaries.change);
786
1223
  const needsGeneration = changedPaths.length > 0 || changeNeedsRefresh;
787
1224
 
788
1225
  workingSnapshot = rawSnapshot;
789
- workingSummaries = {
790
- ...(!changeNeedsRefresh ? { change: previousSummaries.change } : {}),
791
- files: reusableFiles,
792
- meta: {
793
- reviewFingerprint: rawSnapshot.notes.reviewFingerprint,
794
- fileFingerprints,
795
- ...(needsGeneration
796
- ? { startedAt }
797
- : previousSummaries.meta?.generatedAt
798
- ? { generatedAt: previousSummaries.meta.generatedAt }
799
- : {}),
800
- status: needsGeneration ? 'generating' : 'complete',
801
- ...(model ? { model } : {}),
802
- ...(reasoning ? { reasoning } : {}),
1226
+ workingSummaries = addFailures(
1227
+ {
1228
+ ...(!changeNeedsRefresh ? { change: previousSummaries.change } : {}),
1229
+ files: reusableFiles,
1230
+ meta: {
1231
+ agent: selectedAgent,
1232
+ reviewFingerprint: rawSnapshot.notes.reviewFingerprint,
1233
+ fileFingerprints,
1234
+ ...(needsGeneration
1235
+ ? { startedAt }
1236
+ : previousSummaries.meta?.generatedAt
1237
+ ? { generatedAt: previousSummaries.meta.generatedAt }
1238
+ : {}),
1239
+ status: needsGeneration
1240
+ ? 'generating'
1241
+ : inputFailures.length
1242
+ ? 'failed'
1243
+ : 'complete',
1244
+ ...(model ? { model } : {}),
1245
+ ...(reasoning ? { reasoning } : {}),
1246
+ },
803
1247
  },
804
- };
805
- writeJsonAtomic(summariesPath, workingSummaries);
806
- publish(rawSnapshot, workingSummaries);
1248
+ inputFailures,
1249
+ );
1250
+ storeProgress(rawSnapshot, workingSummaries);
807
1251
 
808
1252
  const batches = [];
809
1253
  let batch = [];
@@ -825,8 +1269,7 @@ try {
825
1269
  }
826
1270
  if (batch.length) batches.push(batch);
827
1271
  let nextBatch = 0;
828
- const runBatch = async (index) => {
829
- const batchPaths = batches[index];
1272
+ const requestBatch = async (index, batchPaths) => {
830
1273
  const schemaPath = resolve(
831
1274
  temporaryDirectory,
832
1275
  `summary-schema-${index + 1}.json`,
@@ -866,18 +1309,40 @@ try {
866
1309
  console.error(
867
1310
  `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
868
1311
  );
869
- const stdout = await runAgent(invocation, input);
870
- const response = parseAgentResponse(selectedAgent, stdout);
871
- const normalized = normalizeResponse(response, batchPaths, {
872
- includeChange: false,
873
- });
1312
+ return requestAgent(
1313
+ invocation,
1314
+ input,
1315
+ (stdout) =>
1316
+ normalizeFileResponse(
1317
+ parseAgentResponse(selectedAgent, stdout),
1318
+ batchPaths,
1319
+ ),
1320
+ );
1321
+ };
1322
+ const runBatch = async (index) => {
1323
+ const batchPaths = batches[index];
1324
+ let outcome;
1325
+ try {
1326
+ outcome = await requestBatch(index, batchPaths);
1327
+ } catch (error) {
1328
+ if (interrupted) throw error;
1329
+ const reason = failureReason(error);
1330
+ console.error(
1331
+ error instanceof Error ? error.message : String(error),
1332
+ );
1333
+ outcome = {
1334
+ files: {},
1335
+ failedFiles: batchPaths.map((path) => ({ path, reason })),
1336
+ errors: [],
1337
+ };
1338
+ }
874
1339
  workingSummaries = {
875
1340
  ...(workingSummaries.change
876
1341
  ? { change: workingSummaries.change }
877
1342
  : {}),
878
1343
  files: {
879
1344
  ...workingSummaries.files,
880
- ...normalized.files,
1345
+ ...outcome.files,
881
1346
  },
882
1347
  meta: {
883
1348
  ...workingSummaries.meta,
@@ -885,8 +1350,12 @@ try {
885
1350
  generatedAt: new Date().toISOString(),
886
1351
  },
887
1352
  };
888
- writeJsonAtomic(summariesPath, workingSummaries);
889
- publish(rawSnapshot, workingSummaries);
1353
+ workingSummaries = addFailures(
1354
+ workingSummaries,
1355
+ outcome.failedFiles,
1356
+ outcome.errors,
1357
+ );
1358
+ storeProgress(rawSnapshot, workingSummaries);
890
1359
  if (batchPaths.length) {
891
1360
  console.log(
892
1361
  `Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
@@ -896,7 +1365,7 @@ try {
896
1365
  const workers = Array.from(
897
1366
  { length: Math.min(jobs, batches.length) },
898
1367
  async () => {
899
- while (nextBatch < batches.length) {
1368
+ while (!interrupted && nextBatch < batches.length) {
900
1369
  const index = nextBatch;
901
1370
  nextBatch += 1;
902
1371
  await runBatch(index);
@@ -905,91 +1374,122 @@ try {
905
1374
  );
906
1375
  await Promise.all(workers);
907
1376
  if (changeNeedsRefresh) {
908
- const schemaPath = resolve(
909
- temporaryDirectory,
910
- 'change-summary-schema.json',
911
- );
912
- const schema = outputSchema([]);
913
- writeFileSync(
914
- schemaPath,
915
- `${JSON.stringify(schema, null, 2)}\n`,
916
- );
917
- const input = batchInput(
918
- snapshot,
919
- rawSnapshot,
920
- [],
921
- workingSummaries.files,
922
- );
923
- const inputPath = resolve(
924
- temporaryDirectory,
925
- 'change-summary-input.json',
926
- );
927
- writeFileSync(inputPath, input);
928
- const invocation = agentCommand({
929
- agent: selectedAgent,
930
- binary: agentBinary,
931
- model,
932
- reasoning,
933
- prompt: promptFor([]),
934
- schema,
935
- schemaPath,
936
- inputPath,
937
- workingDirectory: root,
938
- });
939
- console.error(`Asking ${selectedAgent} for the change note...`);
940
- const stdout = await runAgent(invocation, input);
941
- const normalized = normalizeResponse(
942
- parseAgentResponse(selectedAgent, stdout),
943
- [],
944
- );
945
- workingSummaries = {
946
- change: normalized.change,
947
- files: workingSummaries.files,
948
- meta: {
949
- ...workingSummaries.meta,
950
- status: 'complete',
951
- generatedAt: new Date().toISOString(),
952
- },
953
- };
954
- writeJsonAtomic(summariesPath, workingSummaries);
955
- publish(rawSnapshot, workingSummaries);
956
- console.log(`Updated the change note in ${summariesPath}`);
957
- } else if (batches.length) {
958
- workingSummaries = {
959
- ...workingSummaries,
960
- meta: {
961
- ...workingSummaries.meta,
962
- status: 'complete',
963
- generatedAt: new Date().toISOString(),
964
- },
965
- };
966
- writeJsonAtomic(summariesPath, workingSummaries);
967
- publish(rawSnapshot, workingSummaries);
1377
+ try {
1378
+ const schemaPath = resolve(
1379
+ temporaryDirectory,
1380
+ 'change-summary-schema.json',
1381
+ );
1382
+ const schema = outputSchema([]);
1383
+ writeFileSync(
1384
+ schemaPath,
1385
+ `${JSON.stringify(schema, null, 2)}\n`,
1386
+ );
1387
+ const input = batchInput(
1388
+ snapshot,
1389
+ rawSnapshot,
1390
+ [],
1391
+ workingSummaries.files,
1392
+ );
1393
+ const inputPath = resolve(
1394
+ temporaryDirectory,
1395
+ 'change-summary-input.json',
1396
+ );
1397
+ writeFileSync(inputPath, input);
1398
+ const invocation = agentCommand({
1399
+ agent: selectedAgent,
1400
+ binary: agentBinary,
1401
+ model,
1402
+ reasoning,
1403
+ prompt: promptFor([]),
1404
+ schema,
1405
+ schemaPath,
1406
+ inputPath,
1407
+ workingDirectory: root,
1408
+ });
1409
+ console.error(`Asking ${selectedAgent} for the change note...`);
1410
+ const normalized = await requestAgent(
1411
+ invocation,
1412
+ input,
1413
+ (stdout) =>
1414
+ normalizeChangeResponse(
1415
+ parseAgentResponse(selectedAgent, stdout),
1416
+ ),
1417
+ );
1418
+ workingSummaries = {
1419
+ change: normalized,
1420
+ files: workingSummaries.files,
1421
+ meta: workingSummaries.meta,
1422
+ };
1423
+ console.log(`Updated the change note in ${summariesPath}`);
1424
+ } catch (error) {
1425
+ if (interrupted) throw error;
1426
+ console.error(
1427
+ error instanceof Error ? error.message : String(error),
1428
+ );
1429
+ workingSummaries = addFailures(
1430
+ workingSummaries,
1431
+ [],
1432
+ [`Change note: ${failureReason(error)}`],
1433
+ );
1434
+ }
968
1435
  }
969
- if (batches.length === 0) {
1436
+ const failedFiles = workingSummaries.meta.failedFiles || [];
1437
+ const generationErrors = workingSummaries.meta.errors || [];
1438
+ const complete =
1439
+ failedFiles.length === 0 &&
1440
+ generationErrors.length === 0 &&
1441
+ completeChangeNote(workingSummaries.change) &&
1442
+ paths.every((path) =>
1443
+ completeFileNote(workingSummaries.files[path]),
1444
+ );
1445
+ workingSummaries = {
1446
+ ...workingSummaries,
1447
+ meta: {
1448
+ ...workingSummaries.meta,
1449
+ status: complete ? 'complete' : 'failed',
1450
+ generatedAt: new Date().toISOString(),
1451
+ },
1452
+ };
1453
+ storeProgress(rawSnapshot, workingSummaries);
1454
+ if (batches.length === 0 && inputFailures.length === 0) {
970
1455
  console.log('No file summaries changed.');
971
1456
  }
1457
+ for (const failure of failedFiles) {
1458
+ console.error(`${failure.path}: ${failure.reason}`);
1459
+ }
1460
+ for (const error of generationErrors) console.error(error);
1461
+ if (!complete) {
1462
+ process.exitCode = 1;
1463
+ emitFailedSupportRecord(1);
1464
+ }
972
1465
  console.log(`Rebuilt ${outputPath}`);
973
1466
  }
974
1467
  } catch (error) {
975
1468
  if (!interrupted && workingSummaries && workingSnapshot) {
976
1469
  try {
977
- workingSummaries = {
978
- ...workingSummaries,
979
- meta: {
980
- ...workingSummaries.meta,
981
- status: 'failed',
982
- generatedAt: new Date().toISOString(),
1470
+ workingSummaries = addFailures(
1471
+ {
1472
+ ...workingSummaries,
1473
+ meta: {
1474
+ ...workingSummaries.meta,
1475
+ status: 'failed',
1476
+ generatedAt: new Date().toISOString(),
1477
+ },
983
1478
  },
984
- };
985
- writeJsonAtomic(summariesPath, workingSummaries);
986
- publish(workingSnapshot, workingSummaries);
1479
+ [],
1480
+ [failureReason(error)],
1481
+ );
1482
+ storeProgress(workingSnapshot, workingSummaries);
987
1483
  } catch {}
988
1484
  }
989
1485
  if (!interrupted) {
990
1486
  console.error(error instanceof Error ? error.message : String(error));
991
- process.exitCode = 1;
1487
+ process.exitCode =
1488
+ error instanceof Error && error.exitCode === 2 ? 2 : 1;
1489
+ emitFailedSupportRecord(process.exitCode);
992
1490
  }
993
1491
  } finally {
1492
+ if (ownershipHeartbeat) clearInterval(ownershipHeartbeat);
1493
+ if (ownership) { try { releaseLease(ownership); } catch {} }
994
1494
  rmSync(temporaryDirectory, { recursive: true, force: true });
995
1495
  }