@in-the-loop-labs/pair-review 3.1.2 → 3.1.4

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.
@@ -20,6 +20,16 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
20
20
  * Gemini model definitions with tier mappings
21
21
  */
22
22
  const GEMINI_MODELS = [
23
+ {
24
+ id: 'gemini-3.1-flash-lite-preview',
25
+ aliases: ['gemini-3.1-flash-lite'],
26
+ name: '3.1 Flash Lite',
27
+ tier: 'fast',
28
+ tagline: 'Cheapest',
29
+ description: 'Ultra-efficient model for high-volume cost-conscious scans',
30
+ badge: 'Cheapest',
31
+ badgeClass: 'badge-speed'
32
+ },
23
33
  {
24
34
  id: 'gemini-3-flash-preview',
25
35
  aliases: ['gemini-3-flash'],
@@ -16,7 +16,10 @@
16
16
  * for cross-provider switching, which translates to `--provider <provider> --model <model>`.
17
17
  */
18
18
 
19
+ const crypto = require('crypto');
19
20
  const path = require('path');
21
+ const os = require('os');
22
+ const fs = require('fs');
20
23
  const { spawn } = require('child_process');
21
24
  const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
22
25
  const logger = require('../utils/logger');
@@ -244,19 +247,23 @@ class PiProvider extends AIProvider {
244
247
 
245
248
  const levelPrefix = logPrefix || `[Level ${level}]`;
246
249
  logger.info(`${levelPrefix} Executing Pi CLI...`);
247
- logger.info(`${levelPrefix} Writing prompt via stdin: ${prompt.length} bytes`);
250
+ logger.info(`${levelPrefix} Prompt: ${prompt.length} bytes`);
251
+
252
+ // Write prompt to a temp file and use Pi's @file syntax as a positional arg.
253
+ // This bypasses devx stdin interference that breaks --mode json output.
254
+ const tmpFile = path.join(os.tmpdir(), `pair-review-prompt-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
255
+ fs.writeFileSync(tmpFile, prompt);
256
+ const cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
248
257
 
249
- // Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
250
- // Pi reads from stdin when using -p with no positional message arguments
251
258
  let fullCommand;
252
259
  let fullArgs;
253
260
 
254
261
  if (this.useShell) {
255
- fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
262
+ fullCommand = `${this.piCmd} ${quoteShellArgs([...this.baseArgs, `@${tmpFile}`]).join(' ')}`;
256
263
  fullArgs = [];
257
264
  } else {
258
265
  fullCommand = this.piCmd;
259
- fullArgs = [...this.baseArgs];
266
+ fullArgs = [...this.baseArgs, `@${tmpFile}`];
260
267
  }
261
268
 
262
269
  const pi = spawn(fullCommand, fullArgs, {
@@ -269,6 +276,10 @@ class PiProvider extends AIProvider {
269
276
  shell: this.useShell
270
277
  });
271
278
 
279
+ // Close stdin immediately — prompt is delivered via @file, but some
280
+ // wrappers (e.g., devx) keep the process alive until stdin is closed.
281
+ pi.stdin.end();
282
+
272
283
  const pid = pi.pid;
273
284
  logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
274
285
  logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
@@ -340,6 +351,7 @@ class PiProvider extends AIProvider {
340
351
 
341
352
  // Handle completion
342
353
  pi.on('close', (code) => {
354
+ cleanupTmpFile();
343
355
  if (settled) return; // Already settled by timeout or error
344
356
 
345
357
  // Flush any remaining stream parser buffer
@@ -413,7 +425,7 @@ class PiProvider extends AIProvider {
413
425
 
414
426
  // Use async IIFE to handle the async LLM extraction
415
427
  (async () => {
416
- // Guard: if already settled (by timeout, stdin error, or cancellation),
428
+ // Guard: if already settled (by timeout, process error, or cancellation),
417
429
  // skip the LLM extraction entirely to avoid misleading log output
418
430
  if (settled) return;
419
431
 
@@ -437,6 +449,7 @@ class PiProvider extends AIProvider {
437
449
 
438
450
  // Handle errors
439
451
  pi.on('error', (error) => {
452
+ cleanupTmpFile();
440
453
  if (error.code === 'ENOENT') {
441
454
  logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
442
455
  settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
@@ -445,21 +458,6 @@ class PiProvider extends AIProvider {
445
458
  settle(reject, error);
446
459
  }
447
460
  });
448
-
449
- // Handle stdin errors (e.g., EPIPE if process exits before write completes)
450
- pi.stdin.on('error', (err) => {
451
- logger.error(`${levelPrefix} stdin error: ${err.message}`);
452
- });
453
-
454
- // Send the prompt to stdin (Pi reads from stdin when using -p with no args)
455
- pi.stdin.write(prompt, (err) => {
456
- if (err) {
457
- logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
458
- pi.kill('SIGTERM');
459
- settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
460
- }
461
- });
462
- pi.stdin.end();
463
461
  });
464
462
  }
465
463
 
@@ -740,14 +738,13 @@ class PiProvider extends AIProvider {
740
738
  // Build args consistently using the shared method, applying provider and model extra_args
741
739
  const args = this.buildArgsForModel(model);
742
740
 
743
- // For extraction, we pass the prompt via stdin
744
- // Pi reads from stdin when using -p with no positional message arguments
741
+ // Use @file syntax for prompt delivery (bypasses devx stdin interference)
745
742
  if (useShell) {
746
743
  return {
747
744
  command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
748
745
  args: [],
749
746
  useShell: true,
750
- promptViaStdin: true,
747
+ promptViaFile: true,
751
748
  env: this.extraEnv
752
749
  };
753
750
  }
@@ -755,7 +752,7 @@ class PiProvider extends AIProvider {
755
752
  command: piCmd,
756
753
  args,
757
754
  useShell: false,
758
- promptViaStdin: true,
755
+ promptViaFile: true,
759
756
  env: this.extraEnv
760
757
  };
761
758
  }
@@ -6,7 +6,10 @@
6
6
  * and provides a factory function to create provider instances.
7
7
  */
8
8
 
9
+ const crypto = require('crypto');
9
10
  const path = require('path');
11
+ const os = require('os');
12
+ const fs = require('fs');
10
13
  const { spawn } = require('child_process');
11
14
  const logger = require('../utils/logger');
12
15
  const { extractJSON } = require('../utils/json-extractor');
@@ -181,6 +184,7 @@ class AIProvider {
181
184
  * @property {string[]} args - Arguments (prompt will be appended if promptViaStdin is false)
182
185
  * @property {boolean} useShell - Whether to use shell mode
183
186
  * @property {boolean} promptViaStdin - If true, send prompt to stdin; if false, append to args
187
+ * @property {boolean} promptViaFile - If true, write prompt to a temp file and pass @filepath as a positional arg (Pi-specific @file syntax; currently only used by PiProvider)
184
188
  */
185
189
  getExtractionConfig(model) {
186
190
  // Default: extraction not supported
@@ -213,7 +217,7 @@ class AIProvider {
213
217
  };
214
218
  }
215
219
 
216
- const { command, args, useShell, promptViaStdin, env: configEnv } = config;
220
+ const { command, args, useShell, promptViaStdin, promptViaFile, env: configEnv } = config;
217
221
  const prompt = `Extract the JSON object from the following text. Return ONLY the valid JSON, nothing else. Do not include any explanation, markdown formatting, or code blocks - just the raw JSON.
218
222
 
219
223
  === BEGIN INPUT TEXT ===
@@ -222,7 +226,21 @@ ${rawResponse}
222
226
 
223
227
  return new Promise((resolve) => {
224
228
  // Build final command and args based on prompt delivery method
225
- const finalArgs = promptViaStdin ? args : [...args, prompt];
229
+ // promptViaFile: write to temp file, pass @filepath as positional arg (Pi @file syntax)
230
+ // promptViaStdin: write to process stdin after spawn
231
+ // default: pass prompt as positional CLI arg
232
+ let tmpFile = null;
233
+ let cleanupTmpFile = () => {};
234
+ let finalArgs;
235
+
236
+ if (promptViaFile) {
237
+ tmpFile = path.join(os.tmpdir(), `pair-review-extract-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
238
+ fs.writeFileSync(tmpFile, prompt);
239
+ cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
240
+ finalArgs = [...args, `@${tmpFile}`];
241
+ } else {
242
+ finalArgs = promptViaStdin ? args : [...args, prompt];
243
+ }
226
244
 
227
245
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction with ${extractionModel}...`);
228
246
 
@@ -269,6 +287,7 @@ ${rawResponse}
269
287
  });
270
288
 
271
289
  proc.on('close', (code) => {
290
+ cleanupTmpFile();
272
291
  if (settled) return;
273
292
 
274
293
  if (code !== 0) {
@@ -295,11 +314,12 @@ ${rawResponse}
295
314
  });
296
315
 
297
316
  proc.on('error', (error) => {
317
+ cleanupTmpFile();
298
318
  logger.warn(`${levelPrefix} LLM extraction process error: ${error.message}`);
299
319
  settle({ success: false, error: error.message });
300
320
  });
301
321
 
302
- // Send prompt via stdin if configured
322
+ // Deliver prompt based on config method
303
323
  if (promptViaStdin) {
304
324
  // Handle stdin errors (e.g., EPIPE if process exits before write completes)
305
325
  proc.stdin.on('error', (err) => {
@@ -314,6 +334,9 @@ ${rawResponse}
314
334
  }
315
335
  });
316
336
  proc.stdin.end();
337
+ } else if (promptViaFile) {
338
+ // Prompt delivered via @file arg — close stdin so wrappers (e.g., devx) don't hang
339
+ proc.stdin.end();
317
340
  }
318
341
  });
319
342
  }
@@ -35,6 +35,7 @@ class PiBridge extends EventEmitter {
35
35
  * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
36
36
  * @param {string[]} [options.skills] - Array of skill file paths to load via --skill
37
37
  * @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
38
+ * @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
38
39
  * @param {string} [options.sessionPath] - Path to a session file for resumption
39
40
  */
40
41
  constructor(options = {}) {
@@ -49,6 +50,7 @@ class PiBridge extends EventEmitter {
49
50
  this.useShell = options.useShell || false;
50
51
  this.skills = options.skills || [];
51
52
  this.extensions = options.extensions || [];
53
+ this.extraArgs = options.extraArgs || [];
52
54
  this.sessionPath = options.sessionPath || null;
53
55
 
54
56
  this._process = null;
@@ -288,6 +290,12 @@ class PiBridge extends EventEmitter {
288
290
  args.push('-e', ext);
289
291
  }
290
292
 
293
+ // Append extra args from provider config (e.g., extra_args in chat_providers).
294
+ // These go last so they can override earlier flags if needed.
295
+ if (this.extraArgs.length > 0) {
296
+ args.push(...this.extraArgs);
297
+ }
298
+
291
299
  return args;
292
300
  }
293
301
 
@@ -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
 
@@ -580,6 +580,7 @@ class ChatSessionManager {
580
580
  provider: def?.provider || null,
581
581
  model: options.model || def?.model,
582
582
  piCommand: def?.command,
583
+ extraArgs: def?.args,
583
584
  env: def?.env,
584
585
  useShell: def?.useShell,
585
586
  tools: CHAT_TOOLS,
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
 
@@ -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(