@in-the-loop-labs/pair-review 3.2.2 → 3.3.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 (46) hide show
  1. package/README.md +7 -6
  2. package/package.json +5 -4
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
  6. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
  7. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
  8. package/public/css/repo-settings.css +347 -0
  9. package/public/index.html +46 -9
  10. package/public/js/components/AIPanel.js +79 -37
  11. package/public/js/components/DiffOptionsDropdown.js +84 -1
  12. package/public/js/index.js +31 -6
  13. package/public/js/modules/analysis-history.js +11 -7
  14. package/public/js/pr.js +22 -0
  15. package/public/js/repo-settings.js +334 -6
  16. package/public/repo-settings.html +29 -0
  17. package/src/ai/analyzer.js +28 -19
  18. package/src/ai/claude-cli.js +2 -0
  19. package/src/ai/claude-provider.js +4 -1
  20. package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
  21. package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
  22. package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
  23. package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
  24. package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
  25. package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
  26. package/src/ai/provider.js +7 -6
  27. package/src/chat/session-manager.js +6 -3
  28. package/src/config.js +230 -38
  29. package/src/database.js +766 -38
  30. package/src/git/worktree-pool-lifecycle.js +674 -0
  31. package/src/git/worktree-pool-usage.js +216 -0
  32. package/src/git/worktree.js +46 -13
  33. package/src/main.js +185 -26
  34. package/src/routes/analyses.js +48 -26
  35. package/src/routes/chat.js +27 -3
  36. package/src/routes/config.js +17 -5
  37. package/src/routes/executable-analysis.js +38 -19
  38. package/src/routes/local.js +19 -6
  39. package/src/routes/mcp.js +13 -2
  40. package/src/routes/pr.js +72 -29
  41. package/src/routes/setup.js +41 -4
  42. package/src/routes/stack-analysis.js +29 -10
  43. package/src/routes/worktrees.js +294 -9
  44. package/src/server.js +20 -3
  45. package/src/setup/pr-setup.js +161 -27
  46. package/src/ws/server.js +51 -1
@@ -234,7 +234,8 @@ async function runExecutableAnalysis(req, res, params, shared, callbacks) {
234
234
  reviewId, review, selectedProvider, selectedModel,
235
235
  repoInstructions, requestInstructions,
236
236
  runId, analysisId, repository, reviewType, headSha,
237
- extraInitialStatus
237
+ extraInitialStatus,
238
+ providerOverrides = {}
238
239
  } = params;
239
240
 
240
241
  const {
@@ -291,32 +292,48 @@ async function runExecutableAnalysis(req, res, params, shared, callbacks) {
291
292
  broadcastProgress(analysisId, initialStatus);
292
293
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
293
294
 
294
- // 3. Fire analysis.started hook
295
+ // Register analysis hold for pool worktree usage tracking.
296
+ // startAnalysis is inside the try so a synchronous exception in setup still
297
+ // cleans up the hold (the .finally() inside the async IIFE only covers errors after it starts).
298
+ const poolLifecycle = params.poolLifecycle;
299
+ let poolWorktreeId;
295
300
  const analysisHookConfig = req.app.get('config') || {};
296
301
  const hookPayloadFields = buildHookPayload(review, {});
297
- if (hasHooks('analysis.started', analysisHookConfig)) {
298
- getCachedUser(analysisHookConfig).then(user => {
299
- fireHooks('analysis.started', buildAnalysisStartedPayload({
300
- reviewId, analysisId, provider: selectedProvider, model: selectedModel,
301
- ...hookPayloadFields,
302
- user,
303
- }), analysisHookConfig);
304
- }).catch(() => {});
305
- }
302
+ try {
303
+ poolWorktreeId = await poolLifecycle?.startAnalysis(reviewId, analysisId);
304
+
305
+ // 3. Fire analysis.started hook
306
+ if (hasHooks('analysis.started', analysisHookConfig)) {
307
+ getCachedUser(analysisHookConfig).then(user => {
308
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
309
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
310
+ ...hookPayloadFields,
311
+ user,
312
+ }), analysisHookConfig);
313
+ }).catch(() => {});
314
+ }
306
315
 
307
- // 4. Respond immediately — analysis runs async
308
- res.json({
309
- analysisId,
310
- runId,
311
- status: 'running',
312
- message: 'Executable provider analysis started'
313
- });
316
+ // 4. Respond immediately — analysis runs async
317
+ res.json({
318
+ analysisId,
319
+ runId,
320
+ status: 'running',
321
+ message: 'Executable provider analysis started'
322
+ });
323
+ } catch (setupError) {
324
+ // Synchronous setup failure — clean up the analysis hold immediately
325
+ reviewToAnalysisId.delete(reviewId);
326
+ if (poolWorktreeId) {
327
+ poolLifecycle?.endAnalysis(analysisId);
328
+ }
329
+ throw setupError;
330
+ }
314
331
 
315
332
  // 5. Run the executable provider asynchronously
316
333
  (async () => {
317
334
  const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pair-review-exec-'));
318
335
  try {
319
- const provider = createProvider(selectedProvider, selectedModel);
336
+ const provider = createProvider(selectedProvider, selectedModel, providerOverrides);
320
337
 
321
338
  const executableContext = {
322
339
  ...buildContext(review, { selectedModel, requestInstructions }),
@@ -476,6 +493,8 @@ async function runExecutableAnalysis(req, res, params, shared, callbacks) {
476
493
  // Do NOT delete activeAnalyses entry — leave it with terminal status so
477
494
  // clients can poll for final results via HTTP (matches local.js/pr.js).
478
495
  reviewToAnalysisId.delete(reviewId);
496
+ // Remove pool worktree analysis hold
497
+ poolLifecycle?.endAnalysis(analysisId);
479
498
 
480
499
  // Clean up temp directory (keep in debug mode for inspection)
481
500
  if (tmpDir) {
@@ -24,7 +24,7 @@ const { broadcastReviewEvent } = require('../events/review-events');
24
24
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
25
25
  const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
26
26
  const { mergeInstructions } = require('../utils/instructions');
27
- const { getGitHubToken } = require('../config');
27
+ const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
28
28
  const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
29
29
  const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
30
30
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
@@ -1023,7 +1023,8 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
1023
1023
  */
1024
1024
  async function handleExecutableAnalysis(req, res, {
1025
1025
  reviewId, review, localPath, repository, selectedProvider, selectedModel,
1026
- repoInstructions, requestInstructions, combinedInstructions, runId, analysisId, reviewRepo
1026
+ repoInstructions, requestInstructions, combinedInstructions, runId, analysisId, reviewRepo,
1027
+ providerOverrides
1027
1028
  }) {
1028
1029
  return runExecutableAnalysis(req, res, {
1029
1030
  reviewId,
@@ -1036,7 +1037,8 @@ async function handleExecutableAnalysis(req, res, {
1036
1037
  analysisId,
1037
1038
  repository,
1038
1039
  reviewType: review.review_type || 'local',
1039
- headSha: review.local_head_sha
1040
+ headSha: review.local_head_sha,
1041
+ providerOverrides
1040
1042
  }, {
1041
1043
  activeAnalyses,
1042
1044
  reviewToAnalysisId,
@@ -1171,6 +1173,11 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1171
1173
  const runId = uuidv4();
1172
1174
  const analysisId = runId;
1173
1175
 
1176
+ // Resolve load_skills across all config tiers
1177
+ const providerLoadSkills = appConfig.providers?.[selectedProvider]?.load_skills;
1178
+ const loadSkills = resolveLoadSkills(appConfig, repository, repoSettings, providerLoadSkills);
1179
+ const providerOverrides = { load_skills: loadSkills };
1180
+
1174
1181
  // Check if selected provider is an executable provider (external tool)
1175
1182
  const ProviderClass = getProviderClass(selectedProvider);
1176
1183
  if (ProviderClass?.isExecutable) {
@@ -1186,7 +1193,8 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1186
1193
  combinedInstructions,
1187
1194
  runId,
1188
1195
  analysisId,
1189
- reviewRepo
1196
+ reviewRepo,
1197
+ providerOverrides
1190
1198
  });
1191
1199
  }
1192
1200
 
@@ -1258,7 +1266,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1258
1266
  }
1259
1267
 
1260
1268
  // Create analyzer instance with provider and model
1261
- const analyzer = new Analyzer(db, selectedModel, selectedProvider);
1269
+ const analyzer = new Analyzer(db, selectedModel, selectedProvider, providerOverrides);
1262
1270
 
1263
1271
  // Build local review metadata for the analyzer
1264
1272
  // The analyzer uses base_sha and head_sha for git diff commands
@@ -2051,7 +2059,6 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2051
2059
  scopeEnd: councilScopeEnd,
2052
2060
  };
2053
2061
 
2054
- const analyzer = new Analyzer(db, 'council', 'council');
2055
2062
  // Use the scope-aware helper so the file list matches the generated diff
2056
2063
  // (covers branch, staged, unstaged, and untracked stops as appropriate).
2057
2064
  const changedFiles = await getChangedFiles(localPath, {
@@ -2085,6 +2092,10 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2085
2092
  // Import launchCouncilAnalysis from analyses.js
2086
2093
  const analysesRouter = require('./analyses');
2087
2094
  const localCouncilConfig = req.app.get('config') || {};
2095
+
2096
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2097
+ buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
2098
+
2088
2099
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2089
2100
  db,
2090
2101
  {
@@ -2099,6 +2110,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2099
2110
  config: localCouncilConfig,
2100
2111
  excludePrevious,
2101
2112
  serverPort: req.socket.localPort,
2113
+ providerOverrides: councilProviderOverrides,
2114
+ providerOverridesMap: councilProviderOverridesMap,
2102
2115
  hookContext: {
2103
2116
  mode: 'local',
2104
2117
  localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
package/src/routes/mcp.js CHANGED
@@ -23,6 +23,7 @@ const {
23
23
  createProgressCallback
24
24
  } = require('./shared');
25
25
  const { safeParseJson } = require('../utils/safe-parse-json');
26
+ const { resolveLoadSkills } = require('../config');
26
27
 
27
28
  // All valid tier values: canonical tiers + aliases (for Zod enum validation)
28
29
  const ALL_TIER_VALUES = /** @type {[string, ...string[]]} */ ([...TIERS, ...Object.keys(TIER_ALIASES)]);
@@ -595,8 +596,13 @@ function createMCPServer(db, options = {}) {
595
596
  broadcastProgress(analysisId, initialStatus);
596
597
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
597
598
 
599
+ // Resolve load_skills across all config tiers
600
+ const providerLoadSkills = config.providers?.[provider]?.load_skills;
601
+ const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
602
+ const providerOverrides = { load_skills: loadSkills };
603
+
598
604
  // Create analyzer and launch asynchronously
599
- const analyzer = new Analyzer(db, model, provider);
605
+ const analyzer = new Analyzer(db, model, provider, providerOverrides);
600
606
  const localMetadata = {
601
607
  id: reviewId,
602
608
  repository: review.repository,
@@ -747,7 +753,12 @@ function createMCPServer(db, options = {}) {
747
753
  broadcastProgress(analysisId, initialStatus);
748
754
  broadcastReviewEvent(review.id, { type: 'review:analysis_started', analysisId });
749
755
 
750
- const analyzer = new Analyzer(db, model, provider);
756
+ // Resolve load_skills across all config tiers
757
+ const prProviderLoadSkills = config.providers?.[provider]?.load_skills;
758
+ const prLoadSkills = resolveLoadSkills(config, repository, repoSettings, prProviderLoadSkills);
759
+ const prProviderOverrides = { load_skills: prLoadSkills };
760
+
761
+ const analyzer = new Analyzer(db, model, provider, prProviderOverrides);
751
762
  const progressCallback = createProgressCallback(analysisId);
752
763
  const tier = resolveTier(args.tier);
753
764
 
package/src/routes/pr.js CHANGED
@@ -25,7 +25,7 @@ const Analyzer = require('../ai/analyzer');
25
25
  const { v4: uuidv4 } = require('uuid');
26
26
  const fs = require('fs').promises;
27
27
  const path = require('path');
28
- const { getGitHubToken } = require('../config');
28
+ const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
29
29
  const logger = require('../utils/logger');
30
30
  const { buildDiffLineSet } = require('../utils/diff-annotator');
31
31
  const { broadcastReviewEvent } = require('../events/review-events');
@@ -278,6 +278,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
278
278
  additions: extendedData.additions || 0,
279
279
  deletions: extendedData.deletions || 0,
280
280
  diff_content: extendedData.diff || '',
281
+ worktree_path: extendedData.worktree_path || null,
282
+ worktree_name: getWorktreeDisplayName(extendedData.worktree_path, req.app.get('config') || {}, repository),
281
283
  html_url: extendedData.html_url || `https://github.com/${repoOwner}/${repoName}/pull/${prMetadata.pr_number}`,
282
284
  pendingDraft: pendingDraft ? {
283
285
  id: pendingDraft.id,
@@ -392,7 +394,8 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
392
394
  html_url: prData.html_url,
393
395
  base_sha: prData.base_sha,
394
396
  head_sha: prData.head_sha,
395
- node_id: prData.node_id // GraphQL node ID for PR (required for GraphQL review submission)
397
+ node_id: prData.node_id, // GraphQL node ID for PR (required for GraphQL review submission)
398
+ worktree_path: worktreePath
396
399
  };
397
400
 
398
401
  // Update database with new data
@@ -485,7 +488,9 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
485
488
  html_url: parsedData.html_url || `https://github.com/${repoOwner}/${repoName}/pull/${prMetadata.pr_number}`,
486
489
  head_sha: parsedData.head_sha,
487
490
  base_sha: parsedData.base_sha,
488
- node_id: parsedData.node_id
491
+ node_id: parsedData.node_id,
492
+ worktree_path: parsedData.worktree_path || null,
493
+ worktree_name: getWorktreeDisplayName(parsedData.worktree_path, config, repository)
489
494
  }
490
495
  };
491
496
 
@@ -1513,7 +1518,7 @@ router.post('/api/parse-pr-url', (req, res) => {
1513
1518
  async function handleExecutablePRAnalysis(req, res, {
1514
1519
  reviewId, review, prNumber, owner, repo, repository, worktreePath, prMetadata,
1515
1520
  selectedProvider, selectedModel, repoInstructions, requestInstructions,
1516
- combinedInstructions, runId, analysisId, reviewRepo
1521
+ combinedInstructions, runId, analysisId, reviewRepo, poolLifecycle, providerOverrides
1517
1522
  }) {
1518
1523
  const prContext = {
1519
1524
  number: prNumber, owner, repo,
@@ -1533,7 +1538,9 @@ async function handleExecutablePRAnalysis(req, res, {
1533
1538
  repository,
1534
1539
  reviewType: 'pr',
1535
1540
  headSha: prMetadata.head_sha,
1536
- extraInitialStatus: { prNumber }
1541
+ extraInitialStatus: { prNumber },
1542
+ poolLifecycle,
1543
+ providerOverrides
1537
1544
  }, {
1538
1545
  activeAnalyses,
1539
1546
  reviewToAnalysisId,
@@ -1645,7 +1652,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1645
1652
  const appConfig = req.app.get('config') || {};
1646
1653
  const globalInstructions = appConfig.globalInstructions || null;
1647
1654
 
1648
- const { provider, model, repoInstructions, combinedInstructions } = await withTransaction(db, async () => {
1655
+ const { provider, model, repoInstructions, combinedInstructions, repoSettings: fetchedRepoSettings } = await withTransaction(db, async () => {
1649
1656
  const repoSettingsRepo = new RepoSettingsRepository(db);
1650
1657
  const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
1651
1658
 
@@ -1678,7 +1685,8 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1678
1685
  provider: selectedProvider,
1679
1686
  model: selectedModel,
1680
1687
  repoInstructions: fetchedRepoInstructions,
1681
- combinedInstructions: mergedInstructions
1688
+ combinedInstructions: mergedInstructions,
1689
+ repoSettings: fetchedRepoSettings
1682
1690
  };
1683
1691
  });
1684
1692
 
@@ -1687,6 +1695,11 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1687
1695
 
1688
1696
  const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
1689
1697
 
1698
+ // Resolve load_skills across all config tiers
1699
+ const providerLoadSkills = appConfig.providers?.[provider]?.load_skills;
1700
+ const loadSkills = resolveLoadSkills(appConfig, repository, fetchedRepoSettings, providerLoadSkills);
1701
+ const providerOverrides = { load_skills: loadSkills };
1702
+
1690
1703
  // Check if selected provider is an executable provider (external tool)
1691
1704
  const ProviderClass = getProviderClass(provider);
1692
1705
  if (ProviderClass?.isExecutable) {
@@ -1706,7 +1719,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1706
1719
  combinedInstructions,
1707
1720
  runId,
1708
1721
  analysisId,
1709
- reviewRepo
1722
+ reviewRepo,
1723
+ poolLifecycle: req.app.get('poolLifecycle'),
1724
+ providerOverrides
1710
1725
  });
1711
1726
  }
1712
1727
 
@@ -1753,37 +1768,56 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1753
1768
 
1754
1769
  broadcastProgress(analysisId, initialStatus);
1755
1770
  broadcastReviewEvent(review.id, { type: 'review:analysis_started', analysisId });
1771
+
1772
+ // Register analysis hold for pool worktree usage tracking.
1773
+ // startAnalysis is inside the try so a synchronous exception in setup still
1774
+ // cleans up the hold (the .finally() on the promise chain only covers async errors).
1775
+ const poolLifecycle = req.app.get('poolLifecycle');
1776
+ let poolWorktreeId;
1756
1777
  const analysisConfig = appConfig;
1757
1778
  const analysisPrContext = {
1758
1779
  number: prNumber, owner, repo,
1759
1780
  author: prMetadata.author, baseBranch: prMetadata.base_branch, headBranch: prMetadata.head_branch,
1760
1781
  baseSha: prMetadata.base_sha || null, headSha: prMetadata.head_sha || null,
1761
1782
  };
1762
- if (hasHooks('analysis.started', analysisConfig)) {
1763
- getCachedUser(analysisConfig).then(user => {
1764
- fireHooks('analysis.started', buildAnalysisStartedPayload({
1765
- reviewId: review.id, analysisId, provider, model, mode: 'pr', prContext: analysisPrContext, user,
1766
- }), analysisConfig);
1767
- }).catch(() => {});
1768
- }
1783
+ let analysisPromise;
1784
+ try {
1785
+ poolWorktreeId = await poolLifecycle?.startAnalysis(review.id, analysisId);
1786
+ if (hasHooks('analysis.started', analysisConfig)) {
1787
+ getCachedUser(analysisConfig).then(user => {
1788
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
1789
+ reviewId: review.id, analysisId, provider, model, mode: 'pr', prContext: analysisPrContext, user,
1790
+ }), analysisConfig);
1791
+ }).catch(() => {});
1792
+ }
1769
1793
 
1770
- const analyzer = new Analyzer(req.app.get('db'), model, provider);
1794
+ const analyzer = new Analyzer(req.app.get('db'), model, provider, providerOverrides);
1795
+
1796
+ logger.section(`AI Analysis Request - PR #${prNumber}`);
1797
+ logger.log('API', `Repository: ${repository}`, 'magenta');
1798
+ logger.log('API', `Worktree: ${worktreePath}`, 'magenta');
1799
+ logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
1800
+ logger.log('API', `Review ID: ${review.id}`, 'magenta');
1801
+ logger.log('API', `Provider: ${provider}`, 'cyan');
1802
+ logger.log('API', `Model: ${model}`, 'cyan');
1803
+ logger.log('API', `Tier: ${tier}`, 'cyan');
1804
+ if (combinedInstructions) {
1805
+ logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
1806
+ }
1771
1807
 
1772
- logger.section(`AI Analysis Request - PR #${prNumber}`);
1773
- logger.log('API', `Repository: ${repository}`, 'magenta');
1774
- logger.log('API', `Worktree: ${worktreePath}`, 'magenta');
1775
- logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
1776
- logger.log('API', `Review ID: ${review.id}`, 'magenta');
1777
- logger.log('API', `Provider: ${provider}`, 'cyan');
1778
- logger.log('API', `Model: ${model}`, 'cyan');
1779
- logger.log('API', `Tier: ${tier}`, 'cyan');
1780
- if (combinedInstructions) {
1781
- logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
1782
- }
1808
+ const progressCallback = createProgressCallback(analysisId);
1783
1809
 
1784
- const progressCallback = createProgressCallback(analysisId);
1810
+ analysisPromise = 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 });
1811
+ } catch (setupError) {
1812
+ // Synchronous setup failure — clean up the analysis hold immediately
1813
+ reviewToAnalysisId.delete(review.id);
1814
+ if (poolWorktreeId) {
1815
+ poolLifecycle?.endAnalysis(analysisId);
1816
+ }
1817
+ throw setupError;
1818
+ }
1785
1819
 
1786
- 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 })
1820
+ analysisPromise
1787
1821
  .then(async result => {
1788
1822
  logger.section('Analysis Results');
1789
1823
  logger.success(`Analysis complete for PR #${prNumber}`);
@@ -1929,6 +1963,8 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1929
1963
  .finally(() => {
1930
1964
  // Clean up review to analysis ID mapping (unified map)
1931
1965
  reviewToAnalysisId.delete(review.id);
1966
+ // Remove pool worktree analysis hold
1967
+ poolLifecycle?.endAnalysis(analysisId);
1932
1968
  });
1933
1969
 
1934
1970
  res.json({
@@ -2013,6 +2049,10 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2013
2049
  }
2014
2050
 
2015
2051
  const prCouncilConfig = req.app.get('config') || {};
2052
+
2053
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2054
+ buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
2055
+
2016
2056
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2017
2057
  db,
2018
2058
  {
@@ -2027,6 +2067,9 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2027
2067
  config: prCouncilConfig,
2028
2068
  excludePrevious,
2029
2069
  serverPort: req.socket.localPort,
2070
+ poolLifecycle: req.app.get('poolLifecycle'),
2071
+ providerOverrides: councilProviderOverrides,
2072
+ providerOverridesMap: councilProviderOverridesMap,
2030
2073
  hookContext: {
2031
2074
  mode: 'pr',
2032
2075
  prContext: {
@@ -16,7 +16,7 @@ const { activeSetups, broadcastSetupProgress } = require('./shared');
16
16
  const { setupPRReview } = require('../setup/pr-setup');
17
17
  const { setupLocalReview } = require('../setup/local-setup');
18
18
  const { getGitHubToken, expandPath } = require('../config');
19
- const { queryOne } = require('../database');
19
+ const { queryOne, ReviewRepository } = require('../database');
20
20
  const { normalizeRepository } = require('../utils/paths');
21
21
  const logger = require('../utils/logger');
22
22
 
@@ -81,7 +81,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
81
81
  const repository = normalizeRepository(owner, repo);
82
82
  const existingPR = await queryOne(
83
83
  db,
84
- 'SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE',
84
+ 'SELECT id, pr_data FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE',
85
85
  [prNumber, repository]
86
86
  );
87
87
  if (existingPR) {
@@ -91,9 +91,44 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
91
91
  [prNumber, repository]
92
92
  );
93
93
  if (worktree) {
94
- return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
94
+ // If the worktree belongs to the pool, verify it is still actively
95
+ // owned (in_use). Pool slots retain their worktrees row after being
96
+ // released — markAvailable() clears ownership without deleting the
97
+ // record. A released slot may have been reassigned to a different PR,
98
+ // so we must fall through to re-run setup and reacquire a pool slot.
99
+ const poolLifecycle = req.app.get('poolLifecycle');
100
+ const poolEntry = poolLifecycle ? await poolLifecycle.poolRepo.getPoolEntry(worktree.id) : null;
101
+ if (poolEntry && poolEntry.status !== 'in_use') {
102
+ if (poolEntry.status === 'available' && poolEntry.current_pr_number === prNumber) {
103
+ // Still associated with this PR — reclaim without re-setup
104
+ logger.info(`Reclaiming pool worktree ${worktree.id} for ${repository} #${prNumber} (was ${poolEntry.status})`);
105
+ await poolLifecycle.poolRepo.markInUse(poolEntry.id, prNumber);
106
+ const reviewRepo = new ReviewRepository(db);
107
+ const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
108
+ await poolLifecycle.poolRepo.setCurrentReviewId(poolEntry.id, review.id);
109
+ return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
110
+ }
111
+ logger.info(`Pool worktree ${worktree.id} for ${repository} #${prNumber} is ${poolEntry.status}, re-running setup to reacquire`);
112
+ } else {
113
+ return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
114
+ }
115
+ } else {
116
+ logger.info(`PR metadata exists but worktree missing for ${repository} #${prNumber}, re-running setup`);
117
+ }
118
+ }
119
+
120
+ // If we have stored PR data with a head_sha, pass it to setupPRReview
121
+ // so it can attempt restore mode (skip GitHub fetch + diff regeneration).
122
+ let restoreMetadata = null;
123
+ if (existingPR && existingPR.pr_data) {
124
+ try {
125
+ const parsed = JSON.parse(existingPR.pr_data);
126
+ if (parsed.head_sha) {
127
+ restoreMetadata = parsed;
128
+ }
129
+ } catch (e) {
130
+ logger.warn(`Could not parse stored pr_data for ${repository} #${prNumber}`);
95
131
  }
96
- logger.info(`PR metadata exists but worktree missing for ${repository} #${prNumber}, re-running setup`);
97
132
  }
98
133
 
99
134
  // Start the async setup
@@ -108,6 +143,8 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
108
143
  prNumber,
109
144
  githubToken,
110
145
  config,
146
+ poolLifecycle: req.app.get('poolLifecycle'),
147
+ restoreMetadata,
111
148
  onProgress: (progress) => {
112
149
  sendSetupEvent(setupId, 'step', progress);
113
150
  }
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
19
19
  const { mergeInstructions } = require('../utils/instructions');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { GitHubClient } = require('../github/client');
22
- const { getGitHubToken } = require('../config');
22
+ const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
23
23
  const { setupStackPR } = require('../setup/stack-setup');
24
24
  const Analyzer = require('../ai/analyzer');
25
25
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -265,7 +265,7 @@ async function executeStackAnalysis(params) {
265
265
  broadcastStackProgress(stackAnalysisId, state);
266
266
 
267
267
  const prInfo = { owner, repo, number: prNum };
268
- const perPRWorktreePath = await worktreeManager.createWorktreeForPR(
268
+ const { path: perPRWorktreePath } = await worktreeManager.createWorktreeForPR(
269
269
  prInfo, prData, repositoryPath
270
270
  );
271
271
  worktreePathMap.set(prNum, perPRWorktreePath);
@@ -374,29 +374,41 @@ async function analyzeStackPR(deps, db, config, {
374
374
  let analysisResult;
375
375
 
376
376
  if (configType === 'council' || configType === 'advanced' || isCouncil) {
377
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
378
+ buildCouncilProviderOverrides(config, repository, repoSettings);
379
+
377
380
  analysisResult = await launchStackCouncilAnalysis(deps, db, config, {
378
381
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
379
382
  globalInstructions, repoInstructions, requestInstructions,
380
- councilId, rawCouncilConfig, configType, onAnalysisIdReady
383
+ councilId, rawCouncilConfig, configType, onAnalysisIdReady,
384
+ providerOverrides: councilProviderOverrides,
385
+ providerOverridesMap: councilProviderOverridesMap
381
386
  });
382
387
  } else {
383
388
  let selectedProvider = reqProvider || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
384
389
  let selectedModel = reqModel || repoSettings?.default_model || config.default_model || config.model || 'opus';
385
390
 
391
+ // Resolve load_skills across all config tiers
392
+ const providerLoadSkills = config.providers?.[selectedProvider]?.load_skills;
393
+ const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
394
+ const providerOverrides = { load_skills: loadSkills };
395
+
386
396
  const ProviderClass = deps.getProviderClass(selectedProvider);
387
397
 
388
398
  if (ProviderClass?.isExecutable) {
389
399
  analysisResult = await launchStackExecutableAnalysis(deps, db, config, {
390
400
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
391
401
  selectedProvider, selectedModel,
392
- repoInstructions, requestInstructions, onAnalysisIdReady
402
+ repoInstructions, requestInstructions, onAnalysisIdReady,
403
+ providerOverrides
393
404
  });
394
405
  } else {
395
406
  analysisResult = await launchStackSingleAnalysis(deps, db, config, {
396
407
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
397
408
  selectedProvider, selectedModel,
398
409
  globalInstructions, repoInstructions, requestInstructions,
399
- reqTier, reqEnabledLevels, onAnalysisIdReady
410
+ reqTier, reqEnabledLevels, onAnalysisIdReady,
411
+ providerOverrides
400
412
  });
401
413
  }
402
414
  }
@@ -415,7 +427,8 @@ async function launchStackSingleAnalysis(deps, db, config, {
415
427
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
416
428
  selectedProvider, selectedModel,
417
429
  globalInstructions, repoInstructions, requestInstructions,
418
- reqTier, reqEnabledLevels, onAnalysisIdReady
430
+ reqTier, reqEnabledLevels, onAnalysisIdReady,
431
+ providerOverrides = {}
419
432
  }) {
420
433
  const runId = uuidv4();
421
434
  const analysisId = runId;
@@ -462,7 +475,7 @@ async function launchStackSingleAnalysis(deps, db, config, {
462
475
  broadcastProgress(analysisId, initialStatus);
463
476
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
464
477
 
465
- const analyzer = new deps.Analyzer(db, selectedModel, selectedProvider);
478
+ const analyzer = new deps.Analyzer(db, selectedModel, selectedProvider, providerOverrides);
466
479
  const progressCallback = createProgressCallback(analysisId);
467
480
 
468
481
  logger.info(`Stack analysis: starting single-model analysis for PR #${prNum} (${selectedProvider}/${selectedModel})`);
@@ -538,7 +551,9 @@ async function launchStackSingleAnalysis(deps, db, config, {
538
551
  async function launchStackCouncilAnalysis(deps, db, config, {
539
552
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
540
553
  globalInstructions, repoInstructions, requestInstructions,
541
- councilId, rawCouncilConfig, configType, onAnalysisIdReady
554
+ councilId, rawCouncilConfig, configType, onAnalysisIdReady,
555
+ providerOverrides = {},
556
+ providerOverridesMap = null
542
557
  }) {
543
558
  let councilConfig;
544
559
  let resolvedConfigType = configType;
@@ -580,6 +595,8 @@ async function launchStackCouncilAnalysis(deps, db, config, {
580
595
  logLabel: `Stack PR #${prNum}`,
581
596
  initialStatusExtra: { prNumber: prNum, reviewType: 'pr' },
582
597
  config,
598
+ providerOverrides,
599
+ providerOverridesMap,
583
600
  hookContext: {
584
601
  mode: 'pr',
585
602
  prContext: {
@@ -623,7 +640,8 @@ async function launchStackCouncilAnalysis(deps, db, config, {
623
640
  async function launchStackExecutableAnalysis(deps, db, config, {
624
641
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
625
642
  selectedProvider, selectedModel,
626
- repoInstructions, requestInstructions, onAnalysisIdReady
643
+ repoInstructions, requestInstructions, onAnalysisIdReady,
644
+ providerOverrides = {}
627
645
  }) {
628
646
  const runId = uuidv4();
629
647
  const analysisId = runId;
@@ -671,7 +689,8 @@ async function launchStackExecutableAnalysis(deps, db, config, {
671
689
  repository,
672
690
  reviewType: 'pr',
673
691
  headSha: prMetadata.head_sha,
674
- extraInitialStatus: { prNumber: prNum }
692
+ extraInitialStatus: { prNumber: prNum },
693
+ providerOverrides
675
694
  }, {
676
695
  activeAnalyses,
677
696
  reviewToAnalysisId,