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

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 (81) hide show
  1. package/package.json +2 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +103 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +87 -9
  21. package/public/js/components/AnalysisConfigModal.js +206 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +241 -23
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +183 -13
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +166 -51
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +538 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +91 -4
  59. package/src/chat/prompt-builder.js +39 -4
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +113 -29
  64. package/src/github/parser.js +1 -1
  65. package/src/local-review.js +15 -9
  66. package/src/local-scope.js +83 -0
  67. package/src/main.js +3 -2
  68. package/src/routes/analyses.js +34 -8
  69. package/src/routes/chat.js +15 -8
  70. package/src/routes/config.js +3 -120
  71. package/src/routes/councils.js +15 -6
  72. package/src/routes/executable-analysis.js +494 -0
  73. package/src/routes/local.js +152 -15
  74. package/src/routes/mcp.js +9 -4
  75. package/src/routes/pr.js +166 -29
  76. package/src/routes/reviews.js +31 -5
  77. package/src/routes/shared.js +72 -5
  78. package/src/routes/worktrees.js +4 -2
  79. package/src/utils/comment-formatter.js +28 -11
  80. package/src/utils/instructions.js +22 -8
  81. package/src/utils/logger.js +20 -10
@@ -44,6 +44,12 @@ class ChatSessionManager {
44
44
  * @returns {Promise<{id: number, status: string}>}
45
45
  */
46
46
  async createSession({ provider, model, reviewId, contextCommentId, systemPrompt, cwd, initialContext }) {
47
+ // Resolve provider definition once — used for model fallback and bridge construction
48
+ const providerDef = getChatProvider(provider);
49
+
50
+ // Resolve model: explicit request value > provider config default
51
+ const resolvedModel = model || providerDef?.model || null;
52
+
47
53
  // Insert session record into DB
48
54
  const stmt = this._db.prepare(`
49
55
  INSERT INTO chat_sessions (review_id, context_comment_id, provider, model, status)
@@ -53,20 +59,20 @@ class ChatSessionManager {
53
59
  reviewId,
54
60
  contextCommentId || null,
55
61
  provider,
56
- model || null
62
+ resolvedModel
57
63
  );
58
64
  const sessionId = Number(result.lastInsertRowid);
59
65
 
60
- logger.info(`[ChatSession] Creating session ${sessionId} (provider=${provider}, review=${reviewId})`);
66
+ logger.info(`[ChatSession] Creating session ${sessionId} (provider=${provider}, model=${resolvedModel}, review=${reviewId})`);
61
67
 
62
68
  // Create and start the bridge
63
69
  // Chat sessions get bash for git commands; review analysis uses the safe default
64
70
  const bridge = this._createBridge(provider, {
65
71
  provider,
66
- model,
72
+ model: resolvedModel,
67
73
  cwd,
68
74
  systemPrompt,
69
- });
75
+ }, providerDef);
70
76
 
71
77
  const listeners = {
72
78
  delta: new Set(),
@@ -530,39 +536,38 @@ class ChatSessionManager {
530
536
  * ACP providers get an AcpBridge; everything else gets a PiBridge with tools/skills.
531
537
  * @param {string} provider
532
538
  * @param {Object} options - Bridge constructor options
539
+ * @param {Object} [providerDef] - Pre-resolved provider definition (avoids redundant getChatProvider calls)
533
540
  * @returns {PiBridge|AcpBridge}
534
541
  */
535
- _createBridge(provider, options) {
542
+ _createBridge(provider, options, providerDef) {
543
+ const def = providerDef || getChatProvider(provider);
536
544
  if (isAcpProvider(provider)) {
537
- const providerDef = getChatProvider(provider);
538
545
  return new AcpBridge({
539
546
  ...options,
540
- model: options.model || providerDef?.model,
541
- acpCommand: providerDef?.command,
542
- acpArgs: providerDef?.args,
543
- env: providerDef?.env,
544
- useShell: providerDef?.useShell,
547
+ model: options.model || def?.model,
548
+ acpCommand: def?.command,
549
+ acpArgs: def?.args,
550
+ env: def?.env,
551
+ useShell: def?.useShell,
545
552
  });
546
553
  }
547
554
  if (isClaudeCodeProvider(provider)) {
548
- const providerDef = getChatProvider(provider);
549
555
  return new ClaudeCodeBridge({
550
556
  ...options,
551
- model: options.model || providerDef?.model,
552
- claudeCommand: providerDef?.command,
553
- env: providerDef?.env,
554
- useShell: providerDef?.useShell,
557
+ model: options.model || def?.model,
558
+ claudeCommand: def?.command,
559
+ env: def?.env,
560
+ useShell: def?.useShell,
555
561
  });
556
562
  }
557
563
  if (isCodexProvider(provider)) {
558
- const providerDef = getChatProvider(provider);
559
564
  return new CodexBridge({
560
565
  ...options,
561
- model: options.model || providerDef?.model,
562
- codexCommand: providerDef?.command,
563
- codexArgs: providerDef?.args,
564
- env: providerDef?.env,
565
- useShell: providerDef?.useShell,
566
+ model: options.model || def?.model,
567
+ codexCommand: def?.command,
568
+ codexArgs: def?.args,
569
+ env: def?.env,
570
+ useShell: def?.useShell,
566
571
  });
567
572
  }
568
573
  // Pi provider — resolve config overrides (command, model, env) from provider def.
@@ -570,14 +575,13 @@ class ChatSessionManager {
570
575
  // which would forward it as `--provider pi` to the Pi CLI. The CLI's --provider flag
571
576
  // expects a model provider ("google", "anthropic", etc.) and should only come from
572
577
  // explicit user configuration (providerDef.provider).
573
- const providerDef = getChatProvider(provider);
574
578
  return new PiBridge({
575
579
  ...options,
576
- provider: providerDef?.provider || null,
577
- model: options.model || providerDef?.model,
578
- piCommand: providerDef?.command,
579
- env: providerDef?.env,
580
- useShell: providerDef?.useShell,
580
+ provider: def?.provider || null,
581
+ model: options.model || def?.model,
582
+ piCommand: def?.command,
583
+ env: def?.env,
584
+ useShell: def?.useShell,
581
585
  tools: CHAT_TOOLS,
582
586
  extensions: [taskExtensionDir],
583
587
  });
package/src/config.js CHANGED
@@ -31,7 +31,7 @@ const DEFAULT_CONFIG = {
31
31
  enable_chat: true, // When true, enables the chat panel feature (uses chat_provider)
32
32
  chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', 'gemini-acp', 'opencode-acp', 'cursor-acp', 'codex'
33
33
  comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
34
- chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
34
+ chat: { enable_shortcuts: true, enter_to_send: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons, enter_to_send: Enter sends message instead of newline)
35
35
  providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
36
36
  chat_providers: {}, // Custom chat provider configurations (overrides built-in defaults)
37
37
  monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
@@ -227,6 +227,20 @@ async function loadConfig() {
227
227
  process.exit(1);
228
228
  }
229
229
 
230
+ // Load global instructions from ~/.pair-review/global-instructions.md
231
+ const globalInstructionsPath = path.join(CONFIG_DIR, 'global-instructions.md');
232
+ try {
233
+ const content = await fs.readFile(globalInstructionsPath, 'utf-8');
234
+ const trimmed = content.trim();
235
+ if (trimmed) {
236
+ mergedConfig.globalInstructions = trimmed;
237
+ }
238
+ } catch (error) {
239
+ if (error.code !== 'ENOENT') {
240
+ logger.warn(`Could not read global instructions from ${globalInstructionsPath}: ${error.message}`);
241
+ }
242
+ }
243
+
230
244
  return { config: mergedConfig, isFirstRun };
231
245
  }
232
246
 
@@ -535,7 +549,6 @@ function shouldSkipUpdateNotifier() {
535
549
  module.exports = {
536
550
  deepMerge,
537
551
  loadConfig,
538
- saveConfig,
539
552
  getConfigDir,
540
553
  validatePort,
541
554
  getGitHubToken,
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 = 33;
23
+ const CURRENT_SCHEMA_VERSION = 35;
24
24
 
25
25
  /**
26
26
  * Database schema SQL statements
@@ -96,6 +96,8 @@ const SCHEMA_SQL = {
96
96
  voice_id TEXT,
97
97
  is_raw INTEGER DEFAULT 0,
98
98
 
99
+ severity TEXT,
100
+
99
101
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
100
102
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
101
103
 
@@ -162,6 +164,7 @@ const SCHEMA_SQL = {
162
164
  model TEXT,
163
165
  tier TEXT,
164
166
  custom_instructions TEXT,
167
+ global_instructions TEXT,
165
168
  repo_instructions TEXT,
166
169
  request_instructions TEXT,
167
170
  head_sha TEXT,
@@ -1564,6 +1567,44 @@ const MIGRATIONS = {
1564
1567
 
1565
1568
  console.log(' Recreated comments with ON DELETE CASCADE for review_id');
1566
1569
  console.log('Migration to schema version 33 complete');
1570
+ },
1571
+
1572
+ 34: (db) => {
1573
+ console.log('Migrating to schema version 34: Add global_instructions to analysis_runs');
1574
+
1575
+ if (!columnExists(db, 'analysis_runs', 'global_instructions')) {
1576
+ try {
1577
+ db.prepare('ALTER TABLE analysis_runs ADD COLUMN global_instructions TEXT').run();
1578
+ console.log(' Added global_instructions column to analysis_runs');
1579
+ } catch (error) {
1580
+ if (!error.message.includes('duplicate column name')) {
1581
+ throw error;
1582
+ }
1583
+ console.log(' Column global_instructions already exists (race condition)');
1584
+ }
1585
+ } else {
1586
+ console.log(' Column global_instructions already exists');
1587
+ }
1588
+
1589
+ console.log('Migration to schema version 34 complete');
1590
+ },
1591
+
1592
+ // Migration to version 35: Add severity column to comments table
1593
+ 35: (db) => {
1594
+ console.log('Running migration to schema version 35...');
1595
+
1596
+ const addColumnIfNotExists = (table, column, definition) => {
1597
+ const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
1598
+ const columnExists = tableInfo.some(col => col.name === column);
1599
+ if (!columnExists) {
1600
+ db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
1601
+ console.log(` Added column ${column} to ${table}`);
1602
+ }
1603
+ };
1604
+
1605
+ addColumnIfNotExists('comments', 'severity', 'TEXT');
1606
+
1607
+ console.log('Migration to schema version 35 complete');
1567
1608
  }
1568
1609
  };
1569
1610
 
@@ -2567,6 +2608,7 @@ class CommentRepository {
2567
2608
  status,
2568
2609
  parent_id,
2569
2610
  is_file_level,
2611
+ severity,
2570
2612
  created_at,
2571
2613
  updated_at
2572
2614
  FROM comments
@@ -2634,8 +2676,8 @@ class CommentRepository {
2634
2676
  await run(this.db, `
2635
2677
  INSERT INTO comments (
2636
2678
  review_id, source, author, ai_run_id, ai_level, ai_confidence,
2637
- file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level
2638
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2679
+ file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level, severity
2680
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2639
2681
  `, [
2640
2682
  reviewId,
2641
2683
  'ai',
@@ -2653,7 +2695,8 @@ class CommentRepository {
2653
2695
  suggestionText,
2654
2696
  suggestion.reasoning ? JSON.stringify(suggestion.reasoning) : null,
2655
2697
  'active',
2656
- isFileLevel
2698
+ isFileLevel,
2699
+ suggestion.severity ?? null
2657
2700
  ]);
2658
2701
  }
2659
2702
  }
@@ -3482,6 +3525,7 @@ class AnalysisRunRepository {
3482
3525
  * @param {string} [runInfo.provider] - AI provider (claude, gemini, etc.)
3483
3526
  * @param {string} [runInfo.model] - AI model name
3484
3527
  * @param {string} [runInfo.customInstructions] - Merged custom instructions (kept for backward compatibility)
3528
+ * @param {string} [runInfo.globalInstructions] - Global instructions from ~/.pair-review/global-instructions.md
3485
3529
  * @param {string} [runInfo.repoInstructions] - Repository-level instructions from repo_settings
3486
3530
  * @param {string} [runInfo.requestInstructions] - Request-level instructions from the analyze request
3487
3531
  * @param {string} [runInfo.headSha] - Git HEAD SHA at the time of analysis (PR head commit or local HEAD)
@@ -3489,14 +3533,14 @@ class AnalysisRunRepository {
3489
3533
  * @param {string} [runInfo.status='running'] - Initial status (default 'running'; pass 'completed' for externally-produced results)
3490
3534
  * @returns {Promise<Object>} Created analysis run record
3491
3535
  */
3492
- async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, diff = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null, scopeStart = null, scopeEnd = null }) {
3536
+ async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, globalInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, diff = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null, scopeStart = null, scopeEnd = null }) {
3493
3537
  const isTerminal = ['completed', 'failed', 'cancelled'].includes(status);
3494
3538
  const completedAt = isTerminal ? 'CURRENT_TIMESTAMP' : 'NULL';
3495
3539
  const levelsConfigJson = levelsConfig ? JSON.stringify(levelsConfig) : null;
3496
3540
  await run(this.db, `
3497
- INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions, head_sha, diff, status, completed_at, parent_run_id, config_type, levels_config, scope_start, scope_end)
3498
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?, ?, ?)
3499
- `, [id, reviewId, provider, model, tier, customInstructions, repoInstructions, requestInstructions, headSha, diff, status, parentRunId, configType, levelsConfigJson, scopeStart, scopeEnd]);
3541
+ INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, global_instructions, repo_instructions, request_instructions, head_sha, diff, status, completed_at, parent_run_id, config_type, levels_config, scope_start, scope_end)
3542
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?, ?, ?)
3543
+ `, [id, reviewId, provider, model, tier, customInstructions, globalInstructions, repoInstructions, requestInstructions, headSha, diff, status, parentRunId, configType, levelsConfigJson, scopeStart, scopeEnd]);
3500
3544
 
3501
3545
  // Query back the inserted row to return actual database values (including timestamps)
3502
3546
  return await this.getById(id);
@@ -3579,12 +3623,12 @@ class AnalysisRunRepository {
3579
3623
  */
3580
3624
  async getById(id, { includeDiff = false } = {}) {
3581
3625
  const columns = [
3582
- 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3626
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'global_instructions', 'repo_instructions', 'request_instructions',
3583
3627
  'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3584
3628
  'parent_run_id', 'config_type', 'levels_config'
3585
3629
  ];
3586
3630
  if (includeDiff) {
3587
- columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3631
+ columns.splice(columns.indexOf('head_sha') + 1, 0, 'diff'); // Insert diff after head_sha
3588
3632
  }
3589
3633
  const row = await queryOne(this.db, `
3590
3634
  SELECT ${columns.join(', ')}
@@ -3607,12 +3651,12 @@ class AnalysisRunRepository {
3607
3651
  async getByReviewId(reviewId, { limit, includeDiff = false } = {}) {
3608
3652
  const params = [reviewId];
3609
3653
  const columns = [
3610
- 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3654
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'global_instructions', 'repo_instructions', 'request_instructions',
3611
3655
  'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3612
3656
  'parent_run_id', 'config_type', 'levels_config'
3613
3657
  ];
3614
3658
  if (includeDiff) {
3615
- columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3659
+ columns.splice(columns.indexOf('head_sha') + 1, 0, 'diff'); // Insert diff after head_sha
3616
3660
  }
3617
3661
  let sql = `
3618
3662
  SELECT ${columns.join(', ')}
@@ -3645,12 +3689,12 @@ class AnalysisRunRepository {
3645
3689
  */
3646
3690
  async getLatestCompletedByReviewId(reviewId, { includeDiff = false } = {}) {
3647
3691
  const columns = [
3648
- 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3692
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'global_instructions', 'repo_instructions', 'request_instructions',
3649
3693
  'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3650
3694
  'parent_run_id', 'config_type', 'levels_config'
3651
3695
  ];
3652
3696
  if (includeDiff) {
3653
- columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3697
+ columns.splice(columns.indexOf('head_sha') + 1, 0, 'diff'); // Insert diff after head_sha
3654
3698
  }
3655
3699
  const row = await queryOne(this.db, `
3656
3700
  SELECT ${columns.join(', ')}
@@ -3672,7 +3716,7 @@ class AnalysisRunRepository {
3672
3716
  // Note: diff column is intentionally omitted - child runs share the same diff as parent
3673
3717
  // to avoid data duplication. Use the parent run's diff when needed.
3674
3718
  return query(this.db, `
3675
- SELECT id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions,
3719
+ SELECT id, review_id, provider, model, tier, custom_instructions, global_instructions, repo_instructions, request_instructions,
3676
3720
  head_sha, summary, status, total_suggestions, files_analyzed, started_at, completed_at,
3677
3721
  parent_run_id, config_type, levels_config
3678
3722
  FROM analysis_runs
@@ -1,9 +1,12 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const { execSync } = require('child_process');
3
+ const { readFileSync } = require('fs');
4
+ const path = require('path');
3
5
  const logger = require('../utils/logger');
4
6
 
5
7
  const defaults = {
6
8
  execSync,
9
+ readFileSync,
7
10
  // Callers should pass a resolved token via _deps.getGitHubToken.
8
11
  // This default returns empty so GitHub lookup is silently skipped
9
12
  // when no token is provided — never re-resolve config internally.
@@ -18,7 +21,7 @@ const defaults = {
18
21
  * Detect the base branch for the current branch.
19
22
  *
20
23
  * Priority:
21
- * 1. Graphite — `gt trunk` and `gt parent`
24
+ * 1. Graphite — `gt state` (single call for trunk, parent, and stack)
22
25
  * 2. GitHub PR — look up an open PR for this branch
23
26
  * 3. Default branch — `git remote show origin` or local main/master
24
27
  *
@@ -28,7 +31,7 @@ const defaults = {
28
31
  * @param {string} [options.repository] - owner/repo string (needed for GitHub lookup)
29
32
  * @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
30
33
  * @param {Object} [options._deps] - Dependency overrides for testing
31
- * @returns {Promise<{baseBranch: string, source: string, prNumber?: number}|null>}
34
+ * @returns {Promise<{baseBranch: string, source: string, prNumber?: number, stack?: Array}|null>}
32
35
  */
33
36
  async function detectBaseBranch(repoPath, currentBranch, options = {}) {
34
37
  const deps = { ...defaults, ...options._deps };
@@ -40,7 +43,7 @@ async function detectBaseBranch(repoPath, currentBranch, options = {}) {
40
43
 
41
44
  // 1. Graphite (only when enabled via config)
42
45
  if (options.enableGraphite) {
43
- const graphiteResult = tryGraphite(repoPath, currentBranch, deps);
46
+ const graphiteResult = tryGraphiteState(repoPath, currentBranch, deps);
44
47
  if (graphiteResult) return graphiteResult;
45
48
  }
46
49
 
@@ -56,46 +59,127 @@ async function detectBaseBranch(repoPath, currentBranch, options = {}) {
56
59
  }
57
60
 
58
61
  /**
59
- * Try Graphite CLI to find the parent branch.
62
+ * Try Graphite CLI `gt state` to find the parent branch and build the stack.
63
+ * Single execSync call replaces the previous 3 serial calls.
60
64
  */
61
- function tryGraphite(repoPath, currentBranch, deps) {
65
+ function tryGraphiteState(repoPath, currentBranch, deps) {
62
66
  try {
63
- // Check if gt is installed
64
- deps.execSync('which gt', {
67
+ const raw = deps.execSync('gt state', {
68
+ cwd: repoPath,
65
69
  encoding: 'utf8',
66
70
  stdio: ['pipe', 'pipe', 'pipe'],
67
- timeout: 3000
71
+ timeout: 5000
68
72
  });
69
73
 
70
- // Get trunk branch
71
- const trunk = deps.execSync('gt trunk', {
72
- cwd: repoPath,
73
- encoding: 'utf8',
74
- stdio: ['pipe', 'pipe', 'pipe'],
75
- timeout: 3000
76
- }).trim();
74
+ const state = JSON.parse(raw);
75
+ const trunk = Object.entries(state).find(([, v]) => v.trunk)?.[0];
76
+ const entry = state[currentBranch];
77
+ const parent = entry?.parents?.[0]?.ref;
78
+
79
+ if (!entry || !parent || parent === currentBranch) {
80
+ return null;
81
+ }
82
+
83
+ const stack = buildStack(state, currentBranch, trunk);
84
+ return { baseBranch: parent, source: 'graphite', stack };
85
+ } catch (error) {
86
+ logger.debug(`Graphite state failed: ${error.message}`);
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Walk from currentBranch up via parents[0].ref to build the stack,
94
+ * ordered trunk-first. Includes cycle protection.
95
+ *
96
+ * @param {Object} state - Parsed `gt state` output
97
+ * @param {string} currentBranch - Current branch name
98
+ * @param {string|undefined} trunk - Trunk branch name
99
+ * @returns {Array<{branch: string, parentBranch: string|null, parentSha: string|null, isTrunk: boolean}>}
100
+ */
101
+ function buildStack(state, currentBranch, trunk) {
102
+ const entries = [];
103
+ const visited = new Set();
104
+ let branch = currentBranch;
105
+
106
+ while (branch && !visited.has(branch)) {
107
+ visited.add(branch);
108
+ const info = state[branch];
109
+ if (!info) break;
110
+
111
+ const parentRef = info.parents?.[0]?.ref || null;
112
+ const parentSha = info.parents?.[0]?.sha || null;
113
+ entries.push({
114
+ branch,
115
+ parentBranch: parentRef,
116
+ parentSha,
117
+ isTrunk: !!info.trunk
118
+ });
119
+
120
+ branch = parentRef;
121
+ }
77
122
 
78
- // Get parent branch
79
- const parent = deps.execSync('gt parent', {
123
+ entries.reverse();
124
+
125
+ // If the walk terminated before reaching the trunk, prepend it
126
+ if (trunk && !visited.has(trunk) && state[trunk]) {
127
+ entries.unshift({
128
+ branch: trunk,
129
+ parentBranch: null,
130
+ parentSha: null,
131
+ isTrunk: true
132
+ });
133
+ }
134
+
135
+ return entries;
136
+ }
137
+
138
+ /**
139
+ * Read Graphite PR info from the `.graphite_pr_info` file in the git dir.
140
+ *
141
+ * @param {string} repoPath - Absolute path to the repository
142
+ * @param {Object} deps - Dependencies (execSync, readFileSync)
143
+ * @returns {Object|null} Parsed PR info object with `prInfos` array, or null
144
+ */
145
+ function readGraphitePRInfo(repoPath, deps) {
146
+ try {
147
+ const gitCommonDir = deps.execSync('git rev-parse --git-common-dir', {
80
148
  cwd: repoPath,
81
149
  encoding: 'utf8',
82
- stdio: ['pipe', 'pipe', 'pipe'],
83
- timeout: 3000
150
+ stdio: ['pipe', 'pipe', 'pipe']
84
151
  }).trim();
85
152
 
86
- if (parent && parent !== currentBranch) {
87
- return { baseBranch: parent, source: 'graphite' };
88
- }
153
+ const prInfoPath = path.resolve(repoPath, gitCommonDir, '.graphite_pr_info');
154
+ const raw = deps.readFileSync(prInfoPath, 'utf8');
155
+ return JSON.parse(raw);
156
+ } catch (error) {
157
+ logger.debug(`Graphite PR info read failed: ${error.message}`);
158
+ return null;
159
+ }
160
+ }
89
161
 
90
- // If parent is ourselves or empty, try trunk
91
- if (trunk && trunk !== currentBranch) {
92
- return { baseBranch: trunk, source: 'graphite' };
162
+ /**
163
+ * Enrich stack entries with PR numbers from Graphite PR info.
164
+ *
165
+ * @param {Array} stack - Stack entries from buildStack
166
+ * @param {Array} prInfos - Array of PR info objects with headRefName and prNumber
167
+ * @returns {Array} New array of stack entries, each with optional prNumber
168
+ */
169
+ function enrichStackWithPRInfo(stack, prInfos) {
170
+ if (!prInfos || !Array.isArray(prInfos)) return stack;
171
+
172
+ const prMap = new Map();
173
+ for (const info of prInfos) {
174
+ if (info.headRefName) {
175
+ prMap.set(info.headRefName, info.prNumber);
93
176
  }
94
- } catch {
95
- // Graphite not installed or failed — fall through silently
96
177
  }
97
178
 
98
- return null;
179
+ return stack.map(entry => {
180
+ const prNumber = prMap.get(entry.branch);
181
+ return prNumber != null ? { ...entry, prNumber } : { ...entry };
182
+ });
99
183
  }
100
184
 
101
185
  /**
@@ -218,4 +302,4 @@ function getDefaultBranch(localPath, _deps) {
218
302
  return null;
219
303
  }
220
304
 
221
- module.exports = { detectBaseBranch, getDefaultBranch };
305
+ module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack, readGraphitePRInfo, enrichStackWithPRInfo };
@@ -113,7 +113,7 @@ class PRArgumentParser {
113
113
  */
114
114
  parseGraphiteURL(url) {
115
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+)(?:\/.*)?$/);
116
+ const match = url.match(/^https:\/\/app\.graphite\.(?:dev|com)\/github\/pr\/([^\/]+)\/([^\/]+)\/(\d+)(?:\/[^?]*)?(?:\?.*)?$/);
117
117
 
118
118
  if (!match) {
119
119
  throw new Error('Invalid Graphite URL format. Expected: https://app.graphite.com/github/pr/owner/repo/number');
@@ -394,9 +394,11 @@ async function findMergeBase(repoPath, baseBranch) {
394
394
  * @param {string} repoPath - Path to the git repository
395
395
  * @param {Array} untrackedFiles - Array from getUntrackedFiles()
396
396
  * @param {string} wFlag - Whitespace flag (e.g. ' -w' or '')
397
+ * @param {string} [contextFlag=''] - Unified context flag (e.g. ' --unified=3')
398
+ * @param {string} [extraArgsStr=''] - Additional git diff flags (e.g. ' --patience')
397
399
  * @returns {string} Combined diff text for untracked files
398
400
  */
399
- function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
401
+ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag, contextFlag = '', extraArgsStr = '') {
400
402
  let diff = '';
401
403
  for (const untracked of untrackedFiles) {
402
404
  if (!untracked.skipped) {
@@ -404,7 +406,7 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
404
406
  const filePath = path.join(repoPath, untracked.file);
405
407
  let fileDiff;
406
408
  try {
407
- fileDiff = execSync(`git diff --no-index ${GIT_DIFF_FLAGS}${wFlag} -- /dev/null "${filePath}"`, {
409
+ fileDiff = execSync(`git diff --no-index ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag} -- /dev/null "${filePath}"`, {
408
410
  cwd: repoPath,
409
411
  encoding: 'utf8',
410
412
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -450,10 +452,14 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
450
452
  * @param {string} [baseBranch] - Base branch name (required when branch is in scope)
451
453
  * @param {Object} [options]
452
454
  * @param {boolean} [options.hideWhitespace] - Whether to hide whitespace changes
455
+ * @param {number} [options.contextLines=25] - Number of unified context lines (--unified=N). Defaults to 25.
456
+ * @param {string[]} [options.extraArgs=[]] - Additional git diff flags appended to each diff command
453
457
  * @returns {Promise<{diff: string, stats: Object, mergeBaseSha: string|null}>}
454
458
  */
455
459
  async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, options = {}) {
456
460
  const wFlag = options.hideWhitespace ? ' -w' : '';
461
+ const contextFlag = ` --unified=${options.contextLines ?? 25}`;
462
+ const extraArgsStr = (options.extraArgs || []).length > 0 ? ' ' + options.extraArgs.join(' ') : '';
457
463
  const stats = {
458
464
  trackedChanges: 0,
459
465
  untrackedFiles: 0,
@@ -481,37 +487,37 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
481
487
  try {
482
488
  if (hasBranch && !hasStaged && !hasUnstaged) {
483
489
  // Branch only → committed changes since merge-base
484
- diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
490
+ diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
485
491
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
486
492
  maxBuffer: 50 * 1024 * 1024
487
493
  });
488
494
  } else if (hasBranch && hasStaged && !hasUnstaged) {
489
495
  // Branch–Staged → staged changes relative to merge-base
490
- diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
496
+ diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
491
497
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
492
498
  maxBuffer: 50 * 1024 * 1024
493
499
  });
494
500
  } else if (hasBranch && hasUnstaged) {
495
501
  // Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
496
- diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
502
+ diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
497
503
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
498
504
  maxBuffer: 50 * 1024 * 1024
499
505
  });
500
506
  } else if (hasStaged && !hasUnstaged) {
501
507
  // Staged only → cached changes
502
- diff = execSync(`git diff --cached ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
508
+ diff = execSync(`git diff --cached ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
503
509
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
504
510
  maxBuffer: 50 * 1024 * 1024
505
511
  });
506
512
  } else if (hasStaged && hasUnstaged) {
507
513
  // Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
508
- diff = execSync(`git diff HEAD ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
514
+ diff = execSync(`git diff HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
509
515
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
510
516
  maxBuffer: 50 * 1024 * 1024
511
517
  });
512
518
  } else if (hasUnstaged) {
513
519
  // Unstaged only or Unstaged–Untracked → working tree changes
514
- diff = execSync(`git diff ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
520
+ diff = execSync(`git diff ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
515
521
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
516
522
  maxBuffer: 50 * 1024 * 1024
517
523
  });
@@ -555,7 +561,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
555
561
  const untrackedFiles = await getUntrackedFiles(repoPath);
556
562
  stats.untrackedFiles = untrackedFiles.length;
557
563
 
558
- const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, wFlag);
564
+ const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, wFlag, contextFlag, extraArgsStr);
559
565
  if (untrackedDiff) {
560
566
  if (diff) diff += '\n';
561
567
  diff += untrackedDiff;