@in-the-loop-labs/pair-review 3.5.1 → 3.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
@@ -0,0 +1,161 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * @typedef {Object} Hunk
7
+ * @property {string} header - Hunk header line, e.g. "@@ -10,5 +10,7 @@".
8
+ * @property {string[]} lines - Diff lines including their leading marker
9
+ * ('+', '-', ' ', or the literal '\' marker).
10
+ */
11
+
12
+ const LOCKFILE_BASENAMES = new Set([
13
+ 'package-lock.json',
14
+ 'pnpm-lock.yaml',
15
+ 'yarn.lock',
16
+ 'Cargo.lock',
17
+ 'Pipfile.lock',
18
+ 'poetry.lock',
19
+ 'composer.lock',
20
+ 'go.sum'
21
+ ]);
22
+
23
+ const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
24
+ const PYTHON_EXTENSIONS = new Set(['.py']);
25
+
26
+ const JS_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b|(?:const|let|var)\s+\w+\s*=\s*require\()/;
27
+ const PY_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b)/;
28
+ const PACKAGE_JSON_VERSION_PATTERN = /^"([^"]+)"\s*:\s*"[~^>=<]*\d[\w.\-+*]*"\,?\s*$/;
29
+
30
+ /**
31
+ * SHA-256 hex of `${filePath}\n${hunkContent}`.
32
+ * @param {string} filePath
33
+ * @param {string} hunkContent
34
+ * @returns {string}
35
+ */
36
+ function hashHunk(filePath, hunkContent) {
37
+ return crypto.createHash('sha256').update(`${filePath}\n${hunkContent}`).digest('hex');
38
+ }
39
+
40
+ function getExtension(filePath) {
41
+ const slash = filePath.lastIndexOf('/');
42
+ const base = slash === -1 ? filePath : filePath.slice(slash + 1);
43
+ const dot = base.lastIndexOf('.');
44
+ if (dot <= 0) return '';
45
+ return base.slice(dot).toLowerCase();
46
+ }
47
+
48
+ function getBasename(filePath) {
49
+ const slash = filePath.lastIndexOf('/');
50
+ return slash === -1 ? filePath : filePath.slice(slash + 1);
51
+ }
52
+
53
+ function classifyLines(lines) {
54
+ const added = [];
55
+ const removed = [];
56
+ for (const line of lines) {
57
+ if (line.startsWith('\\')) continue;
58
+ if (line.startsWith('+')) added.push(line.slice(1));
59
+ else if (line.startsWith('-')) removed.push(line.slice(1));
60
+ }
61
+ return { added, removed };
62
+ }
63
+
64
+ function isImportOnlyReorder(added, removed, ext) {
65
+ let pattern;
66
+ if (JS_TS_EXTENSIONS.has(ext)) pattern = JS_IMPORT_PATTERN;
67
+ else if (PYTHON_EXTENSIONS.has(ext)) pattern = PY_IMPORT_PATTERN;
68
+ else return false;
69
+
70
+ if (added.length === 0 && removed.length === 0) return false;
71
+
72
+ const addedTrimmed = added.map((l) => l.trim());
73
+ const removedTrimmed = removed.map((l) => l.trim());
74
+
75
+ for (const line of addedTrimmed) {
76
+ if (!pattern.test(line)) return false;
77
+ }
78
+ for (const line of removedTrimmed) {
79
+ if (!pattern.test(line)) return false;
80
+ }
81
+
82
+ if (addedTrimmed.length !== removedTrimmed.length) return false;
83
+ const a = [...addedTrimmed].sort();
84
+ const r = [...removedTrimmed].sort();
85
+ for (let i = 0; i < a.length; i++) {
86
+ if (a[i] !== r[i]) return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function extractPackageJsonVersionKey(line) {
92
+ const match = PACKAGE_JSON_VERSION_PATTERN.exec(line);
93
+ return match ? match[1] : null;
94
+ }
95
+
96
+ function isVersionBumpChange(added, removed, basename) {
97
+ if (basename !== 'package.json' && !LOCKFILE_BASENAMES.has(basename)) return false;
98
+ if (added.length === 0 && removed.length === 0) return false;
99
+
100
+ if (LOCKFILE_BASENAMES.has(basename)) return true;
101
+
102
+ const addedKeys = [];
103
+ for (const line of added) {
104
+ const key = extractPackageJsonVersionKey(line.trim());
105
+ if (key === null) return false;
106
+ addedKeys.push(key);
107
+ }
108
+ const removedKeys = [];
109
+ for (const line of removed) {
110
+ const key = extractPackageJsonVersionKey(line.trim());
111
+ if (key === null) return false;
112
+ removedKeys.push(key);
113
+ }
114
+
115
+ if (addedKeys.length !== removedKeys.length) return false;
116
+ const a = [...addedKeys].sort();
117
+ const r = [...removedKeys].sort();
118
+ for (let i = 0; i < a.length; i++) {
119
+ if (a[i] !== r[i]) return false;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Classify a hunk as trivial under one of several heuristics.
126
+ *
127
+ * Callers that need generated-file detection should pass
128
+ * isGeneratedFile: parser.isGenerated.bind(parser)
129
+ * where `parser` is the result of
130
+ * await getGeneratedFilePatterns(worktreePath)
131
+ * from src/git/gitattributes.js. When `isGeneratedFile` is omitted, the
132
+ * generated-file rule is skipped silently.
133
+ * @param {Hunk} hunk
134
+ * @param {string} filePath
135
+ * @param {{ isGeneratedFile?: (filePath: string) => boolean }} [options]
136
+ * @returns {{ trivial: boolean, reason?: 'imports'|'version_bump'|'generated' }}
137
+ */
138
+ function isTrivialHunk(hunk, filePath, options) {
139
+ const opts = options || {};
140
+
141
+ if (typeof opts.isGeneratedFile === 'function' && opts.isGeneratedFile(filePath) === true) {
142
+ return { trivial: true, reason: 'generated' };
143
+ }
144
+
145
+ const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
146
+ const { added, removed } = classifyLines(lines);
147
+
148
+ const ext = getExtension(filePath);
149
+ if (isImportOnlyReorder(added, removed, ext)) {
150
+ return { trivial: true, reason: 'imports' };
151
+ }
152
+
153
+ const basename = getBasename(filePath);
154
+ if (isVersionBumpChange(added, removed, basename)) {
155
+ return { trivial: true, reason: 'version_bump' };
156
+ }
157
+
158
+ return { trivial: false };
159
+ }
160
+
161
+ module.exports = { hashHunk, isTrivialHunk };
package/src/ai/index.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  registerProvider,
14
14
  getProviderClass,
15
15
  getRegisteredProviderIds,
16
+ resolveNonExecutableProviderId,
16
17
  getAllProvidersInfo,
17
18
  createProvider,
18
19
  testProviderAvailability,
@@ -60,6 +61,7 @@ module.exports = {
60
61
  registerProvider,
61
62
  getProviderClass,
62
63
  getRegisteredProviderIds,
64
+ resolveNonExecutableProviderId,
63
65
  getAllProvidersInfo,
64
66
 
65
67
  // Factory
@@ -18,6 +18,7 @@ const logger = require('../utils/logger');
18
18
  const { extractJSON } = require('../utils/json-extractor');
19
19
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
20
20
  const { StreamParser, parseOpenCodeLine } = require('./stream-parser');
21
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
21
22
 
22
23
  // Directory containing bin scripts (git-diff-lines, etc.)
23
24
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -102,7 +103,7 @@ class OpenCodeProvider extends AIProvider {
102
103
  */
103
104
  async execute(prompt, options = {}) {
104
105
  return new Promise((resolve, reject) => {
105
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
106
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
106
107
 
107
108
  const levelPrefix = logPrefix || `[Level ${level}]`;
108
109
  logger.info(`${levelPrefix} Executing OpenCode CLI...`);
@@ -128,7 +129,8 @@ class OpenCodeProvider extends AIProvider {
128
129
  ...this.extraEnv,
129
130
  PATH: `${BIN_DIR}:${process.env.PATH}`
130
131
  },
131
- shell: this.useShell
132
+ shell: this.useShell,
133
+ detached: this.useShell
132
134
  });
133
135
 
134
136
  const pid = opencode.pid;
@@ -140,6 +142,9 @@ class OpenCodeProvider extends AIProvider {
140
142
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
141
143
  }
142
144
 
145
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
146
+ const abortWiring = wireAbortToChild(opencode, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
147
+
143
148
  let stdout = '';
144
149
  let stderr = '';
145
150
  let timeoutId = null;
@@ -151,6 +156,7 @@ class OpenCodeProvider extends AIProvider {
151
156
  if (settled) return;
152
157
  settled = true;
153
158
  if (timeoutId) clearTimeout(timeoutId);
159
+ abortWiring.detach();
154
160
  fn(value);
155
161
  };
156
162
 
@@ -201,11 +207,20 @@ class OpenCodeProvider extends AIProvider {
201
207
  opencode.on('close', (code) => {
202
208
  if (settled) return; // Already settled by timeout or error
203
209
 
210
+ // Detach is centralized in `settle`.
211
+
204
212
  // Flush any remaining stream parser buffer
205
213
  if (streamParser) {
206
214
  streamParser.flush();
207
215
  }
208
216
 
217
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
218
+ if (abortWiring.cancelled()) {
219
+ logger.info(`${levelPrefix} OpenCode CLI terminated by user cancel (exit code ${code})`);
220
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
221
+ return;
222
+ }
223
+
209
224
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
210
225
  const isCancellationCode = code === 143 || code === 137;
211
226
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -281,6 +296,7 @@ class OpenCodeProvider extends AIProvider {
281
296
 
282
297
  // Handle errors
283
298
  opencode.on('error', (error) => {
299
+ // Detach happens inside `settle`.
284
300
  if (error.code === 'ENOENT') {
285
301
  logger.error(`${levelPrefix} OpenCode CLI not found. Please ensure OpenCode CLI is installed.`);
286
302
  settle(reject, new Error(`${levelPrefix} OpenCode CLI not found. ${OpenCodeProvider.getInstallInstructions()}`));
@@ -491,7 +507,7 @@ class OpenCodeProvider extends AIProvider {
491
507
 
492
508
  if (textContent) {
493
509
  // Try to extract JSON from the accumulated text content
494
- const extracted = extractJSON(textContent, level);
510
+ const extracted = extractJSON(textContent, level, levelPrefix);
495
511
  if (extracted.success) {
496
512
  return extracted;
497
513
  }
@@ -503,12 +519,12 @@ class OpenCodeProvider extends AIProvider {
503
519
  }
504
520
 
505
521
  // No text content found, try extracting JSON directly from stdout
506
- const extracted = extractJSON(stdout, level);
522
+ const extracted = extractJSON(stdout, level, levelPrefix);
507
523
  return extracted;
508
524
 
509
525
  } catch (parseError) {
510
526
  // stdout might not be valid JSONL at all, try extracting JSON from it
511
- const extracted = extractJSON(stdout, level);
527
+ const extracted = extractJSON(stdout, level, levelPrefix);
512
528
  if (extracted.success) {
513
529
  return extracted;
514
530
  }
@@ -26,6 +26,7 @@ const logger = require('../utils/logger');
26
26
  const { extractJSON } = require('../utils/json-extractor');
27
27
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
28
28
  const { createPiLineParser } = require('./stream-parser');
29
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
29
30
 
30
31
  // Directory containing bin scripts (git-diff-lines, etc.)
31
32
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -430,7 +431,7 @@ function appendPiChunkToLineBuffer(state, chunk, levelPrefix) {
430
431
  */
431
432
  function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated = false }, level, levelPrefix) {
432
433
  if (textContent) {
433
- const extracted = extractJSON(textContent, level);
434
+ const extracted = extractJSON(textContent, level, levelPrefix);
434
435
  if (extracted.success) {
435
436
  return extracted;
436
437
  }
@@ -447,7 +448,7 @@ function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated
447
448
  };
448
449
  }
449
450
 
450
- return extractJSON(rawOutput, level);
451
+ return extractJSON(rawOutput, level, levelPrefix);
451
452
  }
452
453
 
453
454
  class PiProvider extends AIProvider {
@@ -579,7 +580,7 @@ class PiProvider extends AIProvider {
579
580
  */
580
581
  async execute(prompt, options = {}) {
581
582
  return new Promise((resolve, reject) => {
582
- const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
583
+ const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
583
584
 
584
585
  const levelPrefix = logPrefix || `[Level ${level}]`;
585
586
  logger.info(`${levelPrefix} Executing Pi CLI...`);
@@ -609,7 +610,8 @@ class PiProvider extends AIProvider {
609
610
  ...this.extraEnv,
610
611
  PATH: `${BIN_DIR}:${process.env.PATH}`
611
612
  },
612
- shell: this.useShell
613
+ shell: this.useShell,
614
+ detached: this.useShell
613
615
  });
614
616
 
615
617
  // Close stdin immediately — prompt is delivered via @file, but some
@@ -626,6 +628,9 @@ class PiProvider extends AIProvider {
626
628
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
627
629
  }
628
630
 
631
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
632
+ const abortWiring = wireAbortToChild(pi, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
633
+
629
634
  const stderrCapture = {
630
635
  head: '',
631
636
  tail: '',
@@ -652,6 +657,7 @@ class PiProvider extends AIProvider {
652
657
  if (settled) return;
653
658
  settled = true;
654
659
  if (timeoutId) clearTimeout(timeoutId);
660
+ abortWiring.detach();
655
661
  fn(value);
656
662
  };
657
663
 
@@ -711,6 +717,15 @@ class PiProvider extends AIProvider {
711
717
  cleanupTmpFile();
712
718
  if (settled) return; // Already settled by timeout or error
713
719
 
720
+ // Detach is centralized in `settle`.
721
+
722
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
723
+ if (abortWiring.cancelled()) {
724
+ logger.info(`${levelPrefix} Pi CLI terminated by user cancel (exit code ${code})`);
725
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
726
+ return;
727
+ }
728
+
714
729
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
715
730
  const isCancellationCode = code === 143 || code === 137;
716
731
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -810,6 +825,7 @@ class PiProvider extends AIProvider {
810
825
  // Handle errors
811
826
  pi.on('error', (error) => {
812
827
  cleanupTmpFile();
828
+ // Detach happens inside `settle`.
813
829
  if (error.code === 'ENOENT') {
814
830
  logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
815
831
  settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
@@ -994,7 +1010,7 @@ class PiProvider extends AIProvider {
994
1010
 
995
1011
  } catch (parseError) {
996
1012
  // stdout might not be valid JSONL at all, try extracting JSON from it
997
- const extracted = extractJSON(stdout, level);
1013
+ const extracted = extractJSON(stdout, level, levelPrefix);
998
1014
  if (extracted.success) {
999
1015
  return extracted;
1000
1016
  }
@@ -0,0 +1,199 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Hunk summary prompt builder.
4
+ *
5
+ * Pure function that produces the prompt body sent to the background provider
6
+ * for summarizing one file's worth of diff hunks. Output schema and length
7
+ * constraints are defined in plans/semantic-hunk-summaries-and-tours.md
8
+ * ("Prompt Design Notes" -> "Summary prompt contract").
9
+ */
10
+
11
+ const MAX_CHANGED_FILES_LISTED = 100;
12
+
13
+ /**
14
+ * @typedef {Object} HunkInput
15
+ * @property {string} header - "@@ -10,5 +10,7 @@" line
16
+ * @property {string[]} lines - Diff body lines with leading +/-/space markers
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} SummaryContext
21
+ * @property {string} filePath - Path of the file being summarized
22
+ * @property {HunkInput[]} hunks - Hunks to summarize, in file order
23
+ * @property {string} [prTitle] - Optional PR title or local-review name
24
+ * @property {string} [prDescription] - Optional PR description
25
+ * @property {string[]} [changedFiles] - Optional list of all changed-file paths in this review (light context)
26
+ * @property {string} [cwd] - Optional working directory the agent is running in.
27
+ * When provided, the prompt invites bounded read-only file access; the path
28
+ * itself is NOT embedded in the prompt. Used purely as a signal flag — when
29
+ * omitted, the prompt does not promise read-only access at all.
30
+ */
31
+
32
+ /**
33
+ * Returns true if the value is a non-empty, non-whitespace-only string.
34
+ * @param {unknown} value
35
+ * @returns {boolean}
36
+ */
37
+ function hasText(value) {
38
+ return typeof value === 'string' && value.trim().length > 0;
39
+ }
40
+
41
+ /**
42
+ * Build the prompt body sent to the background provider for summarizing one
43
+ * file's worth of hunks. Returns a single string (the full prompt).
44
+ * @param {SummaryContext} context
45
+ * @returns {string}
46
+ */
47
+ function buildHunkSummaryPrompt({ filePath, hunks, prTitle, prDescription, changedFiles, cwd } = {}) {
48
+ if (!hasText(filePath)) {
49
+ throw new TypeError('filePath is required');
50
+ }
51
+ if (hunks === undefined || hunks === null) {
52
+ throw new TypeError('hunks is required');
53
+ }
54
+ if (!Array.isArray(hunks)) {
55
+ throw new TypeError('hunks is required');
56
+ }
57
+
58
+ const sections = [];
59
+
60
+ sections.push(
61
+ 'You are summarizing changed hunks from a code review. Treat the diff text provided below as the primary source. Do NOT modify files. Do NOT run write commands (rm, mv, git commit, etc.). Produce concise natural-language summaries.'
62
+ );
63
+
64
+ if (hasText(cwd)) {
65
+ sections.push(
66
+ [
67
+ 'You have read-only access to the current working directory. The diff is',
68
+ 'your primary source. You MAY consult adjacent code ONLY when it materially',
69
+ 'improves the description of WHAT changed:',
70
+ '- A symbol introduced/modified in the diff has callers or a definition',
71
+ ' elsewhere whose existence changes the summary (e.g. "extracts a helper',
72
+ ' now used by 4 sites" vs "adds a helper").',
73
+ '- The diff is locally ambiguous about what changed (e.g. a one-line',
74
+ ' signature change whose meaning depends on the function body not in',
75
+ ' the hunk).',
76
+ '',
77
+ 'Budget per file: at most ~5 file reads, ~3 grep calls. Do not browse',
78
+ 'broadly. Do not read tests, fixtures, or generated files unless directly',
79
+ 'relevant. Do not modify any file.',
80
+ '',
81
+ 'The summary still describes what the DIFF changes, not what the',
82
+ 'surrounding code does. Context informs phrasing; it does not become',
83
+ 'the subject.'
84
+ ].join('\n')
85
+ );
86
+ }
87
+
88
+ sections.push(
89
+ [
90
+ 'Style:',
91
+ '- 1–3 sentences. Aim for one; use two only when a second sentence adds',
92
+ ' information the first cannot. Three is rare.',
93
+ '- Target ~200 characters; hard ceiling 400.',
94
+ '- State WHAT changed in the diff. Context informs phrasing; it does not',
95
+ ' become the subject. Do not speculate beyond what code you can see makes',
96
+ ' unambiguous.',
97
+ '- For mechanical changes (formatting, trivial rename), say so in one short',
98
+ ' sentence and stop.',
99
+ '- Lead with a verb (Adds, Removes, Renames, Refactors, Fixes, Moves,',
100
+ ' Inlines, Extracts).'
101
+ ].join('\n')
102
+ );
103
+
104
+ sections.push(
105
+ [
106
+ 'You MAY return summary: null for a hunk only when ALL of these hold:',
107
+ '- The change is purely mechanical (whitespace, import reorder, lint fix,',
108
+ ' trivial rename) AND',
109
+ '- A reader scanning the diff would learn nothing from a summary.',
110
+ '',
111
+ 'Default is to summarize. When in doubt, write the summary.'
112
+ ].join('\n')
113
+ );
114
+
115
+ if (hasText(prTitle) || hasText(prDescription)) {
116
+ const contextLines = ["Author's stated intent (hint only — verify against the diff):"];
117
+ if (hasText(prTitle)) {
118
+ contextLines.push(` Title: ${prTitle.trim()}`);
119
+ }
120
+ if (hasText(prDescription)) {
121
+ contextLines.push(` Description: ${prDescription.trim()}`);
122
+ }
123
+ sections.push(contextLines.join('\n'));
124
+
125
+ sections.push(
126
+ [
127
+ "The author's stated intent above is a HINT — useful for orientation and",
128
+ 'vocabulary. It is NOT verified ground truth. The diff is ground truth.',
129
+ '- Use the description to orient your reading and to choose vocabulary that',
130
+ ' matches the project (e.g. domain terms).',
131
+ '- Do NOT repeat or paraphrase the description as the summary.',
132
+ '- If the diff and the description disagree, describe the diff. Do not',
133
+ ' paper over the disagreement, and do not editorialize about it — just',
134
+ ' state what the diff does.',
135
+ '- If the description is vague, templated, or empty, ignore it entirely.'
136
+ ].join('\n')
137
+ );
138
+ }
139
+
140
+ if (Array.isArray(changedFiles) && changedFiles.length > 0) {
141
+ if (changedFiles.length > MAX_CHANGED_FILES_LISTED) {
142
+ sections.push(
143
+ `Changed files in this review: ${changedFiles.length} total (list omitted for length)`
144
+ );
145
+ } else {
146
+ const fileLines = ['Changed files in this review:'];
147
+ for (const path of changedFiles) {
148
+ fileLines.push(` - ${path}`);
149
+ }
150
+ sections.push(fileLines.join('\n'));
151
+ }
152
+ }
153
+
154
+ const hunkBlockLines = [`File: ${filePath}`, 'Hunks (numbered):'];
155
+ if (hunks.length === 0) {
156
+ hunkBlockLines.push('(no hunks)');
157
+ } else {
158
+ hunks.forEach((hunk, idx) => {
159
+ const header = hunk && typeof hunk.header === 'string' ? hunk.header : '';
160
+ const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
161
+ hunkBlockLines.push(`[${idx + 1}] ${header}`);
162
+ if (lines.length > 0) {
163
+ hunkBlockLines.push(lines.join('\n'));
164
+ }
165
+ });
166
+ }
167
+ sections.push(hunkBlockLines.join('\n'));
168
+
169
+ if (hunks.length === 0) {
170
+ sections.push(
171
+ [
172
+ 'There are no hunks to summarize.',
173
+ 'Return ONLY this JSON object:',
174
+ '{ "summaries": [] }',
175
+ '',
176
+ 'Do not include any extra fields, explanation, or prose outside the JSON.'
177
+ ].join('\n')
178
+ );
179
+ } else {
180
+ sections.push(
181
+ [
182
+ 'Return ONLY a JSON object with this shape:',
183
+ '{ "summaries": [',
184
+ ' { "index": 1, "summary": "Adds X to do Y." },',
185
+ ' { "index": 2, "summary": null }',
186
+ '] }',
187
+ '',
188
+ 'Rules:',
189
+ '- One entry per hunk above; index matches the [N] label.',
190
+ '- `summary` is `string | null` (null only per the opt-out clause above).',
191
+ '- Do not include any extra fields, explanation, or prose outside the JSON.'
192
+ ].join('\n')
193
+ );
194
+ }
195
+
196
+ return sections.join('\n\n');
197
+ }
198
+
199
+ module.exports = { buildHunkSummaryPrompt };