@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,568 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const crypto = require('crypto');
4
+ const logger = require('../utils/logger');
5
+ const {
6
+ TOUR_PERSIST_MIN_STOPS,
7
+ TOUR_MAX_STOPS,
8
+ TOUR_TITLE_MAX,
9
+ TOUR_DESCRIPTION_MAX
10
+ } = require('./prompts/tour');
11
+
12
+ const TOUR_LOG_PREFIX = '[Tour]';
13
+ const SCRIPT_NAME = 'git-diff-lines';
14
+
15
+ /**
16
+ * Tracks the most recently requested diff hash per review so that an in-flight
17
+ * tour generation can detect it has been superseded by a newer kickoff and
18
+ * skip persistence. Cleared when the latest hash successfully persists.
19
+ *
20
+ * Exported (module.exports) so tests can reset state between runs.
21
+ */
22
+ const latestRequestedDiffHash = new Map();
23
+
24
+ const defaults = {
25
+ TourRepository: require('../database').TourRepository,
26
+ createProvider: require('./provider').createProvider,
27
+ resolveNonExecutableProviderId: require('./provider').resolveNonExecutableProviderId,
28
+ getTourProvider: require('../config').getTourProvider,
29
+ getTourModel: require('../config').getTourModel,
30
+ getTourEnabled: require('../config').getTourEnabled,
31
+ getTourAutoGenerate: require('../config').getTourAutoGenerate,
32
+ buildTourPrompt: require('./prompts/tour').buildTourPrompt,
33
+ extractJSON: require('../utils/json-extractor').extractJSON,
34
+ broadcastReviewEvent: require('../events/review-events').broadcastReviewEvent,
35
+ parseUnifiedDiffHunks: require('../utils/diff-hunks').parseUnifiedDiffHunks,
36
+ hashDiff: (diffText) => crypto.createHash('sha256').update(diffText).digest('hex').slice(0, 16),
37
+ backgroundQueue: null,
38
+ // Indirection so tests can swap the worker thunk and observe scheduling.
39
+ generateTourForReview: null
40
+ };
41
+
42
+ /**
43
+ * Build the bare-name annotated-diff command string passed to the prompt.
44
+ * Bare command name (not absolute path) so provider tool allow-lists match.
45
+ * See analyzer.js `_buildScriptCommand`.
46
+ * @param {string|null} worktreePath
47
+ * @returns {string}
48
+ */
49
+ function buildScriptCommand(worktreePath) {
50
+ if (!worktreePath) return SCRIPT_NAME;
51
+ return `${SCRIPT_NAME} --cwd "${worktreePath}"`;
52
+ }
53
+
54
+ /**
55
+ * Parse a unified-diff hunk header (`@@ -a,b +c,d @@`).
56
+ * @param {string} header
57
+ * @returns {{oldStart: number, oldLen: number, newStart: number, newLen: number}|null}
58
+ */
59
+ function parseHunkHeader(header) {
60
+ const m = header && header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
61
+ if (!m) return null;
62
+ return {
63
+ oldStart: parseInt(m[1], 10),
64
+ oldLen: m[2] != null ? parseInt(m[2], 10) : 1,
65
+ newStart: parseInt(m[3], 10),
66
+ newLen: m[4] != null ? parseInt(m[4], 10) : 1
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Build sets of changed line numbers per file per side from parsed hunks.
72
+ * @param {Map<string, Array<{header: string, lines: string[]}>>} hunksByFile
73
+ * @returns {{left: Map<string, Set<number>>, right: Map<string, Set<number>>}}
74
+ */
75
+ function buildChangedLineIndex(hunksByFile) {
76
+ const left = new Map();
77
+ const right = new Map();
78
+ for (const [filePath, hunks] of hunksByFile.entries()) {
79
+ const leftSet = new Set();
80
+ const rightSet = new Set();
81
+ for (const hunk of hunks) {
82
+ const head = parseHunkHeader(hunk.header);
83
+ if (!head) continue;
84
+ let oldLine = head.oldStart;
85
+ let newLine = head.newStart;
86
+ for (const raw of hunk.lines) {
87
+ if (raw.startsWith('\\')) continue;
88
+ const marker = raw[0];
89
+ if (marker === '+') {
90
+ rightSet.add(newLine);
91
+ newLine++;
92
+ } else if (marker === '-') {
93
+ leftSet.add(oldLine);
94
+ oldLine++;
95
+ } else {
96
+ oldLine++;
97
+ newLine++;
98
+ }
99
+ }
100
+ }
101
+ left.set(filePath, leftSet);
102
+ right.set(filePath, rightSet);
103
+ }
104
+ return { left, right };
105
+ }
106
+
107
+ function rangeIntersectsSet(start, end, set) {
108
+ if (!set || set.size === 0) return false;
109
+ for (let i = start; i <= end; i++) {
110
+ if (set.has(i)) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Validate and normalize a single tour stop. Returns the cleaned stop or null.
117
+ * @param {unknown} stop
118
+ * @param {Object} ctx - { hunksByFile, changedLines, worktreePath }
119
+ * @returns {Promise<Object|null>}
120
+ */
121
+ async function validateStop(stop, ctx) {
122
+ if (!stop || typeof stop !== 'object') return null;
123
+
124
+ const filePath = typeof stop.file_path === 'string' ? stop.file_path : null;
125
+ const title = typeof stop.title === 'string' ? stop.title.trim() : '';
126
+ const description = typeof stop.description === 'string' ? stop.description.trim() : '';
127
+ if (!filePath || !title || !description) return null;
128
+
129
+ const ls = Number(stop.line_start);
130
+ const le = Number(stop.line_end);
131
+ if (!Number.isInteger(ls) || ls < 1) return null;
132
+ if (!Number.isInteger(le) || le < ls) return null;
133
+
134
+ let normSide = typeof stop.side === 'string' ? stop.side.trim().toUpperCase() : 'RIGHT';
135
+ if (normSide !== 'LEFT' && normSide !== 'RIGHT') normSide = 'RIGHT';
136
+
137
+ // Context stops reference lines outside the rendered diff. The frontend
138
+ // renderer cannot anchor to rows that aren't in the DOM, so dropping them
139
+ // here keeps tours pointed only at lines a user can actually navigate to.
140
+ // Gap-expansion is a separate feature; see plans/semantic-hunk-summaries-and-tours.md.
141
+ if (stop.is_context === true) {
142
+ logger.info(
143
+ `${TOUR_LOG_PREFIX} dropping context stop ${filePath}:${ls}-${le} — gap expansion not yet supported in renderer`
144
+ );
145
+ return null;
146
+ }
147
+
148
+ if (!ctx.hunksByFile.has(filePath)) {
149
+ logger.warn(`${TOUR_LOG_PREFIX} dropping changed-file stop for file outside diff: ${filePath}`);
150
+ return null;
151
+ }
152
+ const lineSet = (normSide === 'LEFT' ? ctx.changedLines.left : ctx.changedLines.right).get(filePath);
153
+ if (!rangeIntersectsSet(ls, le, lineSet)) {
154
+ logger.warn(
155
+ `${TOUR_LOG_PREFIX} dropping changed-file stop ${filePath}:${ls}-${le} (${normSide}) — does not intersect changed lines`
156
+ );
157
+ return null;
158
+ }
159
+
160
+ return {
161
+ file_path: filePath,
162
+ side: normSide,
163
+ line_start: ls,
164
+ line_end: le,
165
+ title: title.slice(0, TOUR_TITLE_MAX),
166
+ description: description.slice(0, TOUR_DESCRIPTION_MAX)
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Generate a guided tour for a review and persist + broadcast it.
172
+ * @param {Object} params
173
+ * @param {Object} params.db
174
+ * @param {Object} params.config
175
+ * @param {number} params.reviewId
176
+ * @param {string} params.diffText - Full unified diff for the review snapshot.
177
+ * @param {string} params.worktreePath
178
+ * @param {Object} [params.reviewContext]
179
+ * @param {string} [params.diffHash] - Precomputed hash of `diffText`. When
180
+ * provided (e.g. from `kickOffTourJob`), used directly instead of being
181
+ * recomputed via `deps.hashDiff`. This makes the producer/consumer
182
+ * relationship between the kickoff and worker explicit rather than
183
+ * relying on the implicit invariant that `hashDiff` is deterministic
184
+ * across calls.
185
+ * @param {Object} [params._deps]
186
+ * @returns {Promise<{generated: boolean, stops: number, reason?: string}>}
187
+ */
188
+ async function generateTourForReview({
189
+ db,
190
+ config,
191
+ reviewId,
192
+ diffText,
193
+ worktreePath,
194
+ reviewContext,
195
+ diffHash: providedDiffHash,
196
+ abortSignal,
197
+ _deps
198
+ }) {
199
+ const deps = { ...defaults, ..._deps };
200
+
201
+ // Helper: convert "aborted between awaits" into a rejected promise so we
202
+ // never persist partial work after a user cancel. Throwing an AbortError
203
+ // also tells the BackgroundQueue this completed via cancellation, which
204
+ // it surfaces in the broadcast event.
205
+ const throwIfAborted = () => {
206
+ if (abortSignal && abortSignal.aborted) {
207
+ const err = new Error(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled`);
208
+ err.name = 'AbortError';
209
+ err.isCancellation = true;
210
+ throw err;
211
+ }
212
+ };
213
+
214
+ if (!diffText || !diffText.trim()) {
215
+ logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no diff text; skipping`);
216
+ return { generated: false, stops: 0, reason: 'no_diff' };
217
+ }
218
+
219
+ const hunksByFile = deps.parseUnifiedDiffHunks(diffText);
220
+ if (!hunksByFile || hunksByFile.size === 0) {
221
+ logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: diff parsed to zero files; skipping`);
222
+ return { generated: false, stops: 0, reason: 'empty_diff' };
223
+ }
224
+
225
+ // Use the precomputed hash from the caller (kickOffTourJob) when
226
+ // available — that's the value stamped on `latestRequestedDiffHash`, so
227
+ // sharing it removes any "hashDiff must be deterministic" hidden
228
+ // contract between the two functions. Fall back to recomputing for
229
+ // direct callers (tests, ad-hoc invocations).
230
+ const diffHash = (typeof providedDiffHash === 'string' && providedDiffHash)
231
+ ? providedDiffHash
232
+ : deps.hashDiff(diffText);
233
+
234
+ const tourRepo = new deps.TourRepository(db);
235
+ const existing = await tourRepo.get(reviewId);
236
+ if (existing && existing.diff_hash === diffHash) {
237
+ logger.debug(`${TOUR_LOG_PREFIX} review ${reviewId}: cached tour matches diff_hash; skipping`);
238
+ deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
239
+ return { generated: false, stops: 0, reason: 'cached' };
240
+ }
241
+
242
+ // Skip the expensive provider call if a newer kickoff has already
243
+ // superseded this one before we even started exploring.
244
+ const latestBeforeProvider = latestRequestedDiffHash.get(reviewId);
245
+ if (latestBeforeProvider !== undefined && latestBeforeProvider !== diffHash) {
246
+ logger.debug(
247
+ `${TOUR_LOG_PREFIX} review ${reviewId}: superseded before provider call (have ${latestBeforeProvider}, this ${diffHash}); skipping`
248
+ );
249
+ return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
250
+ }
251
+
252
+ const preferredProviderId = deps.getTourProvider(config);
253
+ const providerId = deps.resolveNonExecutableProviderId(preferredProviderId);
254
+ if (!providerId) {
255
+ logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no agentic provider available; skipping`);
256
+ return { generated: false, stops: 0, reason: 'no_provider' };
257
+ }
258
+
259
+ let provider;
260
+ let resolvedModel;
261
+ try {
262
+ const initial = deps.createProvider(providerId);
263
+ const ProviderClass = initial.constructor;
264
+ resolvedModel = deps.getTourModel(config, ProviderClass);
265
+ provider = deps.createProvider(providerId, resolvedModel);
266
+ } catch (err) {
267
+ logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: provider unavailable (${err.message}); skipping`);
268
+ return { generated: false, stops: 0, reason: 'provider_error' };
269
+ }
270
+
271
+ const ctx = reviewContext || {};
272
+ const prompt = deps.buildTourPrompt({
273
+ prTitle: ctx.prTitle,
274
+ prDescription: ctx.prDescription,
275
+ scriptCommand: buildScriptCommand(worktreePath),
276
+ changedFiles: Array.from(hunksByFile.keys()),
277
+ worktreePath
278
+ });
279
+
280
+ throwIfAborted();
281
+ let result;
282
+ try {
283
+ result = await provider.execute(prompt, {
284
+ cwd: worktreePath,
285
+ logPrefix: TOUR_LOG_PREFIX,
286
+ abortSignal,
287
+ });
288
+ } catch (execErr) {
289
+ if (execErr && (execErr.name === 'AbortError' || execErr.isCancellation)) {
290
+ logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled during provider call`);
291
+ throw execErr;
292
+ }
293
+ logger.error(`${TOUR_LOG_PREFIX} review ${reviewId}: provider error: ${execErr.message}`);
294
+ return { generated: false, stops: 0, reason: 'provider_throw' };
295
+ }
296
+
297
+ let data;
298
+ if (result && Array.isArray(result.stops)) {
299
+ data = { stops: result.stops };
300
+ } else if (result && result.data && (result.parsed || result.success)) {
301
+ data = result.data;
302
+ } else {
303
+ const raw = (result && result.raw) || '';
304
+ const extracted = deps.extractJSON(raw, 'tour', TOUR_LOG_PREFIX);
305
+ if (!extracted || !extracted.success) {
306
+ const errMsg = extracted && extracted.error ? extracted.error : 'unknown error';
307
+ logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: JSON parse failed: ${errMsg}`);
308
+ return { generated: false, stops: 0, reason: 'malformed' };
309
+ }
310
+ data = extracted.data;
311
+ }
312
+
313
+ if (!data || !Array.isArray(data.stops)) {
314
+ logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: response missing stops[]`);
315
+ return { generated: false, stops: 0, reason: 'malformed' };
316
+ }
317
+
318
+ const changedLines = buildChangedLineIndex(hunksByFile);
319
+ const validationCtx = {
320
+ hunksByFile,
321
+ changedLines,
322
+ worktreePath
323
+ };
324
+
325
+ const validated = [];
326
+ for (const stop of data.stops) {
327
+ if (validated.length >= TOUR_MAX_STOPS) break;
328
+ const cleaned = await validateStop(stop, validationCtx);
329
+ if (!cleaned) continue;
330
+
331
+ // Drop stops that overlap an already-accepted stop on the same
332
+ // (file_path, side). Two ranges [a,b] and [c,d] overlap iff a <= d && c <= b.
333
+ const overlaps = validated.some((accepted) => (
334
+ accepted.file_path === cleaned.file_path
335
+ && accepted.side === cleaned.side
336
+ && cleaned.line_start <= accepted.line_end
337
+ && accepted.line_start <= cleaned.line_end
338
+ ));
339
+ if (overlaps) {
340
+ logger.debug(
341
+ `${TOUR_LOG_PREFIX} review ${reviewId}: dropping overlapping stop ${cleaned.file_path}:${cleaned.line_start}-${cleaned.line_end} (${cleaned.side})`
342
+ );
343
+ continue;
344
+ }
345
+
346
+ validated.push(cleaned);
347
+ }
348
+
349
+ if (validated.length < TOUR_PERSIST_MIN_STOPS) {
350
+ logger.info(
351
+ `${TOUR_LOG_PREFIX} review ${reviewId}: ${validated.length} valid stops after filtering; below persist threshold (${TOUR_PERSIST_MIN_STOPS}) — not tour-worthy`
352
+ );
353
+ return { generated: false, stops: 0, reason: 'not_tour_worthy' };
354
+ }
355
+
356
+ throwIfAborted();
357
+ // Last-chance superseded check: another kickoff with a different diff
358
+ // arrived while we were exploring/validating. Skip the write so we don't
359
+ // overwrite a tour that's about to be regenerated for a newer diff.
360
+ const latestBeforeUpsert = latestRequestedDiffHash.get(reviewId);
361
+ if (latestBeforeUpsert !== undefined && latestBeforeUpsert !== diffHash) {
362
+ logger.debug(
363
+ `${TOUR_LOG_PREFIX} review ${reviewId}: superseded before upsert (have ${latestBeforeUpsert}, this ${diffHash}); skipping persist`
364
+ );
365
+ return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
366
+ }
367
+
368
+ await tourRepo.upsert({
369
+ review_id: reviewId,
370
+ stops: JSON.stringify(validated),
371
+ diff_hash: diffHash,
372
+ provider: providerId,
373
+ model: resolvedModel
374
+ });
375
+
376
+ // Intentionally do NOT clear `latestRequestedDiffHash` on success. A
377
+ // predecessor worker whose cancel was lost (e.g., the provider's HTTP call
378
+ // didn't honor AbortSignal) may still be poised to reach its pre-upsert
379
+ // check. If we deleted our entry, that predecessor would see `undefined`,
380
+ // pass its `latestBeforeUpsert !== undefined && ...` guard, and overwrite
381
+ // our fresh row with a tour for the now-stale diff. Leaving the entry set
382
+ // to our hash makes the predecessor's hash mismatch and skip. The map
383
+ // grows by at most one entry per review until the next kickoff overwrites.
384
+ deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
385
+
386
+ logger.info(
387
+ `${TOUR_LOG_PREFIX} review ${reviewId}: persisted tour with ${validated.length} stops (diff_hash=${diffHash})`
388
+ );
389
+ return { generated: true, stops: validated.length };
390
+ }
391
+
392
+ /**
393
+ * Gate the tour job and enqueue it on the background queue.
394
+ *
395
+ * Dedup via the queue's `(reviewId, 'tour')` key — concurrent kickoffs share
396
+ * a single execution. Staleness is checked inside the generator itself via
397
+ * `diff_hash` comparison.
398
+ *
399
+ * `trigger` controls how the enabled/auto_generate config interacts with
400
+ * kickoff:
401
+ * - `'auto'` (default): requires `tours.enabled && tours.auto_generate`
402
+ * - `'manual'`: only requires `tours.enabled` (user-initiated start)
403
+ *
404
+ * @param {Object} params
405
+ * @param {Object} params.db
406
+ * @param {Object} params.config
407
+ * @param {number} params.reviewId
408
+ * @param {string} params.diffText
409
+ * @param {string} params.worktreePath
410
+ * @param {Object} [params.reviewContext]
411
+ * @param {'auto'|'manual'} [params.trigger='auto']
412
+ * @param {Object} [params._deps]
413
+ * @returns {Promise<Object>|null}
414
+ */
415
+ async function kickOffTourJob({
416
+ db,
417
+ config,
418
+ reviewId,
419
+ diffText,
420
+ worktreePath,
421
+ reviewContext,
422
+ trigger = 'auto',
423
+ _deps
424
+ }) {
425
+ const deps = { ...defaults, ...(_deps || {}) };
426
+
427
+ if (!deps.getTourEnabled(config)) return null;
428
+
429
+ if (!reviewId) {
430
+ logger.debug('kickOffTourJob skipped: missing reviewId');
431
+ return null;
432
+ }
433
+
434
+ const queue = deps.backgroundQueue || require('./background-queue').backgroundQueue;
435
+
436
+ // Cancel any in-flight tour job whose diff hash no longer matches. This is
437
+ // load-bearing for both cost (stops a stale provider call from burning
438
+ // tokens) and correctness — the in-generator superseded check relies on
439
+ // `latestRequestedDiffHash` reflecting the current desired snapshot. Calling
440
+ // this even when the new diff is empty (a valid terminal snapshot after a
441
+ // refresh or scope change) keeps the old worker from observing a stale,
442
+ // matching hash and persisting a tour the user has moved past.
443
+ const cancelActiveTourJob = () => {
444
+ if (
445
+ typeof queue.findActiveJobType === 'function' &&
446
+ queue.findActiveJobType(reviewId, 'tour') &&
447
+ typeof queue.cancel === 'function'
448
+ ) {
449
+ queue.cancel(reviewId, 'tour');
450
+ }
451
+ };
452
+
453
+ if (!diffText || !worktreePath) {
454
+ const missing = [];
455
+ if (!diffText) missing.push('diffText');
456
+ if (!worktreePath) missing.push('worktreePath');
457
+ logger.debug(`kickOffTourJob skipped: missing ${missing.join(', ')}`);
458
+ // Stamp a sentinel so any in-flight worker's pre-upsert check sees a
459
+ // different value than its own hash and bails. The sentinel is intentionally
460
+ // non-hashlike so a real diff can never collide with it.
461
+ //
462
+ // Run cleanup unconditionally (not gated on a prior in-process hash). The
463
+ // map is in-memory: after a server restart it's empty, but a persisted
464
+ // row from a pre-restart session can still exist. Without unconditional
465
+ // cleanup, the first post-restart empty-diff transition would leave a
466
+ // stale row that GET /api/reviews/:id/tour serves verbatim (no diff_hash
467
+ // check), pointing the UI at stops no longer in the diff. `deleteByReview`
468
+ // is idempotent; the `changes > 0` guard below suppresses the broadcast
469
+ // on a fresh review that never had a tour.
470
+ latestRequestedDiffHash.set(reviewId, '__empty__');
471
+ cancelActiveTourJob();
472
+ try {
473
+ const repo = new deps.TourRepository(db);
474
+ const result = await repo.deleteByReview(reviewId);
475
+ if (result && result.changes > 0) {
476
+ deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
477
+ }
478
+ } catch (err) {
479
+ logger.warn(
480
+ `${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on empty-diff cleanup: ${err.message}`
481
+ );
482
+ }
483
+ return null;
484
+ }
485
+
486
+ // Stamp the latest requested diff hash BEFORE enqueueing so that an
487
+ // in-flight job (potentially started by a previous kickoff with an older
488
+ // diff) can observe it and decide to skip persistence.
489
+ const diffHash = deps.hashDiff(diffText);
490
+ const previousHash = latestRequestedDiffHash.get(reviewId);
491
+ latestRequestedDiffHash.set(reviewId, diffHash);
492
+
493
+ // Belt-and-suspenders: if a tour job is in flight with a different diff
494
+ // hash, cancel it now. The worker's staleness check at persistence time
495
+ // would discard its output anyway (the suspenders); cancelling stops the
496
+ // upstream provider call from burning more tokens (the belt). The order
497
+ // matters: the hash above is already stamped, so any not-yet-cancelled
498
+ // worker observing the map sees the new hash and skips persistence.
499
+ if (previousHash && previousHash !== diffHash) {
500
+ cancelActiveTourJob();
501
+ }
502
+
503
+ // auto_generate gate, applied here — AFTER cancellation, the empty-diff
504
+ // cleanup, and the latestRequestedDiffHash stamp above — so all of that
505
+ // stale-state hygiene runs regardless of trigger. With `auto_generate`
506
+ // off, an auto-triggered kickoff (e.g. a refresh/scope-change re-invoke
507
+ // after a manual start) still cancels the in-flight job against the old
508
+ // diff and stamps the new hash; it simply declines to enqueue a
509
+ // replacement. Manual kickoffs always proceed.
510
+ //
511
+ // Before declining, reconcile the persisted row against the new diff. GET
512
+ // /api/reviews/:id/tour serves the row verbatim (no diff_hash check) and
513
+ // the frontend treats any non-empty stops as ready, so a stale row would
514
+ // map the old tour onto the new diff AND block the manual-generate click
515
+ // path (stops aren't empty). Compare against the PERSISTED diff_hash, not
516
+ // `previousHash`: `latestRequestedDiffHash` is empty after a server
517
+ // restart, so a previousHash-based guard would let a pre-restart stale
518
+ // row slip through. Same shape as the empty-diff branch above.
519
+ if (trigger !== 'manual' && !deps.getTourAutoGenerate(config)) {
520
+ try {
521
+ const repo = new deps.TourRepository(db);
522
+ const row = await repo.get(reviewId);
523
+ if (row && row.diff_hash !== diffHash) {
524
+ const result = await repo.deleteByReview(reviewId);
525
+ if (result && result.changes > 0) {
526
+ deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
527
+ }
528
+ }
529
+ } catch (err) {
530
+ logger.warn(
531
+ `${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on auto_generate=false gate: ${err.message}`
532
+ );
533
+ }
534
+ return null;
535
+ }
536
+
537
+ const worker = deps.generateTourForReview || generateTourForReview;
538
+ return queue.enqueue(reviewId, 'tour', (signal) =>
539
+ worker({
540
+ db,
541
+ config,
542
+ reviewId,
543
+ diffText,
544
+ worktreePath,
545
+ reviewContext,
546
+ // Thread the hash we already computed through to the worker rather
547
+ // than relying on the (implicit) invariant that hashDiff produces
548
+ // the same output for the same diffText in both call sites.
549
+ diffHash,
550
+ // The BackgroundQueue calls our thunk as `fn(signal)`; pass it on so
551
+ // a user-initiated cancel reaches the upstream provider call.
552
+ abortSignal: signal,
553
+ _deps
554
+ })
555
+ );
556
+ }
557
+
558
+ module.exports = {
559
+ generateTourForReview,
560
+ kickOffTourJob,
561
+ // Exported for tests
562
+ parseHunkHeader,
563
+ buildChangedLineIndex,
564
+ buildScriptCommand,
565
+ validateStop,
566
+ latestRequestedDiffHash,
567
+ resetLatestRequestedDiffHash: () => latestRequestedDiffHash.clear()
568
+ };
package/src/config.js CHANGED
@@ -23,6 +23,20 @@ const DEFAULT_CONFIG = {
23
23
  theme: "light",
24
24
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
25
25
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
26
+ tours: {
27
+ enabled: false, // When true, the guided-tour feature is available (toolbar button visible, etc.)
28
+ auto_generate: true, // When true, a tour generation job is kicked off automatically on review load
29
+ provider: "", // Provider for agentic tour generation. Empty = falls back to summaries.provider, then default_provider
30
+ model: "" // Model for tour generation. Empty = falls back to summaries.model resolution
31
+ },
32
+ summaries: {
33
+ enabled: false, // When true, the hunk-summaries feature is available (toolbar button + per-file toggles visible)
34
+ auto_generate: true, // When true, a summary generation job is kicked off automatically on review load
35
+ provider: "", // Provider for one-shot hunk summary AI tasks. Empty = falls back to default_provider
36
+ model: "", // Model for hunk summary tasks. Empty = uses provider's fast-tier model, then default_model
37
+ max_files: 50, // Skip summary generation for reviews touching more than this many files (perf cap)
38
+ max_lines_added: 3000 // Skip summary generation when the diff adds more than this many lines (perf cap)
39
+ },
26
40
  worktree_retention_days: 7,
27
41
  review_retention_days: 21,
28
42
  dev_mode: false, // When true, disables static file caching for development
@@ -121,6 +135,98 @@ function getDefaultModel(config) {
121
135
  return getConfigValue(config, 'default_model', 'model') || DEFAULT_CONFIG.default_model;
122
136
  }
123
137
 
138
+ /**
139
+ * Whether the summaries feature is enabled (toolbar button visible, kickoff allowed).
140
+ * @param {Object} config - Configuration object
141
+ * @returns {boolean}
142
+ */
143
+ function getSummaryEnabled(config) {
144
+ return Boolean(config && config.summaries && config.summaries.enabled === true);
145
+ }
146
+
147
+ /**
148
+ * Whether summaries should auto-generate on review load. Defaults to true when
149
+ * unset so the feature stays opt-out within the enabled flag.
150
+ * @param {Object} config - Configuration object
151
+ * @returns {boolean}
152
+ */
153
+ function getSummaryAutoGenerate(config) {
154
+ if (!config || !config.summaries) return true;
155
+ return config.summaries.auto_generate !== false;
156
+ }
157
+
158
+ /**
159
+ * Whether the tours feature is enabled (toolbar button visible, kickoff allowed).
160
+ * @param {Object} config - Configuration object
161
+ * @returns {boolean}
162
+ */
163
+ function getTourEnabled(config) {
164
+ return Boolean(config && config.tours && config.tours.enabled === true);
165
+ }
166
+
167
+ /**
168
+ * Whether tours should auto-generate on review load. Defaults to true when
169
+ * unset so the feature stays opt-out within the enabled flag.
170
+ * @param {Object} config - Configuration object
171
+ * @returns {boolean}
172
+ */
173
+ function getTourAutoGenerate(config) {
174
+ if (!config || !config.tours) return true;
175
+ return config.tours.auto_generate !== false;
176
+ }
177
+
178
+ /**
179
+ * Gets the summary provider for summary/tour generation
180
+ * Falls back to default_provider when summaries.provider is not set
181
+ * @param {Object} config - Configuration object
182
+ * @returns {string} - Provider name
183
+ */
184
+ function getSummaryProvider(config) {
185
+ const explicit = config && config.summaries && config.summaries.provider;
186
+ return explicit || getDefaultProvider(config);
187
+ }
188
+
189
+ /**
190
+ * Gets the summary model for summary/tour generation
191
+ * Resolution order: summaries.model → providerClass fast-tier → default_model
192
+ * @param {Object} config - Configuration object
193
+ * @param {Function} [providerClass] - Optional provider class with static getModels()
194
+ * @returns {string} - Model name
195
+ */
196
+ function getSummaryModel(config, providerClass = null) {
197
+ const explicit = config && config.summaries && config.summaries.model;
198
+ if (explicit) return explicit;
199
+ if (providerClass && typeof providerClass.getModels === 'function') {
200
+ const fast = providerClass.getModels().find(m => m.tier === 'fast');
201
+ if (fast) return fast.id;
202
+ }
203
+ return getDefaultModel(config);
204
+ }
205
+
206
+ /**
207
+ * Gets the provider for tour generation.
208
+ * Resolution order: tours.provider → summaries.provider → default_provider
209
+ * @param {Object} config - Configuration object
210
+ * @returns {string} - Provider name
211
+ */
212
+ function getTourProvider(config) {
213
+ const explicit = config && config.tours && config.tours.provider;
214
+ return explicit || getSummaryProvider(config);
215
+ }
216
+
217
+ /**
218
+ * Gets the model for tour generation.
219
+ * Resolution order: tours.model → summaries.model → providerClass fast-tier → default_model
220
+ * @param {Object} config - Configuration object
221
+ * @param {Function} [providerClass] - Optional provider class with static getModels()
222
+ * @returns {string} - Model name
223
+ */
224
+ function getTourModel(config, providerClass = null) {
225
+ const explicit = config && config.tours && config.tours.model;
226
+ if (explicit) return explicit;
227
+ return getSummaryModel(config, providerClass);
228
+ }
229
+
124
230
  /**
125
231
  * Copies the example config file to the user's config directory
126
232
  * @returns {Promise<boolean>} True if copied successfully, false if source doesn't exist
@@ -763,6 +869,14 @@ module.exports = {
763
869
  getGitHubToken,
764
870
  getDefaultProvider,
765
871
  getDefaultModel,
872
+ getSummaryProvider,
873
+ getSummaryModel,
874
+ getSummaryEnabled,
875
+ getSummaryAutoGenerate,
876
+ getTourProvider,
877
+ getTourModel,
878
+ getTourEnabled,
879
+ getTourAutoGenerate,
766
880
  isRunningViaNpx,
767
881
  showWelcomeMessage,
768
882
  expandPath,