diffsplain 0.8.2 → 0.9.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 (93) hide show
  1. package/README.md +10 -5
  2. package/dist/assets/{angular-html-CphzFC_v.js → angular-html-m_m91ZYB.js} +1 -1
  3. package/dist/assets/{angular-ts-Am0-mbFw.js → angular-ts-CO5Z--Rb.js} +1 -1
  4. package/dist/assets/{apl-Cs2rPKDN.js → apl-ogYYgLUu.js} +1 -1
  5. package/dist/assets/{astro-CxKUUn9h.js → astro-DAfY3cNR.js} +1 -1
  6. package/dist/assets/{blade-DqeCa6v5.js → blade-E6RzsLUV.js} +1 -1
  7. package/dist/assets/{c-DdI6lknk.js → c-kKdxhtHF.js} +1 -1
  8. package/dist/assets/{cobol-COuAGE2X.js → cobol-3oDDyFNf.js} +1 -1
  9. package/dist/assets/{coffee-BFlYT3Lp.js → coffee-dsiILuWe.js} +1 -1
  10. package/dist/assets/{cpp-nywLU0Np.js → cpp-DYyNFq__.js} +1 -1
  11. package/dist/assets/{crystal-BWp4dz1s.js → crystal-B7dVp9TF.js} +1 -1
  12. package/dist/assets/{css-CRb9tUpU.js → css-DlxJ0aex.js} +1 -1
  13. package/dist/assets/{edge-rUYSbJy4.js → edge-BjEVphkC.js} +1 -1
  14. package/dist/assets/{elixir-DOq9ybIH.js → elixir-0xTOtMcM.js} +1 -1
  15. package/dist/assets/{elm-C8e38E5-.js → elm-iQk_Wrxq.js} +1 -1
  16. package/dist/assets/{erb-BR9Q37Et.js → erb-BO74ztiE.js} +1 -1
  17. package/dist/assets/{git-rebase-Coo2LUtB.js → git-rebase-BN667QRY.js} +1 -1
  18. package/dist/assets/{glimmer-js-C7hX9jg6.js → glimmer-js-DTGUIHeS.js} +1 -1
  19. package/dist/assets/{glimmer-ts-CZLT0F0k.js → glimmer-ts-BU3oQn2d.js} +1 -1
  20. package/dist/assets/{glsl-BDZRv6e-.js → glsl-KLiqLJi2.js} +1 -1
  21. package/dist/assets/{graphql-DyzFc3y3.js → graphql-D_idcsoJ.js} +1 -1
  22. package/dist/assets/{hack-CWnO7WsF.js → hack-BXlsgPIr.js} +1 -1
  23. package/dist/assets/{haml-CvDj-ghO.js → haml-fP0aUg4-.js} +1 -1
  24. package/dist/assets/{handlebars-BnU_YcVA.js → handlebars-C1ZEaot-.js} +1 -1
  25. package/dist/assets/{html-CjB2TKKa.js → html-D04qIuup.js} +1 -1
  26. package/dist/assets/{html-derivative-CYQqEXS6.js → html-derivative-C6ai-9QL.js} +1 -1
  27. package/dist/assets/{http-NNQbnqIx.js → http-C7bz3ZvQ.js} +1 -1
  28. package/dist/assets/{hurl-DHaAA1BS.js → hurl-jL6B6Ahr.js} +1 -1
  29. package/dist/assets/index-BPaJGi-m.js +1574 -0
  30. package/dist/assets/index-VAyBpb3u.css +2 -0
  31. package/dist/assets/{java-D1J5V2CD.js → java-BVzvLCON.js} +1 -1
  32. package/dist/assets/{javascript-C3pX-HsE.js → javascript-UDbZHxCI.js} +1 -1
  33. package/dist/assets/{jinja-BY9CIY8g.js → jinja-DB-QKo2A.js} +1 -1
  34. package/dist/assets/{jison-Cr0H89gS.js → jison-B1ZbR2FF.js} +1 -1
  35. package/dist/assets/{json-CA3kKNfq.js → json-DV4WwJzT.js} +1 -1
  36. package/dist/assets/{jsx-BuMkR-Lj.js → jsx-DS6_VTvW.js} +1 -1
  37. package/dist/assets/{julia-C2sLwg3f.js → julia-C4tevET0.js} +1 -1
  38. package/dist/assets/{just-pXpkLBXv.js → just-CWMjefzB.js} +1 -1
  39. package/dist/assets/{latex-7v3EPOdF.js → latex-tyEFf_Af.js} +1 -1
  40. package/dist/assets/{liquid-CJZq0Z0t.js → liquid-C3d1X1rL.js} +1 -1
  41. package/dist/assets/{lua-BimBzvTH.js → lua-sXvrzLQY.js} +1 -1
  42. package/dist/assets/{marko-CCZHVnQN.js → marko-CQweucif.js} +1 -1
  43. package/dist/assets/{mdc-SLECNw2A.js → mdc-TrxLWF7s.js} +1 -1
  44. package/dist/assets/{nginx-DUwtUge2.js → nginx-ChE2nSNJ.js} +1 -1
  45. package/dist/assets/{nim-HD8o_Omp.js → nim-BY1CTRfx.js} +1 -1
  46. package/dist/assets/{perl-BGMeIkNk.js → perl-Cvj2PQQH.js} +1 -1
  47. package/dist/assets/{php-BL5xuY-v.js → php-CGmHp8HW.js} +1 -1
  48. package/dist/assets/{pug-C8G1U_u8.js → pug-DM4S0bRr.js} +1 -1
  49. package/dist/assets/{qml-BGVSyw1x.js → qml-DI89Al9K.js} +1 -1
  50. package/dist/assets/{r-BDnRcclC.js → r-Bs5_4yYh.js} +1 -1
  51. package/dist/assets/{razor-I8tbEIVJ.js → razor-DimzayhZ.js} +1 -1
  52. package/dist/assets/{regexp-D0yakB9Q.js → regexp-BLACz0cx.js} +1 -1
  53. package/dist/assets/{rst-DbTCn07T.js → rst-DcWzxQh2.js} +1 -1
  54. package/dist/assets/{ruby-BwgPFIcO.js → ruby-Dlat5E8d.js} +1 -1
  55. package/dist/assets/{sas-dN4ePfXU.js → sas-CCDmMaBY.js} +1 -1
  56. package/dist/assets/{scss-T7M1A4iM.js → scss-BPiU2nu6.js} +1 -1
  57. package/dist/assets/{shellscript-HFjBHS7_.js → shellscript-CKinVNrN.js} +1 -1
  58. package/dist/assets/{shellsession-DCAGskrx.js → shellsession-Cea8Pu5d.js} +1 -1
  59. package/dist/assets/{soy-BgT96TF9.js → soy--BRAPY4-.js} +1 -1
  60. package/dist/assets/{sql-DCIQeRzx.js → sql-DjKiCyWK.js} +1 -1
  61. package/dist/assets/{stata-Dn0tad0_.js → stata-CDvo3J6y.js} +1 -1
  62. package/dist/assets/{surrealql-CCgFEQfW.js → surrealql-C7b7E5sh.js} +1 -1
  63. package/dist/assets/{svelte-ITXimG8u.js → svelte-DO0tKtiK.js} +1 -1
  64. package/dist/assets/{templ-DJVZZqPw.js → templ-DhhmR_rv.js} +1 -1
  65. package/dist/assets/{tex-Bn4N-OtH.js → tex-Cn_V-Gg4.js} +1 -1
  66. package/dist/assets/{ts-tags-CxQ4PoC4.js → ts-tags-B6c-FTmA.js} +1 -1
  67. package/dist/assets/{tsx-CxwgcjkC.js → tsx-Cvtt2sJ-.js} +1 -1
  68. package/dist/assets/{twig-CtbyTyMH.js → twig-B7ZbCUmB.js} +1 -1
  69. package/dist/assets/{typescript-d6ru12L8.js → typescript-i3C6um2B.js} +1 -1
  70. package/dist/assets/{vue-RvqAsYDm.js → vue-DyPX0Vum.js} +1 -1
  71. package/dist/assets/{vue-html-BkHDVENe.js → vue-html-D362KpK3.js} +1 -1
  72. package/dist/assets/{vue-vine-DvS3Mlkn.js → vue-vine-D34cBbwO.js} +1 -1
  73. package/dist/assets/{xml-lzTq5A-V.js → xml-DJEk7u4n.js} +1 -1
  74. package/dist/assets/{xsl-BL0KXNWI.js → xsl-CoTtf90f.js} +1 -1
  75. package/dist/assets/{yaml-CF4XlBJS.js → yaml-Cv5epQ4-.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +11 -3
  78. package/scripts/agent-exclusions.mjs +10 -0
  79. package/scripts/agent-review.mjs +94 -0
  80. package/scripts/build-diff-data.mjs +235 -50
  81. package/scripts/cli-args.mjs +36 -9
  82. package/scripts/coding-agents.mjs +206 -79
  83. package/scripts/generate-summaries.mjs +864 -425
  84. package/scripts/local-target.mjs +106 -0
  85. package/scripts/present.mjs +205 -72
  86. package/scripts/review-chat-context.mjs +510 -0
  87. package/scripts/review-chat-controller.mjs +568 -0
  88. package/scripts/review-chat-provider.mjs +352 -0
  89. package/scripts/review-chat.mjs +8 -0
  90. package/scripts/serve-built.mjs +206 -24
  91. package/scripts/summary-path.mjs +32 -11
  92. package/dist/assets/index-C2p_1iAk.css +0 -2
  93. package/dist/assets/index-f9ekN94b.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) => {
@@ -609,8 +630,31 @@ function pullRequestInfo(pr, remote) {
609
630
  return { value, repository };
610
631
  } catch (error) {
611
632
  const detail = error?.stderr?.toString().trim() || error?.message;
633
+ if (
634
+ repository &&
635
+ /Could not resolve to a PullRequest with the number of/i.test(detail)
636
+ ) {
637
+ const prNumber = String(pr).replace(
638
+ /^.*\/pull\/(\d+)(?:\/.*)?$/,
639
+ "$1",
640
+ );
641
+ const repositoryShape = repository.selector.replace(
642
+ repository.name,
643
+ "owner/repo",
644
+ );
645
+ const pullRequestUrl = `${repository.webUrl.replace(repository.name, "owner/repo")}/pull/${prNumber}`;
646
+ throw new Error(
647
+ `Pull request ${prNumber} was not found in ${repository.selector}. Pass ${repositoryShape} before --pr ${prNumber}, or pass ${pullRequestUrl}.`,
648
+ );
649
+ }
650
+ const authHint =
651
+ /(?:HTTP 401|Bad credentials|authentication failed|not logged (?:in|into)|gh auth login)/i.test(
652
+ detail,
653
+ )
654
+ ? ' Check gh auth status.'
655
+ : '';
612
656
  throw new Error(
613
- `Could not read pull request ${pr} with gh${detail ? `: ${detail}` : ''}. Check gh auth status.`,
657
+ `Could not read pull request ${pr} with gh${detail ? `: ${detail}` : ''}.${authHint}`,
614
658
  );
615
659
  }
616
660
  }
@@ -806,6 +850,36 @@ function resolveCheckoutTarget() {
806
850
  };
807
851
  }
808
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
+
809
883
  function resolveLocalTarget() {
810
884
  const currentHead = tryRepo(['rev-parse', '--verify', 'HEAD']);
811
885
  const worktree = !baseOption && !headOption;
@@ -868,6 +942,7 @@ function resolveTarget() {
868
942
  if (prOption) return resolvePullRequestTarget();
869
943
  if (branchOption) return resolveBranchTarget();
870
944
  if (checkoutOption) return resolveCheckoutTarget();
945
+ if (baseWorktreeOption) return resolveBaseWorktreeTarget();
871
946
  return resolveLocalTarget();
872
947
  }
873
948
 
@@ -936,6 +1011,60 @@ function untrackedStat(path) {
936
1011
  };
937
1012
  }
938
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
+
939
1068
  function githubFileUrl(repositoryUrl, ref, path) {
940
1069
  if (!repositoryUrl || !ref || ref === 'WORKTREE') return undefined;
941
1070
  const filePath = path.split('/').map(encodeURIComponent).join('/');
@@ -949,6 +1078,11 @@ function githubComparisonUrl(repositoryUrl, base, head) {
949
1078
  return `${repositoryUrl}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
950
1079
  }
951
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.
952
1086
  function build() {
953
1087
  const localWorkspace =
954
1088
  tryRepo(['rev-parse', '--is-inside-work-tree']) === 'true';
@@ -956,6 +1090,9 @@ function build() {
956
1090
  throw new Error(`${repo} is not a Git checkout`);
957
1091
  }
958
1092
  const target = resolveTarget();
1093
+ const agentExcluded = createAgentExclusionMatcher(agentExcludeRules, {
1094
+ ignoreCase: tryRepo(['config', '--bool', 'core.ignoreCase']) === 'true',
1095
+ });
959
1096
  const remoteRepository = githubRepository(target.remote?.url);
960
1097
  const summaryDoc = noSummaries ? {} : readJson(summariesPath, {}) || {};
961
1098
  const allTrackedFiles = parseNameStatus(
@@ -985,8 +1122,12 @@ function build() {
985
1122
  );
986
1123
  const patches = trackedPatches(allTrackedFiles, target);
987
1124
 
988
- if (target.kind === 'worktree' || target.kind === 'checkout') {
989
- 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]));
990
1131
  const untracked = tryRepo([
991
1132
  'ls-files',
992
1133
  '--others',
@@ -996,22 +1137,26 @@ function build() {
996
1137
  .split('\0')
997
1138
  .filter((path) => path && !excludedPaths.has(path));
998
1139
  for (const path of untracked) {
999
- 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) {
1000
1150
  nameStatus.push({ path, status: 'added', untracked: true });
1001
1151
  }
1002
1152
  }
1003
1153
  nameStatus.sort((left, right) => left.path.localeCompare(right.path));
1004
1154
  }
1005
1155
 
1006
- const filesWithoutSummaries = nameStatus.map((file) => {
1007
- const stat = file.untracked
1008
- ? untrackedStat(file.path)
1009
- : numstat.get(file.path) || {
1010
- additions: 0,
1011
- deletions: 0,
1012
- isBinary: false,
1013
- };
1014
- 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);
1015
1160
  const binary =
1016
1161
  stat.isBinary ||
1017
1162
  patch.includes('Binary files ') ||
@@ -1042,11 +1187,16 @@ function build() {
1042
1187
  totalDiffLines: textPatch ? textPatch.split('\n').length - 1 : 0,
1043
1188
  patch: textPatch,
1044
1189
  snippet: binary ? '' : compactSnippet(textPatch),
1190
+ ...(agentExcluded(file.path) ? { agentExcluded: true } : {}),
1045
1191
  ...(sourceUrl ? { sourceUrl } : {}),
1046
1192
  ...(comparisonUrl ? { comparisonUrl } : {}),
1047
1193
  };
1048
1194
  });
1049
1195
 
1196
+ const agentFiles = filesWithoutSummaries.filter(
1197
+ (file) => !file.agentExcluded,
1198
+ );
1199
+
1050
1200
  const reviewFingerprint = createHash('sha256')
1051
1201
  .update(
1052
1202
  JSON.stringify({
@@ -1058,38 +1208,55 @@ function build() {
1058
1208
  remote: target.remote?.name,
1059
1209
  targetKind: target.kind,
1060
1210
  },
1061
- files: filesWithoutSummaries.map((file) => ({
1062
- path: file.path,
1063
- oldPath: file.oldPath,
1064
- status: file.status,
1065
- additions: file.additions,
1066
- deletions: file.deletions,
1067
- isBinary: file.isBinary,
1068
- patch: file.patch,
1069
- })),
1211
+ files: filesWithoutSummaries.map(fingerprintFile),
1070
1212
  }),
1071
1213
  )
1072
1214
  .digest('hex');
1073
- const generatedFor =
1074
- !noSummaries &&
1075
- typeof summaryDoc.meta?.reviewFingerprint === 'string'
1076
- ? 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
1077
1233
  : undefined;
1234
+ const generatedFor = !noSummaries && hasAgentReviewFingerprint
1235
+ ? agentFiles.length === 0 && emptyAgentReviewFingerprint
1236
+ ? emptyAgentReviewFingerprint
1237
+ : summaryDoc.meta.agentReviewFingerprint
1238
+ : undefined;
1078
1239
  const summariesAreFresh =
1079
- !noSummaries && (!generatedFor || generatedFor === reviewFingerprint);
1240
+ !noSummaries &&
1241
+ !hasLegacyReviewFingerprint &&
1242
+ (!generatedFor || generatedFor === agentReviewFingerprint);
1080
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));
1081
1246
  const failedFiles = summariesAreFresh
1082
- ? failedFileRecords(summaryDoc.meta?.failedFiles)
1247
+ ? failedFileRecords(summaryDoc.meta?.failedFiles).filter((failure) =>
1248
+ agentPaths.has(failure.path) || !reviewPaths.has(failure.path),
1249
+ )
1083
1250
  : [];
1084
- const summaryErrors = summariesAreFresh
1251
+ const summaryErrors = summariesAreFresh && agentFiles.length
1085
1252
  ? cleanList(summaryDoc.meta?.errors)
1086
1253
  : [];
1087
1254
  const failureByPath = new Map(
1088
1255
  failedFiles.map((failure) => [failure.path, failure.reason]),
1089
1256
  );
1090
1257
  const emptyReviewComplete =
1091
- filesWithoutSummaries.length === 0 &&
1092
- generatedFor === reviewFingerprint &&
1258
+ agentFiles.length === 0 &&
1259
+ generatedFor === agentReviewFingerprint &&
1093
1260
  sourceSummaries.meta?.status === 'complete';
1094
1261
  const summariesAreComplete =
1095
1262
  summariesAreFresh &&
@@ -1097,12 +1264,12 @@ function build() {
1097
1264
  summaryErrors.length === 0 &&
1098
1265
  (emptyReviewComplete ||
1099
1266
  (completeChangeSummary(sourceSummaries.change) &&
1100
- filesWithoutSummaries.every((file) =>
1267
+ agentFiles.every((file) =>
1101
1268
  completeFileSummary(sourceSummaries.files?.[file.path]),
1102
1269
  )));
1103
1270
  const completedFiles = noSummaries
1104
1271
  ? 0
1105
- : filesWithoutSummaries.filter((file) =>
1272
+ : agentFiles.filter((file) =>
1106
1273
  completeFileSummary(sourceSummaries.files?.[file.path]),
1107
1274
  ).length;
1108
1275
  const storedStatus = summaryDoc.meta?.status;
@@ -1117,18 +1284,28 @@ function build() {
1117
1284
  : 'idle';
1118
1285
  const files = filesWithoutSummaries.map((file) => ({
1119
1286
  ...file,
1120
- summary: fileSummary(file.path, sourceSummaries.files?.[file.path]),
1287
+ summary: fileSummary(
1288
+ file.path,
1289
+ file.agentExcluded ? undefined : sourceSummaries.files?.[file.path],
1290
+ ),
1121
1291
  noteReady: Boolean(
1122
- completeFileSummary(sourceSummaries.files?.[file.path]),
1292
+ !file.agentExcluded &&
1293
+ completeFileSummary(sourceSummaries.files?.[file.path]),
1123
1294
  ),
1124
- ...(failureByPath.has(file.path)
1295
+ ...(!file.agentExcluded && failureByPath.has(file.path)
1125
1296
  ? { noteFailure: failureByPath.get(file.path) }
1126
1297
  : {}),
1127
1298
  }));
1128
- const change = changeSummary(sourceSummaries.change, target.changeDefaults);
1299
+ const change = changeSummary(
1300
+ agentFiles.length ? sourceSummaries.change : undefined,
1301
+ target.changeDefaults,
1302
+ );
1129
1303
  const content = {
1130
1304
  repo: {
1131
1305
  name: remoteRepository?.name || basename(repo),
1306
+ ...(remoteRepository?.selector
1307
+ ? { repository: remoteRepository.selector }
1308
+ : {}),
1132
1309
  root: localWorkspace ? repo : target.remote?.url || repo,
1133
1310
  base: target.base,
1134
1311
  head: target.head,
@@ -1143,12 +1320,13 @@ function build() {
1143
1320
  files,
1144
1321
  notes: {
1145
1322
  reviewFingerprint,
1323
+ agentReviewFingerprint,
1146
1324
  ...(generatedFor ? { generatedFor } : {}),
1147
1325
  fresh: summariesAreFresh,
1148
1326
  complete: summariesAreComplete,
1149
1327
  status: noteStatus,
1150
1328
  completedFiles,
1151
- totalFiles: filesWithoutSummaries.length,
1329
+ totalFiles: agentFiles.length,
1152
1330
  ...(failedFiles.length ? { failedFiles } : {}),
1153
1331
  ...(summaryErrors.length ? { errors: summaryErrors } : {}),
1154
1332
  ...(typeof summaryDoc.meta?.model === 'string'
@@ -1160,6 +1338,9 @@ function build() {
1160
1338
  ...(typeof summaryDoc.meta?.reasoning === 'string'
1161
1339
  ? { reasoning: summaryDoc.meta.reasoning }
1162
1340
  : {}),
1341
+ ...(typeof summaryDoc.meta?.accessMode === 'string'
1342
+ ? { accessMode: summaryDoc.meta.accessMode }
1343
+ : {}),
1163
1344
  },
1164
1345
  };
1165
1346
  const version = createHash('sha256')
@@ -1245,8 +1426,11 @@ function fingerprint() {
1245
1426
  ].join('|');
1246
1427
  }
1247
1428
  const content = createHash('sha256');
1429
+ const base = baseWorktreeOption
1430
+ ? resolveBaseWorktreeCommit(repo, baseOption)
1431
+ : 'HEAD';
1248
1432
  content.update(
1249
- tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--']),
1433
+ tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', base, '--']),
1250
1434
  );
1251
1435
  const untracked = tryRepo([
1252
1436
  'ls-files',
@@ -1261,6 +1445,7 @@ function fingerprint() {
1261
1445
  fingerprintUntrackedPath(content, path);
1262
1446
  }
1263
1447
  return [
1448
+ baseWorktreeOption ? base : undefined,
1264
1449
  tryRepo(['rev-parse', 'HEAD']),
1265
1450
  tryRepo(['status', '--porcelain=v1', '--untracked-files=all']),
1266
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)
@@ -80,14 +87,17 @@ Targets:
80
87
  --branch NAME Show a remote branch against its default branch
81
88
  --pr NUMBER|URL Show a GitHub pull request
82
89
  --worktree Show only worktree changes against HEAD
83
- --base REF --head REF
84
- Show an exact local Git range
90
+ --base REF [--head REF]
91
+ Compare a base with the working tree, or show an exact range
85
92
 
86
93
  Options:
87
94
  --repo PATH|URL|OWNER/NAME
88
95
  Repo to review (default: current repo)
89
96
  --agent NAME Use codex, claude, copilot, cursor, or opencode
90
97
  --no-agent Do not write agent notes
98
+ --no-checkout-access
99
+ Limit agent notes to the supplied snapshot
100
+ --exclude PATTERN Omit matching files from automatic agent context
91
101
  --model NAME Model for agent notes
92
102
  --reasoning LEVEL Agent reasoning effort when supported
93
103
  --batch-size COUNT Maximum files per agent pass (default: ${batchSizeOption.default})
@@ -107,8 +117,9 @@ Options:
107
117
  -h, --help Show this help
108
118
  -v, --version Show the installed version
109
119
 
110
- Automatic agent selection:
111
- codex, claude, copilot, cursor, opencode
120
+ Agent choice:
121
+ Without --agent, an interactive terminal lists usable agents. In scripts,
122
+ pass --agent NAME or --no-agent.
112
123
 
113
124
  Cursor:
114
125
  Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
@@ -199,6 +210,7 @@ export function parseCliArgs(
199
210
 
200
211
  const options = new Map();
201
212
  const positionals = [];
213
+ const excludePatterns = [];
202
214
  let agent;
203
215
  let agentSet = false;
204
216
  let noAgent = false;
@@ -246,10 +258,13 @@ export function parseCliArgs(
246
258
  continue;
247
259
  }
248
260
 
249
- if (!valueOptions.has(parsed.name)) {
261
+ const repeatable = repeatableValueOptions.has(parsed.name);
262
+ if (!repeatable && !valueOptions.has(parsed.name)) {
250
263
  fail(`Unknown option: ${parsed.name}`);
251
264
  }
252
- if (options.has(parsed.name)) fail(`${parsed.name} was passed more than once`);
265
+ if (!repeatable && options.has(parsed.name)) {
266
+ fail(`${parsed.name} was passed more than once`);
267
+ }
253
268
 
254
269
  let value = parsed.value;
255
270
  if (value === undefined) {
@@ -258,7 +273,8 @@ export function parseCliArgs(
258
273
  index += 1;
259
274
  }
260
275
  if (!value) fail(`${parsed.name} needs a value`);
261
- options.set(parsed.name, value);
276
+ if (repeatable) excludePatterns.push(value);
277
+ else options.set(parsed.name, value);
262
278
  }
263
279
 
264
280
  if (options.has('--help')) return { help: true };
@@ -303,8 +319,8 @@ export function parseCliArgs(
303
319
  if (worktree && (branch || pullRequest || base || head)) {
304
320
  fail('--worktree cannot be combined with another target');
305
321
  }
306
- if (!branch && !pullRequest && !worktree && Boolean(base) !== Boolean(head)) {
307
- fail('--base and --head must be used together');
322
+ if (!branch && !pullRequest && !worktree && head && !base) {
323
+ fail('--head must be used with --base');
308
324
  }
309
325
  if (pullRequest && !validPullRequest(pullRequest)) {
310
326
  fail('--pr must be a positive number or a pull request URL');
@@ -359,6 +375,9 @@ export function parseCliArgs(
359
375
  }
360
376
 
361
377
  const feedArgs = [...commonArgs];
378
+ for (const pattern of excludePatterns) {
379
+ feedArgs.push(`--exclude=${pattern}`);
380
+ }
362
381
  const supportRecordFile = options.get('--support-record-file');
363
382
  if (supportRecordFile) {
364
383
  feedArgs.push(
@@ -367,6 +386,9 @@ export function parseCliArgs(
367
386
  );
368
387
  }
369
388
  const agentArgs = [...commonArgs];
389
+ for (const pattern of excludePatterns) {
390
+ agentArgs.push(`--exclude=${pattern}`);
391
+ }
370
392
  if (options.has('--force')) agentArgs.push('--force');
371
393
  for (const name of [
372
394
  '--codex-bin',
@@ -382,6 +404,9 @@ export function parseCliArgs(
382
404
  }
383
405
  }
384
406
  if (!noAgent) {
407
+ if (options.has('--no-checkout-access')) {
408
+ agentArgs.push('--no-checkout-access');
409
+ }
385
410
  for (const name of ['--batch-size', '--jobs']) {
386
411
  agentArgs.push(
387
412
  name,
@@ -458,6 +483,8 @@ export function parseCliArgs(
458
483
  version: false,
459
484
  agentEnabled: !noAgent,
460
485
  agent,
486
+ noCheckoutAccess: options.has('--no-checkout-access'),
487
+ excludePatterns,
461
488
  model: options.get('--model'),
462
489
  reasoning,
463
490
  codexBin: options.get('--codex-bin'),