diffsplain 0.5.0 → 0.7.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 (92) hide show
  1. package/README.md +17 -12
  2. package/dist/assets/{angular-html-CoQle5-T.js → angular-html-CphzFC_v.js} +1 -1
  3. package/dist/assets/{angular-ts-BB_A1Lpy.js → angular-ts-Am0-mbFw.js} +1 -1
  4. package/dist/assets/{apl-B7_4_FNq.js → apl-Cs2rPKDN.js} +1 -1
  5. package/dist/assets/{astro-CXVo4VE4.js → astro-CxKUUn9h.js} +1 -1
  6. package/dist/assets/{blade-BVX41dq7.js → blade-DqeCa6v5.js} +1 -1
  7. package/dist/assets/{c-Dhyi8Nd2.js → c-DdI6lknk.js} +1 -1
  8. package/dist/assets/{cobol-CU5oLeZj.js → cobol-COuAGE2X.js} +1 -1
  9. package/dist/assets/{coffee-D_FU6gC_.js → coffee-BFlYT3Lp.js} +1 -1
  10. package/dist/assets/{cpp-CxoYLSi3.js → cpp-nywLU0Np.js} +1 -1
  11. package/dist/assets/{crystal-BdgqBN3z.js → crystal-BWp4dz1s.js} +1 -1
  12. package/dist/assets/{css-wLk1vVwJ.js → css-CRb9tUpU.js} +1 -1
  13. package/dist/assets/{edge-DMylWoGl.js → edge-rUYSbJy4.js} +1 -1
  14. package/dist/assets/{elixir-Djmgto_Z.js → elixir-DOq9ybIH.js} +1 -1
  15. package/dist/assets/{elm-D7B-eBLA.js → elm-C8e38E5-.js} +1 -1
  16. package/dist/assets/{erb-g1sso1GW.js → erb-BR9Q37Et.js} +1 -1
  17. package/dist/assets/{git-rebase-DEAhxutZ.js → git-rebase-Coo2LUtB.js} +1 -1
  18. package/dist/assets/{glimmer-js-CHvGFGMp.js → glimmer-js-C7hX9jg6.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DvhL705p.js → glimmer-ts-CZLT0F0k.js} +1 -1
  20. package/dist/assets/{glsl-C8mS_t8I.js → glsl-BDZRv6e-.js} +1 -1
  21. package/dist/assets/{graphql-DW2pnvqi.js → graphql-DyzFc3y3.js} +1 -1
  22. package/dist/assets/{hack-DYc1d3he.js → hack-CWnO7WsF.js} +1 -1
  23. package/dist/assets/{haml-_5OmzH31.js → haml-CvDj-ghO.js} +1 -1
  24. package/dist/assets/{handlebars-rN-CaXUf.js → handlebars-BnU_YcVA.js} +1 -1
  25. package/dist/assets/{html-DQnaJUjl.js → html-CjB2TKKa.js} +1 -1
  26. package/dist/assets/{html-derivative-BovOubry.js → html-derivative-CYQqEXS6.js} +1 -1
  27. package/dist/assets/{http-BKTgwOYc.js → http-NNQbnqIx.js} +1 -1
  28. package/dist/assets/{hurl-ChS3_IgD.js → hurl-DHaAA1BS.js} +1 -1
  29. package/dist/assets/{index-BLtRBc2n.css → index-C2p_1iAk.css} +1 -1
  30. package/dist/assets/{index-CEglkSs-.js → index-f9ekN94b.js} +11 -11
  31. package/dist/assets/{java-Dgnyk--z.js → java-D1J5V2CD.js} +1 -1
  32. package/dist/assets/{javascript-BfnrwkRN.js → javascript-C3pX-HsE.js} +1 -1
  33. package/dist/assets/{jinja-CD2AIvOi.js → jinja-BY9CIY8g.js} +1 -1
  34. package/dist/assets/{jison-DmKNw3Pl.js → jison-Cr0H89gS.js} +1 -1
  35. package/dist/assets/{json-CKB5a8aj.js → json-CA3kKNfq.js} +1 -1
  36. package/dist/assets/{jsx-jJXrDRBM.js → jsx-BuMkR-Lj.js} +1 -1
  37. package/dist/assets/{julia-G8vVBW-t.js → julia-C2sLwg3f.js} +1 -1
  38. package/dist/assets/{just-DMXLrLZL.js → just-pXpkLBXv.js} +1 -1
  39. package/dist/assets/{latex-DS3xuxld.js → latex-7v3EPOdF.js} +1 -1
  40. package/dist/assets/{liquid-COQnD_C4.js → liquid-CJZq0Z0t.js} +1 -1
  41. package/dist/assets/{lua-BwMKwosd.js → lua-BimBzvTH.js} +1 -1
  42. package/dist/assets/{marko-cQCK_WIk.js → marko-CCZHVnQN.js} +1 -1
  43. package/dist/assets/{mdc-DQUjY13N.js → mdc-SLECNw2A.js} +1 -1
  44. package/dist/assets/{nginx-C4Kv3DJi.js → nginx-DUwtUge2.js} +1 -1
  45. package/dist/assets/{nim-ChzLzuYD.js → nim-HD8o_Omp.js} +1 -1
  46. package/dist/assets/{perl-z5xUiWAY.js → perl-BGMeIkNk.js} +1 -1
  47. package/dist/assets/{php-BHqm-Xeh.js → php-BL5xuY-v.js} +1 -1
  48. package/dist/assets/{pug-C9C_Llft.js → pug-C8G1U_u8.js} +1 -1
  49. package/dist/assets/{qml-dtpuwXJZ.js → qml-BGVSyw1x.js} +1 -1
  50. package/dist/assets/{r-BicQMs_c.js → r-BDnRcclC.js} +1 -1
  51. package/dist/assets/{razor-D5h00YTZ.js → razor-I8tbEIVJ.js} +1 -1
  52. package/dist/assets/{regexp-CTa8S86v.js → regexp-D0yakB9Q.js} +1 -1
  53. package/dist/assets/{rst-Bg-b-7HK.js → rst-DbTCn07T.js} +1 -1
  54. package/dist/assets/{ruby-BiWYm056.js → ruby-BwgPFIcO.js} +1 -1
  55. package/dist/assets/{sas-bJTdfgUz.js → sas-dN4ePfXU.js} +1 -1
  56. package/dist/assets/{scss-0qkNyHr1.js → scss-T7M1A4iM.js} +1 -1
  57. package/dist/assets/{shellscript-3sDkDE9z.js → shellscript-HFjBHS7_.js} +1 -1
  58. package/dist/assets/{shellsession-D4EBhEmz.js → shellsession-DCAGskrx.js} +1 -1
  59. package/dist/assets/{soy-DG9hUwLL.js → soy-BgT96TF9.js} +1 -1
  60. package/dist/assets/{sql-B0UoKHkx.js → sql-DCIQeRzx.js} +1 -1
  61. package/dist/assets/{stata-CsQbuXQp.js → stata-Dn0tad0_.js} +1 -1
  62. package/dist/assets/{surrealql-D63VLxos.js → surrealql-CCgFEQfW.js} +1 -1
  63. package/dist/assets/{svelte-BApdnAma.js → svelte-ITXimG8u.js} +1 -1
  64. package/dist/assets/{templ-B64pN6Lz.js → templ-DJVZZqPw.js} +1 -1
  65. package/dist/assets/{tex-DR2UVIPF.js → tex-Bn4N-OtH.js} +1 -1
  66. package/dist/assets/{ts-tags-IwhomvQv.js → ts-tags-CxQ4PoC4.js} +1 -1
  67. package/dist/assets/{tsx-DROdxqzS.js → tsx-CxwgcjkC.js} +1 -1
  68. package/dist/assets/{twig-BtbUqpR4.js → twig-CtbyTyMH.js} +1 -1
  69. package/dist/assets/{typescript-CHpSjYZL.js → typescript-d6ru12L8.js} +1 -1
  70. package/dist/assets/{vue-DCvEu_ug.js → vue-RvqAsYDm.js} +1 -1
  71. package/dist/assets/{vue-html-BgL4Rf-L.js → vue-html-BkHDVENe.js} +1 -1
  72. package/dist/assets/{vue-vine-CRNdhPTn.js → vue-vine-DvS3Mlkn.js} +1 -1
  73. package/dist/assets/{xml-CkfPxZuz.js → xml-lzTq5A-V.js} +1 -1
  74. package/dist/assets/{xsl-CteaoFSV.js → xsl-BL0KXNWI.js} +1 -1
  75. package/dist/assets/{yaml-CUr3TDRN.js → yaml-CF4XlBJS.js} +1 -1
  76. package/dist/demo-diff-data.json +6 -1
  77. package/dist/index.html +2 -2
  78. package/package.json +50 -23
  79. package/scripts/access-token.mjs +21 -0
  80. package/scripts/build-diff-data.mjs +260 -39
  81. package/scripts/cache.mjs +301 -0
  82. package/scripts/cli-args.mjs +206 -57
  83. package/scripts/coding-agents.mjs +279 -162
  84. package/scripts/dev.mjs +218 -0
  85. package/scripts/doctor.mjs +265 -46
  86. package/scripts/generate-summaries.mjs +760 -260
  87. package/scripts/mock-agent.mjs +165 -0
  88. package/scripts/present.mjs +394 -85
  89. package/scripts/presenter-runtime.mjs +148 -0
  90. package/scripts/serve-built.mjs +343 -71
  91. package/scripts/summary-path.mjs +24 -7
  92. package/scripts/support-record.mjs +242 -0
@@ -3,14 +3,20 @@
3
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
5
  import {
6
+ closeSync,
7
+ constants,
6
8
  existsSync,
9
+ fstatSync,
10
+ lstatSync,
7
11
  mkdirSync,
12
+ openSync,
8
13
  readFileSync,
14
+ readlinkSync,
9
15
  renameSync,
10
16
  statSync,
11
17
  writeFileSync,
12
18
  } from 'node:fs';
13
- import { dirname, relative, resolve } from 'node:path';
19
+ import { basename, dirname, relative, resolve } from 'node:path';
14
20
  import { fileURLToPath } from 'node:url';
15
21
  import { summaryPath } from './summary-path.mjs';
16
22
 
@@ -26,6 +32,10 @@ const option = (name) => {
26
32
  if (!value || value.startsWith('--')) fail(`${name} needs a value`);
27
33
  return value;
28
34
  };
35
+ const options = (name) =>
36
+ args.flatMap((argument, index) =>
37
+ argument === name && args[index + 1] ? [args[index + 1]] : [],
38
+ );
29
39
  const has = (name) => args.includes(name);
30
40
 
31
41
  if (has('--help')) {
@@ -52,7 +62,7 @@ Options:
52
62
 
53
63
  const repo = resolve(option('--repo') || process.cwd());
54
64
  const output = resolve(option('--output') || '.cache/diff-data.json');
55
- const excludedOutput = option('--exclude-output');
65
+ const excludedOutputs = options('--exclude-output');
56
66
  const baseOption = option('--base');
57
67
  const headOption = option('--head');
58
68
  const prOption = option('--pr');
@@ -79,7 +89,18 @@ const cacheRoot = cacheOption
79
89
  : resolve(projectRoot, '.cache/git');
80
90
  const remoteMode = Boolean(prOption || branchOption);
81
91
  const watching = has('--watch');
92
+ const watchContent = has('--watch-content');
82
93
  const ignoreSummaryWatch = has('--ignore-summary-watch');
94
+ const noSummaries = has('--no-summaries');
95
+ const interval = (name, fallback) => {
96
+ const value = Number(process.env[name]);
97
+ return Number.isFinite(value) && value > 0 ? value : fallback;
98
+ };
99
+ const watchInterval = interval('DIFFSPLAIN_WATCH_INTERVAL_MS', 2_000);
100
+ const remoteRefreshInterval = interval(
101
+ 'DIFFSPLAIN_REMOTE_REFRESH_INTERVAL_MS',
102
+ 30_000,
103
+ );
83
104
 
84
105
  if (prOption && branchOption) fail('--pr and --branch cannot be used together');
85
106
  if (prOption && (baseOption || headOption)) fail('--pr cannot be used with --base or --head');
@@ -109,11 +130,13 @@ const repoPath = (file) => {
109
130
  const path = relative(repo, file).replaceAll('\\', '/');
110
131
  return path && path !== '..' && !path.startsWith('../') ? path : undefined;
111
132
  };
133
+ const summariesRepoPath = repoPath(summariesPath);
112
134
  const excludedPaths = new Set(
113
135
  [
114
- repoPath(summariesPath),
136
+ summariesRepoPath,
137
+ summariesRepoPath ? `${summariesRepoPath}.lock` : undefined,
115
138
  repoPath(output),
116
- excludedOutput ? repoPath(resolve(excludedOutput)) : undefined,
139
+ ...excludedOutputs.map((path) => repoPath(resolve(path))),
117
140
  ].filter(Boolean),
118
141
  );
119
142
 
@@ -176,6 +199,22 @@ const completeChangeSummary = (value) =>
176
199
  completeText(value.why) &&
177
200
  completeList(value.highlights) &&
178
201
  completeList(value.risks);
202
+ const failedFileRecords = (value) =>
203
+ Array.isArray(value)
204
+ ? value
205
+ .filter(
206
+ (item) =>
207
+ item &&
208
+ typeof item.path === 'string' &&
209
+ item.path.trim() &&
210
+ typeof item.reason === 'string' &&
211
+ item.reason.trim(),
212
+ )
213
+ .map((item) => ({
214
+ path: item.path.trim(),
215
+ reason: item.reason.trim(),
216
+ }))
217
+ : [];
179
218
 
180
219
  function fileSummary(path, value) {
181
220
  return {
@@ -311,12 +350,24 @@ function normalizeBranch(value, remoteName) {
311
350
  }
312
351
 
313
352
  function resolveRemote() {
314
- const configured = tryRepo(['remote', 'get-url', remoteOption]);
315
- if (configured) return { name: remoteOption, url: configured };
353
+ const configured = tryRepo([
354
+ 'config',
355
+ '--get-all',
356
+ `remote.${remoteOption}.url`,
357
+ ])
358
+ .split('\n')
359
+ .find(Boolean);
360
+ if (configured) {
361
+ return {
362
+ name: remoteOption,
363
+ url: configured,
364
+ fetchUrl: tryRepo(['remote', 'get-url', remoteOption]) || configured,
365
+ };
366
+ }
316
367
  if (remoteOption === 'origin') {
317
368
  throw new Error(`Git remote "origin" was not found in ${repo}`);
318
369
  }
319
- return { name: remoteOption, url: remoteOption };
370
+ return { name: remoteOption, url: remoteOption, fetchUrl: remoteOption };
320
371
  }
321
372
 
322
373
  function bareCache(remoteUrl) {
@@ -387,6 +438,34 @@ function remoteDefaultBranch(remoteUrl) {
387
438
  return remoteDefaultBranchInfo(remoteUrl).name;
388
439
  }
389
440
 
441
+ function remoteContainsCommits(remoteUrl, commits) {
442
+ let raw;
443
+ try {
444
+ raw = runRepo(['ls-remote', remoteUrl]);
445
+ } catch {
446
+ return false;
447
+ }
448
+ const tips = [
449
+ ...new Set(
450
+ raw
451
+ .split('\n')
452
+ .map((line) => line.trim().split(/\s+/, 1)[0])
453
+ .filter(Boolean),
454
+ ),
455
+ ];
456
+ return commits.every((commit) =>
457
+ tips.some((tip) => {
458
+ if (tip === commit) return true;
459
+ const result = spawnSync(
460
+ 'git',
461
+ ['-C', repo, 'merge-base', '--is-ancestor', commit, tip],
462
+ { stdio: 'ignore' },
463
+ );
464
+ return result.status === 0;
465
+ }),
466
+ );
467
+ }
468
+
390
469
  function localDefaultBranch(remote) {
391
470
  if (baseOption) return { name: baseOption };
392
471
 
@@ -506,7 +585,7 @@ function resolveBranchTarget() {
506
585
  const remote = resolveRemote();
507
586
  const branch = normalizeBranch(branchOption, remote.name);
508
587
  const baseBranch = normalizeBranch(
509
- baseOption || remoteDefaultBranch(remote.url),
588
+ baseOption || remoteDefaultBranch(remote.fetchUrl),
510
589
  remote.name,
511
590
  );
512
591
  const cache = bareCache(remote.url);
@@ -516,7 +595,7 @@ function resolveBranchTarget() {
516
595
  .slice(0, 16);
517
596
  const baseRef = `refs/diffsplain/branch/${key}/base`;
518
597
  const headRef = `refs/diffsplain/branch/${key}/head`;
519
- fetchInto(cache, remote.url, [
598
+ fetchInto(cache, remote.fetchUrl, [
520
599
  `+refs/heads/${baseBranch}:${baseRef}`,
521
600
  `+refs/heads/${branch}:${headRef}`,
522
601
  ]);
@@ -535,6 +614,7 @@ function resolveBranchTarget() {
535
614
  remote,
536
615
  sourceRepositoryUrl: repository?.webUrl,
537
616
  baseRepositoryUrl: repository?.webUrl,
617
+ comparisonCommitsOnRemote: true,
538
618
  target: {
539
619
  kind: 'branch',
540
620
  remote: remote.name,
@@ -562,7 +642,7 @@ function resolvePullRequestTarget() {
562
642
  .slice(0, 16);
563
643
  const baseRef = `refs/diffsplain/pr/${key}/base`;
564
644
  const headRef = `refs/diffsplain/pr/${key}/head`;
565
- fetchInto(cache, remote.url, [
645
+ fetchInto(cache, remote.fetchUrl, [
566
646
  `+refs/heads/${pr.baseRefName}:${baseRef}`,
567
647
  `+refs/pull/${pr.number}/head:${headRef}`,
568
648
  ]);
@@ -600,6 +680,7 @@ function resolvePullRequestTarget() {
600
680
  pr.url.replace(/\/pull\/\d+(?:\/.*)?$/, ''),
601
681
  baseRepositoryUrl:
602
682
  repository?.webUrl || pr.url.replace(/\/pull\/\d+(?:\/.*)?$/, ''),
683
+ comparisonCommitsOnRemote: true,
603
684
  target: {
604
685
  kind: 'pull-request',
605
686
  remote: remote.name,
@@ -646,6 +727,9 @@ function resolveCheckoutTarget() {
646
727
  const repository = githubRepository(remoteUrl);
647
728
  const headLabel = branch || currentHead;
648
729
  const hasCommittedChanges = mergeBaseOid !== currentHead;
730
+ const hasUncommittedChanges = Boolean(
731
+ tryRepo(['status', '--porcelain=v1', '-z']),
732
+ );
649
733
  const isDefaultBranchCheckout = branch === defaultBranch.name;
650
734
 
651
735
  return {
@@ -659,6 +743,12 @@ function resolveCheckoutTarget() {
659
743
  remote,
660
744
  sourceRepositoryUrl: repository?.webUrl,
661
745
  baseRepositoryUrl: repository?.webUrl,
746
+ comparisonCommitsOnRemote:
747
+ !hasUncommittedChanges &&
748
+ Boolean(
749
+ remote?.url &&
750
+ remoteContainsCommits(remote.url, [mergeBaseOid, currentHead]),
751
+ ),
662
752
  target: {
663
753
  kind: 'checkout',
664
754
  ...(remote ? { remote: remote.name } : {}),
@@ -712,6 +802,12 @@ function resolveLocalTarget() {
712
802
  baseRepositoryUrl: worktree
713
803
  ? undefined
714
804
  : githubRepository(remoteUrl)?.webUrl,
805
+ comparisonCommitsOnRemote:
806
+ !worktree &&
807
+ Boolean(
808
+ remoteUrl &&
809
+ remoteContainsCommits(remoteUrl, [resolvedBase, resolvedHead]),
810
+ ),
715
811
  target: worktree
716
812
  ? { kind: 'worktree', base: { ref: 'HEAD', oid: currentHead || null } }
717
813
  : {
@@ -812,6 +908,13 @@ function githubFileUrl(repositoryUrl, ref, path) {
812
908
  return `${repositoryUrl}/blob/${encodeURIComponent(ref)}/${filePath}`;
813
909
  }
814
910
 
911
+ function githubComparisonUrl(repositoryUrl, base, head) {
912
+ if (!repositoryUrl || !base || !head || head === 'WORKTREE') {
913
+ return undefined;
914
+ }
915
+ return `${repositoryUrl}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
916
+ }
917
+
815
918
  function build() {
816
919
  const localWorkspace =
817
920
  tryRepo(['rev-parse', '--is-inside-work-tree']) === 'true';
@@ -820,7 +923,7 @@ function build() {
820
923
  }
821
924
  const target = resolveTarget();
822
925
  const remoteRepository = githubRepository(target.remote?.url);
823
- const summaryDoc = readJson(summariesPath, {}) || {};
926
+ const summaryDoc = noSummaries ? {} : readJson(summariesPath, {}) || {};
824
927
  const allTrackedFiles = parseNameStatus(
825
928
  target.runGit([
826
929
  'diff',
@@ -887,6 +990,13 @@ function build() {
887
990
  file.status === 'deleted' ? target.base : target.head,
888
991
  file.path,
889
992
  );
993
+ const comparisonUrl = githubComparisonUrl(
994
+ target.comparisonCommitsOnRemote
995
+ ? target.sourceRepositoryUrl
996
+ : undefined,
997
+ target.base,
998
+ target.head,
999
+ );
890
1000
  return {
891
1001
  path: file.path,
892
1002
  ...(file.oldPath ? { oldPath: file.oldPath } : {}),
@@ -899,6 +1009,7 @@ function build() {
899
1009
  patch: textPatch,
900
1010
  snippet: binary ? '' : compactSnippet(textPatch),
901
1011
  ...(sourceUrl ? { sourceUrl } : {}),
1012
+ ...(comparisonUrl ? { comparisonUrl } : {}),
902
1013
  };
903
1014
  });
904
1015
 
@@ -926,39 +1037,64 @@ function build() {
926
1037
  )
927
1038
  .digest('hex');
928
1039
  const generatedFor =
1040
+ !noSummaries &&
929
1041
  typeof summaryDoc.meta?.reviewFingerprint === 'string'
930
1042
  ? summaryDoc.meta.reviewFingerprint
931
1043
  : undefined;
932
- const summariesAreFresh = !generatedFor || generatedFor === reviewFingerprint;
1044
+ const summariesAreFresh =
1045
+ !noSummaries && (!generatedFor || generatedFor === reviewFingerprint);
933
1046
  const sourceSummaries = summariesAreFresh ? summaryDoc : {};
1047
+ const failedFiles = summariesAreFresh
1048
+ ? failedFileRecords(summaryDoc.meta?.failedFiles)
1049
+ : [];
1050
+ const summaryErrors = summariesAreFresh
1051
+ ? cleanList(summaryDoc.meta?.errors)
1052
+ : [];
1053
+ const failureByPath = new Map(
1054
+ failedFiles.map((failure) => [failure.path, failure.reason]),
1055
+ );
1056
+ const emptyReviewComplete =
1057
+ filesWithoutSummaries.length === 0 &&
1058
+ generatedFor === reviewFingerprint &&
1059
+ sourceSummaries.meta?.status === 'complete';
934
1060
  const summariesAreComplete =
935
1061
  summariesAreFresh &&
936
- completeChangeSummary(sourceSummaries.change) &&
937
- filesWithoutSummaries.every((file) =>
938
- completeFileSummary(sourceSummaries.files?.[file.path]),
939
- );
940
- const completedFiles = filesWithoutSummaries.filter((file) =>
941
- completeFileSummary(sourceSummaries.files?.[file.path]),
942
- ).length;
1062
+ failedFiles.length === 0 &&
1063
+ summaryErrors.length === 0 &&
1064
+ (emptyReviewComplete ||
1065
+ (completeChangeSummary(sourceSummaries.change) &&
1066
+ filesWithoutSummaries.every((file) =>
1067
+ completeFileSummary(sourceSummaries.files?.[file.path]),
1068
+ )));
1069
+ const completedFiles = noSummaries
1070
+ ? 0
1071
+ : filesWithoutSummaries.filter((file) =>
1072
+ completeFileSummary(sourceSummaries.files?.[file.path]),
1073
+ ).length;
943
1074
  const storedStatus = summaryDoc.meta?.status;
944
- const noteStatus = summariesAreComplete
945
- ? 'complete'
946
- : !summariesAreFresh
947
- ? 'stale'
948
- : ['generating', 'failed'].includes(storedStatus)
949
- ? storedStatus
950
- : 'idle';
1075
+ const noteStatus = noSummaries
1076
+ ? 'idle'
1077
+ : summariesAreComplete
1078
+ ? 'complete'
1079
+ : !summariesAreFresh
1080
+ ? 'stale'
1081
+ : ['generating', 'failed'].includes(storedStatus)
1082
+ ? storedStatus
1083
+ : 'idle';
951
1084
  const files = filesWithoutSummaries.map((file) => ({
952
1085
  ...file,
953
1086
  summary: fileSummary(file.path, sourceSummaries.files?.[file.path]),
954
1087
  noteReady: Boolean(
955
1088
  completeFileSummary(sourceSummaries.files?.[file.path]),
956
1089
  ),
1090
+ ...(failureByPath.has(file.path)
1091
+ ? { noteFailure: failureByPath.get(file.path) }
1092
+ : {}),
957
1093
  }));
958
1094
  const change = changeSummary(sourceSummaries.change, target.changeDefaults);
959
1095
  const content = {
960
1096
  repo: {
961
- name: remoteRepository?.name || repo.split('/').pop(),
1097
+ name: remoteRepository?.name || basename(repo),
962
1098
  root: localWorkspace ? repo : target.remote?.url || repo,
963
1099
  base: target.base,
964
1100
  head: target.head,
@@ -979,9 +1115,14 @@ function build() {
979
1115
  status: noteStatus,
980
1116
  completedFiles,
981
1117
  totalFiles: filesWithoutSummaries.length,
1118
+ ...(failedFiles.length ? { failedFiles } : {}),
1119
+ ...(summaryErrors.length ? { errors: summaryErrors } : {}),
982
1120
  ...(typeof summaryDoc.meta?.model === 'string'
983
1121
  ? { model: summaryDoc.meta.model }
984
1122
  : {}),
1123
+ ...(typeof summaryDoc.meta?.agent === 'string'
1124
+ ? { agent: summaryDoc.meta.agent }
1125
+ : {}),
985
1126
  ...(typeof summaryDoc.meta?.reasoning === 'string'
986
1127
  ? { reasoning: summaryDoc.meta.reasoning }
987
1128
  : {}),
@@ -1011,9 +1152,52 @@ function build() {
1011
1152
  return true;
1012
1153
  }
1013
1154
 
1155
+ function untrackedFileKind(stat) {
1156
+ if (stat.isFile()) return 'file';
1157
+ if (stat.isSymbolicLink()) return 'symlink';
1158
+ if (stat.isDirectory()) return 'directory';
1159
+ return 'other';
1160
+ }
1161
+
1162
+ function fingerprintUntrackedPath(content, path) {
1163
+ const absolutePath = resolve(repo, path);
1164
+ const stat = lstatSync(absolutePath, { bigint: true });
1165
+ content.update(path);
1166
+ content.update('\0');
1167
+ content.update(untrackedFileKind(stat));
1168
+ content.update('\0');
1169
+ content.update(String(stat.size));
1170
+ content.update('\0');
1171
+ content.update(String(stat.mtimeNs));
1172
+ content.update('\0');
1173
+ if (stat.isFile()) {
1174
+ const descriptor = openSync(
1175
+ absolutePath,
1176
+ constants.O_RDONLY | constants.O_NOFOLLOW,
1177
+ );
1178
+ try {
1179
+ const opened = fstatSync(descriptor, { bigint: true });
1180
+ if (
1181
+ !opened.isFile()
1182
+ || opened.dev !== stat.dev
1183
+ || opened.ino !== stat.ino
1184
+ ) {
1185
+ throw new Error(`${path} changed while its fingerprint was read`);
1186
+ }
1187
+ content.update(readFileSync(descriptor));
1188
+ } finally {
1189
+ closeSync(descriptor);
1190
+ }
1191
+ content.update('\0');
1192
+ } else if (stat.isSymbolicLink()) {
1193
+ content.update(readlinkSync(absolutePath));
1194
+ content.update('\0');
1195
+ }
1196
+ }
1197
+
1014
1198
  function fingerprint() {
1015
1199
  let summariesTime = '';
1016
- if (!ignoreSummaryWatch) {
1200
+ if (!ignoreSummaryWatch && !noSummaries) {
1017
1201
  try {
1018
1202
  summariesTime = String(statSync(summariesPath).mtimeMs);
1019
1203
  } catch {}
@@ -1026,9 +1210,26 @@ function fingerprint() {
1026
1210
  summariesTime,
1027
1211
  ].join('|');
1028
1212
  }
1213
+ const content = createHash('sha256');
1214
+ content.update(
1215
+ tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--']),
1216
+ );
1217
+ const untracked = tryRepo([
1218
+ 'ls-files',
1219
+ '--others',
1220
+ '--exclude-standard',
1221
+ '-z',
1222
+ ])
1223
+ .split('\0')
1224
+ .filter((path) => path && !excludedPaths.has(path))
1225
+ .sort();
1226
+ for (const path of untracked) {
1227
+ fingerprintUntrackedPath(content, path);
1228
+ }
1029
1229
  return [
1030
1230
  tryRepo(['rev-parse', 'HEAD']),
1031
1231
  tryRepo(['status', '--porcelain=v1', '--untracked-files=all']),
1232
+ content.digest('hex'),
1032
1233
  summariesTime,
1033
1234
  ].join('|');
1034
1235
  }
@@ -1040,25 +1241,45 @@ const refresh = () => {
1040
1241
  return true;
1041
1242
  } catch (error) {
1042
1243
  console.error(error.message);
1043
- if (!watching) process.exitCode = 1;
1244
+ process.exitCode = 1;
1044
1245
  return false;
1045
1246
  }
1046
1247
  };
1047
1248
 
1048
1249
  const started = refresh();
1049
1250
  if (watching && started) {
1050
- let last = fingerprint();
1251
+ let last;
1051
1252
  let remoteWait = 0;
1052
- setInterval(() => {
1053
- const next = fingerprint();
1054
- remoteWait += 2_000;
1055
- const remoteDue = remoteMode && remoteWait >= 30_000;
1056
- if (next !== last || remoteDue) {
1057
- last = next;
1058
- remoteWait = 0;
1059
- refresh();
1253
+ let watcher;
1254
+ const stopWatching = (error) => {
1255
+ console.error(error instanceof Error ? error.message : String(error));
1256
+ process.exitCode = 1;
1257
+ if (watcher) clearInterval(watcher);
1258
+ };
1259
+ const poll = () => {
1260
+ try {
1261
+ const next = fingerprint();
1262
+ if (last === undefined) {
1263
+ last = next;
1264
+ return true;
1265
+ }
1266
+ remoteWait += watchInterval;
1267
+ const remoteDue = remoteMode && remoteWait >= remoteRefreshInterval;
1268
+ if (next !== last || remoteDue || watchContent) {
1269
+ last = next;
1270
+ remoteWait = 0;
1271
+ if (!refresh()) {
1272
+ clearInterval(watcher);
1273
+ return false;
1274
+ }
1275
+ }
1276
+ return true;
1277
+ } catch (error) {
1278
+ stopWatching(error);
1279
+ return false;
1060
1280
  }
1061
- }, 2_000);
1281
+ };
1282
+ if (poll()) watcher = setInterval(poll, watchInterval);
1062
1283
  } else if (watching) {
1063
1284
  process.exitCode = 1;
1064
1285
  }