@in-the-loop-labs/pair-review 3.0.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +2 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +83 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +83 -8
  21. package/public/js/components/AnalysisConfigModal.js +155 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +239 -22
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +179 -12
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +141 -47
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +534 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +89 -4
  59. package/src/chat/prompt-builder.js +17 -1
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +113 -29
  64. package/src/local-review.js +15 -9
  65. package/src/main.js +3 -2
  66. package/src/routes/analyses.js +34 -8
  67. package/src/routes/chat.js +15 -8
  68. package/src/routes/config.js +3 -120
  69. package/src/routes/councils.js +15 -6
  70. package/src/routes/executable-analysis.js +494 -0
  71. package/src/routes/local.js +152 -15
  72. package/src/routes/mcp.js +9 -4
  73. package/src/routes/pr.js +166 -29
  74. package/src/routes/reviews.js +31 -5
  75. package/src/routes/shared.js +72 -5
  76. package/src/routes/worktrees.js +4 -2
  77. package/src/utils/comment-formatter.js +28 -11
  78. package/src/utils/instructions.js +22 -8
  79. package/src/utils/logger.js +20 -10
@@ -275,6 +275,28 @@ class AnalysisConfigModal {
275
275
  </div>
276
276
  </section>
277
277
 
278
+ <!-- Exclude Previous Findings -->
279
+ <details class="config-section exclude-previous-section">
280
+ <summary class="section-title">
281
+ Exclude Previous Findings
282
+ <span class="section-hint">(optional)</span>
283
+ </summary>
284
+ <div class="exclude-previous-options">
285
+ <label class="remember-toggle">
286
+ <input type="checkbox" id="exclude-github-comments" />
287
+ <span class="toggle-switch"></span>
288
+ <span class="toggle-label">GitHub PR review comments</span>
289
+ </label>
290
+ <p class="option-hint">Skip issues already noted in inline PR review comments</p>
291
+ <label class="remember-toggle">
292
+ <input type="checkbox" id="exclude-pr-feedback" />
293
+ <span class="toggle-switch"></span>
294
+ <span class="toggle-label">Existing pair-review feedback</span>
295
+ </label>
296
+ <p class="option-hint">Skip issues from previous AI suggestions and reviewer comments</p>
297
+ </div>
298
+ </details>
299
+
278
300
  <!-- Focus Presets - Hidden for now, may reintroduce later -->
279
301
  <section class="config-section" style="display: none;">
280
302
  <h4 class="section-title">
@@ -499,7 +521,7 @@ class AnalysisConfigModal {
499
521
  container.innerHTML = this.models.map(model => `
500
522
  <button class="model-card ${model.id === this.selectedModel ? 'selected' : ''}" data-model="${model.id}" data-tier="${model.tier}">
501
523
  <div class="model-badge ${model.badgeClass || ''}">${model.badge || ''}</div>
502
- <div class="model-icon">${this.getModelIcon(model.tier)}</div>
524
+ <div class="model-icon">${model.icon || this.getModelIcon(model.tier)}</div>
503
525
  <div class="model-info">
504
526
  <span class="model-name">${model.name}</span>
505
527
  <span class="model-tagline">${model.tagline || ''}</span>
@@ -549,6 +571,47 @@ class AnalysisConfigModal {
549
571
  // Re-render model cards (handles its own event listeners)
550
572
  this.renderModelCards();
551
573
 
574
+ // Toggle level toggles visibility based on provider's capabilities
575
+ const provider = this.providers[providerId];
576
+ const supportsLevels = provider.capabilities?.review_levels !== false;
577
+ const supportsCustomInstructions = provider.capabilities?.custom_instructions !== false;
578
+ const levelToggles = this.modal.querySelector('.single-level-toggles');
579
+ const skipLevel3Info = this.modal.querySelector('#skip-level3-info');
580
+ const existingLevelsNote = this.modal.querySelector('.executable-provider-levels-note');
581
+ const customInstructionsSection = this.modal.querySelector('#custom-instructions')?.closest('.config-section');
582
+
583
+ if (!supportsLevels) {
584
+ if (levelToggles) levelToggles.style.display = 'none';
585
+ if (skipLevel3Info) skipLevel3Info.style.display = 'none';
586
+ if (!existingLevelsNote) {
587
+ const note = document.createElement('div');
588
+ note.className = 'executable-provider-note executable-provider-levels-note';
589
+ note.textContent = 'This provider runs its own analysis pipeline';
590
+ levelToggles?.parentNode?.appendChild(note);
591
+ }
592
+ } else {
593
+ if (levelToggles) levelToggles.style.display = '';
594
+ if (skipLevel3Info) skipLevel3Info.style.display = '';
595
+ if (existingLevelsNote) existingLevelsNote.remove();
596
+ }
597
+
598
+ // Toggle custom instructions visibility based on provider's capabilities
599
+ const instructionsContainer = customInstructionsSection?.querySelector('.instructions-container');
600
+ const existingInstructionsNote = customInstructionsSection?.querySelector('.executable-provider-instructions-note');
601
+
602
+ if (!supportsCustomInstructions) {
603
+ if (instructionsContainer) instructionsContainer.style.display = 'none';
604
+ if (!existingInstructionsNote && customInstructionsSection) {
605
+ const note = document.createElement('div');
606
+ note.className = 'executable-provider-note executable-provider-instructions-note';
607
+ note.textContent = 'This provider runs its own analysis pipeline and does not accept custom instructions.';
608
+ customInstructionsSection.appendChild(note);
609
+ }
610
+ } else {
611
+ if (instructionsContainer) instructionsContainer.style.display = '';
612
+ if (existingInstructionsNote) existingInstructionsNote.remove();
613
+ }
614
+
552
615
  // Update selection state for the selected model
553
616
  if (this.selectedModel) {
554
617
  this.selectModel(this.selectedModel);
@@ -711,7 +774,8 @@ class AnalysisConfigModal {
711
774
  councilName: selectedCouncil?.name || null,
712
775
  councilConfig: councilConfig,
713
776
  customInstructions: this.modal.querySelector('#vc-custom-instructions')?.value?.trim() || '',
714
- repoInstructions: this.repoInstructions
777
+ repoInstructions: this.repoInstructions,
778
+ excludePrevious: this._getAndSaveExcludePrevious()
715
779
  };
716
780
 
717
781
  if (this.onSubmit) this.onSubmit(config);
@@ -736,7 +800,8 @@ class AnalysisConfigModal {
736
800
  councilName: selectedCouncil?.name || null,
737
801
  councilConfig: councilConfig,
738
802
  customInstructions: this.modal.querySelector('#council-custom-instructions')?.value?.trim() || '',
739
- repoInstructions: this.repoInstructions
803
+ repoInstructions: this.repoInstructions,
804
+ excludePrevious: this._getAndSaveExcludePrevious()
740
805
  };
741
806
 
742
807
  if (this.onSubmit) this.onSubmit(config);
@@ -748,16 +813,21 @@ class AnalysisConfigModal {
748
813
  const selectedModelCard = this.modal.querySelector('.model-card.selected');
749
814
  const tier = selectedModelCard?.dataset?.tier || 'balanced';
750
815
 
816
+ const selectedProvider = this.providers[this.selectedProvider];
817
+ const noLevels = selectedProvider?.capabilities?.review_levels === false;
818
+
751
819
  const config = {
752
820
  provider: this.selectedProvider,
753
821
  model: this.selectedModel,
754
822
  tier: tier,
755
823
  instructions: this.buildInstructions(),
756
- customInstructions: this.modal.querySelector('#custom-instructions')?.value?.trim() || '',
824
+ customInstructions: selectedProvider?.capabilities?.custom_instructions === false ? '' : (this.modal.querySelector('#custom-instructions')?.value?.trim() || ''),
757
825
  presets: Array.from(this.selectedPresets),
758
826
  repoInstructions: this.repoInstructions,
759
827
  enabledLevels: [...this.enabledLevels],
760
- skipLevel3: !this.enabledLevels.includes(3)
828
+ skipLevel3: !this.enabledLevels.includes(3),
829
+ noLevels,
830
+ excludePrevious: this._getAndSaveExcludePrevious()
761
831
  };
762
832
 
763
833
  if (this.onSubmit) this.onSubmit(config);
@@ -895,6 +965,9 @@ class AnalysisConfigModal {
895
965
  }
896
966
  }
897
967
 
968
+ // Restore exclude-previous checkbox state from localStorage
969
+ this._restoreExcludePrevious(options);
970
+
898
971
  // Remove loading state and reveal content
899
972
  this._showLoading(false);
900
973
 
@@ -953,6 +1026,10 @@ class AnalysisConfigModal {
953
1026
  <button class="analysis-tab" data-tab="advanced">Advanced</button>
954
1027
  `;
955
1028
 
1029
+ // Hoist the exclude-previous section out of the single-model panel
1030
+ // so it remains visible across all tabs.
1031
+ const excludePreviousSection = singlePanel.querySelector('.exclude-previous-section');
1032
+
956
1033
  // Assemble
957
1034
  modalBody.innerHTML = '';
958
1035
  modalBody.appendChild(tabBar);
@@ -960,6 +1037,12 @@ class AnalysisConfigModal {
960
1037
  modalBody.appendChild(councilPanel);
961
1038
  modalBody.appendChild(advancedPanel);
962
1039
 
1040
+ // Place the exclude-previous section after all tab panels,
1041
+ // just before the modal footer, so it's visible on every tab.
1042
+ if (excludePreviousSection) {
1043
+ modalBody.appendChild(excludePreviousSection);
1044
+ }
1045
+
963
1046
  // Tab click listeners
964
1047
  tabBar.querySelectorAll('.analysis-tab').forEach(tab => {
965
1048
  tab.addEventListener('click', () => {
@@ -1212,6 +1295,73 @@ class AnalysisConfigModal {
1212
1295
  }, 200);
1213
1296
  }
1214
1297
 
1298
+ /**
1299
+ * Restore exclude-previous checkbox state from localStorage.
1300
+ * Disables the GitHub checkbox when there is no PR or no GitHub token.
1301
+ * @param {Object} options - show() options
1302
+ * @private
1303
+ */
1304
+ _restoreExcludePrevious(options) {
1305
+ const githubCb = this.modal.querySelector('#exclude-github-comments');
1306
+ const feedbackCb = this.modal.querySelector('#exclude-pr-feedback');
1307
+ if (!githubCb || !feedbackCb) return;
1308
+
1309
+ // Restore saved state
1310
+ let saved = { github: false, feedback: false };
1311
+ try {
1312
+ const raw = localStorage.getItem('pair-review-exclude-previous');
1313
+ if (raw) saved = JSON.parse(raw);
1314
+ } catch (_) { /* ignore parse errors */ }
1315
+
1316
+ githubCb.checked = !!saved.github;
1317
+ feedbackCb.checked = !!saved.feedback;
1318
+
1319
+ // Disable the GitHub checkbox when it cannot be used
1320
+ const canUseGithub = options.hasPr !== false && options.hasGithubToken !== false;
1321
+ githubCb.disabled = !canUseGithub;
1322
+ if (!canUseGithub) {
1323
+ const label = githubCb.closest('.remember-toggle');
1324
+ if (label) label.title = 'Not available — requires a PR with a GitHub token';
1325
+ } else {
1326
+ const label = githubCb.closest('.remember-toggle');
1327
+ if (label) label.title = '';
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * Read current exclude-previous checkbox state, save to localStorage,
1333
+ * and return the config object.
1334
+ * @returns {{ github: boolean, feedback: boolean }}
1335
+ * @private
1336
+ */
1337
+ _getAndSaveExcludePrevious() {
1338
+ const githubCb = this.modal.querySelector('#exclude-github-comments');
1339
+ const feedbackCb = this.modal.querySelector('#exclude-pr-feedback');
1340
+
1341
+ // When the GitHub checkbox is disabled, preserve the existing localStorage
1342
+ // value for `github` so the user's preference survives contexts where the
1343
+ // option is unavailable.
1344
+ let preservedGithub = false;
1345
+ if (githubCb?.disabled) {
1346
+ try {
1347
+ const raw = localStorage.getItem('pair-review-exclude-previous');
1348
+ if (raw) {
1349
+ const prev = JSON.parse(raw);
1350
+ preservedGithub = !!prev.github;
1351
+ }
1352
+ } catch (_) { /* ignore parse errors */ }
1353
+ }
1354
+
1355
+ const state = {
1356
+ github: githubCb?.disabled ? preservedGithub : !!githubCb?.checked,
1357
+ feedback: !!feedbackCb?.checked
1358
+ };
1359
+ try {
1360
+ localStorage.setItem('pair-review-exclude-previous', JSON.stringify(state));
1361
+ } catch (_) { /* ignore storage errors */ }
1362
+ return state;
1363
+ }
1364
+
1215
1365
  /**
1216
1366
  * Cleanup event listeners and pending timeouts
1217
1367
  */
@@ -43,6 +43,7 @@ class ChatPanel {
43
43
  this._sessionWarm = false; // true once the session has been used in this page load
44
44
  this._activeProvider = window.__pairReview?.chatProvider || 'pi';
45
45
  this._chatProviders = window.__pairReview?.chatProviders || [];
46
+ this._enterToSend = window.__pairReview?.chatEnterToSend ?? true;
46
47
 
47
48
  this._render();
48
49
  this._bindEvents();
@@ -141,7 +142,7 @@ class ChatPanel {
141
142
  <div class="chat-panel__input-area">
142
143
  <textarea class="chat-panel__input" placeholder="Ask about this review..." rows="1"></textarea>
143
144
  <div class="chat-panel__input-footer">
144
- <span class="chat-panel__input-hint">${typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Enter to send</span>
145
+ <span class="chat-panel__input-hint" title="Configure with chat.enter_to_send">${this._enterToSend ? 'Enter to send, Shift+Enter for newline' : `${typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Enter to send`}</span>
145
146
  <div class="chat-panel__input-actions">
146
147
  <button class="chat-panel__send-btn" title="Send" disabled>
147
148
  <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
@@ -250,10 +251,26 @@ class ChatPanel {
250
251
 
251
252
  // Keyboard shortcuts
252
253
  this.inputEl.addEventListener('keydown', (e) => {
253
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
254
- e.preventDefault();
255
- if (this.inputEl.value.trim() && !this.isStreaming) {
256
- this.sendMessage();
254
+ if (e.key === 'Enter') {
255
+ // Ignore Enter during IME composition (e.g. CJK input) so the
256
+ // composition-confirming keystroke is not swallowed.
257
+ if (e.isComposing) return;
258
+
259
+ if (this._enterToSend) {
260
+ // Enter sends, Shift+Enter inserts newline
261
+ if (e.shiftKey) return; // let browser insert newline
262
+ e.preventDefault();
263
+ if (this.inputEl.value.trim() && !this.isStreaming) {
264
+ this.sendMessage();
265
+ }
266
+ } else {
267
+ // Cmd+Enter / Ctrl+Enter sends, plain Enter inserts newline
268
+ if (e.metaKey || e.ctrlKey) {
269
+ e.preventDefault();
270
+ if (this.inputEl.value.trim() && !this.isStreaming) {
271
+ this.sendMessage();
272
+ }
273
+ }
257
274
  }
258
275
  }
259
276
  });
@@ -23,6 +23,11 @@ class CouncilProgressModal {
23
23
  // Track per-voice completion state
24
24
  this._voiceStates = {};
25
25
 
26
+ // Track which voice keys are executable providers.
27
+ // Populated from progress events (levels.exec.voices) and persisted
28
+ // across show()/hide() cycles so reopened modals render correctly.
29
+ this._executableVoices = new Set();
30
+
26
31
  this._createModal();
27
32
  this._setupEventListeners();
28
33
  }
@@ -54,9 +59,12 @@ class CouncilProgressModal {
54
59
  // Detect rendering mode
55
60
  const configType = options.configType || (councilConfig ? 'advanced' : 'single');
56
61
  this._renderMode = configType;
62
+ this._noLevels = options.noLevels || false;
57
63
 
58
64
  // Rebuild DOM based on mode
59
- if (configType === 'single') {
65
+ if (configType === 'single' && this._noLevels) {
66
+ this._rebuildBodyNoLevels();
67
+ } else if (configType === 'single') {
60
68
  const enabledLevels = options.enabledLevels || [1, 2, 3];
61
69
  this._rebuildBodySingleModel(enabledLevels);
62
70
  } else if (configType === 'council') {
@@ -239,11 +247,34 @@ class CouncilProgressModal {
239
247
  * @param {Object} status
240
248
  */
241
249
  updateProgress(status) {
250
+ // No-levels mode: update the single row based on overall status
251
+ if (this._noLevels) {
252
+ this._updateNoLevelsProgress(status);
253
+ // Terminal states
254
+ if (status.status === 'completed') {
255
+ this._handleCompletion(status);
256
+ } else if (status.status === 'failed') {
257
+ this._handleFailure(status);
258
+ } else if (status.status === 'cancelled') {
259
+ this._handleCancellation(status);
260
+ }
261
+ return;
262
+ }
263
+
242
264
  if (!status.levels || typeof status.levels !== 'object') {
243
265
  console.warn('Council progress: invalid status structure', status);
244
266
  return;
245
267
  }
246
268
 
269
+ // Track executable voice keys from exec level data so that reopened
270
+ // modals can render executable reviewers correctly without relying on
271
+ // the config modal's provider cache (which may be empty on reopen).
272
+ if (status.levels.exec?.voices) {
273
+ for (const voiceKey of Object.keys(status.levels.exec.voices)) {
274
+ this._executableVoices.add(voiceKey);
275
+ }
276
+ }
277
+
247
278
  if (this._renderMode === 'single') {
248
279
  // Single-model: update level headers directly
249
280
  for (let level = 1; level <= 3; level++) {
@@ -292,6 +323,63 @@ class CouncilProgressModal {
292
323
  }
293
324
  }
294
325
 
326
+ // ---------------------------------------------------------------------------
327
+ // No-levels progress
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Update the single row for no-levels providers.
332
+ * Maps overall analysis status to the single "Running analysis..." row.
333
+ */
334
+ _updateNoLevelsProgress(status) {
335
+ const header = this.modal.querySelector('.council-level-header[data-level="analysis"]');
336
+ if (!header) return;
337
+
338
+ const iconEl = header.querySelector('.council-level-icon');
339
+ const statusEl = header.querySelector('.council-level-status');
340
+ if (!iconEl || !statusEl) return;
341
+
342
+ // Derive state from overall status or from any level status
343
+ let state = 'pending';
344
+ if (status.status === 'running' || status.status === 'in_progress') {
345
+ state = 'running';
346
+ } else if (status.status === 'completed') {
347
+ state = 'completed';
348
+ } else if (status.status === 'failed') {
349
+ state = 'failed';
350
+ } else if (status.status === 'cancelled') {
351
+ state = 'cancelled';
352
+ } else if (status.levels) {
353
+ // Fall back to checking individual levels
354
+ const levelValues = Object.values(status.levels);
355
+ if (levelValues.some(l => l.status === 'running')) state = 'running';
356
+ else if (levelValues.some(l => l.status === 'completed')) state = 'running';
357
+ }
358
+
359
+ this._renderState(iconEl, statusEl, state, 'council-level');
360
+
361
+ // Show stream event text in the snippet element
362
+ const levelEl = header.closest('.council-level');
363
+ const snippetEl = levelEl?.querySelector('.council-level-snippet');
364
+ if (snippetEl) {
365
+ // Check for stream events in any level
366
+ let streamText = null;
367
+ if (status.levels) {
368
+ for (const lvl of Object.values(status.levels)) {
369
+ if (lvl.streamEvent?.text) {
370
+ streamText = lvl.streamEvent.text;
371
+ }
372
+ }
373
+ }
374
+ if (state === 'running' && streamText) {
375
+ snippetEl.textContent = streamText;
376
+ snippetEl.style.display = 'block';
377
+ } else if (state !== 'running') {
378
+ snippetEl.style.display = 'none';
379
+ }
380
+ }
381
+ }
382
+
295
383
  // ---------------------------------------------------------------------------
296
384
  // Per-voice progress
297
385
  // ---------------------------------------------------------------------------
@@ -518,6 +606,55 @@ class CouncilProgressModal {
518
606
  }
519
607
  }
520
608
 
609
+ // Handle executable voice updates (level 'exec'):
610
+ // Executable voices emit progress with level: 'exec' instead of numeric 1/2/3.
611
+ // The single "Running analysis..." row uses data-vc-level="exec".
612
+ const execLevel = status.levels?.exec;
613
+ if (execLevel) {
614
+ if (execLevel.voices) {
615
+ // Check if any exec voice was rendered with native L1/L2/L3 structure
616
+ // instead of the single exec row. This happens when the modal was opened
617
+ // before _executableVoices was populated. If so, re-render the full body.
618
+ let needsRerender = false;
619
+ for (const voiceId of Object.keys(execLevel.voices)) {
620
+ const execEl = this.modal.querySelector(`[data-vc-voice="${voiceId}"][data-vc-level="exec"]`);
621
+ if (!execEl) {
622
+ // Voice exists in exec progress but has no exec DOM element — was likely
623
+ // rendered with native structure. Re-render to fix.
624
+ needsRerender = true;
625
+ break;
626
+ }
627
+ }
628
+ if (needsRerender && this.councilConfig) {
629
+ this._rebuildBodyVoiceCentric(this.councilConfig);
630
+ // Re-apply all progress to the fresh DOM
631
+ this._updateVoiceCentric(status);
632
+ return;
633
+ }
634
+
635
+ for (const [voiceId, vStatus] of Object.entries(execLevel.voices)) {
636
+ this._setVoiceCentricLevelState(voiceId, 'exec', vStatus.status || 'running', vStatus);
637
+ }
638
+ } else if (execLevel.status && execLevel.status !== 'pending') {
639
+ // Aggregate terminal update without per-voice breakdown (completion/cancellation
640
+ // paths may collapse exec progress to just { status, progress }). Propagate the
641
+ // status to every rendered exec row, mirroring the pattern used for numeric levels
642
+ // with shared terminal state in _updateVoiceFromLevelStatus.
643
+ const terminalState = execLevel.status;
644
+ const execEls = this.modal.querySelectorAll('[data-vc-level="exec"]');
645
+ execEls.forEach(el => {
646
+ const voiceKey = el.dataset.vcVoice;
647
+ const stateKey = `${voiceKey}:exec`;
648
+ if (this._voiceStates[stateKey] !== 'completed' && this._voiceStates[stateKey] !== 'failed') {
649
+ this._setVoiceCentricLevelState(voiceKey, 'exec', terminalState, execLevel);
650
+ }
651
+ });
652
+ }
653
+ if (execLevel.streamEvent?.text && execLevel.voiceId) {
654
+ this._setVoiceCentricStreamText(execLevel.voiceId, 'exec', execLevel.streamEvent.text);
655
+ }
656
+ }
657
+
521
658
  // Handle per-voice orchestration updates (level 4):
522
659
  // In voice-centric mode, each reviewer has a consolidation child at data-vc-level="4".
523
660
  // The backend tracks per-voice orchestration state in levels[4].voices, including
@@ -1073,6 +1210,26 @@ class CouncilProgressModal {
1073
1210
  }
1074
1211
  }
1075
1212
 
1213
+ // Executable voices don't require enabled levels — add any not already captured
1214
+ // (handles all-executable councils where no levels are enabled).
1215
+ // Use _executableVoices (populated from progress events) as primary source,
1216
+ // falling back to the config modal's provider cache. This ensures executable
1217
+ // voices render correctly even when the modal is reopened for an already-running
1218
+ // analysis where the config modal cache may be empty.
1219
+ if (config.voices) {
1220
+ const providersInfo = window.analysisConfigModal?.providers || {};
1221
+ for (const voice of config.voices) {
1222
+ const isExec = providersInfo[voice.provider]?.isExecutable || false;
1223
+ if (isExec) {
1224
+ const sig = `${voice.provider}|${voice.model}|${voice.tier || 'balanced'}|${voice.customInstructions || ''}`;
1225
+ if (!seenSignatures.has(sig)) {
1226
+ seenSignatures.add(sig);
1227
+ uniqueVoices.push(voice);
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+
1076
1233
  // Build voiceMap: voiceKey -> { voice, levels } using deduplicated array indices
1077
1234
  const voiceMap = new Map();
1078
1235
  uniqueVoices.forEach((voice, idx) => {
@@ -1080,11 +1237,33 @@ class CouncilProgressModal {
1080
1237
  voiceMap.set(voiceKey, { voice, levels: enabledLevelNums });
1081
1238
  });
1082
1239
 
1240
+ // Also add any executable voices known from progress events but not yet
1241
+ // in the voiceMap (covers the case where config modal providers cache is
1242
+ // empty but we've already seen exec-level progress data).
1243
+ for (const execVoiceKey of this._executableVoices) {
1244
+ if (!voiceMap.has(execVoiceKey)) {
1245
+ // Find the matching voice in config.voices by key pattern
1246
+ const configVoices = config.voices || [];
1247
+ const matchingVoice = configVoices.find((v, idx) => {
1248
+ const candidateKey = `${v.provider}-${v.model}${idx > 0 ? `-${idx}` : ''}`;
1249
+ return candidateKey === execVoiceKey;
1250
+ });
1251
+ if (matchingVoice) {
1252
+ voiceMap.set(execVoiceKey, { voice: matchingVoice, levels: enabledLevelNums });
1253
+ }
1254
+ }
1255
+ }
1256
+
1083
1257
  let html = '<div class="council-progress-tree">';
1084
1258
 
1085
- // Build a parent row for each unique voice
1259
+ // Build a parent row for each unique voice.
1260
+ // Determine executable status from: (1) _executableVoices set (populated from
1261
+ // progress events), or (2) config modal provider cache as fallback.
1262
+ const providersMap = window.analysisConfigModal?.providers || {};
1263
+
1086
1264
  for (const [voiceKey, { voice, levels }] of voiceMap) {
1087
- const label = this._formatVoiceLabel(voice);
1265
+ const isExecutable = this._executableVoices.has(voiceKey) || (providersMap[voice.provider]?.isExecutable || false);
1266
+ const label = this._formatVoiceLabel(voice, { isExecutable });
1088
1267
 
1089
1268
  html += `
1090
1269
  <div class="council-level" data-voice-key="${voiceKey}">
@@ -1096,31 +1275,46 @@ class CouncilProgressModal {
1096
1275
  <div class="council-level-children">
1097
1276
  `;
1098
1277
 
1099
- // Level children (orchestration row is always last, added separately below)
1100
- levels.forEach((levelNum) => {
1101
- const connectorClass = 'connector-mid';
1278
+ if (isExecutable) {
1279
+ // Executable voices: single "Running analysis..." row instead of L1/L2/L3
1102
1280
  html += `
1103
- <div class="council-voice ${connectorClass}" data-vc-voice="${voiceKey}" data-vc-level="${levelNum}">
1104
- <span class="council-voice-connector ${connectorClass}"></span>
1105
- <span class="council-voice-icon running"><span class="council-spinner"></span></span>
1106
- <span class="council-voice-label">Level ${levelNum} \u2014 ${levelNames[levelNum]}</span>
1107
- <span class="council-voice-status running">Running...</span>
1281
+ <div class="council-voice connector-last" data-vc-voice="${voiceKey}" data-vc-level="exec">
1282
+ <span class="council-voice-connector connector-last"></span>
1283
+ <span class="council-voice-icon pending">\u25CB</span>
1284
+ <span class="council-voice-label">Running analysis\u2026</span>
1285
+ <span class="council-voice-status pending">Pending</span>
1108
1286
  <div class="council-voice-detail">
1109
1287
  <div class="council-voice-snippet" style="display: none;"></div>
1110
1288
  </div>
1111
1289
  </div>
1112
1290
  `;
1113
- });
1291
+ } else {
1292
+ // Native voices: L1/L2/L3 children + orchestration
1293
+ levels.forEach((levelNum) => {
1294
+ const connectorClass = 'connector-mid';
1295
+ html += `
1296
+ <div class="council-voice ${connectorClass}" data-vc-voice="${voiceKey}" data-vc-level="${levelNum}">
1297
+ <span class="council-voice-connector ${connectorClass}"></span>
1298
+ <span class="council-voice-icon running"><span class="council-spinner"></span></span>
1299
+ <span class="council-voice-label">Level ${levelNum} \u2014 ${levelNames[levelNum]}</span>
1300
+ <span class="council-voice-status running">Running...</span>
1301
+ <div class="council-voice-detail">
1302
+ <div class="council-voice-snippet" style="display: none;"></div>
1303
+ </div>
1304
+ </div>
1305
+ `;
1306
+ });
1114
1307
 
1115
- // Orchestration child (always last)
1116
- html += `
1117
- <div class="council-voice connector-last" data-vc-voice="${voiceKey}" data-vc-level="4">
1118
- <span class="council-voice-connector connector-last"></span>
1119
- <span class="council-voice-icon pending">\u25CB</span>
1120
- <span class="council-voice-label">Consolidation</span>
1121
- <span class="council-voice-status pending">Pending</span>
1122
- </div>
1123
- `;
1308
+ // Orchestration child (always last)
1309
+ html += `
1310
+ <div class="council-voice connector-last" data-vc-voice="${voiceKey}" data-vc-level="4">
1311
+ <span class="council-voice-connector connector-last"></span>
1312
+ <span class="council-voice-icon pending">\u25CB</span>
1313
+ <span class="council-voice-label">Consolidation</span>
1314
+ <span class="council-voice-status pending">Pending</span>
1315
+ </div>
1316
+ `;
1317
+ }
1124
1318
 
1125
1319
  html += `
1126
1320
  </div>
@@ -1205,6 +1399,28 @@ class CouncilProgressModal {
1205
1399
  body.innerHTML = html;
1206
1400
  }
1207
1401
 
1402
+ /**
1403
+ * Rebuild the modal body for no-levels providers (e.g., executable providers).
1404
+ * Shows a single "Running analysis..." entry instead of the L1/L2/L3 breakdown.
1405
+ */
1406
+ _rebuildBodyNoLevels() {
1407
+ const body = this.modal.querySelector('.council-progress-body');
1408
+ if (!body) return;
1409
+
1410
+ body.innerHTML = `
1411
+ <div class="council-progress-tree">
1412
+ <div class="council-level" data-level="analysis">
1413
+ <div class="council-level-header" data-level="analysis">
1414
+ <span class="council-level-icon pending">\u25CB</span>
1415
+ <span class="council-level-title">Running analysis\u2026</span>
1416
+ <span class="council-level-status pending">Pending</span>
1417
+ </div>
1418
+ <div class="council-level-snippet" style="display: none;"></div>
1419
+ </div>
1420
+ </div>
1421
+ `;
1422
+ }
1423
+
1208
1424
  _resetFooter() {
1209
1425
  const bgBtn = this.modal.querySelector('.council-bg-btn');
1210
1426
  const cancelBtn = this.modal.querySelector('.council-cancel-btn');
@@ -1404,9 +1620,10 @@ class CouncilProgressModal {
1404
1620
  * Example: { provider: 'claude', model: 'sonnet-4-5', tier: 'balanced' }
1405
1621
  * -> "Claude sonnet-4-5 (Balanced)"
1406
1622
  */
1407
- _formatVoiceLabel(voice) {
1623
+ _formatVoiceLabel(voice, { isExecutable = false } = {}) {
1408
1624
  const provider = this._capitalize(voice.provider || 'unknown');
1409
1625
  const model = voice.model || 'default';
1626
+ if (isExecutable) return `${provider} ${model}`;
1410
1627
  const tier = this._capitalize(voice.tier || 'balanced');
1411
1628
  return `${provider} ${model} (${tier})`;
1412
1629
  }
@@ -37,6 +37,8 @@ class TimeoutSelect {
37
37
  { value: '600000', label: '10m', selected: true },
38
38
  { value: '900000', label: '15m' },
39
39
  { value: '1800000', label: '30m' },
40
+ { value: '2700000', label: '45m' },
41
+ { value: '3600000', label: '60m' },
40
42
  ];
41
43
 
42
44
  /**