@in-the-loop-labs/pair-review 3.0.5 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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 +83 -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 +83 -8
  21. package/public/js/components/AnalysisConfigModal.js +155 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +239 -22
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +179 -12
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +141 -47
  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 +534 -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 +89 -4
  59. package/src/chat/prompt-builder.js +17 -1
  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 +133 -52
  64. package/src/local-review.js +15 -9
  65. package/src/main.js +3 -2
  66. package/src/routes/analyses.js +34 -8
  67. package/src/routes/chat.js +15 -8
  68. package/src/routes/config.js +3 -120
  69. package/src/routes/councils.js +15 -6
  70. package/src/routes/executable-analysis.js +494 -0
  71. package/src/routes/local.js +160 -26
  72. package/src/routes/mcp.js +9 -4
  73. package/src/routes/pr.js +166 -29
  74. package/src/routes/reviews.js +31 -5
  75. package/src/routes/shared.js +72 -5
  76. package/src/routes/worktrees.js +4 -2
  77. package/src/utils/comment-formatter.js +28 -11
  78. package/src/utils/instructions.js +22 -8
  79. package/src/utils/logger.js +20 -10
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
+ }
122
+
123
+ entries.reverse();
77
124
 
78
- // Get parent branch
79
- const parent = deps.execSync('gt parent', {
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
  /**
@@ -171,46 +255,43 @@ function tryDefaultBranch(repoPath, currentBranch, deps) {
171
255
  }
172
256
 
173
257
  /**
174
- * Synchronously detect the default branch for a repository.
258
+ * Synchronously detect the default branch for a repository using only
259
+ * local refs (no network I/O).
175
260
  *
176
- * Uses the same logic as tryDefaultBranch but returns just the branch name
177
- * (or null). Suitable for call sites that need a quick, synchronous answer
178
- * without the full detectBaseBranch priority chain.
261
+ * Priority:
262
+ * 1. `git symbolic-ref refs/remotes/origin/HEAD` reads the local ref
263
+ * that `git clone` sets automatically.
264
+ * 2. Check whether `refs/heads/main` or `refs/heads/master` exist locally.
179
265
  *
180
- * @param {string} repoPath - Absolute path to the repository
266
+ * @param {string} localPath - Absolute path to the repository
181
267
  * @param {Object} [_deps] - Dependency overrides for testing
182
268
  * @returns {string|null} Default branch name, or null if it cannot be determined
183
269
  */
184
- function getDefaultBranch(repoPath, _deps) {
270
+ function getDefaultBranch(localPath, _deps) {
271
+ if (!localPath) return null;
185
272
  const deps = { ...defaults, ..._deps };
186
273
 
187
- // Try `git remote show origin`
274
+ // Try symbolic-ref (set by git clone)
188
275
  try {
189
- const output = deps.execSync('git remote show origin', {
190
- cwd: repoPath,
276
+ const ref = deps.execSync('git symbolic-ref refs/remotes/origin/HEAD', {
277
+ cwd: localPath,
191
278
  encoding: 'utf8',
192
279
  stdio: ['pipe', 'pipe', 'pipe'],
193
- timeout: 5000
194
- });
195
-
196
- const match = output.match(/HEAD branch:\s*(.+)/);
197
- if (match) {
198
- const branch = match[1].trim();
199
- if (branch && branch !== '(unknown)') {
200
- return branch;
201
- }
202
- }
280
+ }).trim();
281
+ // ref looks like "refs/remotes/origin/main"
282
+ const branch = ref.replace(/^refs\/remotes\/origin\//, '');
283
+ if (branch && branch !== ref) return branch;
203
284
  } catch {
204
- // No remote or network issue try local refs
285
+ // origin/HEAD not set fall through to local check
205
286
  }
206
287
 
207
288
  // Fallback: check if main or master exists locally
208
289
  for (const candidate of ['main', 'master']) {
209
290
  try {
210
- deps.execSync(`git rev-parse --verify ${candidate}`, {
211
- cwd: repoPath,
291
+ deps.execSync(`git rev-parse --verify refs/heads/${candidate}`, {
292
+ cwd: localPath,
212
293
  encoding: 'utf8',
213
- stdio: ['pipe', 'pipe', 'pipe']
294
+ stdio: ['pipe', 'pipe', 'pipe'],
214
295
  });
215
296
  return candidate;
216
297
  } catch {
@@ -221,4 +302,4 @@ function getDefaultBranch(repoPath, _deps) {
221
302
  return null;
222
303
  }
223
304
 
224
- module.exports = { detectBaseBranch, getDefaultBranch };
305
+ module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack, readGraphitePRInfo, enrichStackWithPRInfo };
@@ -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;
package/src/main.js CHANGED
@@ -829,6 +829,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
829
829
  const repoSettingsRepo = new RepoSettingsRepository(db);
830
830
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
831
831
  const repoInstructions = repoSettings?.default_instructions || null;
832
+ const globalInstructions = config.globalInstructions || null;
832
833
 
833
834
  // Run AI analysis
834
835
  console.log('Running AI analysis (all 3 levels)...');
@@ -837,8 +838,8 @@ async function performHeadlessReview(args, config, db, flags, options) {
837
838
 
838
839
  let analysisSummary = null;
839
840
  try {
840
- // Pass repo instructions to ensure they're captured in the analysis run
841
- const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { repoInstructions });
841
+ // Pass all instruction levels to ensure they're captured in the analysis run
842
+ const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions });
842
843
  analysisSummary = analysisResult.summary;
843
844
  console.log('AI analysis completed successfully');
844
845
  } catch (analysisError) {