@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
package/src/main.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveMonorepoOptions } = require('./config');
4
- const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository } = require('./database');
3
+ const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
4
+ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
7
7
  const { GitWorktreeManager } = require('./git/worktree');
8
+ const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
8
9
  const { startServer } = require('./server');
9
10
  const Analyzer = require('./ai/analyzer');
10
11
  const { applyConfigOverrides } = require('./ai');
@@ -445,6 +446,10 @@ AI PROVIDERS:
445
446
  console.warn('Some worktrees could not be migrated:', migrationResult.errors);
446
447
  }
447
448
 
449
+ // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
450
+ const poolLifecycle = new WorktreePoolLifecycle(db, config);
451
+ await poolLifecycle.resetAndRehydrate();
452
+
448
453
  // Parse command line arguments including flags
449
454
  const { prArgs, flags } = parseArgs(args);
450
455
 
@@ -477,8 +482,9 @@ AI PROVIDERS:
477
482
  // Async cleanup of stale worktrees and reviews (don't block startup)
478
483
  cleanupStaleWorktreesAsync(config);
479
484
  cleanupStaleReviewsAsync(config);
485
+ startPoolBackgroundFetches(db, config);
480
486
 
481
- return; // Exit after local review
487
+ return;
482
488
  }
483
489
 
484
490
  // Auto-detect GitHub Actions environment
@@ -510,11 +516,11 @@ AI PROVIDERS:
510
516
 
511
517
  // Check for --ai-review mode (takes precedence over --ai-draft and --ai)
512
518
  if (flags.aiReview) {
513
- await handleActionReview(effectivePrArgs, config, db, flags);
519
+ await handleActionReview(effectivePrArgs, config, db, flags, poolLifecycle);
514
520
  } else if (flags.aiDraft) {
515
- await handleDraftModeReview(effectivePrArgs, config, db, flags);
521
+ await handleDraftModeReview(effectivePrArgs, config, db, flags, poolLifecycle);
516
522
  } else {
517
- await handlePullRequest(effectivePrArgs, config, db, flags);
523
+ await handlePullRequest(effectivePrArgs, config, db, flags, poolLifecycle);
518
524
  }
519
525
  } else {
520
526
  // Check if --ai or --ai-draft flags were used without PR identifier
@@ -530,7 +536,7 @@ AI PROVIDERS:
530
536
 
531
537
  // No PR arguments - just start the server
532
538
  console.log('No pull request specified. Starting server...');
533
- await startServerOnly(config);
539
+ await startServerOnly(config, poolLifecycle);
534
540
  }
535
541
 
536
542
  } catch (error) {
@@ -545,8 +551,9 @@ AI PROVIDERS:
545
551
  * @param {Object} config - Application configuration
546
552
  * @param {Object} db - Database instance
547
553
  * @param {Object} flags - Parsed command line flags
554
+ * @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Pool lifecycle instance
548
555
  */
549
- async function handlePullRequest(args, config, db, flags = {}) {
556
+ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = null) {
550
557
  try {
551
558
  // Get GitHub token (env var takes precedence over config)
552
559
  const githubToken = getGitHubToken(config);
@@ -571,11 +578,12 @@ async function handlePullRequest(args, config, db, flags = {}) {
571
578
  }
572
579
 
573
580
  // Start server and open browser to setup page
574
- const port = await startServer(db);
581
+ const port = await startServer(db, poolLifecycle);
575
582
 
576
583
  // Async cleanup of stale worktrees and reviews (don't block startup)
577
584
  cleanupStaleWorktreesAsync(config);
578
585
  cleanupStaleReviewsAsync(config);
586
+ startPoolBackgroundFetches(db, config);
579
587
 
580
588
  let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
581
589
  if (flags.ai) {
@@ -594,13 +602,15 @@ async function handlePullRequest(args, config, db, flags = {}) {
594
602
  /**
595
603
  * Start server without PR context
596
604
  * @param {Object} config - Application configuration
605
+ * @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Pool lifecycle instance
597
606
  */
598
- async function startServerOnly(config) {
599
- const port = await startServer(db);
607
+ async function startServerOnly(config, poolLifecycle = null) {
608
+ const port = await startServer(db, poolLifecycle);
600
609
 
601
610
  // Async cleanup of stale worktrees and reviews (don't block startup)
602
611
  cleanupStaleWorktreesAsync(config);
603
612
  cleanupStaleReviewsAsync(config);
613
+ startPoolBackgroundFetches(db, config);
604
614
 
605
615
  // Open browser to landing page
606
616
  const url = `http://localhost:${port}/`;
@@ -641,9 +651,12 @@ function formatAISuggestion(text, category) {
641
651
  * @param {string} options.reviewEvent - 'DRAFT' or 'COMMENT'
642
652
  * @param {string} options.commentStatus - 'draft' or 'submitted'
643
653
  * @param {string} options.modeLabel - Display label for log messages (e.g., 'draft mode', 'action review mode')
654
+ * @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [externalPoolLifecycle] - Shared pool lifecycle instance (avoids creating a fresh singleton)
644
655
  */
645
- async function performHeadlessReview(args, config, db, flags, options) {
656
+ async function performHeadlessReview(args, config, db, flags, options, externalPoolLifecycle = null) {
646
657
  let prInfo = null;
658
+ let poolWorktreeId = null;
659
+ let poolLifecycle = null;
647
660
 
648
661
  try {
649
662
  // Get GitHub token (env var takes precedence over config)
@@ -746,6 +759,8 @@ async function performHeadlessReview(args, config, db, flags, options) {
746
759
  let checkoutScript;
747
760
  let checkoutTimeout;
748
761
  let worktreeConfig = null;
762
+ let poolSize = 0;
763
+ let resetScript = null;
749
764
  if (isMatchingRepo) {
750
765
  // Current directory is a checkout of the target repository
751
766
  repositoryPath = currentDir;
@@ -753,10 +768,14 @@ async function performHeadlessReview(args, config, db, flags, options) {
753
768
 
754
769
  // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
755
770
  // even when running from inside the target repo, so they are not silently ignored.
756
- const resolved = resolveMonorepoOptions(config, repository);
771
+ const repoSettingsRepo = new RepoSettingsRepository(db);
772
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
773
+ const resolved = resolveRepoOptions(config, repository, repoSettings);
757
774
  checkoutScript = resolved.checkoutScript;
758
775
  checkoutTimeout = resolved.checkoutTimeout;
759
776
  worktreeConfig = resolved.worktreeConfig;
777
+ poolSize = resolved.poolSize || 0;
778
+ resetScript = resolved.resetScript || null;
760
779
  } else {
761
780
  // Current directory is not the target repository - find or clone it
762
781
  console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
@@ -778,15 +797,37 @@ async function performHeadlessReview(args, config, db, flags, options) {
778
797
  checkoutScript = result.checkoutScript;
779
798
  checkoutTimeout = result.checkoutTimeout;
780
799
  worktreeConfig = result.worktreeConfig;
800
+ // findRepositoryPath doesn't return pool config; resolve from DB + file config
801
+ const repoSettingsRepo = new RepoSettingsRepository(db);
802
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
803
+ const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repository, repoSettings);
804
+ poolSize = resolvedPoolSize || 0;
805
+ resetScript = config ? getRepoResetScript(config, repository) : null;
781
806
  }
782
807
 
783
- console.log('Setting up git worktree...');
784
808
  const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
785
- worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
786
- worktreeSourcePath,
787
- checkoutScript,
788
- checkoutTimeout
789
- });
809
+ if (poolSize > 0) {
810
+ // Pool mode: use WorktreePoolLifecycle
811
+ console.log('Acquiring pool worktree...');
812
+ poolLifecycle = externalPoolLifecycle || new WorktreePoolLifecycle(db, config);
813
+ const result = await poolLifecycle.acquireForPR(
814
+ { owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number, repository },
815
+ prData,
816
+ repositoryPath,
817
+ { worktreeSourcePath, checkoutScript, checkoutTimeout, resetScript, worktreeConfig, poolSize }
818
+ );
819
+ worktreePath = result.worktreePath;
820
+ poolWorktreeId = result.worktreeId;
821
+ console.log('Pool worktree acquired');
822
+ } else {
823
+ // Non-pool mode: existing behavior
824
+ console.log('Setting up git worktree...');
825
+ ({ path: worktreePath } = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
826
+ worktreeSourcePath,
827
+ checkoutScript,
828
+ checkoutTimeout
829
+ }));
830
+ }
790
831
 
791
832
  console.log('Generating unified diff...');
792
833
  diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
@@ -799,6 +840,11 @@ async function performHeadlessReview(args, config, db, flags, options) {
799
840
  skipWorktreeRecord: !!flags.useCheckout
800
841
  });
801
842
 
843
+ // Persist review→worktree mapping in DB for pool usage tracking
844
+ if (poolWorktreeId && poolLifecycle) {
845
+ await poolLifecycle.setReviewOwner(poolWorktreeId, storedReviewId);
846
+ }
847
+
802
848
  // Fire review.started hook for new reviews (non-blocking)
803
849
  if (isNewReview) {
804
850
  fireReviewStartedHook({
@@ -833,8 +879,12 @@ async function performHeadlessReview(args, config, db, flags, options) {
833
879
 
834
880
  // Run AI analysis
835
881
  console.log('Running AI analysis (all 3 levels)...');
836
- const model = flags.model || process.env.PAIR_REVIEW_MODEL || 'opus';
837
- const analyzer = new Analyzer(db, model);
882
+ const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
883
+ const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
884
+ const providerLoadSkills = config.providers?.[cliProvider]?.load_skills;
885
+ const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
886
+ const providerOverrides = { load_skills: loadSkills };
887
+ const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
838
888
 
839
889
  let analysisSummary = null;
840
890
  try {
@@ -1039,7 +1089,19 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
1039
1089
  // For other errors, show a clean message without stack trace
1040
1090
  console.error(`\n❌ Error: ${error.message}\n`);
1041
1091
  }
1042
- process.exit(1);
1092
+ process.exitCode = 1;
1093
+ } finally {
1094
+ // Release pool worktree after headless review completes (success or failure).
1095
+ // Headless reviews are fire-and-forget with no persistent browser session,
1096
+ // so the pool slot must be freed immediately.
1097
+ if (poolWorktreeId && poolLifecycle) {
1098
+ try {
1099
+ await poolLifecycle.releaseAfterHeadless(poolWorktreeId);
1100
+ logger.info(`Released pool worktree ${poolWorktreeId} after headless review`);
1101
+ } catch (releaseErr) {
1102
+ logger.error(`Failed to release pool worktree ${poolWorktreeId}: ${releaseErr.message}`);
1103
+ }
1104
+ }
1043
1105
  }
1044
1106
  }
1045
1107
 
@@ -1050,14 +1112,15 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
1050
1112
  * @param {Object} config - Application configuration
1051
1113
  * @param {Object} db - Database instance
1052
1114
  * @param {Object} flags - Parsed command line flags
1115
+ * @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Shared pool lifecycle instance
1053
1116
  */
1054
- async function handleDraftModeReview(args, config, db, flags = {}) {
1117
+ async function handleDraftModeReview(args, config, db, flags = {}, poolLifecycle = null) {
1055
1118
  await performHeadlessReview(args, config, db, flags, {
1056
1119
  mode: 'draft',
1057
1120
  reviewEvent: 'DRAFT',
1058
1121
  commentStatus: 'draft',
1059
1122
  modeLabel: 'draft mode'
1060
- });
1123
+ }, poolLifecycle);
1061
1124
  }
1062
1125
 
1063
1126
  /**
@@ -1068,14 +1131,110 @@ async function handleDraftModeReview(args, config, db, flags = {}) {
1068
1131
  * @param {Object} config - Application configuration
1069
1132
  * @param {Object} db - Database instance
1070
1133
  * @param {Object} flags - Parsed command line flags
1134
+ * @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Shared pool lifecycle instance
1071
1135
  */
1072
- async function handleActionReview(args, config, db, flags = {}) {
1136
+ async function handleActionReview(args, config, db, flags = {}, poolLifecycle = null) {
1073
1137
  await performHeadlessReview(args, config, db, flags, {
1074
1138
  mode: 'review',
1075
1139
  reviewEvent: 'COMMENT',
1076
1140
  commentStatus: 'submitted',
1077
1141
  modeLabel: 'action review mode'
1078
- });
1142
+ }, poolLifecycle);
1143
+ }
1144
+
1145
+ /**
1146
+ * Start periodic background fetches for pool worktrees.
1147
+ * For each repo with pool_fetch_interval_minutes configured, run git fetch
1148
+ * on all pool worktrees serially, coldest first.
1149
+ * @param {Object} db - Database instance
1150
+ * @param {Object} config - Configuration object
1151
+ */
1152
+ const POOL_FETCH_TICK_MS = 60 * 1000; // Check every minute
1153
+
1154
+ function startPoolBackgroundFetches(db, config) {
1155
+ let fetchInProgress = false;
1156
+
1157
+ const timer = setInterval(async () => {
1158
+ if (fetchInProgress) return;
1159
+ fetchInProgress = true;
1160
+ try {
1161
+ const poolRepo = new WorktreePoolRepository(db);
1162
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1163
+
1164
+ // Collect repos that might have pool config from either source.
1165
+ // Normalize to lowercase to match the database's COLLATE NOCASE identity.
1166
+ const repoNames = new Set(Object.keys(config.repos || {}).map(r => r.toLowerCase()));
1167
+ const allRepoSettings = await query(db, 'SELECT repository, pool_size, pool_fetch_interval_minutes FROM repo_settings WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL');
1168
+ for (const row of allRepoSettings) {
1169
+ repoNames.add(row.repository.toLowerCase());
1170
+ }
1171
+
1172
+ if (repoNames.size === 0) return;
1173
+
1174
+ for (const repoName of repoNames) {
1175
+ const repoSettings = allRepoSettings.find(r => r.repository.toLowerCase() === repoName.toLowerCase()) || null;
1176
+ const { poolSize, poolFetchIntervalMinutes } = resolvePoolConfig(config, repoName, repoSettings);
1177
+ if (!poolSize || !poolFetchIntervalMinutes) continue;
1178
+
1179
+ const intervalMs = poolFetchIntervalMinutes * 60 * 1000;
1180
+ const worktrees = await poolRepo.findAllForFetch(repoName);
1181
+
1182
+ // Check if any worktree actually needs fetching before claiming the lease
1183
+ const needsFetch = worktrees.some(entry => {
1184
+ if (!entry.last_fetched_at) return true;
1185
+ const elapsed = Date.now() - new Date(entry.last_fetched_at).getTime();
1186
+ return elapsed >= intervalMs;
1187
+ });
1188
+ if (!needsFetch) continue;
1189
+
1190
+ // Atomically claim the fetch lease — skips if another instance holds it.
1191
+ // Pool worktrees share a git object store so concurrent fetches conflict.
1192
+ if (!(await repoSettingsRepo.tryClaimFetch(repoName))) {
1193
+ logger.info(`Background fetch skipped for ${repoName}: another instance is fetching`);
1194
+ continue;
1195
+ }
1196
+ try {
1197
+ for (const entry of worktrees) {
1198
+ // Skip if fetched recently (within the configured interval)
1199
+ if (entry.last_fetched_at) {
1200
+ const elapsed = Date.now() - new Date(entry.last_fetched_at).getTime();
1201
+ if (elapsed < intervalMs) continue;
1202
+ }
1203
+
1204
+ logger.info(`Background fetch starting for ${repoName} pool worktree ${entry.id}`);
1205
+ try {
1206
+ const git = simpleGit(entry.path, { timeout: { block: 300000 } });
1207
+ const remotes = await git.getRemotes();
1208
+ const remote = remotes.find(r => r.name === 'origin') || remotes[0];
1209
+ if (remote) await git.fetch([remote.name, '--prune']);
1210
+ await poolRepo.updateLastFetched(entry.id);
1211
+ // Refresh the lease so the stale guard only needs to outlive
1212
+ // a single stalled fetch, not the entire serial loop.
1213
+ await repoSettingsRepo.refreshFetchLease(repoName);
1214
+ logger.info(`Background fetch complete for ${repoName} pool worktree ${entry.id}`);
1215
+ } catch (fetchErr) {
1216
+ logger.warn(`Background fetch failed for ${entry.id}: ${fetchErr.message}`);
1217
+ }
1218
+ }
1219
+ } finally {
1220
+ try {
1221
+ await repoSettingsRepo.markFetchFinished(repoName);
1222
+ } catch (finishErr) {
1223
+ logger.warn(`Failed to release fetch lease for ${repoName}: ${finishErr.message}`);
1224
+ }
1225
+ }
1226
+ }
1227
+ } catch (err) {
1228
+ logger.error(`Background pool fetch error: ${err.message}`, err);
1229
+ } finally {
1230
+ fetchInProgress = false;
1231
+ }
1232
+ }, POOL_FETCH_TICK_MS);
1233
+
1234
+ // Don't keep the process alive just for background fetches
1235
+ if (timer.unref) timer.unref();
1236
+
1237
+ logger.info(`Background pool fetch ticker started (checking every ${POOL_FETCH_TICK_MS / 1000}s)`);
1079
1238
  }
1080
1239
 
1081
1240
  /**
@@ -37,6 +37,7 @@ const { generateLocalDiff, computeLocalDiffDigest, getCurrentBranch } = require(
37
37
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
38
38
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
39
39
 
40
+
40
41
  const router = express.Router();
41
42
 
42
43
  /**
@@ -493,6 +494,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
493
494
  config: modeConfig,
494
495
  excludePrevious,
495
496
  serverPort,
497
+ providerOverrides = {},
498
+ providerOverridesMap = null,
496
499
  hookContext = {},
497
500
  } = modeContext;
498
501
 
@@ -563,38 +566,55 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
563
566
 
564
567
  broadcastProgress(analysisId, initialStatus);
565
568
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
569
+
570
+ // Register analysis hold for pool worktree usage tracking.
571
+ // startAnalysis is inside the try so a synchronous exception in setup still
572
+ // cleans up the hold (the .finally() on the promise chain only covers async errors).
573
+ const poolLifecycle = modeContext?.poolLifecycle;
574
+ let poolWorktreeId;
566
575
  const effectiveConfig = modeConfig || {};
567
- if (hasHooks('analysis.started', effectiveConfig)) {
568
- getCachedUser(effectiveConfig).then(user => {
569
- fireHooks('analysis.started', buildAnalysisStartedPayload({
570
- reviewId, analysisId, provider: 'council', model: councilId || 'inline-config',
571
- mode: initialStatusExtra?.reviewType || 'pr',
572
- prContext: hookContext.prContext, localContext: hookContext.localContext,
573
- user,
574
- }), effectiveConfig);
575
- }).catch(err => { logger.warn(`Analysis hook failed: ${err.message}`); });
576
- }
576
+ let analysisPromise;
577
+ try {
578
+ poolWorktreeId = await poolLifecycle?.startAnalysis(reviewId, analysisId);
579
+ if (hasHooks('analysis.started', effectiveConfig)) {
580
+ getCachedUser(effectiveConfig).then(user => {
581
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
582
+ reviewId, analysisId, provider: 'council', model: councilId || 'inline-config',
583
+ mode: initialStatusExtra?.reviewType || 'pr',
584
+ prContext: hookContext.prContext, localContext: hookContext.localContext,
585
+ user,
586
+ }), effectiveConfig);
587
+ }).catch(err => { logger.warn(`Analysis hook failed: ${err.message}`); });
588
+ }
577
589
 
578
- const analyzer = new Analyzer(db, 'council', 'council');
590
+ const analyzer = new Analyzer(db, 'council', 'council', providerOverrides, providerOverridesMap);
579
591
 
580
- logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
581
- logger.log('API', `Repository: ${repository}`, 'magenta');
582
- logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
583
- logger.log('API', `Config type: ${configType}`, 'magenta');
592
+ logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
593
+ logger.log('API', `Repository: ${repository}`, 'magenta');
594
+ logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
595
+ logger.log('API', `Config type: ${configType}`, 'magenta');
584
596
 
585
- const progressCallback = createProgressCallback(analysisId);
597
+ const progressCallback = createProgressCallback(analysisId);
586
598
 
587
- const reviewContext = {
588
- reviewId,
589
- worktreePath,
590
- prMetadata,
591
- changedFiles,
592
- instructions: { globalInstructions, repoInstructions, requestInstructions }
593
- };
599
+ const reviewContext = {
600
+ reviewId,
601
+ worktreePath,
602
+ prMetadata,
603
+ changedFiles,
604
+ instructions: { globalInstructions, repoInstructions, requestInstructions }
605
+ };
594
606
 
595
- const analysisPromise = isVoiceCentric
596
- ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
597
- : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
607
+ analysisPromise = isVoiceCentric
608
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
609
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
610
+ } catch (setupError) {
611
+ // Synchronous setup failure — clean up the analysis hold immediately
612
+ reviewToAnalysisId.delete(reviewId);
613
+ if (poolWorktreeId) {
614
+ poolLifecycle?.endAnalysis(analysisId);
615
+ }
616
+ throw setupError;
617
+ }
598
618
 
599
619
  analysisPromise
600
620
  .then(async result => {
@@ -700,6 +720,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
700
720
  .finally(() => {
701
721
  // Clean up unified tracking map entry
702
722
  reviewToAnalysisId.delete(reviewId);
723
+ // Remove pool worktree analysis hold
724
+ poolLifecycle?.endAnalysis(analysisId);
703
725
  });
704
726
 
705
727
  return { analysisId, runId };
@@ -21,6 +21,7 @@ const ws = require('../ws');
21
21
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
22
22
  const { buildChatStartedPayload, buildChatResumedPayload, buildChatHookContext, getCachedUser } = require('../hooks/payloads');
23
23
  const { resolveFormat } = require('../utils/comment-formatter');
24
+ const { resolveLoadSkills } = require('../config');
24
25
 
25
26
  /**
26
27
  * Fire a chat hook event (non-blocking). Skips async work when no hooks are configured.
@@ -226,6 +227,22 @@ async function getChatInstructions(db, review) {
226
227
  return repoSettings ? repoSettings.default_chat_instructions : null;
227
228
  }
228
229
 
230
+ /**
231
+ * Resolve load_skills for a chat session based on repo settings and config.
232
+ * @param {Object} db - Database instance
233
+ * @param {Object} review - Review record
234
+ * @param {Object} config - App config
235
+ * @param {string} provider - Chat provider ID (e.g. 'pi')
236
+ * @returns {Promise<boolean|undefined>} Resolved load_skills, or undefined if no repo override
237
+ */
238
+ async function getRepoLoadSkillsForChat(db, review, config, provider) {
239
+ if (!review || !review.repository) return undefined;
240
+ const repoSettingsRepo = new RepoSettingsRepository(db);
241
+ const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
242
+ const providerLoadSkills = config.chat_providers?.[provider]?.load_skills;
243
+ return resolveLoadSkills(config, review.repository, repoSettings, providerLoadSkills);
244
+ }
245
+
229
246
  /**
230
247
  * Create a new chat session
231
248
  */
@@ -316,6 +333,10 @@ router.post('/api/chat/session', async (req, res) => {
316
333
  ? sessionPreamble + '\n\n' + initialContext
317
334
  : sessionPreamble;
318
335
 
336
+ // Resolve load_skills from repo settings, falling back to provider config
337
+ const loadSkillsConfig = req.app.get('config') || {};
338
+ const loadSkills = await getRepoLoadSkillsForChat(db, review, loadSkillsConfig, provider);
339
+
319
340
  const session = await chatSessionManager.createSession({
320
341
  provider,
321
342
  model,
@@ -323,7 +344,8 @@ router.post('/api/chat/session', async (req, res) => {
323
344
  contextCommentId: contextCommentId || null,
324
345
  systemPrompt: finalSystemPrompt,
325
346
  cwd: resolvedCwd,
326
- initialContext: initialContextWithPort
347
+ initialContext: initialContextWithPort,
348
+ loadSkills
327
349
  });
328
350
 
329
351
  logger.info(`Chat session created: ${session.id} (provider=${provider})`);
@@ -398,9 +420,10 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
398
420
 
399
421
  const systemPrompt = buildChatPrompt({ review, prData, chatInstructions, commentFormatTemplate: fmtConfig.template });
400
422
  const cwd = await resolveReviewCwd(db, review);
423
+ const loadSkills = await getRepoLoadSkillsForChat(db, review, config, session.provider);
401
424
 
402
425
  try {
403
- await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
426
+ await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd, loadSkills });
404
427
  unregisterChatBroadcast(sessionId);
405
428
  registerChatBroadcast(chatSessionManager, sessionId, req.socket.localPort);
406
429
  logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
@@ -540,8 +563,9 @@ router.post('/api/chat/session/:id/resume', async (req, res) => {
540
563
 
541
564
  const systemPrompt = buildChatPrompt({ review, prData, chatInstructions, commentFormatTemplate: fmtConfig.template });
542
565
  const cwd = await resolveReviewCwd(db, review);
566
+ const loadSkills = await getRepoLoadSkillsForChat(db, review, config, session.provider);
543
567
 
544
- await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
568
+ await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd, loadSkills });
545
569
  unregisterChatBroadcast(sessionId);
546
570
  const serverPort = req.socket.localPort;
547
571
  registerChatBroadcast(chatSessionManager, sessionId, serverPort);
@@ -98,7 +98,10 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
98
98
  local_path: null,
99
99
  default_council_id: null,
100
100
  default_tab: null,
101
- default_chat_instructions: null
101
+ default_chat_instructions: null,
102
+ pool_size: null,
103
+ pool_fetch_interval_minutes: null,
104
+ load_skills: null
102
105
  });
103
106
  }
104
107
 
@@ -111,6 +114,9 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
111
114
  default_council_id: settings.default_council_id,
112
115
  default_tab: settings.default_tab,
113
116
  default_chat_instructions: settings.default_chat_instructions,
117
+ pool_size: settings.pool_size ?? null,
118
+ pool_fetch_interval_minutes: settings.pool_fetch_interval_minutes ?? null,
119
+ load_skills: settings.load_skills ?? null,
114
120
  created_at: settings.created_at,
115
121
  updated_at: settings.updated_at
116
122
  });
@@ -130,14 +136,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
130
136
  router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
131
137
  try {
132
138
  const { owner, repo } = req.params;
133
- const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, default_chat_instructions } = req.body;
139
+ const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, default_chat_instructions, pool_size, pool_fetch_interval_minutes, load_skills } = req.body;
134
140
  const repository = normalizeRepository(owner, repo);
135
141
  const db = req.app.get('db');
136
142
 
137
143
  // Validate that at least one setting is provided
138
- if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined && default_chat_instructions === undefined) {
144
+ if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined && default_chat_instructions === undefined && pool_size === undefined && pool_fetch_interval_minutes === undefined && load_skills === undefined) {
139
145
  return res.status(400).json({
140
- error: 'At least one setting (default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, or default_chat_instructions) must be provided'
146
+ error: 'At least one setting must be provided'
141
147
  });
142
148
  }
143
149
 
@@ -149,7 +155,10 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
149
155
  local_path,
150
156
  default_council_id,
151
157
  default_tab,
152
- default_chat_instructions
158
+ default_chat_instructions,
159
+ pool_size,
160
+ pool_fetch_interval_minutes,
161
+ load_skills
153
162
  });
154
163
 
155
164
  logger.info(`Saved repo settings for ${repository}`);
@@ -165,6 +174,9 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
165
174
  default_council_id: settings.default_council_id,
166
175
  default_tab: settings.default_tab,
167
176
  default_chat_instructions: settings.default_chat_instructions,
177
+ pool_size: settings.pool_size ?? null,
178
+ pool_fetch_interval_minutes: settings.pool_fetch_interval_minutes ?? null,
179
+ load_skills: settings.load_skills ?? null,
168
180
  updated_at: settings.updated_at
169
181
  }
170
182
  });