diffsplain 0.8.3 → 0.10.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 (94) hide show
  1. package/README.md +26 -5
  2. package/dist/assets/{angular-html-e4D_THVy.js → angular-html-2u-u5H-N.js} +1 -1
  3. package/dist/assets/{angular-ts-Cx3870JF.js → angular-ts-4_HVGlKp.js} +1 -1
  4. package/dist/assets/{apl-DZuu22Wh.js → apl-icaNWZX3.js} +1 -1
  5. package/dist/assets/{astro-K-NemFAk.js → astro-HvIOE4vz.js} +1 -1
  6. package/dist/assets/{blade-ConbVu3U.js → blade-BpKJV8ev.js} +1 -1
  7. package/dist/assets/{c-DtmaBqln.js → c-BNvtxyRQ.js} +1 -1
  8. package/dist/assets/{cobol-BMHoPOfB.js → cobol-8js4hfxa.js} +1 -1
  9. package/dist/assets/{coffee-DfweJ19Y.js → coffee-DXg_piQ9.js} +1 -1
  10. package/dist/assets/{cpp-CPh_zIOW.js → cpp-BZDFAjyh.js} +1 -1
  11. package/dist/assets/{crystal-CAEX7C1J.js → crystal-aCF3ira4.js} +1 -1
  12. package/dist/assets/{css-C8ihGqCA.js → css-DfRtUkhE.js} +1 -1
  13. package/dist/assets/{edge-DVZPwQ9j.js → edge-vS6_AmQv.js} +1 -1
  14. package/dist/assets/{elixir-D03TKulP.js → elixir-Ce0nUHSq.js} +1 -1
  15. package/dist/assets/{elm-JUX-2xVV.js → elm-DmaeyizZ.js} +1 -1
  16. package/dist/assets/{erb-HbktAFZJ.js → erb-DD6OUyvt.js} +1 -1
  17. package/dist/assets/{git-rebase-Ccd_ELAP.js → git-rebase-HbJMn7iS.js} +1 -1
  18. package/dist/assets/{glimmer-js-BcinUfi2.js → glimmer-js-BRuqbNaV.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DuN2UkN9.js → glimmer-ts-ZBpkdEEA.js} +1 -1
  20. package/dist/assets/{glsl-BWmCEPsR.js → glsl-CrQ4GScf.js} +1 -1
  21. package/dist/assets/{graphql-DJGYoBFW.js → graphql-Tn6PBdQY.js} +1 -1
  22. package/dist/assets/{hack-ZPqBE9w3.js → hack-CoApvrbo.js} +1 -1
  23. package/dist/assets/{haml-CZDZk6Gw.js → haml-COKq8Xjp.js} +1 -1
  24. package/dist/assets/{handlebars-d23PDS0n.js → handlebars-CAna2DTA.js} +1 -1
  25. package/dist/assets/{html-BgzX2DyB.js → html-Ch4xJ7YF.js} +1 -1
  26. package/dist/assets/{html-derivative-Bq6M-ZFw.js → html-derivative-0ITK-MAI.js} +1 -1
  27. package/dist/assets/{http-DVhMl_cW.js → http-DiMWQ8Qr.js} +1 -1
  28. package/dist/assets/{hurl-CbspBAqF.js → hurl-OcXymC0J.js} +1 -1
  29. package/dist/assets/index-BmcCug2M.js +1574 -0
  30. package/dist/assets/index-CADUNiTy.css +2 -0
  31. package/dist/assets/{java--YZbq0z2.js → java-CFb3qN4r.js} +1 -1
  32. package/dist/assets/{javascript-cvRuifRJ.js → javascript-CRmk2JrF.js} +1 -1
  33. package/dist/assets/{jinja-BeZmr3LP.js → jinja-Da6lDTEy.js} +1 -1
  34. package/dist/assets/{jison-qrpGZaxs.js → jison-Bnivzmtu.js} +1 -1
  35. package/dist/assets/{json-4xUzsNJn.js → json-m8vJP_KK.js} +1 -1
  36. package/dist/assets/{jsx-BlXrTlSg.js → jsx-C3HX_Sbu.js} +1 -1
  37. package/dist/assets/{julia-IN3jzmBc.js → julia-Ckg-wSHA.js} +1 -1
  38. package/dist/assets/{just-C_50nLQH.js → just-DCjIAyvO.js} +1 -1
  39. package/dist/assets/{latex-Cd-_2IS1.js → latex-CrYSm4pl.js} +1 -1
  40. package/dist/assets/{liquid-CEJf6Xg5.js → liquid-MQUFSPYU.js} +1 -1
  41. package/dist/assets/{lua-BlaV_n8i.js → lua-DcuzJJ77.js} +1 -1
  42. package/dist/assets/{marko-Cb-NPdc_.js → marko-4JNmQaB4.js} +1 -1
  43. package/dist/assets/{mdc-Dt4ywVAS.js → mdc-C_j8HvRL.js} +1 -1
  44. package/dist/assets/{nginx-C_6SKViP.js → nginx-ta3Gn1HE.js} +1 -1
  45. package/dist/assets/{nim-BbkBjoeZ.js → nim-BwWLst9M.js} +1 -1
  46. package/dist/assets/{perl-CYPU6TVa.js → perl-DfW7Uq5O.js} +1 -1
  47. package/dist/assets/{php-B6Oqilrc.js → php-DGf5G3Cf.js} +1 -1
  48. package/dist/assets/{pug-C9Poo2KU.js → pug-CWI-NaYz.js} +1 -1
  49. package/dist/assets/{qml-DkMURMPv.js → qml-Csblgh7U.js} +1 -1
  50. package/dist/assets/{r-Bm-Gfi0p.js → r-QGooaGDI.js} +1 -1
  51. package/dist/assets/{razor-B0LqOok3.js → razor-Dp9ihLYf.js} +1 -1
  52. package/dist/assets/{regexp-jAbMHzwe.js → regexp-DwUnLcVr.js} +1 -1
  53. package/dist/assets/{rst-BlWHfg8c.js → rst-BY8GIwDJ.js} +1 -1
  54. package/dist/assets/{ruby-Bt5lKwPL.js → ruby-VgzI9htT.js} +1 -1
  55. package/dist/assets/{sas-TuY2-8EF.js → sas-DYLsm-0j.js} +1 -1
  56. package/dist/assets/{scss-Dh1SsWlK.js → scss-tIFkeUQX.js} +1 -1
  57. package/dist/assets/{shellscript-CThiwmnp.js → shellscript-CHjuQ4Pl.js} +1 -1
  58. package/dist/assets/{shellsession-DQ5iRUWd.js → shellsession-Bv0S0hj9.js} +1 -1
  59. package/dist/assets/{soy-BfM-BrZG.js → soy-D6U1tZod.js} +1 -1
  60. package/dist/assets/{sql-F617W1uN.js → sql-CGz8kjKv.js} +1 -1
  61. package/dist/assets/{stata-Ctov3ki2.js → stata-C9ytERUq.js} +1 -1
  62. package/dist/assets/{surrealql-C-i2bJGB.js → surrealql-DRfRnaYu.js} +1 -1
  63. package/dist/assets/{svelte-oZT41pTl.js → svelte-wYnk_1sI.js} +1 -1
  64. package/dist/assets/{templ-CxX9Hf3e.js → templ-DWrGVE0u.js} +1 -1
  65. package/dist/assets/{tex-GyzJq8GJ.js → tex-CYTzoUJh.js} +1 -1
  66. package/dist/assets/{ts-tags-BugzKu0x.js → ts-tags-1TIXEGea.js} +1 -1
  67. package/dist/assets/{tsx-DLqwDEUq.js → tsx-a3BoAoOh.js} +1 -1
  68. package/dist/assets/{twig-DADiR9Vq.js → twig-e3dvNv6o.js} +1 -1
  69. package/dist/assets/{typescript-BlDIG_FO.js → typescript-BBhnpnTf.js} +1 -1
  70. package/dist/assets/{vue-COXmmg2A.js → vue-DzzVbu9q.js} +1 -1
  71. package/dist/assets/{vue-html-Cy_3KYj1.js → vue-html-quBNv38K.js} +1 -1
  72. package/dist/assets/{vue-vine-CUqOY8dY.js → vue-vine-CDuR69FE.js} +1 -1
  73. package/dist/assets/{xml-C9JY7Wf6.js → xml-DMifOLSe.js} +1 -1
  74. package/dist/assets/{xsl-DBCl8hSu.js → xsl-D8Ef-5HK.js} +1 -1
  75. package/dist/assets/{yaml-BbMebKkS.js → yaml-5c38gpmA.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +12 -3
  78. package/scripts/agent-config.mjs +184 -0
  79. package/scripts/agent-exclusions.mjs +10 -0
  80. package/scripts/agent-review.mjs +94 -0
  81. package/scripts/build-diff-data.mjs +211 -49
  82. package/scripts/cli-args.mjs +63 -9
  83. package/scripts/coding-agents.mjs +255 -77
  84. package/scripts/generate-summaries.mjs +872 -426
  85. package/scripts/local-target.mjs +106 -0
  86. package/scripts/present.mjs +333 -117
  87. package/scripts/review-chat-context.mjs +510 -0
  88. package/scripts/review-chat-controller.mjs +691 -0
  89. package/scripts/review-chat-provider.mjs +363 -0
  90. package/scripts/review-chat.mjs +8 -0
  91. package/scripts/serve-built.mjs +245 -27
  92. package/scripts/summary-path.mjs +32 -11
  93. package/dist/assets/index-C2p_1iAk.css +0 -2
  94. package/dist/assets/index-divlsuKs.js +0 -1568
@@ -9,15 +9,28 @@ import {
9
9
  fstatSync,
10
10
  lstatSync,
11
11
  mkdirSync,
12
+ mkdtempSync,
12
13
  openSync,
13
14
  readFileSync,
14
15
  readlinkSync,
15
16
  renameSync,
17
+ rmSync,
16
18
  statSync,
17
19
  writeFileSync,
18
20
  } from 'node:fs';
19
- import { basename, dirname, relative, resolve } from 'node:path';
21
+ import { tmpdir } from 'node:os';
22
+ import { basename, dirname, join, relative, resolve } from 'node:path';
20
23
  import { fileURLToPath } from 'node:url';
24
+ import {
25
+ isBaseWorktreeTarget,
26
+ resolveBaseWorktreeCommit,
27
+ } from './local-target.mjs';
28
+ import { createAgentExclusionMatcher } from './agent-exclusions.mjs';
29
+ import {
30
+ agentReviewContext,
31
+ agentReviewFile,
32
+ createAgentReviewFingerprint,
33
+ } from './agent-review.mjs';
21
34
  import { summaryPath } from './summary-path.mjs';
22
35
 
23
36
  const args = process.argv.slice(2);
@@ -34,7 +47,11 @@ const option = (name) => {
34
47
  };
35
48
  const options = (name) =>
36
49
  args.flatMap((argument, index) =>
37
- argument === name && args[index + 1] ? [args[index + 1]] : [],
50
+ argument === name && args[index + 1]
51
+ ? [args[index + 1]]
52
+ : argument.startsWith(`${name}=`)
53
+ ? [argument.slice(name.length + 1)]
54
+ : [],
38
55
  );
39
56
  const has = (name) => args.includes(name);
40
57
 
@@ -45,8 +62,8 @@ Targets:
45
62
  --pr NUMBER|URL Fetch and show a GitHub pull request
46
63
  --branch NAME Fetch and show a remote branch
47
64
  --checkout Show the current checkout against its default branch
48
- --base REF --head REF
49
- Show an exact local Git range
65
+ --base REF [--head REF]
66
+ Compare a base with the working tree, or show an exact range
50
67
  (no target) Show worktree changes against HEAD
51
68
 
52
69
  Options:
@@ -63,6 +80,7 @@ Options:
63
80
  const repo = resolve(option('--repo') || process.cwd());
64
81
  const output = resolve(option('--output') || '.cache/diff-data.json');
65
82
  const excludedOutputs = options('--exclude-output');
83
+ const agentExcludeRules = options('--exclude');
66
84
  const baseOption = option('--base');
67
85
  const headOption = option('--head');
68
86
  const prOption = option('--pr');
@@ -88,6 +106,14 @@ const cacheRoot = cacheOption
88
106
  ? resolve(cacheOption)
89
107
  : resolve(projectRoot, '.cache/git');
90
108
  const remoteMode = Boolean(prOption || branchOption);
109
+ const baseWorktreeOption = isBaseWorktreeTarget({
110
+ base: baseOption,
111
+ branch: branchOption,
112
+ checkout: checkoutOption,
113
+ head: headOption,
114
+ pullRequest: prOption,
115
+ worktree: worktreeOption,
116
+ });
91
117
  const watching = has('--watch');
92
118
  const watchContent = has('--watch-content');
93
119
  const ignoreSummaryWatch = has('--ignore-summary-watch');
@@ -117,13 +143,8 @@ if (
117
143
  ) {
118
144
  fail('--worktree cannot be combined with another target');
119
145
  }
120
- if (
121
- !prOption &&
122
- !branchOption &&
123
- !checkoutOption &&
124
- Boolean(baseOption) !== Boolean(headOption)
125
- ) {
126
- fail('--base and --head must be used together');
146
+ if (!prOption && !branchOption && !checkoutOption && headOption && !baseOption) {
147
+ fail('--head must be used with --base');
127
148
  }
128
149
 
129
150
  const repoPath = (file) => {
@@ -829,6 +850,36 @@ function resolveCheckoutTarget() {
829
850
  };
830
851
  }
831
852
 
853
+ function resolveBaseWorktreeTarget() {
854
+ const currentHead = tryRepo(['rev-parse', '--verify', 'HEAD']);
855
+ const resolvedBase = resolveBaseWorktreeCommit(repo, baseOption);
856
+ const remoteUrl = tryRepo(['remote', 'get-url', 'origin']) || undefined;
857
+ return {
858
+ kind: 'base-worktree',
859
+ runGit: runRepo,
860
+ range: [resolvedBase],
861
+ base: resolvedBase,
862
+ head: currentHead || 'WORKTREE',
863
+ branch: tryRepo(['branch', '--show-current']) || undefined,
864
+ remote: remoteUrl ? { name: 'origin', url: remoteUrl } : undefined,
865
+ sourceRepositoryUrl: undefined,
866
+ baseRepositoryUrl: undefined,
867
+ comparisonCommitsOnRemote: false,
868
+ target: {
869
+ kind: 'base-worktree',
870
+ base: { ref: baseOption, oid: resolvedBase },
871
+ head: { ref: 'WORKTREE', oid: currentHead || null },
872
+ },
873
+ changeDefaults: {
874
+ title: `Changes since ${baseOption}`,
875
+ summary: `Shows commits and working-tree changes since ${baseOption}.`,
876
+ why: 'Reviews the working tree against the chosen base without changing the repo.',
877
+ highlights: [],
878
+ risks: [],
879
+ },
880
+ };
881
+ }
882
+
832
883
  function resolveLocalTarget() {
833
884
  const currentHead = tryRepo(['rev-parse', '--verify', 'HEAD']);
834
885
  const worktree = !baseOption && !headOption;
@@ -891,6 +942,7 @@ function resolveTarget() {
891
942
  if (prOption) return resolvePullRequestTarget();
892
943
  if (branchOption) return resolveBranchTarget();
893
944
  if (checkoutOption) return resolveCheckoutTarget();
945
+ if (baseWorktreeOption) return resolveBaseWorktreeTarget();
894
946
  return resolveLocalTarget();
895
947
  }
896
948
 
@@ -959,6 +1011,60 @@ function untrackedStat(path) {
959
1011
  };
960
1012
  }
961
1013
 
1014
+ function baseWorktreeReplacement(path, target) {
1015
+ const directory = mkdtempSync(join(tmpdir(), 'diffsplain-base-index-'));
1016
+ const env = { ...process.env, GIT_INDEX_FILE: join(directory, 'index') };
1017
+ const diff = (args) => command('git', ['-C', repo, 'diff', ...args], { env });
1018
+
1019
+ try {
1020
+ command('git', ['-C', repo, 'read-tree', target.base], { env });
1021
+ const patch = diff([
1022
+ '--no-ext-diff',
1023
+ '--no-textconv',
1024
+ '--binary',
1025
+ '--find-renames',
1026
+ target.base,
1027
+ '--',
1028
+ path,
1029
+ ]);
1030
+ const stat = parseNumstat(
1031
+ diff([
1032
+ '--no-ext-diff',
1033
+ '--no-textconv',
1034
+ '--numstat',
1035
+ '-z',
1036
+ '--find-renames',
1037
+ target.base,
1038
+ '--',
1039
+ path,
1040
+ ]),
1041
+ ).get(path) || {
1042
+ additions: 0,
1043
+ deletions: 0,
1044
+ isBinary: false,
1045
+ };
1046
+ return { patch, stat };
1047
+ } finally {
1048
+ rmSync(directory, { recursive: true, force: true });
1049
+ }
1050
+ }
1051
+
1052
+ function reviewFileStat(file, numstat) {
1053
+ if (file.replacement) return file.replacement.stat;
1054
+ if (file.untracked) return untrackedStat(file.path);
1055
+ return (
1056
+ numstat.get(file.path) || {
1057
+ additions: 0,
1058
+ deletions: 0,
1059
+ isBinary: false,
1060
+ }
1061
+ );
1062
+ }
1063
+
1064
+ function reviewFilePatch(file, target, patches) {
1065
+ return file.replacement?.patch ?? filePatch(file, target, patches);
1066
+ }
1067
+
962
1068
  function githubFileUrl(repositoryUrl, ref, path) {
963
1069
  if (!repositoryUrl || !ref || ref === 'WORKTREE') return undefined;
964
1070
  const filePath = path.split('/').map(encodeURIComponent).join('/');
@@ -972,6 +1078,11 @@ function githubComparisonUrl(repositoryUrl, base, head) {
972
1078
  return `${repositoryUrl}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
973
1079
  }
974
1080
 
1081
+ function fingerprintFile(file) {
1082
+ return agentReviewFile(file);
1083
+ }
1084
+
1085
+ // fallow-ignore-next-line complexity -- Existing aggregation boundary; target handling stays in resolvers.
975
1086
  function build() {
976
1087
  const localWorkspace =
977
1088
  tryRepo(['rev-parse', '--is-inside-work-tree']) === 'true';
@@ -979,6 +1090,9 @@ function build() {
979
1090
  throw new Error(`${repo} is not a Git checkout`);
980
1091
  }
981
1092
  const target = resolveTarget();
1093
+ const agentExcluded = createAgentExclusionMatcher(agentExcludeRules, {
1094
+ ignoreCase: tryRepo(['config', '--bool', 'core.ignoreCase']) === 'true',
1095
+ });
982
1096
  const remoteRepository = githubRepository(target.remote?.url);
983
1097
  const summaryDoc = noSummaries ? {} : readJson(summariesPath, {}) || {};
984
1098
  const allTrackedFiles = parseNameStatus(
@@ -1008,8 +1122,12 @@ function build() {
1008
1122
  );
1009
1123
  const patches = trackedPatches(allTrackedFiles, target);
1010
1124
 
1011
- if (target.kind === 'worktree' || target.kind === 'checkout') {
1012
- const trackedPaths = new Set(nameStatus.map((file) => file.path));
1125
+ if (
1126
+ target.kind === 'worktree' ||
1127
+ target.kind === 'base-worktree' ||
1128
+ target.kind === 'checkout'
1129
+ ) {
1130
+ const trackedByPath = new Map(nameStatus.map((file) => [file.path, file]));
1013
1131
  const untracked = tryRepo([
1014
1132
  'ls-files',
1015
1133
  '--others',
@@ -1019,22 +1137,26 @@ function build() {
1019
1137
  .split('\0')
1020
1138
  .filter((path) => path && !excludedPaths.has(path));
1021
1139
  for (const path of untracked) {
1022
- if (!trackedPaths.has(path)) {
1140
+ const tracked = trackedByPath.get(path);
1141
+ if (target.kind === 'base-worktree' && tracked?.status === 'deleted') {
1142
+ const replacement = baseWorktreeReplacement(path, target);
1143
+ if (replacement.patch) {
1144
+ tracked.status = 'modified';
1145
+ tracked.replacement = replacement;
1146
+ } else {
1147
+ tracked.omit = true;
1148
+ }
1149
+ } else if (!tracked) {
1023
1150
  nameStatus.push({ path, status: 'added', untracked: true });
1024
1151
  }
1025
1152
  }
1026
1153
  nameStatus.sort((left, right) => left.path.localeCompare(right.path));
1027
1154
  }
1028
1155
 
1029
- const filesWithoutSummaries = nameStatus.map((file) => {
1030
- const stat = file.untracked
1031
- ? untrackedStat(file.path)
1032
- : numstat.get(file.path) || {
1033
- additions: 0,
1034
- deletions: 0,
1035
- isBinary: false,
1036
- };
1037
- const patch = filePatch(file, target, patches);
1156
+ const reviewFiles = nameStatus.filter((file) => !file.omit);
1157
+ const filesWithoutSummaries = reviewFiles.map((file) => {
1158
+ const stat = reviewFileStat(file, numstat);
1159
+ const patch = reviewFilePatch(file, target, patches);
1038
1160
  const binary =
1039
1161
  stat.isBinary ||
1040
1162
  patch.includes('Binary files ') ||
@@ -1065,11 +1187,16 @@ function build() {
1065
1187
  totalDiffLines: textPatch ? textPatch.split('\n').length - 1 : 0,
1066
1188
  patch: textPatch,
1067
1189
  snippet: binary ? '' : compactSnippet(textPatch),
1190
+ ...(agentExcluded(file.path) ? { agentExcluded: true } : {}),
1068
1191
  ...(sourceUrl ? { sourceUrl } : {}),
1069
1192
  ...(comparisonUrl ? { comparisonUrl } : {}),
1070
1193
  };
1071
1194
  });
1072
1195
 
1196
+ const agentFiles = filesWithoutSummaries.filter(
1197
+ (file) => !file.agentExcluded,
1198
+ );
1199
+
1073
1200
  const reviewFingerprint = createHash('sha256')
1074
1201
  .update(
1075
1202
  JSON.stringify({
@@ -1081,38 +1208,55 @@ function build() {
1081
1208
  remote: target.remote?.name,
1082
1209
  targetKind: target.kind,
1083
1210
  },
1084
- files: filesWithoutSummaries.map((file) => ({
1085
- path: file.path,
1086
- oldPath: file.oldPath,
1087
- status: file.status,
1088
- additions: file.additions,
1089
- deletions: file.deletions,
1090
- isBinary: file.isBinary,
1091
- patch: file.patch,
1092
- })),
1211
+ files: filesWithoutSummaries.map(fingerprintFile),
1093
1212
  }),
1094
1213
  )
1095
1214
  .digest('hex');
1096
- const generatedFor =
1097
- !noSummaries &&
1098
- typeof summaryDoc.meta?.reviewFingerprint === 'string'
1099
- ? summaryDoc.meta.reviewFingerprint
1215
+ const agentReviewFingerprint = createAgentReviewFingerprint({
1216
+ context: agentReviewContext({
1217
+ name: remoteRepository?.name || basename(repo),
1218
+ selector: remoteRepository?.selector,
1219
+ target: target.target,
1220
+ branch: target.branch,
1221
+ baseBranch: target.baseBranch,
1222
+ }),
1223
+ files: agentFiles,
1224
+ });
1225
+ const hasAgentReviewFingerprint =
1226
+ typeof summaryDoc.meta?.agentReviewFingerprint === 'string';
1227
+ const hasLegacyReviewFingerprint =
1228
+ !hasAgentReviewFingerprint &&
1229
+ typeof summaryDoc.meta?.reviewFingerprint === 'string';
1230
+ const emptyAgentReviewFingerprint =
1231
+ typeof summaryDoc.meta?.emptyAgentReviewFingerprint === 'string'
1232
+ ? summaryDoc.meta.emptyAgentReviewFingerprint
1100
1233
  : undefined;
1234
+ const generatedFor = !noSummaries && hasAgentReviewFingerprint
1235
+ ? agentFiles.length === 0 && emptyAgentReviewFingerprint
1236
+ ? emptyAgentReviewFingerprint
1237
+ : summaryDoc.meta.agentReviewFingerprint
1238
+ : undefined;
1101
1239
  const summariesAreFresh =
1102
- !noSummaries && (!generatedFor || generatedFor === reviewFingerprint);
1240
+ !noSummaries &&
1241
+ !hasLegacyReviewFingerprint &&
1242
+ (!generatedFor || generatedFor === agentReviewFingerprint);
1103
1243
  const sourceSummaries = summariesAreFresh ? summaryDoc : {};
1244
+ const agentPaths = new Set(agentFiles.map((file) => file.path));
1245
+ const reviewPaths = new Set(filesWithoutSummaries.map((file) => file.path));
1104
1246
  const failedFiles = summariesAreFresh
1105
- ? failedFileRecords(summaryDoc.meta?.failedFiles)
1247
+ ? failedFileRecords(summaryDoc.meta?.failedFiles).filter((failure) =>
1248
+ agentPaths.has(failure.path) || !reviewPaths.has(failure.path),
1249
+ )
1106
1250
  : [];
1107
- const summaryErrors = summariesAreFresh
1251
+ const summaryErrors = summariesAreFresh && agentFiles.length
1108
1252
  ? cleanList(summaryDoc.meta?.errors)
1109
1253
  : [];
1110
1254
  const failureByPath = new Map(
1111
1255
  failedFiles.map((failure) => [failure.path, failure.reason]),
1112
1256
  );
1113
1257
  const emptyReviewComplete =
1114
- filesWithoutSummaries.length === 0 &&
1115
- generatedFor === reviewFingerprint &&
1258
+ agentFiles.length === 0 &&
1259
+ generatedFor === agentReviewFingerprint &&
1116
1260
  sourceSummaries.meta?.status === 'complete';
1117
1261
  const summariesAreComplete =
1118
1262
  summariesAreFresh &&
@@ -1120,12 +1264,12 @@ function build() {
1120
1264
  summaryErrors.length === 0 &&
1121
1265
  (emptyReviewComplete ||
1122
1266
  (completeChangeSummary(sourceSummaries.change) &&
1123
- filesWithoutSummaries.every((file) =>
1267
+ agentFiles.every((file) =>
1124
1268
  completeFileSummary(sourceSummaries.files?.[file.path]),
1125
1269
  )));
1126
1270
  const completedFiles = noSummaries
1127
1271
  ? 0
1128
- : filesWithoutSummaries.filter((file) =>
1272
+ : agentFiles.filter((file) =>
1129
1273
  completeFileSummary(sourceSummaries.files?.[file.path]),
1130
1274
  ).length;
1131
1275
  const storedStatus = summaryDoc.meta?.status;
@@ -1140,18 +1284,28 @@ function build() {
1140
1284
  : 'idle';
1141
1285
  const files = filesWithoutSummaries.map((file) => ({
1142
1286
  ...file,
1143
- summary: fileSummary(file.path, sourceSummaries.files?.[file.path]),
1287
+ summary: fileSummary(
1288
+ file.path,
1289
+ file.agentExcluded ? undefined : sourceSummaries.files?.[file.path],
1290
+ ),
1144
1291
  noteReady: Boolean(
1145
- completeFileSummary(sourceSummaries.files?.[file.path]),
1292
+ !file.agentExcluded &&
1293
+ completeFileSummary(sourceSummaries.files?.[file.path]),
1146
1294
  ),
1147
- ...(failureByPath.has(file.path)
1295
+ ...(!file.agentExcluded && failureByPath.has(file.path)
1148
1296
  ? { noteFailure: failureByPath.get(file.path) }
1149
1297
  : {}),
1150
1298
  }));
1151
- const change = changeSummary(sourceSummaries.change, target.changeDefaults);
1299
+ const change = changeSummary(
1300
+ agentFiles.length ? sourceSummaries.change : undefined,
1301
+ target.changeDefaults,
1302
+ );
1152
1303
  const content = {
1153
1304
  repo: {
1154
1305
  name: remoteRepository?.name || basename(repo),
1306
+ ...(remoteRepository?.selector
1307
+ ? { repository: remoteRepository.selector }
1308
+ : {}),
1155
1309
  root: localWorkspace ? repo : target.remote?.url || repo,
1156
1310
  base: target.base,
1157
1311
  head: target.head,
@@ -1166,12 +1320,13 @@ function build() {
1166
1320
  files,
1167
1321
  notes: {
1168
1322
  reviewFingerprint,
1323
+ agentReviewFingerprint,
1169
1324
  ...(generatedFor ? { generatedFor } : {}),
1170
1325
  fresh: summariesAreFresh,
1171
1326
  complete: summariesAreComplete,
1172
1327
  status: noteStatus,
1173
1328
  completedFiles,
1174
- totalFiles: filesWithoutSummaries.length,
1329
+ totalFiles: agentFiles.length,
1175
1330
  ...(failedFiles.length ? { failedFiles } : {}),
1176
1331
  ...(summaryErrors.length ? { errors: summaryErrors } : {}),
1177
1332
  ...(typeof summaryDoc.meta?.model === 'string'
@@ -1183,6 +1338,9 @@ function build() {
1183
1338
  ...(typeof summaryDoc.meta?.reasoning === 'string'
1184
1339
  ? { reasoning: summaryDoc.meta.reasoning }
1185
1340
  : {}),
1341
+ ...(typeof summaryDoc.meta?.accessMode === 'string'
1342
+ ? { accessMode: summaryDoc.meta.accessMode }
1343
+ : {}),
1186
1344
  },
1187
1345
  };
1188
1346
  const version = createHash('sha256')
@@ -1268,8 +1426,11 @@ function fingerprint() {
1268
1426
  ].join('|');
1269
1427
  }
1270
1428
  const content = createHash('sha256');
1429
+ const base = baseWorktreeOption
1430
+ ? resolveBaseWorktreeCommit(repo, baseOption)
1431
+ : 'HEAD';
1271
1432
  content.update(
1272
- tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--']),
1433
+ tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', base, '--']),
1273
1434
  );
1274
1435
  const untracked = tryRepo([
1275
1436
  'ls-files',
@@ -1284,6 +1445,7 @@ function fingerprint() {
1284
1445
  fingerprintUntrackedPath(content, path);
1285
1446
  }
1286
1447
  return [
1448
+ baseWorktreeOption ? base : undefined,
1287
1449
  tryRepo(['rev-parse', 'HEAD']),
1288
1450
  tryRepo(['status', '--porcelain=v1', '--untracked-files=all']),
1289
1451
  content.digest('hex'),
@@ -29,6 +29,7 @@ export const cliOptions = defineCliOptions({
29
29
  '--cache-dir': { kind: 'value', path: true },
30
30
  '--codex-bin': { kind: 'value', path: true },
31
31
  '--support-record-file': { kind: 'value', path: true },
32
+ '--exclude': { kind: 'repeatable-value' },
32
33
  '--model': { kind: 'value' },
33
34
  '--reasoning': { kind: 'value' },
34
35
  '--batch-size': { kind: 'value', default: 12, min: 1, max: 50 },
@@ -39,6 +40,7 @@ export const cliOptions = defineCliOptions({
39
40
  '--version': { kind: 'flag' },
40
41
  '--agent': { kind: 'agent' },
41
42
  '--no-agent': { kind: 'no-agent' },
43
+ '--no-checkout-access': { kind: 'flag' },
42
44
  '--force': { kind: 'flag' },
43
45
  '--worktree': { kind: 'flag' },
44
46
  '--no-browser': { kind: 'flag' },
@@ -55,6 +57,11 @@ const flagOptions = new Set(
55
57
  .filter(([, record]) => record.kind === 'flag')
56
58
  .map(([name]) => name),
57
59
  );
60
+ const repeatableValueOptions = new Set(
61
+ Object.entries(cliOptions)
62
+ .filter(([, record]) => record.kind === 'repeatable-value')
63
+ .map(([name]) => name),
64
+ );
58
65
  const pathOptions = new Set(
59
66
  Object.entries(cliOptions)
60
67
  .filter(([, record]) => record.path)
@@ -75,19 +82,24 @@ Commands:
75
82
  doctor [--json] [--deep]
76
83
  Check review, agent, and pull request capabilities
77
84
  cache Show or prune saved agent notes
85
+ config agent [NAME|--unset]
86
+ Show, set, or unset the default coding agent
78
87
 
79
88
  Targets:
80
89
  --branch NAME Show a remote branch against its default branch
81
90
  --pr NUMBER|URL Show a GitHub pull request
82
91
  --worktree Show only worktree changes against HEAD
83
- --base REF --head REF
84
- Show an exact local Git range
92
+ --base REF [--head REF]
93
+ Compare a base with the working tree, or show an exact range
85
94
 
86
95
  Options:
87
96
  --repo PATH|URL|OWNER/NAME
88
97
  Repo to review (default: current repo)
89
98
  --agent NAME Use codex, claude, copilot, cursor, or opencode
90
99
  --no-agent Do not write agent notes
100
+ --no-checkout-access
101
+ Limit agent notes to the supplied snapshot
102
+ --exclude PATTERN Omit matching files from automatic agent context
91
103
  --model NAME Model for agent notes
92
104
  --reasoning LEVEL Agent reasoning effort when supported
93
105
  --batch-size COUNT Maximum files per agent pass (default: ${batchSizeOption.default})
@@ -107,8 +119,9 @@ Options:
107
119
  -h, --help Show this help
108
120
  -v, --version Show the installed version
109
121
 
110
- Automatic agent selection:
111
- codex, claude, copilot, cursor, opencode
122
+ Agent choice:
123
+ Choice order is --no-agent, --agent, then the configured default.
124
+ Without any choice, an interactive terminal lists usable agents.
112
125
 
113
126
  Cursor:
114
127
  Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
@@ -184,6 +197,31 @@ export function parseCliArgs(
184
197
  pathExists = existsSync,
185
198
  } = {},
186
199
  ) {
200
+ if (rawArgs[0] === 'config') {
201
+ if (rawArgs[1] !== 'agent') {
202
+ fail('Use: diffsplain config agent [NAME|--unset]');
203
+ }
204
+ if (rawArgs.length === 2) {
205
+ return { config: { kind: 'show' } };
206
+ }
207
+ if (rawArgs.length !== 3) {
208
+ fail('Use: diffsplain config agent [NAME|--unset]');
209
+ }
210
+ const value = rawArgs[2];
211
+ if (value === '--unset') {
212
+ return { config: { kind: 'unset' } };
213
+ }
214
+ if (value.startsWith('--')) {
215
+ fail('Use: diffsplain config agent [NAME|--unset]');
216
+ }
217
+ if (!codingAgents.includes(value)) {
218
+ fail(
219
+ `Unsupported agent "${value}". Choose ${enabledCodingAgents.join(', ')}.`,
220
+ );
221
+ }
222
+ return { config: { kind: 'set', agent: value } };
223
+ }
224
+
187
225
  if (rawArgs[0] === 'doctor') {
188
226
  const options = new Set(rawArgs.slice(1));
189
227
  for (const option of options) {
@@ -199,6 +237,7 @@ export function parseCliArgs(
199
237
 
200
238
  const options = new Map();
201
239
  const positionals = [];
240
+ const excludePatterns = [];
202
241
  let agent;
203
242
  let agentSet = false;
204
243
  let noAgent = false;
@@ -246,10 +285,13 @@ export function parseCliArgs(
246
285
  continue;
247
286
  }
248
287
 
249
- if (!valueOptions.has(parsed.name)) {
288
+ const repeatable = repeatableValueOptions.has(parsed.name);
289
+ if (!repeatable && !valueOptions.has(parsed.name)) {
250
290
  fail(`Unknown option: ${parsed.name}`);
251
291
  }
252
- if (options.has(parsed.name)) fail(`${parsed.name} was passed more than once`);
292
+ if (!repeatable && options.has(parsed.name)) {
293
+ fail(`${parsed.name} was passed more than once`);
294
+ }
253
295
 
254
296
  let value = parsed.value;
255
297
  if (value === undefined) {
@@ -258,7 +300,8 @@ export function parseCliArgs(
258
300
  index += 1;
259
301
  }
260
302
  if (!value) fail(`${parsed.name} needs a value`);
261
- options.set(parsed.name, value);
303
+ if (repeatable) excludePatterns.push(value);
304
+ else options.set(parsed.name, value);
262
305
  }
263
306
 
264
307
  if (options.has('--help')) return { help: true };
@@ -303,8 +346,8 @@ export function parseCliArgs(
303
346
  if (worktree && (branch || pullRequest || base || head)) {
304
347
  fail('--worktree cannot be combined with another target');
305
348
  }
306
- if (!branch && !pullRequest && !worktree && Boolean(base) !== Boolean(head)) {
307
- fail('--base and --head must be used together');
349
+ if (!branch && !pullRequest && !worktree && head && !base) {
350
+ fail('--head must be used with --base');
308
351
  }
309
352
  if (pullRequest && !validPullRequest(pullRequest)) {
310
353
  fail('--pr must be a positive number or a pull request URL');
@@ -359,6 +402,9 @@ export function parseCliArgs(
359
402
  }
360
403
 
361
404
  const feedArgs = [...commonArgs];
405
+ for (const pattern of excludePatterns) {
406
+ feedArgs.push(`--exclude=${pattern}`);
407
+ }
362
408
  const supportRecordFile = options.get('--support-record-file');
363
409
  if (supportRecordFile) {
364
410
  feedArgs.push(
@@ -367,6 +413,9 @@ export function parseCliArgs(
367
413
  );
368
414
  }
369
415
  const agentArgs = [...commonArgs];
416
+ for (const pattern of excludePatterns) {
417
+ agentArgs.push(`--exclude=${pattern}`);
418
+ }
370
419
  if (options.has('--force')) agentArgs.push('--force');
371
420
  for (const name of [
372
421
  '--codex-bin',
@@ -382,6 +431,9 @@ export function parseCliArgs(
382
431
  }
383
432
  }
384
433
  if (!noAgent) {
434
+ if (options.has('--no-checkout-access')) {
435
+ agentArgs.push('--no-checkout-access');
436
+ }
385
437
  for (const name of ['--batch-size', '--jobs']) {
386
438
  agentArgs.push(
387
439
  name,
@@ -458,6 +510,8 @@ export function parseCliArgs(
458
510
  version: false,
459
511
  agentEnabled: !noAgent,
460
512
  agent,
513
+ noCheckoutAccess: options.has('--no-checkout-access'),
514
+ excludePatterns,
461
515
  model: options.get('--model'),
462
516
  reasoning,
463
517
  codexBin: options.get('--codex-bin'),