@in-the-loop-labs/pair-review 3.5.2 → 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.
- package/package.json +15 -20
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/pr.css +603 -6
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Tour prompt builder.
|
|
4
|
+
*
|
|
5
|
+
* Pure function that produces the prompt body sent to the background provider
|
|
6
|
+
* for generating a guided "tour" of a code review. The agent is expected to
|
|
7
|
+
* actively explore the worktree (read files, run the annotated diff tool,
|
|
8
|
+
* grep) and choose stops grounded in real code it has read directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { buildAnalysisLineNumberGuidance } = require('./line-number-guidance');
|
|
12
|
+
|
|
13
|
+
// What the prompt asks the model for. Trivial diffs may legitimately yield
|
|
14
|
+
// only one or two stops; we don't want to coerce padding.
|
|
15
|
+
const PROMPT_MIN_STOPS = 1;
|
|
16
|
+
// Persistence gate: fewer than this and the result is treated as
|
|
17
|
+
// "not tour-worthy" rather than published.
|
|
18
|
+
const PERSIST_MIN_STOPS = 2;
|
|
19
|
+
const MAX_STOPS = 12;
|
|
20
|
+
const TITLE_MAX = 120;
|
|
21
|
+
// Storage cap. The UI clamps the visible description to ~3 lines and
|
|
22
|
+
// reveals the rest behind a "Show more" toggle, so we can afford to give
|
|
23
|
+
// the model more room than the old 280-char hard cap (which produced
|
|
24
|
+
// visibly mid-sentence truncations the user hated).
|
|
25
|
+
const DESCRIPTION_MAX = 800;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if the value is a non-empty, non-whitespace-only string.
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function hasText(value) {
|
|
33
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the prompt body sent to the background provider for generating a tour.
|
|
38
|
+
*
|
|
39
|
+
* The agent is expected to explore the worktree directly (Read, grep, the
|
|
40
|
+
* annotated diff tool) and ground every stop in real file content.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} context
|
|
43
|
+
* @param {string} [context.prTitle] Optional PR title or local-review name.
|
|
44
|
+
* @param {string} [context.prDescription] Optional PR description.
|
|
45
|
+
* @param {string} context.scriptCommand Annotated-diff command (e.g. `git-diff-lines --cwd "/abs"`).
|
|
46
|
+
* @param {string[]} context.changedFiles Repo-relative paths of files in the diff.
|
|
47
|
+
* @param {string} [context.worktreePath] Informational; the agent's cwd.
|
|
48
|
+
* @returns {string} The full prompt.
|
|
49
|
+
*/
|
|
50
|
+
function buildTourPrompt({
|
|
51
|
+
prTitle,
|
|
52
|
+
prDescription,
|
|
53
|
+
scriptCommand,
|
|
54
|
+
changedFiles,
|
|
55
|
+
worktreePath
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (!hasText(scriptCommand)) {
|
|
58
|
+
throw new TypeError('scriptCommand is required');
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(changedFiles)) {
|
|
61
|
+
throw new TypeError('changedFiles is required');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sections = [];
|
|
65
|
+
|
|
66
|
+
sections.push(
|
|
67
|
+
[
|
|
68
|
+
'You are building a guided tour of a pull request or local code change.',
|
|
69
|
+
'The tour exists for ONE reason: accelerated understanding of the change',
|
|
70
|
+
'for the reader.',
|
|
71
|
+
'',
|
|
72
|
+
'Audience: a reviewer who needs to build an accurate mental model of',
|
|
73
|
+
'the change fast and know where to focus scrutiny before deciding',
|
|
74
|
+
'whether to approve, push back, or dig deeper.',
|
|
75
|
+
'',
|
|
76
|
+
'Success looks like: after reading the tour in order, the reviewer',
|
|
77
|
+
'(a) understands what changed and why, and (b) knows which parts',
|
|
78
|
+
'deserve careful attention.',
|
|
79
|
+
'',
|
|
80
|
+
'This is NOT a changelog or a hunk-by-hunk summary. Skip anything a',
|
|
81
|
+
'competent reviewer absorbs at a glance. Privilege load-bearing logic,',
|
|
82
|
+
'contract changes, and subtle risk over breadth of coverage.',
|
|
83
|
+
'',
|
|
84
|
+
'You have shell tools (Read, the annotated diff tool, cat, grep).',
|
|
85
|
+
'EXPLORE the worktree directly. Ground every stop in real file content',
|
|
86
|
+
'you have read. Do NOT modify files. Do NOT run write commands.'
|
|
87
|
+
].join('\n')
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
sections.push(
|
|
91
|
+
[
|
|
92
|
+
'Return ONLY a JSON object with this shape:',
|
|
93
|
+
'{',
|
|
94
|
+
' "stops": [',
|
|
95
|
+
' {',
|
|
96
|
+
' "file_path": "src/foo.ts",',
|
|
97
|
+
' "side": "RIGHT",',
|
|
98
|
+
' "line_start": 42,',
|
|
99
|
+
' "line_end": 58,',
|
|
100
|
+
` "title": "<= ${TITLE_MAX} chars",`,
|
|
101
|
+
` "description": "1-3 sentences; aim for ~200-300 chars, up to ${DESCRIPTION_MAX}"`,
|
|
102
|
+
' }',
|
|
103
|
+
' ]',
|
|
104
|
+
'}',
|
|
105
|
+
'',
|
|
106
|
+
'`side` is "LEFT" or "RIGHT".'
|
|
107
|
+
].join('\n')
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
sections.push(
|
|
111
|
+
[
|
|
112
|
+
'Style:',
|
|
113
|
+
`- title: a short noun phrase, <= ${TITLE_MAX} characters.`,
|
|
114
|
+
`- description: 1–3 sentences. Aim for ~200–300 characters; up to ${DESCRIPTION_MAX}`,
|
|
115
|
+
' if more context is genuinely needed. Explain WHY this stop matters and',
|
|
116
|
+
' what to look for. Do NOT restate what the code does; say why it is load-bearing.',
|
|
117
|
+
'- Be concrete. No fluff like "this is important code".'
|
|
118
|
+
].join('\n')
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
sections.push(
|
|
122
|
+
[
|
|
123
|
+
'Stop selection:',
|
|
124
|
+
'- Order stops as a coherent reading path — start with the most load-bearing',
|
|
125
|
+
' change, then dependents, then supporting changes. Order does NOT have to',
|
|
126
|
+
' follow file order.',
|
|
127
|
+
'- Skip mechanical/uninteresting code (formatting, imports, whitespace).',
|
|
128
|
+
' Include only stops a reviewer benefits from reading explicitly.',
|
|
129
|
+
'- `[line_start, line_end]` should be tight — a function, a block, a few',
|
|
130
|
+
' related lines — not an entire file.',
|
|
131
|
+
`- Return as many stops as the diff genuinely warrants, up to ${MAX_STOPS}.`,
|
|
132
|
+
' Trivial diffs may legitimately yield only 1-2 stops; do not pad. Fewer',
|
|
133
|
+
' well-chosen stops beat a long laundry list.',
|
|
134
|
+
'- Stops must not overlap. Pick the single most informative range per',
|
|
135
|
+
' region; do not return multiple stops covering the same function or',
|
|
136
|
+
' block under different framings.'
|
|
137
|
+
].join('\n')
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
sections.push(
|
|
141
|
+
[
|
|
142
|
+
'Every stop MUST point at lines that actually changed in the diff:',
|
|
143
|
+
'- The chosen `[line_start, line_end]` range MUST intersect changed',
|
|
144
|
+
' lines for the chosen `side` in the chosen file.',
|
|
145
|
+
'- Stops on unchanged code or on files outside the diff will be rejected.',
|
|
146
|
+
'- If you want to call out unchanged code that the change interacts with,',
|
|
147
|
+
' pick a stop on a changed line nearby and reference the unchanged code',
|
|
148
|
+
' in the description text.'
|
|
149
|
+
].join('\n')
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
sections.push(
|
|
153
|
+
[
|
|
154
|
+
'Side semantics:',
|
|
155
|
+
'- `RIGHT` = post-change content (added or context lines, by NEW line numbers).',
|
|
156
|
+
'- `LEFT` = pre-change content (deleted lines, by OLD line numbers). Use only',
|
|
157
|
+
' when calling out something that was removed.',
|
|
158
|
+
'- The range must intersect a changed-line region on the chosen side. Use the',
|
|
159
|
+
' annotated diff tool to confirm.'
|
|
160
|
+
].join('\n')
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
sections.push(
|
|
164
|
+
[
|
|
165
|
+
'Exploration discipline:',
|
|
166
|
+
'- Use the annotated diff tool to ground every line number. Treat it as the',
|
|
167
|
+
' authoritative source for which lines changed and on which side.',
|
|
168
|
+
'- Read the relevant files to verify ranges sit on meaningful boundaries',
|
|
169
|
+
' (full function, full block) before committing to a stop.'
|
|
170
|
+
].join('\n')
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
sections.push(buildAnalysisLineNumberGuidance({ scriptCommand }).trim());
|
|
174
|
+
|
|
175
|
+
if (hasText(prTitle) || hasText(prDescription)) {
|
|
176
|
+
const intentLines = ["Author's stated intent (HINT only — verify against the code):"];
|
|
177
|
+
if (hasText(prTitle)) {
|
|
178
|
+
intentLines.push(` Title: ${prTitle.trim()}`);
|
|
179
|
+
}
|
|
180
|
+
if (hasText(prDescription)) {
|
|
181
|
+
intentLines.push(` Description: ${prDescription.trim()}`);
|
|
182
|
+
}
|
|
183
|
+
sections.push(intentLines.join('\n'));
|
|
184
|
+
|
|
185
|
+
sections.push(
|
|
186
|
+
[
|
|
187
|
+
"The author's stated intent above is a HINT for orientation and vocabulary.",
|
|
188
|
+
'It is NOT verified ground truth. The code and diff are ground truth.',
|
|
189
|
+
'- If the description and the code disagree, follow the code.',
|
|
190
|
+
'- If the description is vague, templated, or empty, ignore it entirely.'
|
|
191
|
+
].join('\n')
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const changedFilesBlock = ['Changed files in this diff:'];
|
|
196
|
+
if (changedFiles.length === 0) {
|
|
197
|
+
changedFilesBlock.push(' (none)');
|
|
198
|
+
} else {
|
|
199
|
+
for (const path of changedFiles) {
|
|
200
|
+
changedFilesBlock.push(` - ${path}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
changedFilesBlock.push('');
|
|
204
|
+
changedFilesBlock.push(
|
|
205
|
+
'Stops MUST be in one of the files above. Stops on other files will be rejected.'
|
|
206
|
+
);
|
|
207
|
+
sections.push(changedFilesBlock.join('\n'));
|
|
208
|
+
|
|
209
|
+
if (hasText(worktreePath)) {
|
|
210
|
+
sections.push(`Your working directory is: ${worktreePath}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
sections.push(
|
|
214
|
+
[
|
|
215
|
+
'Final output rules:',
|
|
216
|
+
'- JSON only. No prose, no markdown fences.',
|
|
217
|
+
'- Validate every range against file bounds and (for changed-file stops)',
|
|
218
|
+
' against the annotated diff before returning.'
|
|
219
|
+
].join('\n')
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return sections.join('\n\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
buildTourPrompt,
|
|
227
|
+
TOUR_PROMPT_MIN_STOPS: PROMPT_MIN_STOPS,
|
|
228
|
+
TOUR_PERSIST_MIN_STOPS: PERSIST_MIN_STOPS,
|
|
229
|
+
TOUR_MAX_STOPS: MAX_STOPS,
|
|
230
|
+
TOUR_TITLE_MAX: TITLE_MAX,
|
|
231
|
+
TOUR_DESCRIPTION_MAX: DESCRIPTION_MAX
|
|
232
|
+
};
|
package/src/ai/provider.js
CHANGED
|
@@ -300,7 +300,7 @@ ${rawResponse}
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
// Use the generic extractJSON for all providers - the LLM should return raw JSON
|
|
303
|
-
const extracted = extractJSON(stdout, level);
|
|
303
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
304
304
|
if (extracted.success) {
|
|
305
305
|
logger.success(`${levelPrefix} LLM extraction successful`);
|
|
306
306
|
settle(extracted);
|
|
@@ -604,6 +604,25 @@ function getRegisteredProviderIds() {
|
|
|
604
604
|
return Array.from(providerRegistry.keys());
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Resolve a non-executable provider id, preferring `preferredId` if it is
|
|
609
|
+
* non-executable. Falls back to the first registered non-executable provider.
|
|
610
|
+
* Returns null if none are available.
|
|
611
|
+
* @param {string} [preferredId] - Preferred provider id
|
|
612
|
+
* @returns {string|null}
|
|
613
|
+
*/
|
|
614
|
+
function resolveNonExecutableProviderId(preferredId) {
|
|
615
|
+
if (preferredId) {
|
|
616
|
+
const cls = getProviderClass(preferredId);
|
|
617
|
+
if (cls && !cls.isExecutable) return preferredId;
|
|
618
|
+
}
|
|
619
|
+
for (const pid of getRegisteredProviderIds()) {
|
|
620
|
+
const cls = getProviderClass(pid);
|
|
621
|
+
if (cls && !cls.isExecutable) return pid;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
607
626
|
/**
|
|
608
627
|
* Merge config-override models with a provider's built-in models.
|
|
609
628
|
* Config models with matching IDs replace built-ins; config models with new IDs
|
|
@@ -768,6 +787,7 @@ module.exports = {
|
|
|
768
787
|
registerProvider,
|
|
769
788
|
getProviderClass,
|
|
770
789
|
getRegisteredProviderIds,
|
|
790
|
+
resolveNonExecutableProviderId,
|
|
771
791
|
getAllProvidersInfo,
|
|
772
792
|
createProvider,
|
|
773
793
|
testProviderAvailability,
|