@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
@@ -14,6 +14,7 @@ class RepoSettingsPage {
14
14
  this.providers = {};
15
15
  this.selectedProvider = null;
16
16
  this.councils = [];
17
+ this.worktreeData = null;
17
18
 
18
19
  this.init();
19
20
  }
@@ -39,6 +40,9 @@ class RepoSettingsPage {
39
40
 
40
41
  // Load settings
41
42
  await this.loadSettings();
43
+
44
+ // Load worktrees (non-blocking, section stays hidden on error)
45
+ await this.loadWorktrees();
42
46
  }
43
47
 
44
48
  /**
@@ -210,6 +214,16 @@ class RepoSettingsPage {
210
214
  });
211
215
  }
212
216
 
217
+ // Load skills select
218
+ const loadSkillsSelect = document.getElementById('load-skills-select');
219
+ if (loadSkillsSelect) {
220
+ loadSkillsSelect.addEventListener('change', () => {
221
+ const val = loadSkillsSelect.value;
222
+ this.currentSettings.load_skills = val === '' ? null : parseInt(val, 10);
223
+ this.checkForChanges();
224
+ });
225
+ }
226
+
213
227
  // Analysis mode segmented control
214
228
  const modeToggle = document.getElementById('analysis-mode-toggle');
215
229
  if (modeToggle) {
@@ -249,6 +263,22 @@ class RepoSettingsPage {
249
263
  clearLocalPathBtn.addEventListener('click', () => this.handleClearLocalPath());
250
264
  }
251
265
 
266
+ // Worktree actions (delegated)
267
+ const worktreesContent = document.getElementById('worktrees-content');
268
+ if (worktreesContent) {
269
+ worktreesContent.addEventListener('click', (e) => {
270
+ const deleteBtn = e.target.closest('.worktree-delete-btn');
271
+ if (deleteBtn) {
272
+ this.deleteWorktree(deleteBtn.dataset.worktreeId);
273
+ return;
274
+ }
275
+ const deleteAllBtn = e.target.closest('.worktree-delete-all-btn');
276
+ if (deleteAllBtn) {
277
+ this.deleteAllWorktrees();
278
+ }
279
+ });
280
+ }
281
+
252
282
  // Warn before leaving with unsaved changes
253
283
  window.addEventListener('beforeunload', (e) => {
254
284
  if (this.hasUnsavedChanges) {
@@ -825,7 +855,10 @@ class RepoSettingsPage {
825
855
  default_council_id: settings.default_council_id || null,
826
856
  default_instructions: settings.default_instructions || '',
827
857
  local_path: settings.local_path || null,
828
- default_chat_instructions: settings.default_chat_instructions || ''
858
+ default_chat_instructions: settings.default_chat_instructions || '',
859
+ pool_size: settings.pool_size ?? null,
860
+ pool_fetch_interval_minutes: settings.pool_fetch_interval_minutes ?? null,
861
+ load_skills: settings.load_skills ?? null
829
862
  };
830
863
 
831
864
  // Set current settings
@@ -844,13 +877,279 @@ class RepoSettingsPage {
844
877
  default_council_id: null,
845
878
  default_instructions: '',
846
879
  local_path: null,
847
- default_chat_instructions: ''
880
+ default_chat_instructions: '',
881
+ pool_size: null,
882
+ pool_fetch_interval_minutes: null,
883
+ load_skills: null
848
884
  };
849
885
  this.currentSettings = { ...this.originalSettings };
850
886
  this.updateUI();
851
887
  }
852
888
  }
853
889
 
890
+ /**
891
+ * Load worktree data for the current repository
892
+ */
893
+ async loadWorktrees() {
894
+ if (!this.owner || !this.repo) return;
895
+
896
+ try {
897
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/worktrees`);
898
+ if (!response.ok) {
899
+ throw new Error('Failed to load worktrees');
900
+ }
901
+ this.worktreeData = await response.json();
902
+
903
+ // Seed pool settings from resolved config values when DB has no override
904
+ const pool = this.worktreeData.pool || {};
905
+ if (this.originalSettings.pool_size == null && pool.size) {
906
+ this.originalSettings.pool_size = pool.size;
907
+ this.currentSettings.pool_size = pool.size;
908
+ }
909
+ if (this.originalSettings.pool_fetch_interval_minutes == null && pool.fetch_interval_minutes) {
910
+ this.originalSettings.pool_fetch_interval_minutes = pool.fetch_interval_minutes;
911
+ this.currentSettings.pool_fetch_interval_minutes = pool.fetch_interval_minutes;
912
+ }
913
+
914
+ this.renderWorktrees();
915
+ } catch (error) {
916
+ console.error('Error loading worktrees:', error);
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Render the worktrees section content
922
+ */
923
+ renderWorktrees() {
924
+ const section = document.getElementById('worktrees-section');
925
+ const content = document.getElementById('worktrees-content');
926
+ if (!section || !content) return;
927
+
928
+ if (!this.worktreeData) return;
929
+
930
+ section.style.display = '';
931
+
932
+ const pool = this.worktreeData.pool || {};
933
+ const worktrees = this.worktreeData.worktrees || [];
934
+ let html = '';
935
+
936
+ // Pool settings (editable)
937
+ const poolSizeValue = this.currentSettings.pool_size ?? '';
938
+ const fetchIntervalValue = this.currentSettings.pool_fetch_interval_minutes ?? '';
939
+ const currentCount = pool.current_count || 0;
940
+ const countNote = poolSizeValue ? ` (${currentCount} active)` : '';
941
+
942
+ html += `<div class="worktree-pool-config">
943
+ <div class="worktree-pool-config-items">
944
+ <div class="worktree-pool-config-item">
945
+ <label class="worktree-pool-config-label" for="pool-size-input">Pool Size</label>
946
+ <div class="worktree-pool-input-group">
947
+ <input type="number" id="pool-size-input" class="worktree-pool-input"
948
+ min="0" max="20" step="1" placeholder="0"
949
+ value="${this.escapeHtml(String(poolSizeValue))}">
950
+ <span class="worktree-pool-input-note">${this.escapeHtml(countNote)}</span>
951
+ </div>
952
+ </div>
953
+ <div class="worktree-pool-config-item">
954
+ <label class="worktree-pool-config-label" for="pool-fetch-interval-input">Fetch Interval</label>
955
+ <div class="worktree-pool-input-group">
956
+ <input type="number" id="pool-fetch-interval-input" class="worktree-pool-input"
957
+ min="0" max="1440" step="1" placeholder="Off"
958
+ value="${this.escapeHtml(String(fetchIntervalValue))}">
959
+ <span class="worktree-pool-input-note">minutes</span>
960
+ </div>
961
+ </div>
962
+ </div>
963
+ <p class="worktree-pool-hint">
964
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"/></svg>
965
+ Pre-warms worktrees so PR reviews start instantly. Set size to 0 to disable.
966
+ </p>
967
+ </div>`;
968
+
969
+ // Worktree list
970
+ if (worktrees.length > 0) {
971
+ html += '<div class="worktree-list">';
972
+ for (const wt of worktrees) {
973
+ const badgeHtml = wt.is_pool
974
+ ? '<span class="worktree-pool-badge">Pool</span>'
975
+ : '<span class="worktree-adhoc-badge">Ad-hoc</span>';
976
+ const prInfo = wt.pr_number ? '#' + wt.pr_number : 'Unassigned';
977
+ const branchInfo = wt.branch ? ' &middot; ' + this.escapeHtml(wt.branch) : '';
978
+ const fullPath = wt.path || '';
979
+ const diskWarning = !wt.disk_exists
980
+ ? '<span class="worktree-disk-warning">Missing from disk</span>'
981
+ : '';
982
+ const statusIcon = this.getWorktreeStatusIcon(wt.status);
983
+ const statusLabel = this.getWorktreeStatusLabel(wt.status);
984
+ const fetchedHtml = wt.last_fetched_at
985
+ ? `<span class="worktree-timestamp">Fetched ${this.formatRelativeTime(wt.last_fetched_at)}</span>`
986
+ : '';
987
+
988
+ html += `<div class="worktree-item">
989
+ <div class="worktree-item-top">
990
+ <div class="worktree-item-left">
991
+ ${badgeHtml}
992
+ <span class="worktree-pr-info">${prInfo}${branchInfo}</span>
993
+ ${diskWarning}
994
+ </div>
995
+ <div class="worktree-item-right">
996
+ <span class="worktree-status worktree-status--${this.escapeHtml(wt.status || 'unknown')}">
997
+ ${statusIcon} ${statusLabel}
998
+ </span>
999
+ ${fetchedHtml}
1000
+ <button class="worktree-delete-btn" data-worktree-id="${this.escapeHtml(wt.id)}" title="Delete worktree">
1001
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1002
+ <path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"/>
1003
+ </svg>
1004
+ </button>
1005
+ </div>
1006
+ </div>
1007
+ <div class="worktree-item-bottom">
1008
+ <span class="worktree-path">${this.escapeHtml(fullPath)}</span>
1009
+ </div>
1010
+ </div>`;
1011
+ }
1012
+ html += '</div>';
1013
+
1014
+ // Delete all button
1015
+ html += `<div class="worktree-actions">
1016
+ <button class="worktree-delete-all-btn">
1017
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1018
+ <path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"/>
1019
+ </svg>
1020
+ Delete All Worktrees
1021
+ </button>
1022
+ </div>`;
1023
+ } else {
1024
+ html += '<div class="worktree-empty">No worktrees found for this repository.</div>';
1025
+ }
1026
+
1027
+ content.innerHTML = html;
1028
+
1029
+ // Wire up pool setting inputs
1030
+ const poolSizeInput = document.getElementById('pool-size-input');
1031
+ if (poolSizeInput) {
1032
+ poolSizeInput.addEventListener('input', () => {
1033
+ const val = poolSizeInput.value.trim();
1034
+ this.currentSettings.pool_size = val === '' ? null : parseInt(val, 10);
1035
+ this.checkForChanges();
1036
+ });
1037
+ }
1038
+ const poolFetchInput = document.getElementById('pool-fetch-interval-input');
1039
+ if (poolFetchInput) {
1040
+ poolFetchInput.addEventListener('input', () => {
1041
+ const val = poolFetchInput.value.trim();
1042
+ this.currentSettings.pool_fetch_interval_minutes = val === '' ? null : parseInt(val, 10);
1043
+ this.checkForChanges();
1044
+ });
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Get the SVG icon for a worktree status
1050
+ * @param {string} status
1051
+ * @returns {string} Inline SVG string
1052
+ */
1053
+ getWorktreeStatusIcon(status) {
1054
+ switch (status) {
1055
+ case 'available':
1056
+ return '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="4" fill="#2da44e"/></svg>';
1057
+ case 'in_use':
1058
+ return '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/></svg>';
1059
+ case 'switching':
1060
+ return '<svg class="worktree-icon-spin" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2.5a5.5 5.5 0 00-5.23 3.79.75.75 0 01-1.42-.48A7.001 7.001 0 0115 8a7 7 0 01-13.65 2.19.75.75 0 011.42-.48A5.5 5.5 0 108 2.5z"/></svg>';
1061
+ case 'creating':
1062
+ return '<svg class="worktree-icon-spin" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2.5a5.5 5.5 0 00-5.23 3.79.75.75 0 01-1.42-.48A7.001 7.001 0 0115 8a7 7 0 01-13.65 2.19.75.75 0 011.42-.48A5.5 5.5 0 108 2.5z"/></svg>';
1063
+ case 'active':
1064
+ return '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="4" fill="#0969da"/></svg>';
1065
+ default:
1066
+ return '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="4" fill="#8b949e"/></svg>';
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Get the display label for a worktree status
1072
+ * @param {string} status
1073
+ * @returns {string}
1074
+ */
1075
+ getWorktreeStatusLabel(status) {
1076
+ switch (status) {
1077
+ case 'available': return 'Available';
1078
+ case 'in_use': return 'In use';
1079
+ case 'switching': return 'Switching';
1080
+ case 'creating': return 'Creating';
1081
+ case 'active': return 'Active';
1082
+ default: return status || 'Unknown';
1083
+ }
1084
+ }
1085
+
1086
+
1087
+
1088
+ /**
1089
+ * Format an ISO timestamp as a human-readable relative time string
1090
+ * @param {string} isoString
1091
+ * @returns {string}
1092
+ */
1093
+ formatRelativeTime(isoString) {
1094
+ if (!isoString) return '';
1095
+ const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
1096
+ if (seconds < 60) return 'just now';
1097
+ const minutes = Math.floor(seconds / 60);
1098
+ if (minutes < 60) return minutes + 'm ago';
1099
+ const hours = Math.floor(minutes / 60);
1100
+ if (hours < 24) return hours + 'h ago';
1101
+ const days = Math.floor(hours / 24);
1102
+ if (days === 1) return 'yesterday';
1103
+ return days + 'd ago';
1104
+ }
1105
+
1106
+ /**
1107
+ * Delete a single worktree by ID
1108
+ * @param {string} worktreeId
1109
+ */
1110
+ async deleteWorktree(worktreeId) {
1111
+ if (!confirm('Delete this worktree? This removes it from disk and cannot be undone.')) return;
1112
+
1113
+ try {
1114
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/worktrees/${worktreeId}`, {
1115
+ method: 'DELETE'
1116
+ });
1117
+ if (!response.ok) {
1118
+ const data = await response.json().catch(() => ({}));
1119
+ throw new Error(data.error || 'Failed to delete worktree');
1120
+ }
1121
+ this.showToast('success', 'Worktree deleted');
1122
+ await this.loadWorktrees();
1123
+ } catch (error) {
1124
+ this.showToast('error', 'Failed to delete worktree: ' + error.message);
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Delete all worktrees for the current repository
1130
+ */
1131
+ async deleteAllWorktrees() {
1132
+ const count = this.worktreeData && this.worktreeData.worktrees
1133
+ ? this.worktreeData.worktrees.length
1134
+ : 0;
1135
+ if (!confirm(`Delete all ${count} worktree(s)? This removes them from disk and cannot be undone.`)) return;
1136
+
1137
+ try {
1138
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/worktrees`, {
1139
+ method: 'DELETE'
1140
+ });
1141
+ if (!response.ok) {
1142
+ const data = await response.json().catch(() => ({}));
1143
+ throw new Error(data.error || 'Failed to delete worktrees');
1144
+ }
1145
+ const data = await response.json();
1146
+ this.showToast('success', `Deleted ${data.deleted} worktree(s)`);
1147
+ await this.loadWorktrees();
1148
+ } catch (error) {
1149
+ this.showToast('error', 'Failed to delete worktrees: ' + error.message);
1150
+ }
1151
+ }
1152
+
854
1153
  updateUI() {
855
1154
  // Update provider selection - validate provider exists before selecting
856
1155
  let providerId = this.currentSettings.default_provider;
@@ -908,6 +1207,23 @@ class RepoSettingsPage {
908
1207
 
909
1208
  // Update local path display
910
1209
  this.updateLocalPathDisplay();
1210
+
1211
+ // Update pool setting inputs
1212
+ const poolSizeInput = document.getElementById('pool-size-input');
1213
+ if (poolSizeInput) {
1214
+ poolSizeInput.value = this.currentSettings.pool_size ?? '';
1215
+ }
1216
+ const poolFetchInput = document.getElementById('pool-fetch-interval-input');
1217
+ if (poolFetchInput) {
1218
+ poolFetchInput.value = this.currentSettings.pool_fetch_interval_minutes ?? '';
1219
+ }
1220
+
1221
+ // Update load skills select
1222
+ const loadSkillsSelect = document.getElementById('load-skills-select');
1223
+ if (loadSkillsSelect) {
1224
+ const val = this.currentSettings.load_skills;
1225
+ loadSkillsSelect.value = val === null || val === undefined ? '' : String(val);
1226
+ }
911
1227
  }
912
1228
 
913
1229
  /**
@@ -957,8 +1273,11 @@ class RepoSettingsPage {
957
1273
  const councilChanged = (this.currentSettings.default_council_id ?? null) !== (this.originalSettings.default_council_id ?? null);
958
1274
  const instructionsChanged = (this.currentSettings.default_instructions ?? '') !== (this.originalSettings.default_instructions ?? '');
959
1275
  const chatInstructionsChanged = (this.currentSettings.default_chat_instructions ?? '') !== (this.originalSettings.default_chat_instructions ?? '');
1276
+ const poolSizeChanged = (this.currentSettings.pool_size ?? null) !== (this.originalSettings.pool_size ?? null);
1277
+ const poolFetchChanged = (this.currentSettings.pool_fetch_interval_minutes ?? null) !== (this.originalSettings.pool_fetch_interval_minutes ?? null);
1278
+ const loadSkillsChanged = (this.currentSettings.load_skills ?? null) !== (this.originalSettings.load_skills ?? null);
960
1279
 
961
- this.hasUnsavedChanges = providerChanged || modelChanged || tabChanged || councilChanged || instructionsChanged || chatInstructionsChanged;
1280
+ this.hasUnsavedChanges = providerChanged || modelChanged || tabChanged || councilChanged || instructionsChanged || chatInstructionsChanged || poolSizeChanged || poolFetchChanged || loadSkillsChanged;
962
1281
 
963
1282
  // Show/hide action bar
964
1283
  const actionBar = document.getElementById('action-bar');
@@ -993,7 +1312,10 @@ class RepoSettingsPage {
993
1312
  default_tab: this.currentSettings.default_tab,
994
1313
  default_council_id: this.currentSettings.default_council_id,
995
1314
  default_instructions: this.currentSettings.default_instructions,
996
- default_chat_instructions: this.currentSettings.default_chat_instructions
1315
+ default_chat_instructions: this.currentSettings.default_chat_instructions,
1316
+ pool_size: this.currentSettings.pool_size,
1317
+ pool_fetch_interval_minutes: this.currentSettings.pool_fetch_interval_minutes,
1318
+ load_skills: this.currentSettings.load_skills
997
1319
  })
998
1320
  });
999
1321
 
@@ -1068,7 +1390,10 @@ class RepoSettingsPage {
1068
1390
  default_council_id: null,
1069
1391
  default_instructions: '',
1070
1392
  local_path: null,
1071
- default_chat_instructions: ''
1393
+ default_chat_instructions: '',
1394
+ pool_size: null,
1395
+ pool_fetch_interval_minutes: null,
1396
+ load_skills: null
1072
1397
  })
1073
1398
  });
1074
1399
 
@@ -1084,7 +1409,10 @@ class RepoSettingsPage {
1084
1409
  default_council_id: null,
1085
1410
  default_instructions: '',
1086
1411
  local_path: null,
1087
- default_chat_instructions: ''
1412
+ default_chat_instructions: '',
1413
+ pool_size: null,
1414
+ pool_fetch_interval_minutes: null,
1415
+ load_skills: null
1088
1416
  };
1089
1417
  this.currentSettings = { ...this.originalSettings };
1090
1418
  this.hasUnsavedChanges = false;
@@ -198,6 +198,22 @@ Examples:
198
198
  </div>
199
199
  </section>
200
200
 
201
+ <!-- Provider Skill Discovery Section -->
202
+ <section class="settings-section">
203
+ <div class="section-header">
204
+ <h2>Provider Skill Discovery</h2>
205
+ <p class="section-description">Controls whether AI providers load environment-level skills automatically. Currently applies to Pi's skill auto-discovery. When disabled, Pi runs with <code>--no-skills</code>.</p>
206
+ </div>
207
+ <div class="form-group" style="max-width: 300px;">
208
+ <label for="load-skills-select" class="settings-label">Load Skills</label>
209
+ <select class="settings-select" id="load-skills-select">
210
+ <option value="">Default (inherit from config)</option>
211
+ <option value="1">Enabled</option>
212
+ <option value="0">Disabled</option>
213
+ </select>
214
+ </div>
215
+ </section>
216
+
201
217
  <!-- Repository Location Section -->
202
218
  <section class="settings-section" id="local-path-section">
203
219
  <div class="section-header">
@@ -227,6 +243,19 @@ Examples:
227
243
  </div>
228
244
  </section>
229
245
 
246
+ <!-- Worktrees Section -->
247
+ <section class="settings-section" id="worktrees-section" style="display: none;">
248
+ <div class="section-header">
249
+ <h2>Worktrees</h2>
250
+ <p class="section-description">
251
+ Manage worktree directories used for reviewing pull requests in this repository.
252
+ </p>
253
+ </div>
254
+ <div id="worktrees-content">
255
+ <!-- Rendered dynamically by JS -->
256
+ </div>
257
+ </section>
258
+
230
259
  <!-- Danger Zone -->
231
260
  <section class="settings-section danger-zone">
232
261
  <div class="section-header">
@@ -73,9 +73,10 @@ async function captureDiffSnapshot(analyzer, worktreePath, prMetadata, logPrefix
73
73
  * @param {Object|null} instructions - Instructions object { repoInstructions, requestInstructions }
74
74
  * @param {Function|null} progressCallback - Parent progress callback to wrap
75
75
  * @param {Object} db - Database instance
76
+ * @param {Object} providerOverrides - Per-call config overrides passed to createProvider (optional)
76
77
  * @returns {Object} { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout }
77
78
  */
78
- function buildVoiceContext(voice, idx, instructions, progressCallback, db) {
79
+ function buildVoiceContext(voice, idx, instructions, progressCallback, db, providerOverrides = {}, providerOverridesMap = null) {
79
80
  const voiceKey = `${voice.provider}-${voice.model}${idx > 0 ? `-${idx}` : ''}`;
80
81
  const reviewerLabel = buildReviewerLabel(idx, voice);
81
82
 
@@ -87,13 +88,16 @@ function buildVoiceContext(voice, idx, instructions, progressCallback, db) {
87
88
  : voice.customInstructions;
88
89
  }
89
90
 
91
+ // Resolve per-voice overrides: prefer provider-specific from map, fall back to shared overrides
92
+ const effectiveOverrides = providerOverridesMap?.[voice.provider] || providerOverrides;
93
+
90
94
  const ProviderClass = getProviderClass(voice.provider);
91
95
  const isExecutable = ProviderClass?.isExecutable || false;
92
96
 
93
97
  // Only create Analyzer for native voices
94
- const voiceAnalyzer = isExecutable ? null : new Analyzer(db, voice.model, voice.provider);
98
+ const voiceAnalyzer = isExecutable ? null : new Analyzer(db, voice.model, voice.provider, effectiveOverrides);
95
99
  // Create provider instance for executable voices (used directly)
96
- const voiceProvider = isExecutable ? createProvider(voice.provider, voice.model) : null;
100
+ const voiceProvider = isExecutable ? createProvider(voice.provider, voice.model, effectiveOverrides) : null;
97
101
 
98
102
  const voiceTier = voice.tier || 'balanced';
99
103
  const voiceTimeout = voice.timeout || ProviderClass?.defaultTimeout || 600000;
@@ -272,12 +276,16 @@ class Analyzer {
272
276
  * @param {Object} database - Database instance
273
277
  * @param {string} model - Model to use (e.g., 'opus', 'gemini-2.5-pro')
274
278
  * @param {string} provider - Provider ID (e.g., 'claude', 'gemini'). Defaults to 'claude'.
279
+ * @param {Object} providerOverrides - Per-call config overrides passed to createProvider (optional)
280
+ * @param {Object|null} providerOverridesMap - Per-provider overrides map for council mode (provider ID → overrides)
275
281
  */
276
- constructor(database, model = 'opus', provider = 'claude') {
282
+ constructor(database, model = 'opus', provider = 'claude', providerOverrides = {}, providerOverridesMap = null) {
277
283
  // Store model and provider for creating provider instances per level
278
284
  this.model = model;
279
285
  this.provider = provider;
280
286
  this.db = database;
287
+ this.providerOverrides = providerOverrides;
288
+ this.providerOverridesMap = providerOverridesMap;
281
289
  this.testContextCache = new Map(); // Cache test detection results per worktree
282
290
  this._worktreeManager = null; // Lazy-initialized for sparse-checkout queries
283
291
  }
@@ -1015,7 +1023,7 @@ Or simply ignore any changes to files matching these patterns in your analysis.
1015
1023
  }
1016
1024
 
1017
1025
  // Create provider instance for this level
1018
- const aiProvider = createProvider(this.provider, this.model);
1026
+ const aiProvider = createProvider(this.provider, this.model, this.providerOverrides);
1019
1027
 
1020
1028
  const updateProgress = (step) => {
1021
1029
  const progress = `${lp}[Level 1] ${step}...`;
@@ -1975,7 +1983,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1975
1983
  }
1976
1984
 
1977
1985
  // Create provider instance for this level
1978
- const aiProvider = createProvider(this.provider, this.model);
1986
+ const aiProvider = createProvider(this.provider, this.model, this.providerOverrides);
1979
1987
 
1980
1988
  const updateProgress = (step) => {
1981
1989
  const progress = `${lp}[Level 2] ${step}...`;
@@ -2085,7 +2093,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
2085
2093
  }
2086
2094
 
2087
2095
  // Create provider instance for this level
2088
- const aiProvider = createProvider(this.provider, this.model);
2096
+ const aiProvider = createProvider(this.provider, this.model, this.providerOverrides);
2089
2097
 
2090
2098
  const updateProgress = (step) => {
2091
2099
  const progress = `${lp}[Level 3] ${step}...`;
@@ -2655,7 +2663,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2655
2663
  }
2656
2664
 
2657
2665
  // Create provider instance for consolidation (use overrides if provided)
2658
- const aiProvider = createProvider(providerOverride || this.provider, modelOverride || this.model);
2666
+ const aiProvider = createProvider(providerOverride || this.provider, modelOverride || this.model, this.providerOverrides);
2659
2667
 
2660
2668
  // Build the consolidation prompt
2661
2669
  const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp, { excludePrevious, dedupContext });
@@ -2932,7 +2940,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2932
2940
  if (voices.length === 1) {
2933
2941
  const voice = voices[0];
2934
2942
  const { voiceAnalyzer, voiceProvider, isExecutable, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
2935
- buildVoiceContext(voice, 0, instructions, progressCallback, this.db);
2943
+ buildVoiceContext(voice, 0, instructions, progressCallback, this.db, this.providerOverrides, this.providerOverridesMap);
2936
2944
  logger.info(`[ReviewerCouncil] Single reviewer (${reviewerLabel}) — running directly on parent run, no child run`);
2937
2945
 
2938
2946
  // Report voice-centric progress structure
@@ -3034,7 +3042,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3034
3042
  const commentRepo = new CommentRepository(this.db);
3035
3043
  const voicePromises = voices.map(async (voice, idx) => {
3036
3044
  const { voiceAnalyzer, voiceProvider, isExecutable, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
3037
- buildVoiceContext(voice, idx, instructions, progressCallback, this.db);
3045
+ buildVoiceContext(voice, idx, instructions, progressCallback, this.db, this.providerOverrides, this.providerOverridesMap);
3038
3046
  const childRunId = uuidv4();
3039
3047
 
3040
3048
  // Create child analysis run record
@@ -3271,7 +3279,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3271
3279
 
3272
3280
  const consolidated = await this._crossVoiceConsolidate(
3273
3281
  voiceReviews, prMetadata, consolInstructions, worktreePath,
3274
- { provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback, excludePrevious, dedupContext }
3282
+ { provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback, excludePrevious, dedupContext, providerOverrides: this.providerOverrides }
3275
3283
  );
3276
3284
 
3277
3285
  const finalSuggestions = this.validateAndFinalizeSuggestions(
@@ -3410,7 +3418,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3410
3418
  tier,
3411
3419
  timeout: voice.timeout || VoiceProviderClass?.defaultTimeout || 600000,
3412
3420
  customInstructions: voiceInstructions,
3413
- voiceCustomInstructions: voice.customInstructions || null
3421
+ voiceCustomInstructions: voice.customInstructions || null,
3422
+ providerOverrides: this.providerOverridesMap?.[voice.provider] || this.providerOverrides
3414
3423
  });
3415
3424
  }
3416
3425
  }
@@ -3575,7 +3584,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3575
3584
  }));
3576
3585
  const consolidated = await this._intraLevelConsolidate(
3577
3586
  level, voiceGroups, prMetadata, orchInstructions, worktreePath,
3578
- { provider: orchProvider, model: orchModel, tier: orchTier, timeout: orchConfig.timeout, analysisId, progressCallback, reviewerCount: successfulVoicesForLevel.length }
3587
+ { provider: orchProvider, model: orchModel, tier: orchTier, timeout: orchConfig.timeout, analysisId, progressCallback, reviewerCount: successfulVoicesForLevel.length, providerOverrides: this.providerOverrides }
3579
3588
  );
3580
3589
  consolidatedPerLevel[level] = consolidated;
3581
3590
  // Report intra-level consolidation step as completed
@@ -3661,7 +3670,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3661
3670
  * @private
3662
3671
  */
3663
3672
  async _executeCouncilVoice(task, context) {
3664
- const { voiceId, reviewerLabel, reviewerLogPrefix, level, provider, model, tier, timeout = 600000, customInstructions } = task;
3673
+ const { voiceId, reviewerLabel, reviewerLogPrefix, level, provider, model, tier, timeout = 600000, customInstructions, providerOverrides } = task;
3665
3674
  const { reviewId, runId, worktreePath, prMetadata, generatedPatterns, validFiles, analysisId, progressCallback } = context;
3666
3675
  const displayLabel = reviewerLabel || voiceId;
3667
3676
 
@@ -3672,7 +3681,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3672
3681
  }
3673
3682
 
3674
3683
  // Create provider instance for this voice
3675
- const aiProvider = createProvider(provider, model);
3684
+ const aiProvider = createProvider(provider, model, providerOverrides || {});
3676
3685
 
3677
3686
  // Build prompt based on level
3678
3687
  let prompt;
@@ -3748,9 +3757,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3748
3757
  * @private
3749
3758
  */
3750
3759
  async _intraLevelConsolidate(level, voiceGroups, prMetadata, customInstructions, worktreePath, orchConfig) {
3751
- const { provider, model, tier, timeout, analysisId, progressCallback, reviewerCount } = orchConfig;
3760
+ const { provider, model, tier, timeout, analysisId, progressCallback, reviewerCount, providerOverrides } = orchConfig;
3752
3761
 
3753
- const aiProvider = createProvider(provider, model);
3762
+ const aiProvider = createProvider(provider, model, providerOverrides || {});
3754
3763
 
3755
3764
  const isLocal = prMetadata.reviewType === 'local';
3756
3765
  const reviewDescription = isLocal
@@ -3916,9 +3925,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3916
3925
  * @private
3917
3926
  */
3918
3927
  async _crossVoiceConsolidate(voiceReviews, prMetadata, customInstructions, worktreePath, config) {
3919
- const { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext } = config;
3928
+ const { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext, providerOverrides } = config;
3920
3929
 
3921
- const aiProvider = createProvider(provider, model);
3930
+ const aiProvider = createProvider(provider, model, providerOverrides || {});
3922
3931
 
3923
3932
  const voiceDescriptions = voiceReviews.map(v => {
3924
3933
  let desc = `### Reviewer: ${v.voiceKey}`;
@@ -53,6 +53,8 @@ class ClaudeCLI {
53
53
  });
54
54
 
55
55
  const pid = claude.pid;
56
+ const fullCommand = this.useShell ? this.command : `${this.command} ${this.args.join(' ')}`;
57
+ logger.debug(`${levelPrefix} Claude CLI command: ${fullCommand}`);
56
58
  logger.info(`${levelPrefix} Spawned Claude CLI process: PID ${pid}`);
57
59
 
58
60
  let stdout = '';
@@ -257,7 +257,8 @@ class ClaudeProvider extends AIProvider {
257
257
  logger.info(`${levelPrefix} Executing Claude CLI...`);
258
258
  logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
259
259
 
260
- const claude = spawn(this.command, this.args, {
260
+ const spawnArgs = [...this.args];
261
+ const claude = spawn(this.command, spawnArgs, {
261
262
  cwd,
262
263
  env: {
263
264
  ...process.env,
@@ -268,6 +269,8 @@ class ClaudeProvider extends AIProvider {
268
269
  });
269
270
 
270
271
  const pid = claude.pid;
272
+ const fullCommand = this.useShell ? this.command : `${this.command} ${spawnArgs.join(' ')}`;
273
+ logger.debug(`${levelPrefix} Claude CLI command: ${fullCommand}`);
271
274
  logger.info(`${levelPrefix} Spawned Claude CLI process: PID ${pid}`);
272
275
 
273
276
  // Register process for cancellation tracking if analysisId provided