@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/routes/pr.js CHANGED
@@ -32,7 +32,10 @@ const { broadcastReviewEvent } = require('../events/review-events');
32
32
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
33
33
  const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
34
34
  const simpleGit = require('simple-git');
35
+ const { execSync } = require('child_process');
36
+ const { readFileSync } = require('fs');
35
37
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
38
+ const { tryGraphiteState, readGraphitePRInfo, enrichStackWithPRInfo } = require('../git/base-branch');
36
39
  const {
37
40
  activeAnalyses,
38
41
  reviewToAnalysisId,
@@ -40,11 +43,15 @@ const {
40
43
  determineCompletionInfo,
41
44
  broadcastProgress,
42
45
  createProgressCallback,
43
- parseEnabledLevels
46
+ parseEnabledLevels,
47
+ registerProcess: registerProcessForCancellation
44
48
  } = require('./shared');
45
49
  const { safeParseJson } = require('../utils/safe-parse-json');
46
50
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
47
51
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
52
+ const { getProviderClass, createProvider } = require('../ai/provider');
53
+ const { CommentRepository } = require('../database');
54
+ const { runExecutableAnalysis } = require('./executable-analysis');
48
55
  const analysesRouter = require('./analyses');
49
56
 
50
57
  const router = express.Router();
@@ -229,6 +236,25 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
229
236
  ? getShaAbbrevLength(extendedData.worktree_path)
230
237
  : DEFAULT_SHA_ABBREV_LENGTH;
231
238
 
239
+ // Detect Graphite stack if enabled
240
+ let stackData = null;
241
+ {
242
+ const stackConfig = req.app.get('config') || {};
243
+ if (stackConfig.enable_graphite === true && extendedData.worktree_path && prMetadata.head_branch) {
244
+ try {
245
+ const graphiteResult = tryGraphiteState(extendedData.worktree_path, prMetadata.head_branch, { execSync, readFileSync });
246
+ if (graphiteResult?.stack) {
247
+ const prInfo = readGraphitePRInfo(extendedData.worktree_path, { execSync, readFileSync });
248
+ stackData = prInfo?.prInfos
249
+ ? enrichStackWithPRInfo(graphiteResult.stack, prInfo.prInfos)
250
+ : graphiteResult.stack;
251
+ }
252
+ } catch {
253
+ // Non-fatal — stack detection is an enhancement
254
+ }
255
+ }
256
+ }
257
+
232
258
  // Prepare response
233
259
  // Use review.id instead of prMetadata.id to avoid ID collision with local mode
234
260
  const response = {
@@ -246,6 +272,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
246
272
  head_branch: prMetadata.head_branch,
247
273
  head_sha: extendedData.head_sha || null, // Head commit SHA for GitHub API comments
248
274
  node_id: extendedData.node_id || null, // GraphQL node ID for review submission
275
+ stack_data: stackData,
249
276
  shaAbbrevLength,
250
277
  created_at: prMetadata.created_at,
251
278
  updated_at: prMetadata.updated_at,
@@ -406,6 +433,22 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
406
433
  const parsedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
407
434
  const [repoOwner, repoName] = repository.split('/');
408
435
 
436
+ // Detect Graphite stack if enabled
437
+ let stackData = null;
438
+ if (config.enable_graphite === true && worktreePath && prData.head_branch) {
439
+ try {
440
+ const graphiteResult = tryGraphiteState(worktreePath, prData.head_branch, { execSync, readFileSync });
441
+ if (graphiteResult?.stack) {
442
+ const prInfo = readGraphitePRInfo(worktreePath, { execSync, readFileSync });
443
+ stackData = prInfo?.prInfos
444
+ ? enrichStackWithPRInfo(graphiteResult.stack, prInfo.prInfos)
445
+ : graphiteResult.stack;
446
+ }
447
+ } catch {
448
+ // Non-fatal — stack detection is an enhancement
449
+ }
450
+ }
451
+
409
452
  // Use review.id instead of prMetadata.id to avoid ID collision with local mode
410
453
  const response = {
411
454
  success: true,
@@ -420,6 +463,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
420
463
  state: parsedData.state || 'open',
421
464
  base_branch: prMetadata.base_branch,
422
465
  head_branch: prMetadata.head_branch,
466
+ stack_data: stackData,
423
467
  created_at: prMetadata.created_at,
424
468
  updated_at: prMetadata.updated_at,
425
469
  file_changes: parsedData.changed_files ? parsedData.changed_files.length : 0,
@@ -692,7 +736,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
692
736
  const worktreeRepo = new WorktreeRepository(db);
693
737
  const worktreeRecord = await worktreeRepo.findByPR(prNumber, repository);
694
738
 
695
- // When ?w=1, regenerate the diff from the worktree with whitespace changes hidden
739
+ // When ?w=1, regenerate the diff from the worktree with whitespace hidden
696
740
  const hideWhitespace = req.query.w === '1';
697
741
  let diffContent = prData.diff || '';
698
742
  let changedFiles = prData.changed_files || [];
@@ -702,24 +746,16 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
702
746
  const worktreePath = worktreeRecord.path;
703
747
  await fs.access(worktreePath);
704
748
  const git = simpleGit(worktreePath);
749
+
705
750
  const baseSha = prData.base_sha;
706
751
  const headSha = prData.head_sha;
707
752
 
708
753
  if (baseSha && headSha) {
709
- // Regenerate diff with -w flag to ignore whitespace changes
710
- diffContent = await git.diff([
711
- `${baseSha}...${headSha}`,
712
- '--unified=3',
713
- '-w',
714
- ...GIT_DIFF_FLAGS_ARRAY
715
- ]);
716
-
717
- // Regenerate changed files stats with -w flag
718
- const diffSummary = await git.diffSummary([
719
- `${baseSha}...${headSha}`,
720
- '-w',
721
- ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
722
- ]);
754
+ const diffArgs = [`${baseSha}...${headSha}`, '--unified=3', ...GIT_DIFF_FLAGS_ARRAY, '-w'];
755
+ diffContent = await git.diff(diffArgs);
756
+
757
+ const summaryArgs = [`${baseSha}...${headSha}`, ...GIT_DIFF_SUMMARY_FLAGS_ARRAY, '-w'];
758
+ const diffSummary = await git.diffSummary(summaryArgs);
723
759
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
724
760
  changedFiles = diffSummary.files.map(file => {
725
761
  const resolvedFile = resolveRenamedFile(file.file);
@@ -739,7 +775,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
739
775
  });
740
776
  }
741
777
  } catch (wsError) {
742
- logger.warn(`Could not generate whitespace-filtered diff for PR #${prNumber}: ${wsError.message}`);
778
+ logger.warn(`Could not generate diff for PR #${prNumber}: ${wsError.message}`);
743
779
  // Fall back to cached diff (diffContent and changedFiles already set from prData)
744
780
  }
745
781
  } else if (worktreeRecord && worktreeRecord.path) {
@@ -756,9 +792,8 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
756
792
  }
757
793
  }
758
794
 
759
- // When hideWhitespace is active and diff was regenerated, compute
760
- // aggregate stats from the regenerated changedFiles instead of using
761
- // stale cached values from prData.
795
+ // When diff was regenerated (whitespace), compute aggregate stats from
796
+ // the regenerated changedFiles instead of using stale cached values from prData.
762
797
  const additions = hideWhitespace
763
798
  ? changedFiles.reduce((sum, f) => sum + (f.insertions || 0), 0)
764
799
  : (prData.additions || 0);
@@ -1459,6 +1494,79 @@ router.post('/api/parse-pr-url', (req, res) => {
1459
1494
  // PR Analysis Routes
1460
1495
  // ==========================================================================
1461
1496
 
1497
+ /**
1498
+ * Handle analysis for executable providers in PR mode.
1499
+ * Spawns the external CLI against the PR worktree and maps output to suggestions.
1500
+ */
1501
+ async function handleExecutablePRAnalysis(req, res, {
1502
+ reviewId, review, prNumber, owner, repo, repository, worktreePath, prMetadata,
1503
+ selectedProvider, selectedModel, repoInstructions, requestInstructions,
1504
+ combinedInstructions, runId, analysisId, reviewRepo
1505
+ }) {
1506
+ const prContext = {
1507
+ number: prNumber, owner, repo,
1508
+ author: prMetadata.author, baseBranch: prMetadata.base_branch, headBranch: prMetadata.head_branch,
1509
+ baseSha: prMetadata.base_sha || null, headSha: prMetadata.head_sha || null,
1510
+ };
1511
+
1512
+ return runExecutableAnalysis(req, res, {
1513
+ reviewId,
1514
+ review,
1515
+ selectedProvider,
1516
+ selectedModel,
1517
+ repoInstructions,
1518
+ requestInstructions,
1519
+ runId,
1520
+ analysisId,
1521
+ repository,
1522
+ reviewType: 'pr',
1523
+ headSha: prMetadata.head_sha,
1524
+ extraInitialStatus: { prNumber }
1525
+ }, {
1526
+ activeAnalyses,
1527
+ reviewToAnalysisId,
1528
+ broadcastProgress,
1529
+ broadcastReviewEvent,
1530
+ registerProcessForCancellation
1531
+ }, {
1532
+ logLabel: `PR #${prNumber}`,
1533
+ buildContext: (_r, { selectedModel: model, requestInstructions: customInstructions }) => ({
1534
+ title: prMetadata.title || `PR #${prNumber}`,
1535
+ description: prMetadata.description || '',
1536
+ cwd: worktreePath,
1537
+ model,
1538
+ baseSha: prMetadata.base_sha || null,
1539
+ headSha: prMetadata.head_sha || null,
1540
+ baseBranch: prMetadata.base_branch || null,
1541
+ headBranch: prMetadata.head_branch || null,
1542
+ customInstructions: customInstructions || null
1543
+ }),
1544
+ buildHookPayload: () => ({
1545
+ mode: 'pr',
1546
+ prContext
1547
+ }),
1548
+ onSuccess: async (db, _runId, { summary }) => {
1549
+ // Update pr_metadata with last_ai_run_id
1550
+ const { PRMetadataRepository } = require('../database');
1551
+ const prMetadataRepo = new PRMetadataRepository(db);
1552
+ try {
1553
+ await prMetadataRepo.updateLastAiRunId(prMetadata.id, _runId);
1554
+ } catch (e) {
1555
+ logger.warn(`Failed to update pr_metadata: ${e.message}`);
1556
+ }
1557
+
1558
+ // Save summary
1559
+ if (summary) {
1560
+ try {
1561
+ await reviewRepo.upsertSummary(prNumber, repository, summary);
1562
+ } catch (e) {
1563
+ logger.warn(`Failed to save summary: ${e.message}`);
1564
+ }
1565
+ }
1566
+ }
1567
+ });
1568
+ }
1569
+
1462
1570
  /**
1463
1571
  * Trigger AI analysis for a PR
1464
1572
  */
@@ -1467,7 +1575,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1467
1575
  const { owner, repo, number } = req.params;
1468
1576
  const prNumber = parseInt(number);
1469
1577
 
1470
- const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
1578
+ const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels, excludePrevious } = req.body || {};
1471
1579
 
1472
1580
  const MAX_INSTRUCTIONS_LENGTH = 5000;
1473
1581
  let requestInstructions = rawInstructions?.trim() || null;
@@ -1511,6 +1619,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1511
1619
  });
1512
1620
  }
1513
1621
 
1622
+ const appConfig = req.app.get('config') || {};
1623
+ const globalInstructions = appConfig.globalInstructions || null;
1624
+
1514
1625
  const { provider, model, repoInstructions, combinedInstructions } = await withTransaction(db, async () => {
1515
1626
  const repoSettingsRepo = new RepoSettingsRepository(db);
1516
1627
  const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
@@ -1521,8 +1632,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1521
1632
  } else if (fetchedRepoSettings && fetchedRepoSettings.default_provider) {
1522
1633
  selectedProvider = fetchedRepoSettings.default_provider;
1523
1634
  } else {
1524
- const config = req.app.get('config') || {};
1525
- selectedProvider = config.default_provider || config.provider || 'claude';
1635
+ selectedProvider = appConfig.default_provider || appConfig.provider || 'claude';
1526
1636
  }
1527
1637
 
1528
1638
  let selectedModel;
@@ -1535,7 +1645,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1535
1645
  }
1536
1646
 
1537
1647
  const fetchedRepoInstructions = fetchedRepoSettings?.default_instructions || null;
1538
- const mergedInstructions = mergeInstructions(fetchedRepoInstructions, requestInstructions);
1648
+ const mergedInstructions = mergeInstructions({ globalInstructions, repoInstructions: fetchedRepoInstructions, requestInstructions });
1539
1649
 
1540
1650
  if (requestInstructions) {
1541
1651
  await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
@@ -1554,6 +1664,29 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1554
1664
 
1555
1665
  const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
1556
1666
 
1667
+ // Check if selected provider is an executable provider (external tool)
1668
+ const ProviderClass = getProviderClass(provider);
1669
+ if (ProviderClass?.isExecutable) {
1670
+ return handleExecutablePRAnalysis(req, res, {
1671
+ reviewId: review.id,
1672
+ review,
1673
+ prNumber,
1674
+ owner,
1675
+ repo,
1676
+ repository,
1677
+ worktreePath,
1678
+ prMetadata,
1679
+ selectedProvider: provider,
1680
+ selectedModel: model,
1681
+ repoInstructions,
1682
+ requestInstructions,
1683
+ combinedInstructions,
1684
+ runId,
1685
+ analysisId,
1686
+ reviewRepo
1687
+ });
1688
+ }
1689
+
1557
1690
  const analysisRunRepo = new AnalysisRunRepository(db);
1558
1691
  const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
1559
1692
  const tier = requestTier ? resolveTier(requestTier) : 'balanced';
@@ -1563,6 +1696,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1563
1696
  provider,
1564
1697
  model,
1565
1698
  tier,
1699
+ globalInstructions,
1566
1700
  repoInstructions,
1567
1701
  requestInstructions,
1568
1702
  headSha: prMetadata.head_sha || null,
@@ -1596,7 +1730,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1596
1730
 
1597
1731
  broadcastProgress(analysisId, initialStatus);
1598
1732
  broadcastReviewEvent(review.id, { type: 'review:analysis_started', analysisId });
1599
- const analysisConfig = req.app.get('config') || {};
1733
+ const analysisConfig = appConfig;
1600
1734
  const analysisPrContext = {
1601
1735
  number: prNumber, owner, repo,
1602
1736
  author: prMetadata.author, baseBranch: prMetadata.base_branch, headBranch: prMetadata.head_branch,
@@ -1626,7 +1760,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1626
1760
 
1627
1761
  const progressCallback = createProgressCallback(analysisId);
1628
1762
 
1629
- analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
1763
+ analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort })
1630
1764
  .then(async result => {
1631
1765
  logger.section('Analysis Results');
1632
1766
  logger.success(`Analysis complete for PR #${prNumber}`);
@@ -1796,7 +1930,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
1796
1930
  try {
1797
1931
  const { owner, repo, number } = req.params;
1798
1932
  const prNumber = parseInt(number);
1799
- const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
1933
+ const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType, excludePrevious } = req.body || {};
1800
1934
 
1801
1935
  if (isNaN(prNumber) || prNumber <= 0) {
1802
1936
  return res.status(400).json({ error: 'Invalid pull request number' });
@@ -1855,6 +1989,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
1855
1989
  await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
1856
1990
  }
1857
1991
 
1992
+ const prCouncilConfig = req.app.get('config') || {};
1858
1993
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
1859
1994
  db,
1860
1995
  {
@@ -1866,7 +2001,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
1866
2001
  headSha: prMetadata.head_sha,
1867
2002
  logLabel: `PR #${prNumber}`,
1868
2003
  initialStatusExtra: { prNumber, reviewType: 'pr' },
1869
- config: req.app.get('config') || {},
2004
+ config: prCouncilConfig,
2005
+ excludePrevious,
2006
+ serverPort: req.socket.localPort,
1870
2007
  hookContext: {
1871
2008
  mode: 'pr',
1872
2009
  prContext: {
@@ -1884,7 +2021,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
1884
2021
  },
1885
2022
  councilConfig,
1886
2023
  councilId,
1887
- { repoInstructions, requestInstructions },
2024
+ { globalInstructions: prCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
1888
2025
  configType
1889
2026
  );
1890
2027
 
@@ -456,6 +456,8 @@ router.get('/api/reviews/:reviewId/suggestions/check', validateReviewId, async (
456
456
  * Query params:
457
457
  * - levels: comma-separated list of levels (e.g., 'final,1,2'). Default: 'final'
458
458
  * - runId: specific analysis run ID. Default: latest run
459
+ * - allRuns: when 'true', return suggestions from all analysis runs instead of only the latest
460
+ * - excludeRunId: when used with allRuns=true, exclude suggestions from specific run ID(s). Supports comma-separated values (e.g., 'id1,id2')
459
461
  */
460
462
  router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, res) => {
461
463
  try {
@@ -471,6 +473,13 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
471
473
  // If not provided, defaults to the latest run
472
474
  const runIdParam = req.query.runId;
473
475
 
476
+ // Parse allRuns flag — when true, skip the "latest run only" filter
477
+ const allRuns = req.query.allRuns === 'true';
478
+
479
+ // Parse optional excludeRunId — when used with allRuns=true, exclude suggestions from these runs
480
+ // Supports comma-separated values for excluding multiple run IDs (e.g., excludeRunId=id1,id2)
481
+ const excludeRunIds = req.query.excludeRunId ? req.query.excludeRunId.split(',').filter(Boolean) : [];
482
+
474
483
  // Build level filter clause
475
484
  const levelConditions = [];
476
485
  requestedLevels.forEach(level => {
@@ -487,10 +496,22 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
487
496
  : 'ai_level IS NULL';
488
497
 
489
498
  // Build the run ID filter clause
490
- // If a specific runId is provided, use it directly; otherwise use subquery for latest
499
+ // allRuns=true skips run filtering entirely return suggestions from all runs
500
+ // runId param targets a specific run
501
+ // Default: subquery for the latest run only
491
502
  let runIdFilter;
492
503
  let queryParams;
493
- if (runIdParam) {
504
+ if (allRuns) {
505
+ if (excludeRunIds.length > 0) {
506
+ // Return suggestions from all runs except the excluded ones
507
+ runIdFilter = `ai_run_id NOT IN (${excludeRunIds.map(() => '?').join(', ')})`;
508
+ queryParams = [reviewId, ...excludeRunIds];
509
+ } else {
510
+ // No run ID filter — return suggestions from all analysis runs
511
+ runIdFilter = '1 = 1';
512
+ queryParams = [reviewId];
513
+ }
514
+ } else if (runIdParam) {
494
515
  runIdFilter = 'ai_run_id = ?';
495
516
  queryParams = [reviewId, runIdParam];
496
517
  } else {
@@ -514,6 +535,8 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
514
535
  queryParams = [reviewId, reviewId];
515
536
  }
516
537
 
538
+ const statusFilter = "status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')";
539
+
517
540
  const rows = await query(db, `
518
541
  SELECT
519
542
  id,
@@ -533,13 +556,14 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
533
556
  reasoning,
534
557
  status,
535
558
  is_file_level,
559
+ severity,
536
560
  created_at,
537
561
  updated_at
538
562
  FROM comments
539
563
  WHERE review_id = ?
540
564
  AND source = 'ai'
541
565
  AND ${levelFilter}
542
- AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
566
+ AND ${statusFilter}
543
567
  AND (is_raw = 0 OR is_raw IS NULL)
544
568
  AND ${runIdFilter}
545
569
  ORDER BY
@@ -564,7 +588,8 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
564
588
  body: row.body,
565
589
  suggestionText: row.suggestion_text,
566
590
  category: row.type,
567
- title: row.title
591
+ title: row.title,
592
+ severity: row.severity
568
593
  }, formatConfig);
569
594
 
570
595
  return {
@@ -694,7 +719,8 @@ router.post('/api/reviews/:reviewId/suggestions/:id/adopt', validateReviewId, as
694
719
  body: suggestion.body,
695
720
  suggestionText: suggestion.suggestion_text,
696
721
  category: suggestion.type,
697
- title: suggestion.title
722
+ title: suggestion.title,
723
+ severity: suggestion.severity
698
724
  }, formatConfig);
699
725
 
700
726
  // Atomically adopt: create user comment and update suggestion status in one transaction
@@ -108,13 +108,50 @@ function determineCompletionInfo(result) {
108
108
  };
109
109
  }
110
110
 
111
+ // Track which analysisIds have been announced on the index topic
112
+ const _indexAnnouncedIds = new Set();
113
+
114
+ /**
115
+ * Broadcast an analysis status change on the `index:analyses` topic
116
+ * so the index page can show/hide spinners in real time.
117
+ * @param {Object} data - Event data (type, analysisId, reviewId, etc.)
118
+ */
119
+ function broadcastIndexAnalysisEvent(data) {
120
+ ws.broadcast('index:analyses', data);
121
+ }
122
+
111
123
  /**
112
124
  * Broadcast progress update to all WebSocket clients subscribed to `analysis:{analysisId}`.
125
+ * Also emits index-level start/end events for the index page spinners.
113
126
  * @param {string} analysisId - Analysis ID
114
127
  * @param {Object} progressData - Progress data to broadcast
115
128
  */
116
129
  function broadcastProgress(analysisId, progressData) {
117
130
  ws.broadcast('analysis:' + analysisId, { type: 'progress', ...progressData });
131
+
132
+ // Emit index-level events for analysis lifecycle transitions
133
+ const status = progressData.status;
134
+ if (status === 'running' && !_indexAnnouncedIds.has(analysisId)) {
135
+ _indexAnnouncedIds.add(analysisId);
136
+ broadcastIndexAnalysisEvent({
137
+ type: 'analysis_started',
138
+ analysisId,
139
+ reviewId: progressData.reviewId,
140
+ reviewType: progressData.reviewType || null,
141
+ repository: progressData.repository || null,
142
+ prNumber: progressData.prNumber || null
143
+ });
144
+ } else if (['completed', 'failed', 'cancelled'].includes(status) && _indexAnnouncedIds.has(analysisId)) {
145
+ _indexAnnouncedIds.delete(analysisId);
146
+ broadcastIndexAnalysisEvent({
147
+ type: 'analysis_ended',
148
+ analysisId,
149
+ reviewId: progressData.reviewId,
150
+ reviewType: progressData.reviewType || null,
151
+ repository: progressData.repository || null,
152
+ prNumber: progressData.prNumber || null
153
+ });
154
+ }
118
155
  }
119
156
 
120
157
  /**
@@ -231,11 +268,13 @@ function createProgressCallback(analysisId) {
231
268
  }
232
269
  }
233
270
 
234
- // Per-voice orchestration streams: store in voices map, not shared state.
235
- // This prevents per-reviewer orchestration (within each voice's analysis)
236
- // from overwriting the shared consolidation streamEvent/consolidationStep.
237
- const isPerVoiceOrchestration = (level === 'orchestration' || consolidationMatch) && progressUpdate.voiceId;
238
- if (isPerVoiceOrchestration) {
271
+ // Per-voice streams for orchestration and exec levels: store in voices map.
272
+ // For orchestration: prevents per-reviewer orchestration from overwriting
273
+ // the shared consolidation streamEvent/consolidationStep.
274
+ // For exec: ensures exec.voices[voiceId] stays updated with stream events
275
+ // so the frontend can render both state and stream text from the same object.
276
+ const isPerVoiceStream = ((level === 'orchestration' || consolidationMatch) || level === 'exec') && progressUpdate.voiceId;
277
+ if (isPerVoiceStream) {
239
278
  if (!currentStatus.levels[levelKey].voices) {
240
279
  currentStatus.levels[levelKey].voices = {};
241
280
  }
@@ -301,6 +340,32 @@ function createProgressCallback(analysisId) {
301
340
  }
302
341
  }
303
342
 
343
+ // Handle executable voice progress (level === 'exec').
344
+ // Executable voices run a single analysis step instead of L1/L2/L3.
345
+ // Track per-voice state in levels.exec.voices, mirroring the L1-L3 pattern.
346
+ if (level === 'exec' && currentStatus.levels.exec) {
347
+ if (progressUpdate.voiceId) {
348
+ if (!currentStatus.levels.exec.voices) {
349
+ currentStatus.levels.exec.voices = {};
350
+ }
351
+ currentStatus.levels.exec.voices[progressUpdate.voiceId] = {
352
+ status: progressUpdate.status || 'running',
353
+ progress: progressUpdate.progress || 'In progress...'
354
+ };
355
+ currentStatus.levels.exec.voiceId = progressUpdate.voiceId;
356
+ currentStatus.levels.exec.status = progressUpdate.status || 'running';
357
+ currentStatus.levels.exec.progress = progressUpdate.progress || 'In progress...';
358
+ currentStatus.levels.exec.streamEvent = undefined;
359
+ } else {
360
+ currentStatus.levels.exec = {
361
+ status: progressUpdate.status || 'running',
362
+ progress: progressUpdate.progress || 'In progress...',
363
+ streamEvent: undefined,
364
+ voiceId: undefined
365
+ };
366
+ }
367
+ }
368
+
304
369
  // Handle orchestration and consolidation as level 4.
305
370
  //
306
371
  // levels[4] is intentionally denormalized with two parallel maps:
@@ -411,6 +476,8 @@ module.exports = {
411
476
  getModel,
412
477
  determineCompletionInfo,
413
478
  broadcastProgress,
479
+ broadcastIndexAnalysisEvent,
480
+ _indexAnnouncedIds,
414
481
  broadcastSetupProgress,
415
482
  registerProcess,
416
483
  killProcesses,
@@ -154,7 +154,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
154
154
  json_extract(pm.pr_data, '$.html_url') as html_url,
155
155
  w.id as worktree_id,
156
156
  w.path as worktree_path,
157
- w.branch
157
+ w.branch,
158
+ (SELECT r.id FROM reviews r WHERE r.pr_number = pm.pr_number AND r.repository = pm.repository COLLATE NOCASE ORDER BY r.updated_at DESC LIMIT 1) as review_id
158
159
  FROM pr_metadata pm
159
160
  LEFT JOIN worktrees w ON pm.pr_number = w.pr_number AND pm.repository = w.repository COLLATE NOCASE
160
161
  WHERE pm.title IS NOT NULL AND pm.title != ''
@@ -195,7 +196,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
195
196
  last_accessed_at: row.last_accessed_at,
196
197
  created_at: row.created_at,
197
198
  storage_status: storageStatus,
198
- html_url: row.html_url || null
199
+ html_url: row.html_url || null,
200
+ review_id: row.review_id || null
199
201
  });
200
202
  }
201
203
 
@@ -8,15 +8,17 @@ const { getEmoji } = require('./category-emoji');
8
8
 
9
9
  /**
10
10
  * Preset format templates for adopted comments.
11
- * Template placeholders: {emoji}, {category}, {title}, {description}, {suggestion}
11
+ * Template placeholders: {emoji}, {category}, {severity}, {SEVERITY}, {title}, {description}, {suggestion}
12
+ * {severity} renders as Title Case (e.g., "Critical"), {SEVERITY} renders as UPPERCASE (e.g., "CRITICAL").
12
13
  * Conditional sections: {?field}...{/field} — content is kept when field is truthy, stripped when falsy.
14
+ * Nesting is supported: {?outer}...{?inner}...{/inner}...{/outer}
13
15
  */
14
16
  const PRESETS = {
15
17
  legacy: '{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}',
16
18
  minimal: '[{category}] {description}{?suggestion}\n\n{suggestion}{/suggestion}',
17
19
  plain: '{description}{?suggestion}\n\n{suggestion}{/suggestion}',
18
20
  'emoji-only': '{emoji} {description}{?suggestion}\n\n{suggestion}{/suggestion}',
19
- maximal: '{emoji} **{category}**{?title}: {title}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}'
21
+ maximal: '{emoji} **{category}**{?title}: {title}{?severity} ({severity}){/severity}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}'
20
22
  };
21
23
 
22
24
  /**
@@ -63,25 +65,32 @@ function capitalizeCategory(category) {
63
65
  * @returns {string} Template with conditional sections resolved
64
66
  */
65
67
  function processConditionalSections(template, values) {
66
- return template.replace(/\{\?(\w+)\}([\s\S]*?)\{\/\1\}/g, (match, fieldName, content) => {
67
- const value = values[fieldName];
68
- if (value !== undefined && value !== null && value !== '') {
69
- return content;
70
- }
71
- return '';
72
- });
68
+ // Loop to resolve nested conditionals (outer pass exposes inner ones)
69
+ let result = template;
70
+ let prev;
71
+ do {
72
+ prev = result;
73
+ result = result.replace(/\{\?(\w+)\}([\s\S]*?)\{\/\1\}/g, (match, fieldName, content) => {
74
+ const value = values[fieldName];
75
+ if (value !== undefined && value !== null && value !== '') {
76
+ return content;
77
+ }
78
+ return '';
79
+ });
80
+ } while (result !== prev);
81
+ return result;
73
82
  }
74
83
 
75
84
  /**
76
85
  * Format an adopted comment using the given format configuration.
77
86
  * Handles legacy data where suggestion_text was concatenated into body.
78
87
  *
79
- * @param {{ body: string, suggestionText?: string, category?: string, title?: string }} fields
88
+ * @param {{ body: string, suggestionText?: string, category?: string, title?: string, severity?: string }} fields
80
89
  * @param {{ template: string, emojiOverrides: Object, categoryOverrides: Object }} formatConfig
81
90
  * @returns {string} Formatted comment text
82
91
  */
83
92
  function formatAdoptedComment(fields, formatConfig) {
84
- const { body, title } = fields;
93
+ const { body, title, severity } = fields;
85
94
  let { category, suggestionText } = fields;
86
95
 
87
96
  if (!category) {
@@ -111,9 +120,15 @@ function formatAdoptedComment(fields, formatConfig) {
111
120
  const capitalizedCategory = capitalizeCategory(category);
112
121
 
113
122
  // Process conditional sections first, then replace individual placeholders
123
+ const capitalizedSeverity = severity ? capitalizeCategory(severity) : '';
124
+
125
+ const uppercaseSeverity = severity ? severity.toUpperCase() : '';
126
+
114
127
  const fieldValues = {
115
128
  suggestion: suggestionText || '',
116
129
  title: title || '',
130
+ severity: capitalizedSeverity,
131
+ SEVERITY: uppercaseSeverity,
117
132
  emoji,
118
133
  category: capitalizedCategory,
119
134
  description
@@ -124,6 +139,8 @@ function formatAdoptedComment(fields, formatConfig) {
124
139
  // Replace placeholders
125
140
  result = result.replace(/\{emoji\}/g, emoji);
126
141
  result = result.replace(/\{category\}/g, capitalizedCategory);
142
+ result = result.replace(/\{severity\}/g, capitalizedSeverity);
143
+ result = result.replace(/\{SEVERITY\}/g, uppercaseSeverity);
127
144
  result = result.replace(/\{title\}/g, title || '');
128
145
  result = result.replace(/\{description\}/g, description);
129
146
  result = result.replace(/\{suggestion\}/g, suggestionText || '');