@in-the-loop-labs/pair-review 3.0.6 → 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 +113 -29
  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 +152 -15
  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
+ }
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 };
@@ -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) {
@@ -300,6 +300,25 @@ router.post('/api/analyses/results', async (req, res) => {
300
300
  }
301
301
  });
302
302
 
303
+ /**
304
+ * List currently active (running) analyses across all reviews.
305
+ * Returns lightweight projections from the in-memory activeAnalyses map.
306
+ */
307
+ router.get('/api/analyses/active', (req, res) => {
308
+ const active = [];
309
+ for (const status of activeAnalyses.values()) {
310
+ if (status.status !== 'running') continue;
311
+ active.push({
312
+ analysisId: status.id,
313
+ reviewId: status.reviewId,
314
+ reviewType: status.reviewType || null,
315
+ repository: status.repository || null,
316
+ prNumber: status.prNumber || null
317
+ });
318
+ }
319
+ res.json({ active });
320
+ });
321
+
303
322
  // ==========================================================================
304
323
  // Parameterised :id routes — registered AFTER static paths
305
324
  // ==========================================================================
@@ -396,7 +415,10 @@ router.post('/api/analyses/:id/cancel', async (req, res) => {
396
415
  : analysis.levels?.[3],
397
416
  4: analysis.levels?.[4]?.status === 'running'
398
417
  ? { status: 'cancelled', progress: 'Cancelled' }
399
- : analysis.levels?.[4]
418
+ : analysis.levels?.[4],
419
+ exec: analysis.levels?.exec?.status === 'running'
420
+ ? { status: 'cancelled', progress: 'Cancelled' }
421
+ : analysis.levels?.exec
400
422
  }
401
423
  };
402
424
 
@@ -446,7 +468,7 @@ router.post('/api/analyses/:id/cancel', async (req, res) => {
446
468
  * @param {Object} modeContext - Mode-specific values
447
469
  * @param {Object} councilConfig - Validated council configuration
448
470
  * @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
449
- * @param {Object} instructions - { repoInstructions, requestInstructions }
471
+ * @param {Object} instructions - { globalInstructions, repoInstructions, requestInstructions }
450
472
  * @param {string} [configType='advanced'] - Config type
451
473
  * @returns {{ analysisId: string, runId: string }}
452
474
  */
@@ -469,10 +491,12 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
469
491
  onSuccess,
470
492
  runUpdateExtra,
471
493
  config: modeConfig,
494
+ excludePrevious,
495
+ serverPort,
472
496
  hookContext = {},
473
497
  } = modeContext;
474
498
 
475
- const { repoInstructions, requestInstructions } = instructions;
499
+ const { globalInstructions, repoInstructions, requestInstructions } = instructions;
476
500
 
477
501
  const isVoiceCentric = configType === 'council';
478
502
 
@@ -496,6 +520,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
496
520
  provider: 'council',
497
521
  model: councilId || 'inline-config',
498
522
  tier: null,
523
+ globalInstructions,
499
524
  repoInstructions,
500
525
  requestInstructions,
501
526
  headSha: headSha || null,
@@ -521,7 +546,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
521
546
  1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
522
547
  2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
523
548
  3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
524
- 4: { status: 'pending', progress: 'Pending' }
549
+ 4: { status: 'pending', progress: 'Pending' },
550
+ exec: { status: 'pending', progress: 'Pending' }
525
551
  },
526
552
  isCouncil: true,
527
553
  councilConfig,
@@ -563,12 +589,12 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
563
589
  worktreePath,
564
590
  prMetadata,
565
591
  changedFiles,
566
- instructions: { repoInstructions, requestInstructions }
592
+ instructions: { globalInstructions, repoInstructions, requestInstructions }
567
593
  };
568
594
 
569
595
  const analysisPromise = isVoiceCentric
570
- ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
571
- : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
596
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
597
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
572
598
 
573
599
  analysisPromise
574
600
  .then(async result => {
@@ -607,7 +633,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
607
633
  4: { status: 'completed', progress: 'Results finalized' }
608
634
  }
609
635
  };
610
- for (const levelKey of ['1', '2', '3']) {
636
+ for (const levelKey of ['1', '2', '3', 'exec']) {
611
637
  if (currentStatus.levels?.[levelKey]?.status === 'running') {
612
638
  completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
613
639
  }