@in-the-loop-labs/pair-review 3.1.1 → 3.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -57,11 +57,29 @@ class DiffOptionsDropdown {
57
57
  this._outsideClickHandler = null;
58
58
  this._escapeHandler = null;
59
59
 
60
- // Scope state
61
- const LS = window.LocalScope;
60
+ // Scope state — resolve LocalScope with inline fallback so the scope selector
61
+ // renders even if window.LocalScope failed to load (race condition guard).
62
+ const FALLBACK_STOPS = ['branch', 'staged', 'unstaged', 'untracked']; // Keep in sync with local-scope.js:STOPS
63
+ const FALLBACK_DEFAULT = { start: 'unstaged', end: 'untracked' };
64
+ this._localScope = window.LocalScope || {
65
+ STOPS: FALLBACK_STOPS,
66
+ DEFAULT_SCOPE: FALLBACK_DEFAULT,
67
+ isValidScope: (s, e) => {
68
+ const si = FALLBACK_STOPS.indexOf(s);
69
+ const ei = FALLBACK_STOPS.indexOf(e);
70
+ return si !== -1 && ei !== -1 && si <= ei;
71
+ },
72
+ scopeIncludes: (s, e, stop) => {
73
+ const si = FALLBACK_STOPS.indexOf(s);
74
+ const ei = FALLBACK_STOPS.indexOf(e);
75
+ const ti = FALLBACK_STOPS.indexOf(stop);
76
+ return ti !== -1 && ti >= si && ti <= ei;
77
+ }
78
+ };
79
+ const LS = this._localScope;
62
80
  this._branchAvailable = Boolean(branchAvailable);
63
- this._scopeStart = (initialScope && initialScope.start) || (LS ? LS.DEFAULT_SCOPE.start : 'unstaged');
64
- this._scopeEnd = (initialScope && initialScope.end) || (LS ? LS.DEFAULT_SCOPE.end : 'untracked');
81
+ this._scopeStart = (initialScope && initialScope.start) || LS.DEFAULT_SCOPE.start;
82
+ this._scopeEnd = (initialScope && initialScope.end) || LS.DEFAULT_SCOPE.end;
65
83
  this._scopeStops = [];
66
84
  this._scopeTrackEl = null;
67
85
  this._scopeDebounceTimer = null;
@@ -144,7 +162,7 @@ class DiffOptionsDropdown {
144
162
  /** Programmatically set scope. */
145
163
  set scope(val) {
146
164
  if (!val) return;
147
- const LS = window.LocalScope;
165
+ const LS = this._localScope;
148
166
  if (LS && !LS.isValidScope(val.start, val.end)) return;
149
167
  this._scopeStart = val.start;
150
168
  this._scopeEnd = val.end;
@@ -188,8 +206,11 @@ class DiffOptionsDropdown {
188
206
  popover.style.zIndex = '1100';
189
207
  popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
190
208
 
191
- // Scope selector first — only in local mode
192
- if (window.PAIR_REVIEW_LOCAL_MODE && window.LocalScope) {
209
+ // Scope selector first — only in local mode.
210
+ // Belt-and-suspenders: also render when scope callbacks were explicitly provided,
211
+ // in case a race condition prevents the globals from being set in time.
212
+ const hasLocalScope = (window.PAIR_REVIEW_LOCAL_MODE && window.LocalScope) || this._onScopeChange;
213
+ if (hasLocalScope) {
193
214
  this._renderScopeSelector(popover);
194
215
 
195
216
  // Divider between scope selector and whitespace checkbox
@@ -261,7 +282,7 @@ class DiffOptionsDropdown {
261
282
  }
262
283
 
263
284
  _renderScopeSelector(popover) {
264
- const LS = window.LocalScope;
285
+ const LS = this._localScope;
265
286
 
266
287
  // Section container — generous horizontal padding so dots/labels breathe
267
288
  const section = document.createElement('div');
@@ -362,13 +383,43 @@ class DiffOptionsDropdown {
362
383
  stopEl.appendChild(dot);
363
384
  stopEl.appendChild(labelEl);
364
385
 
386
+ // Custom tooltip element (positioned above the dot, hidden by default)
387
+ const tooltipEl = document.createElement('div');
388
+ tooltipEl.style.position = 'absolute';
389
+ tooltipEl.style.bottom = '100%';
390
+ tooltipEl.style.left = '50%';
391
+ tooltipEl.style.transform = 'translateX(-50%)';
392
+ tooltipEl.style.marginBottom = '6px';
393
+ tooltipEl.style.padding = '4px 8px';
394
+ tooltipEl.style.fontSize = '11px';
395
+ tooltipEl.style.lineHeight = '1.3';
396
+ tooltipEl.style.color = 'var(--color-text-on-emphasis, #ffffff)';
397
+ tooltipEl.style.background = 'var(--color-neutral-emphasis, #24292f)';
398
+ tooltipEl.style.borderRadius = '4px';
399
+ tooltipEl.style.whiteSpace = 'nowrap';
400
+ tooltipEl.style.pointerEvents = 'none';
401
+ tooltipEl.style.opacity = '0';
402
+ tooltipEl.style.transition = 'opacity 0.12s ease';
403
+ tooltipEl.style.zIndex = '2';
404
+ stopEl.style.position = 'relative';
405
+ stopEl.appendChild(tooltipEl);
406
+
407
+ stopEl.addEventListener('mouseenter', () => {
408
+ if (tooltipEl.textContent) {
409
+ tooltipEl.style.opacity = '1';
410
+ }
411
+ });
412
+ stopEl.addEventListener('mouseleave', () => {
413
+ tooltipEl.style.opacity = '0';
414
+ });
415
+
365
416
  stopEl.addEventListener('click', (e) => {
366
417
  e.stopPropagation();
367
418
  this._handleStopClick(stop, e);
368
419
  });
369
420
 
370
421
  stopsRow.appendChild(stopEl);
371
- this._scopeStops.push({ stop, dotEl: dot, labelEl, containerEl: stopEl });
422
+ this._scopeStops.push({ stop, dotEl: dot, labelEl, containerEl: stopEl, tooltipEl });
372
423
  });
373
424
 
374
425
  trackContainer.appendChild(stopsRow);
@@ -382,7 +433,7 @@ class DiffOptionsDropdown {
382
433
  }
383
434
 
384
435
  _handleStopClick(clickedStop, event) {
385
- const LS = window.LocalScope;
436
+ const LS = this._localScope;
386
437
  if (!LS) return;
387
438
 
388
439
  // Branch disabled? Ignore.
@@ -396,10 +447,14 @@ class DiffOptionsDropdown {
396
447
  let newStart = this._scopeStart;
397
448
  let newEnd = this._scopeEnd;
398
449
 
399
- // Alt/Option-click: solo-select this single stop
450
+ // 'unstaged' is always included — the AI reads files from the working
451
+ // tree, so the diff must always cover at least the unstaged state.
452
+ const ui = stops.indexOf('unstaged');
453
+
454
+ // Alt/Option-click: select this stop with minimum scope including unstaged
400
455
  if (event && event.altKey) {
401
- newStart = clickedStop;
402
- newEnd = clickedStop;
456
+ newStart = stops[Math.min(ci, ui)];
457
+ newEnd = stops[Math.max(ci, ui)];
403
458
  } else {
404
459
  // Checkbox-like toggle with contiguity constraint
405
460
  const included = ci >= si && ci <= ei;
@@ -407,6 +462,7 @@ class DiffOptionsDropdown {
407
462
  if (included) {
408
463
  // Toggling OFF — only allowed at boundaries, and range must have >1 stop
409
464
  if (si === ei) return;
465
+ if (clickedStop === 'unstaged') return; // unstaged is mandatory
410
466
  if (ci === si) {
411
467
  newStart = stops[si + 1];
412
468
  } else if (ci === ei) {
@@ -457,30 +513,48 @@ class DiffOptionsDropdown {
457
513
  }
458
514
 
459
515
  _updateScopeUI() {
460
- const LS = window.LocalScope;
516
+ const LS = this._localScope;
461
517
  if (!LS || !this._scopeStops.length) return;
462
518
 
463
519
  const stops = LS.STOPS;
464
520
  const si = stops.indexOf(this._scopeStart);
465
521
  const ei = stops.indexOf(this._scopeEnd);
466
522
 
467
- this._scopeStops.forEach(({ stop, dotEl, labelEl, containerEl }, i) => {
523
+ this._scopeStops.forEach(({ stop, dotEl, labelEl, containerEl, tooltipEl }, i) => {
468
524
  const included = LS.scopeIncludes(this._scopeStart, this._scopeEnd, stop);
469
525
  const isBranch = stop === 'branch';
470
526
  const disabled = isBranch && !this._branchAvailable;
471
527
 
472
- // Determine if clicking this stop would do anything (for cursor hint)
473
- const isBoundary = included && (i === si || i === ei) && si !== ei;
528
+ // Determine if clicking this stop would do anything (for cursor hint).
529
+ // 'unstaged' is mandatory and cannot be toggled off, so it is never a
530
+ // clickable boundary even when it sits at a range edge.
531
+ const isMandatory = stop === 'unstaged';
532
+ const atRangeEdge = included && (i === si || i === ei) && si !== ei;
533
+ const isBoundary = atRangeEdge && !isMandatory;
474
534
  const isAdjacent = !included && (i === si - 1 || i === ei + 1);
475
535
  const clickable = !disabled && (isBoundary || isAdjacent);
476
536
 
537
+ // Tooltip for disabled branch stop
538
+ containerEl.title = disabled ? 'No feature branch detected' : '';
539
+
540
+ // Mandatory stop sitting at a range edge — user might expect to toggle
541
+ // it off but can't. Show not-allowed cursor and explanatory tooltip.
542
+ const mandatoryEdge = isMandatory && atRangeEdge;
543
+
544
+ // Update tooltip text (empty string hides the tooltip on hover)
545
+ if (tooltipEl) {
546
+ tooltipEl.textContent = mandatoryEdge
547
+ ? 'Unstaged changes are always included \u2014 the agent reads from your working tree'
548
+ : '';
549
+ }
550
+
477
551
  if (disabled) {
478
552
  // Disabled state
479
553
  dotEl.style.background = 'var(--color-bg-tertiary, #f6f8fa)';
480
554
  dotEl.style.borderColor = 'var(--color-border-secondary, #e1e4e8)';
481
555
  dotEl.style.boxShadow = 'none';
482
556
  labelEl.style.color = 'var(--color-text-tertiary, #8b949e)';
483
- containerEl.style.cursor = 'not-allowed';
557
+ containerEl.style.cursor = 'default';
484
558
  containerEl.style.opacity = '0.5';
485
559
  } else if (included) {
486
560
  // Included (filled) state
@@ -489,7 +563,7 @@ class DiffOptionsDropdown {
489
563
  dotEl.style.boxShadow = '0 0 0 2px rgba(139, 92, 246, 0.2)';
490
564
  labelEl.style.color = 'var(--color-text-primary, #24292f)';
491
565
  labelEl.style.fontWeight = '600';
492
- containerEl.style.cursor = clickable ? 'pointer' : 'default';
566
+ containerEl.style.cursor = clickable ? 'pointer' : (mandatoryEdge ? 'not-allowed' : 'default');
493
567
  containerEl.style.opacity = '1';
494
568
  } else {
495
569
  // Excluded (empty) state
@@ -126,8 +126,8 @@ async function runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetad
126
126
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pair-review-exec-'));
127
127
  try {
128
128
  const executableContext = {
129
- title: prMetadata.title || '',
130
- description: prMetadata.description || '',
129
+ title: prMetadata.title || null,
130
+ description: prMetadata.description || null,
131
131
  cwd: worktreePath,
132
132
  outputDir: tmpDir,
133
133
  model: voiceProvider.resolvedModel !== undefined ? voiceProvider.resolvedModel : (voiceProvider.model || null),
@@ -176,11 +176,16 @@ async function runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetad
176
176
  });
177
177
 
178
178
  if (!result?.success || !result?.data) {
179
+ if (progressCallback) {
180
+ progressCallback({ level: 'exec', status: 'failed', progress: 'External tool returned no data' });
181
+ }
179
182
  throw new Error(`${logPrefix || ''}Executable provider returned no data`);
180
183
  }
181
184
 
185
+ const suggestions = result.data.suggestions || [];
186
+
182
187
  return {
183
- suggestions: result.data.suggestions || [],
188
+ suggestions,
184
189
  summary: result.data.summary || ''
185
190
  };
186
191
  } finally {
@@ -2948,6 +2953,10 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2948
2953
  const finalSuggestions = this.validateAndFinalizeSuggestions(result.suggestions, fileLineCountMap, validFiles);
2949
2954
  await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
2950
2955
 
2956
+ if (progressCallback) {
2957
+ progressCallback({ level: 'exec', status: 'completed', progress: `External tool complete: ${finalSuggestions.length} suggestions` });
2958
+ }
2959
+
2951
2960
  try {
2952
2961
  await analysisRunRepo.update(parentRunId, {
2953
2962
  status: 'completed',
@@ -3074,6 +3083,10 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3074
3083
  // Store validated voice suggestions
3075
3084
  await commentRepo.bulkInsertAISuggestions(reviewId, childRunId, validatedSuggestions, null);
3076
3085
 
3086
+ if (voiceProgressCallback) {
3087
+ voiceProgressCallback({ level: 'exec', status: 'completed', progress: `External tool complete: ${validatedSuggestions.length} suggestions` });
3088
+ }
3089
+
3077
3090
  const validatedResult = { ...result, suggestions: validatedSuggestions };
3078
3091
  return { voiceKey, reviewerLabel, childRunId, result: validatedResult, provider: voice.provider, model: voice.model, isExecutable: true, customInstructions: voice.customInstructions || null };
3079
3092
  }
@@ -3121,6 +3134,11 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3121
3134
  } catch (err) {
3122
3135
  logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
3123
3136
  }
3137
+ // Notify the progress dialog so the voice row stops spinning
3138
+ if (isExecutable && voiceProgressCallback) {
3139
+ const terminalStatus = error.isCancellation ? 'cancelled' : 'failed';
3140
+ voiceProgressCallback({ level: 'exec', status: terminalStatus, progress: error.message || 'Voice failed' });
3141
+ }
3124
3142
  throw error;
3125
3143
  }
3126
3144
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  const logger = require('../utils/logger');
11
- const { scopeGitHints, DEFAULT_SCOPE } = require('../local-scope');
11
+ const { scopeGitHints, reviewScope } = require('../local-scope');
12
12
 
13
13
  /**
14
14
  * Build a lean system prompt for chat sessions.
@@ -142,8 +142,7 @@ function buildReviewContext(review, prData) {
142
142
  lines.push('');
143
143
  lines.push('## Viewing Code Changes');
144
144
 
145
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
146
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
145
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
147
146
  const baseBranch = review.local_base_branch || null;
148
147
  const hints = scopeGitHints(scopeStart, scopeEnd, baseBranch);
149
148
 
package/src/config.js CHANGED
@@ -385,6 +385,7 @@ function showWelcomeMessage() {
385
385
  */
386
386
  function expandPath(p) {
387
387
  if (!p) return p;
388
+ if (p === '~') return os.homedir();
388
389
  if (p.startsWith('~/')) {
389
390
  return path.join(os.homedir(), p.slice(2));
390
391
  }
package/src/database.js CHANGED
@@ -20,7 +20,7 @@ function getDbPath() {
20
20
  /**
21
21
  * Current schema version - increment this when adding new migrations
22
22
  */
23
- const CURRENT_SCHEMA_VERSION = 35;
23
+ const CURRENT_SCHEMA_VERSION = 36;
24
24
 
25
25
  /**
26
26
  * Database schema SQL statements
@@ -1605,6 +1605,36 @@ const MIGRATIONS = {
1605
1605
  addColumnIfNotExists('comments', 'severity', 'TEXT');
1606
1606
 
1607
1607
  console.log('Migration to schema version 35 complete');
1608
+ },
1609
+
1610
+ // Migration to version 36: Normalize diff scopes to always include 'unstaged'.
1611
+ // AI models read files from the working tree, so the diff scope must always
1612
+ // cover at least the unstaged state for review context to be coherent.
1613
+ 36: (db) => {
1614
+ console.log('Running migration to schema version 36...');
1615
+
1616
+ // Expand scopes where end < unstaged (branch-only, branch-staged, staged-only)
1617
+ const expandEnd = db.prepare(
1618
+ `UPDATE reviews SET local_scope_end = 'unstaged'
1619
+ WHERE local_scope_end IN ('branch', 'staged') AND review_type = 'local'`
1620
+ );
1621
+ const expandResult = expandEnd.run();
1622
+ if (expandResult.changes > 0) {
1623
+ console.log(` Expanded scope end to 'unstaged' for ${expandResult.changes} review(s)`);
1624
+ }
1625
+
1626
+ // Fix untracked-only → unstaged..untracked
1627
+ const fixUntracked = db.prepare(
1628
+ `UPDATE reviews SET local_scope_start = 'unstaged'
1629
+ WHERE local_scope_start = 'untracked' AND local_scope_end = 'untracked'
1630
+ AND review_type = 'local'`
1631
+ );
1632
+ const fixResult = fixUntracked.run();
1633
+ if (fixResult.changes > 0) {
1634
+ console.log(` Normalized untracked-only scope for ${fixResult.changes} review(s)`);
1635
+ }
1636
+
1637
+ console.log('Migration to schema version 36 complete');
1608
1638
  }
1609
1639
  };
1610
1640
 
@@ -30,7 +30,7 @@ class PRArgumentParser {
30
30
  // Check if input is a PR number
31
31
  const prNumber = parseInt(input);
32
32
  if (isNaN(prNumber) || prNumber <= 0) {
33
- throw new Error('Invalid input format. Expected: PR number, GitHub URL (https://github.com/owner/repo/pull/number), Graphite URL (https://app.graphite.com/github/pr/owner/repo/number), or pair-review:// URL');
33
+ throw new Error('Invalid input format. Expected: PR number, GitHub URL (https://github.com/owner/repo/pull/number), Graphite URL (https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number), or pair-review:// URL');
34
34
  }
35
35
 
36
36
  // Parse repository from current directory's git remote
@@ -112,14 +112,18 @@ class PRArgumentParser {
112
112
  * @returns {Object} Parsed information { owner, repo, number }
113
113
  */
114
114
  parseGraphiteURL(url) {
115
- // Match Graphite PR URL pattern: https://app.graphite.{dev|com}/github/pr/owner/repo/number[/optional-title]
116
- const match = url.match(/^https:\/\/app\.graphite\.(?:dev|com)\/github\/pr\/([^\/]+)\/([^\/]+)\/(\d+)(?:\/[^?]*)?(?:\?.*)?$/);
115
+ // Match Graphite PR URL patterns:
116
+ // https://app.graphite.{dev|com}/github/pr/owner/repo/number[/optional-title]
117
+ // https://app.graphite.{dev|com}/github/owner/repo/pull/number[/optional-title]
118
+ const match = url.match(/^https:\/\/app\.graphite\.(?:dev|com)\/github\/(?:pr\/([^\/]+)\/([^\/]+)\/(\d+)|([^\/]+)\/([^\/]+)\/pull\/(\d+))(?:\/[^?]*)?(?:\?.*)?$/);
117
119
 
118
120
  if (!match) {
119
- throw new Error('Invalid Graphite URL format. Expected: https://app.graphite.com/github/pr/owner/repo/number');
121
+ throw new Error('Invalid Graphite URL format. Expected: https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number');
120
122
  }
121
123
 
122
- const [, owner, repo, numberStr] = match;
124
+ const owner = match[1] || match[4];
125
+ const repo = match[2] || match[5];
126
+ const numberStr = match[3] || match[6];
123
127
  return this._createPRInfo(owner, repo, numberStr, 'Graphite');
124
128
  }
125
129
 
@@ -156,7 +160,7 @@ class PRArgumentParser {
156
160
  ? 'https://github.com/owner/repo/pull/number'
157
161
  : source === 'pair-review://'
158
162
  ? 'pair-review://pr/owner/repo/number'
159
- : 'https://app.graphite.com/github/pr/owner/repo/number';
163
+ : 'https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number';
160
164
  throw new Error(`Invalid ${source} URL format. Expected: ${exampleUrl}`);
161
165
  }
162
166
 
@@ -10,7 +10,7 @@ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
10
10
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
11
11
 
12
12
  const execAsync = promisify(exec);
13
- const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel } = require('./local-scope');
13
+ const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
14
14
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
15
15
  const { startServer } = require('./server');
16
16
  const { localReviewDiffs } = require('./routes/shared');
@@ -472,6 +472,13 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
472
472
  const hasUnstaged = scopeIncludes(scopeStart, scopeEnd, 'unstaged');
473
473
  const hasUntracked = scopeIncludes(scopeStart, scopeEnd, 'untracked');
474
474
 
475
+ // Fail fast if the scope is invalid. scopeIncludes returns false for all
476
+ // stops when the scope is invalid, so all four flags would be false and the
477
+ // branching logic below would silently produce a wrong diff.
478
+ if (!hasUnstaged) {
479
+ throw new Error(`Invalid scope ${scopeStart}..${scopeEnd}: scope must include 'unstaged'`);
480
+ }
481
+
475
482
  let mergeBaseSha = null;
476
483
  let diff = '';
477
484
 
@@ -483,46 +490,30 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
483
490
  mergeBaseSha = await findMergeBase(repoPath, baseBranch);
484
491
  }
485
492
 
486
- // Build the git diff command based on scope range
493
+ // Build the git diff command based on scope range.
494
+ // hasUnstaged is always true by invariant — isValidScope requires the scope
495
+ // to include 'unstaged', since AI models read files from the working tree
496
+ // and the diff must cover at least the unstaged state.
487
497
  try {
488
- if (hasBranch && !hasStaged && !hasUnstaged) {
489
- // Branch onlycommitted changes since merge-base
490
- diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
491
- cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
492
- maxBuffer: 50 * 1024 * 1024
493
- });
494
- } else if (hasBranch && hasStaged && !hasUnstaged) {
495
- // Branch–Staged → staged changes relative to merge-base
496
- diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
497
- cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
498
- maxBuffer: 50 * 1024 * 1024
499
- });
500
- } else if (hasBranch && hasUnstaged) {
501
- // Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
498
+ if (hasBranch) {
499
+ // Branch scopeworking tree vs merge-base (includes committed + staged + unstaged)
502
500
  diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
503
501
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
504
502
  maxBuffer: 50 * 1024 * 1024
505
503
  });
506
- } else if (hasStaged && !hasUnstaged) {
507
- // Staged onlycached changes
508
- diff = execSync(`git diff --cached ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
509
- cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
510
- maxBuffer: 50 * 1024 * 1024
511
- });
512
- } else if (hasStaged && hasUnstaged) {
513
- // Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
504
+ } else if (hasStaged) {
505
+ // Staged + Unstaged scope all changes vs HEAD
514
506
  diff = execSync(`git diff HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
515
507
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
516
508
  maxBuffer: 50 * 1024 * 1024
517
509
  });
518
- } else if (hasUnstaged) {
519
- // Unstaged only or Unstaged–Untracked → working tree changes
510
+ } else {
511
+ // Unstaged only (or Unstaged–Untracked) → working tree changes
520
512
  diff = execSync(`git diff ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
521
513
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
522
514
  maxBuffer: 50 * 1024 * 1024
523
515
  });
524
516
  }
525
- // hasUntracked-only: no git diff needed, just untracked files below
526
517
  } catch (error) {
527
518
  if (error.message && error.message.includes('maxBuffer')) {
528
519
  throw new Error('Diff output exceeded maximum buffer size (50MB).');
@@ -798,8 +789,8 @@ async function handleLocalReview(targetPath, flags = {}) {
798
789
  }
799
790
 
800
791
  // Read scope from session (or use defaults for new sessions)
801
- const scopeStart = existingReview?.local_scope_start || DEFAULT_SCOPE.start;
802
- const scopeEnd = existingReview?.local_scope_end || DEFAULT_SCOPE.end;
792
+ // Use reviewScope() to normalize legacy scopes that may not include 'unstaged'
793
+ const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
803
794
 
804
795
  // Fire review hook (non-blocking)
805
796
  const hookEvent = existingReview ? 'review.loaded' : 'review.started';
@@ -937,7 +928,7 @@ async function computeLocalDiffDigest(localPath) {
937
928
  * @returns {Promise<{diff: string, stats: Object, mergeBaseSha: string}>}
938
929
  */
939
930
  async function generateBranchDiff(repoPath, baseBranch, options = {}) {
940
- return generateScopedDiff(repoPath, 'branch', 'branch', baseBranch, options);
931
+ return generateScopedDiff(repoPath, 'branch', 'unstaged', baseBranch, options);
941
932
  }
942
933
 
943
934
  /**
@@ -4,10 +4,29 @@ const STOPS = ['branch', 'staged', 'unstaged', 'untracked'];
4
4
 
5
5
  const DEFAULT_SCOPE = { start: 'unstaged', end: 'untracked' };
6
6
 
7
+ const UNSTAGED_INDEX = STOPS.indexOf('unstaged');
8
+
7
9
  function isValidScope(start, end) {
8
10
  const si = STOPS.indexOf(start);
9
11
  const ei = STOPS.indexOf(end);
10
- return si !== -1 && ei !== -1 && si <= ei;
12
+ // Scope must be contiguous AND must include the 'unstaged' stop.
13
+ // This ensures the diff always covers the working tree state that AI models
14
+ // see when reading files, since we cannot modify local git state.
15
+ return si !== -1 && ei !== -1 && si <= ei && si <= UNSTAGED_INDEX && ei >= UNSTAGED_INDEX;
16
+ }
17
+
18
+ function normalizeScope(start, end) {
19
+ if (isValidScope(start, end)) return { start, end };
20
+ const si = STOPS.indexOf(start);
21
+ const ei = STOPS.indexOf(end);
22
+ if (si === -1 || ei === -1) return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
23
+ const newEi = Math.max(ei, UNSTAGED_INDEX);
24
+ const newSi = Math.min(si, UNSTAGED_INDEX);
25
+ const finalSi = Math.min(newSi, newEi);
26
+ const newStart = STOPS[finalSi];
27
+ const newEnd = STOPS[newEi];
28
+ if (isValidScope(newStart, newEnd)) return { start: newStart, end: newEnd };
29
+ return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
11
30
  }
12
31
 
13
32
  function scopeIncludes(start, end, stop) {
@@ -27,11 +46,18 @@ function fromLegacyMode(localMode) {
27
46
  return { start: 'unstaged', end: 'untracked' };
28
47
  }
29
48
  if (localMode === 'branch') {
30
- return { start: 'branch', end: 'branch' };
49
+ return { start: 'branch', end: 'unstaged' };
31
50
  }
32
51
  return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
33
52
  }
34
53
 
54
+ function reviewScope(review) {
55
+ return normalizeScope(
56
+ review.local_scope_start || DEFAULT_SCOPE.start,
57
+ review.local_scope_end || DEFAULT_SCOPE.end
58
+ );
59
+ }
60
+
35
61
  function scopeLabel(start, end) {
36
62
  if (!isValidScope(start, end)) return '';
37
63
  const label = s => s.charAt(0).toUpperCase() + s.slice(1);
@@ -57,16 +83,6 @@ function scopeGitHints(start, end, baseBranch) {
57
83
 
58
84
  const key = start + '-' + end;
59
85
  const hints = {
60
- 'branch-branch': {
61
- description: 'Committed changes on this branch since the merge-base.',
62
- diffCommand: 'git diff --no-ext-diff ' + mb + '..HEAD',
63
- excludes: 'Staged, unstaged, and untracked changes are NOT included in the review.'
64
- },
65
- 'branch-staged': {
66
- description: 'Committed changes plus staged changes, relative to the merge-base.',
67
- diffCommand: 'git diff --no-ext-diff --cached ' + mb,
68
- excludes: 'Unstaged and untracked changes are NOT included in the review.'
69
- },
70
86
  'branch-unstaged': {
71
87
  description: 'All tracked changes (committed, staged, and unstaged) relative to the merge-base.',
72
88
  diffCommand: 'git diff --no-ext-diff ' + mb,
@@ -77,11 +93,6 @@ function scopeGitHints(start, end, baseBranch) {
77
93
  diffCommand: 'git diff --no-ext-diff ' + mb,
78
94
  excludes: ''
79
95
  },
80
- 'staged-staged': {
81
- description: 'Only staged changes (added to the index but not yet committed).',
82
- diffCommand: 'git diff --no-ext-diff --cached',
83
- excludes: 'Unstaged, untracked, and committed branch changes are NOT included in the review.'
84
- },
85
96
  'staged-unstaged': {
86
97
  description: 'Staged and unstaged changes relative to HEAD.',
87
98
  diffCommand: 'git diff --no-ext-diff HEAD',
@@ -101,11 +112,6 @@ function scopeGitHints(start, end, baseBranch) {
101
112
  description: 'Unstaged and untracked local changes.',
102
113
  diffCommand: 'git diff --no-ext-diff',
103
114
  excludes: 'Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.'
104
- },
105
- 'untracked-untracked': {
106
- description: 'Only untracked files (new files not yet added to git).',
107
- diffCommand: 'git ls-files --others --exclude-standard',
108
- excludes: 'Tracked file changes (staged, unstaged, committed) are NOT included in the review.'
109
115
  }
110
116
  };
111
117
 
@@ -117,7 +123,7 @@ function scopeGitHints(start, end, baseBranch) {
117
123
  description: entry.description,
118
124
  diffCommand: entry.diffCommand,
119
125
  excludes: entry.excludes,
120
- includesUntracked: incUntracked && key !== 'untracked-untracked'
126
+ includesUntracked: incUntracked
121
127
  };
122
128
  }
123
129
 
@@ -125,6 +131,8 @@ const LocalScope = {
125
131
  STOPS,
126
132
  DEFAULT_SCOPE,
127
133
  isValidScope,
134
+ normalizeScope,
135
+ reviewScope,
128
136
  scopeIncludes,
129
137
  includesBranch,
130
138
  fromLegacyMode,
@@ -55,15 +55,10 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
55
55
  let diff;
56
56
  const extraFlags = diffArgs.length > 0 ? ' ' + diffArgs.join(' ') : '';
57
57
 
58
- if (context.baseSha && context.headSha) {
59
- // PR mode: straightforward base...head diff
60
- const { stdout } = await execPromise(
61
- `git diff ${GIT_DIFF_FLAGS}${extraFlags} ${context.baseSha}...${context.headSha}`,
62
- { cwd, maxBuffer: 50 * 1024 * 1024 }
63
- );
64
- diff = stdout;
65
- } else if (context.scopeStart && context.scopeEnd) {
66
- // Local mode: scope-aware diff generation
58
+ if (context.scopeStart && context.scopeEnd) {
59
+ // Local mode: scope-aware diff generation (checked first because local reviews
60
+ // may also carry baseSha/headSha pointing at the same commit, which would
61
+ // produce an empty diff if the SHA path ran instead).
67
62
  // Note: diffArgs are passed as extraArgs to generateScopedDiff, which handles
68
63
  // appending them to the git diff command internally (extraFlags is not used here).
69
64
  const result = await generateScopedDiff(
@@ -74,6 +69,13 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
74
69
  { contextLines: 3, extraArgs: diffArgs }
75
70
  );
76
71
  diff = result.diff;
72
+ } else if (context.baseSha && context.headSha) {
73
+ // PR mode: straightforward base...head diff
74
+ const { stdout } = await execPromise(
75
+ `git diff ${GIT_DIFF_FLAGS}${extraFlags} ${context.baseSha}...${context.headSha}`,
76
+ { cwd, maxBuffer: 50 * 1024 * 1024 }
77
+ );
78
+ diff = stdout;
77
79
  } else {
78
80
  // Fallback: simple working-tree diff
79
81
  const { stdout } = await execPromise(
@@ -26,7 +26,7 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStarte
26
26
  const { mergeInstructions } = require('../utils/instructions');
27
27
  const { getGitHubToken } = require('../config');
28
28
  const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
29
- const { STOPS, isValidScope, scopeIncludes, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
29
+ const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
30
30
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
31
31
  const { getShaAbbrevLength } = require('../git/sha-abbrev');
32
32
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
@@ -77,9 +77,10 @@ function deleteLocalReviewDiff(reviewId) {
77
77
  * Returns true if the guard fired (response already sent), false otherwise.
78
78
  */
79
79
  async function rejectIfEmptyScope(res, review, localPath) {
80
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
80
81
  const scopeContext = {
81
- scopeStart: review.local_scope_start || DEFAULT_SCOPE.start,
82
- scopeEnd: review.local_scope_end || DEFAULT_SCOPE.end,
82
+ scopeStart,
83
+ scopeEnd,
83
84
  baseBranch: review.local_base_branch || null,
84
85
  };
85
86
  const changedFiles = await getChangedFiles(localPath, scopeContext);
@@ -461,8 +462,7 @@ router.post('/api/local/start', async (req, res) => {
461
462
  const config = req.app.get('config') || {};
462
463
  // Generate diff using default scope
463
464
  logger.log('API', `Starting local review for ${repoPath}`, 'cyan');
464
- const scopeStart = existing?.local_scope_start || DEFAULT_SCOPE.start;
465
- const scopeEnd = existing?.local_scope_end || DEFAULT_SCOPE.end;
465
+ const { start: scopeStart, end: scopeEnd } = existing ? reviewScope(existing) : DEFAULT_SCOPE;
466
466
 
467
467
  // Fire review hook (non-blocking, after scope is resolved)
468
468
  const hookEvent = existing ? 'review.loaded' : 'review.started';
@@ -580,9 +580,11 @@ router.get('/api/local/:reviewId', async (req, res) => {
580
580
  }
581
581
  }
582
582
 
583
- // Build scope info for the response
584
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
585
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
583
+ // Build scope info for the response.
584
+ // normalizeScope clamps any legacy invalid scopes (e.g. branch-only,
585
+ // staged-only) to always include 'unstaged', since AI models read files
586
+ // from the working tree and the diff must match what they see.
587
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
586
588
  const baseBranch = review.local_base_branch || null;
587
589
 
588
590
  // When scope does NOT include branch, check for branch detection info
@@ -693,8 +695,7 @@ router.get('/api/local/:reviewId', async (req, res) => {
693
695
  const hookConfig = req.app.get('config') || {};
694
696
  if (hasHooks('review.loaded', hookConfig)) {
695
697
  getCachedUser(hookConfig).then(user => {
696
- const hookScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
697
- const hookScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
698
+ const { start: hookScopeStart, end: hookScopeEnd } = reviewScope(review);
698
699
  const si = STOPS.indexOf(hookScopeStart);
699
700
  const ei = STOPS.indexOf(hookScopeEnd);
700
701
  const scope = STOPS.slice(si, ei + 1);
@@ -787,8 +788,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
787
788
  // When ?w=1 or ?base=<branch>, regenerate the diff (transient view, not cached)
788
789
  const hideWhitespace = req.query.w === '1';
789
790
  const baseBranchOverride = req.query.base;
790
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
791
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
791
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
792
792
  const baseBranch = baseBranchOverride || review.local_base_branch;
793
793
  let diffData;
794
794
 
@@ -900,8 +900,7 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
900
900
  });
901
901
  }
902
902
 
903
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
904
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
903
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
905
904
 
906
905
  // Always check HEAD SHA for supplementary fields
907
906
  let headShaChanged = false;
@@ -1042,19 +1041,22 @@ async function handleExecutableAnalysis(req, res, {
1042
1041
  registerProcessForCancellation
1043
1042
  }, {
1044
1043
  logLabel: `Review #${reviewId}`,
1045
- buildContext: (r, { selectedModel: model, requestInstructions: customInstructions }) => ({
1046
- title: null,
1047
- description: null,
1048
- cwd: localPath,
1049
- model,
1050
- baseSha: null,
1051
- headSha: r.local_head_sha || null,
1052
- baseBranch: r.local_base_branch || null,
1053
- headBranch: r.local_head_branch || null,
1054
- scopeStart: r.local_scope_start || DEFAULT_SCOPE.start,
1055
- scopeEnd: r.local_scope_end || DEFAULT_SCOPE.end,
1056
- customInstructions: customInstructions || null
1057
- }),
1044
+ buildContext: (r, { selectedModel: model, requestInstructions: customInstructions }) => {
1045
+ const { start: scopeStart, end: scopeEnd } = reviewScope(r);
1046
+ return {
1047
+ title: null,
1048
+ description: null,
1049
+ cwd: localPath,
1050
+ model,
1051
+ baseSha: null,
1052
+ headSha: r.local_head_sha || null,
1053
+ baseBranch: r.local_base_branch || null,
1054
+ headBranch: r.local_head_branch || null,
1055
+ scopeStart,
1056
+ scopeEnd,
1057
+ customInstructions: customInstructions || null
1058
+ };
1059
+ },
1058
1060
  buildHookPayload: () => ({
1059
1061
  mode: review.review_type || 'local',
1060
1062
  localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha }
@@ -1185,8 +1187,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1185
1187
  }
1186
1188
 
1187
1189
  // Extract scope early — needed for both analysis run creation and diff generation
1188
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1189
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1190
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
1190
1191
 
1191
1192
  // Create DB analysis_runs record immediately so it's queryable for polling
1192
1193
  const analysisRunRepo = new AnalysisRunRepository(db);
@@ -1281,13 +1282,14 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1281
1282
  reviewType: 'local'
1282
1283
  };
1283
1284
 
1284
- // Get changed files for local mode path validation
1285
- // When branch is in scope, pass null so analyzer falls through to getChangedFilesList
1286
- // which correctly uses git diff base_sha...head_sha --name-only
1287
- const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
1288
- const changedFiles = hasBranch
1289
- ? null
1290
- : await analyzer.getLocalChangedFiles(localPath, { includeStaged: hasStaged });
1285
+ // Get changed files for local mode path validation.
1286
+ // Use the scope-aware helper so the file list matches the generated diff
1287
+ // (covers branch, staged, unstaged, and untracked stops as appropriate).
1288
+ const changedFiles = await getChangedFiles(localPath, {
1289
+ scopeStart,
1290
+ scopeEnd,
1291
+ baseBranch: review.local_base_branch || null,
1292
+ });
1291
1293
 
1292
1294
  // Log analysis start
1293
1295
  logger.section(`Local AI Analysis Request - Review #${reviewId}`);
@@ -1297,7 +1299,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1297
1299
  logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
1298
1300
  logger.log('API', `Model: ${selectedModel}`, 'cyan');
1299
1301
  logger.log('API', `Tier: ${tier}`, 'cyan');
1300
- logger.log('API', `Changed files: ${changedFiles ? changedFiles.length : '(branch mode)'}`, 'cyan');
1302
+ logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
1301
1303
  if (combinedInstructions) {
1302
1304
  logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
1303
1305
  }
@@ -1508,8 +1510,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1508
1510
 
1509
1511
  // Check if HEAD has changed
1510
1512
  const { getHeadSha } = require('../local-review');
1511
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1512
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1513
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
1513
1514
  const hasBranch = includesBranch(scopeStart);
1514
1515
  let currentHeadSha;
1515
1516
  let headShaChanged = false;
@@ -1627,8 +1628,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1627
1628
  return res.status(400).json({ error: 'Local review is missing path information' });
1628
1629
  }
1629
1630
 
1630
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1631
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1631
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
1632
1632
 
1633
1633
  if (action === 'update') {
1634
1634
  // Read live branch — may differ from stored value after a checkout.
@@ -1788,7 +1788,7 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1788
1788
  await reviewRepo.updateLocalHeadSha(reviewId, headSha);
1789
1789
 
1790
1790
  // Auto-name review from first commit subject when branch is newly in scope
1791
- const oldScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1791
+ const { start: oldScopeStart } = reviewScope(review);
1792
1792
  if (!review.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
1793
1793
  const firstSubject = await getFirstCommitSubject(localPath, baseBranch);
1794
1794
  if (firstSubject) {
@@ -2021,8 +2021,7 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2021
2021
  // Guard: reject if scope resolves to zero changed files
2022
2022
  if (await rejectIfEmptyScope(res, review, localPath)) return;
2023
2023
 
2024
- const councilScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
2025
- const councilScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
2024
+ const { start: councilScopeStart, end: councilScopeEnd } = reviewScope(review);
2026
2025
  const councilHasBranch = includesBranch(councilScopeStart);
2027
2026
 
2028
2027
  // Compute merge-base when branch is in scope
@@ -2038,8 +2037,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2038
2037
  const prMetadata = {
2039
2038
  reviewType: 'local',
2040
2039
  repository: review.repository,
2041
- title: review.name || (councilHasBranch ? `Branch changes: ${review.local_base_branch}..HEAD` : 'Local changes'),
2042
- description: '',
2040
+ title: null,
2041
+ description: null,
2043
2042
  base_sha: analysisBaseSha,
2044
2043
  head_sha: review.local_head_sha,
2045
2044
  base_branch: review.local_base_branch || null,
@@ -2049,10 +2048,13 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2049
2048
  };
2050
2049
 
2051
2050
  const analyzer = new Analyzer(db, 'council', 'council');
2052
- const councilHasStaged = scopeIncludes(councilScopeStart, councilScopeEnd, 'staged');
2053
- const changedFiles = councilHasBranch
2054
- ? null
2055
- : await analyzer.getLocalChangedFiles(localPath, { includeStaged: councilHasStaged });
2051
+ // Use the scope-aware helper so the file list matches the generated diff
2052
+ // (covers branch, staged, unstaged, and untracked stops as appropriate).
2053
+ const changedFiles = await getChangedFiles(localPath, {
2054
+ scopeStart: councilScopeStart,
2055
+ scopeEnd: councilScopeEnd,
2056
+ baseBranch: review.local_base_branch || null,
2057
+ });
2056
2058
 
2057
2059
  // Generate and cache diff
2058
2060
  try {
@@ -15,7 +15,7 @@ const crypto = require('crypto');
15
15
  const { activeSetups, broadcastSetupProgress } = require('./shared');
16
16
  const { setupPRReview } = require('../setup/pr-setup');
17
17
  const { setupLocalReview } = require('../setup/local-setup');
18
- const { getGitHubToken } = require('../config');
18
+ const { getGitHubToken, expandPath } = require('../config');
19
19
  const { queryOne } = require('../database');
20
20
  const { normalizeRepository } = require('../utils/paths');
21
21
  const logger = require('../utils/logger');
@@ -143,12 +143,13 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
143
143
  */
144
144
  router.post('/api/setup/local', async (req, res) => {
145
145
  try {
146
- const { path: targetPath } = req.body;
146
+ const { path: rawPath } = req.body;
147
147
 
148
- if (!targetPath) {
148
+ if (!rawPath) {
149
149
  return res.status(400).json({ error: 'Missing required field: path' });
150
150
  }
151
151
 
152
+ const targetPath = expandPath(rawPath);
152
153
  const db = req.app.get('db');
153
154
 
154
155
  // Concurrency guard
@@ -1,10 +1,10 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
- const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateLocalDiff, generateLocalReviewId, computeLocalDiffDigest, findMainGitRoot } = require('../local-review');
2
+ const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateScopedDiff, generateLocalReviewId, computeScopedDigest, findMainGitRoot } = require('../local-review');
3
3
  const { ReviewRepository, RepoSettingsRepository } = require('../database');
4
4
  const { localReviewDiffs } = require('../routes/shared');
5
5
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
6
6
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
7
- const { STOPS, DEFAULT_SCOPE } = require('../local-scope');
7
+ const { STOPS, DEFAULT_SCOPE, reviewScope } = require('../local-scope');
8
8
  const logger = require('../utils/logger');
9
9
  const path = require('path');
10
10
  const fs = require('fs').promises;
@@ -102,13 +102,16 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
102
102
  // ── Step: diff ──────────────────────────────────────────────────────
103
103
  let diff, stats, digest;
104
104
  try {
105
+ const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
106
+ const baseBranch = existingReview?.local_base_branch || null;
107
+
105
108
  progress({ step: 'diff', status: 'running', message: 'Generating diff for local changes...' });
106
109
 
107
- const diffResult = await generateLocalDiff(repoPath);
110
+ const diffResult = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
108
111
  diff = diffResult.diff;
109
112
  stats = diffResult.stats;
110
113
 
111
- digest = await computeLocalDiffDigest(repoPath);
114
+ digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
112
115
 
113
116
  progress({ step: 'diff', status: 'completed', message: `Diff ready: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked` });
114
117
  } catch (err) {
@@ -152,8 +155,7 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
152
155
  if (config && hasHooks(hookEvent, config)) {
153
156
  getCachedUser(config).then(user => {
154
157
  const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
155
- const scopeStart = existingReview?.local_scope_start || DEFAULT_SCOPE.start;
156
- const scopeEnd = existingReview?.local_scope_end || DEFAULT_SCOPE.end;
158
+ const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
157
159
  const si = STOPS.indexOf(scopeStart);
158
160
  const ei = STOPS.indexOf(scopeEnd);
159
161
  const scope = STOPS.slice(si, ei + 1);