@in-the-loop-labs/pair-review 1.4.3 → 1.5.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -23,6 +23,59 @@ const { mergeInstructions } = require('../utils/instructions');
23
23
  const { GitWorktreeManager } = require('../git/worktree');
24
24
  const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guidance');
25
25
 
26
+ /** Minimum total suggestion count across all voices before consolidation is applied */
27
+ const COUNCIL_CONSOLIDATION_THRESHOLD = 8;
28
+
29
+ /**
30
+ * Build a human-readable display label for a council voice/reviewer.
31
+ * Uses 1-based index so logs read "Reviewer 1", "Reviewer 2", etc.
32
+ * @param {number} idx - 0-based array index
33
+ * @param {Object} voice - Voice config with provider, model, tier
34
+ * @returns {string} e.g. "Reviewer 1 (claude/sonnet-4-5)"
35
+ */
36
+ function buildReviewerLabel(idx, voice) {
37
+ return `Reviewer ${idx + 1} (${voice.provider}/${voice.model})`;
38
+ }
39
+
40
+ /**
41
+ * Build shared context for a council voice/reviewer.
42
+ * Used by both single-voice and multi-voice paths in runReviewerCentricCouncil.
43
+ *
44
+ * @param {Object} voice - Voice config with provider, model, tier, timeout, customInstructions
45
+ * @param {number} idx - 0-based voice index
46
+ * @param {Object|null} instructions - Instructions object { repoInstructions, requestInstructions }
47
+ * @param {Function|null} progressCallback - Parent progress callback to wrap
48
+ * @param {Object} db - Database instance
49
+ * @returns {Object} { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout }
50
+ */
51
+ function buildVoiceContext(voice, idx, instructions, progressCallback, db) {
52
+ const voiceKey = `${voice.provider}-${voice.model}${idx > 0 ? `-${idx}` : ''}`;
53
+ const reviewerLabel = buildReviewerLabel(idx, voice);
54
+
55
+ // Build per-voice request instructions, treating whitespace-only as null
56
+ let voiceRequestInstructions = instructions?.requestInstructions?.trim() || null;
57
+ if (voice.customInstructions) {
58
+ voiceRequestInstructions = voiceRequestInstructions
59
+ ? `${voiceRequestInstructions}\n\n${voice.customInstructions}`
60
+ : voice.customInstructions;
61
+ }
62
+
63
+ const voiceAnalyzer = new Analyzer(db, voice.model, voice.provider);
64
+ const voiceTier = voice.tier || 'balanced';
65
+ const voiceTimeout = voice.timeout || 600000;
66
+
67
+ // Wrap progress callback with voice-centric metadata
68
+ const voiceProgressCallback = progressCallback ? (update) => {
69
+ progressCallback({
70
+ ...update,
71
+ voiceId: voiceKey,
72
+ voiceCentric: true
73
+ });
74
+ } : null;
75
+
76
+ return { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout };
77
+ }
78
+
26
79
  class Analyzer {
27
80
  /**
28
81
  * @param {Object} database - Database instance
@@ -63,13 +116,20 @@ class Analyzer {
63
116
  * @param {string} options.analysisId - Analysis ID for process tracking (enables cancellation)
64
117
  * @param {string} [options.runId] - Pre-generated run ID (required if skipRunCreation is true)
65
118
  * @param {boolean} [options.skipRunCreation] - Skip creating analysis_run record in database
66
- * @param {boolean} [options.skipLevel3] - Skip Level 3 codebase context analysis
119
+ * @param {boolean} [options.skipLevel3] - Skip Level 3 codebase context analysis (deprecated: use enabledLevels)
120
+ * @param {Object} [options.enabledLevels] - Which levels to run, e.g. {1: true, 2: true, 3: false}
67
121
  * @param {string} [options.tier='balanced'] - Analysis tier (fast, balanced, thorough)
68
122
  * @returns {Promise<Object>} Analysis results
69
123
  */
70
124
  async analyzeAllLevels(prId, worktreePath, prMetadata, progressCallback = null, instructions = null, changedFiles = null, options = {}) {
71
125
  const runId = options.runId || uuidv4();
72
- const { analysisId, skipRunCreation, skipLevel3 } = options;
126
+ const { analysisId, skipRunCreation, skipLevel3, reviewerNum } = options;
127
+ const logPrefix = options.logPrefix || '';
128
+ const executionTimeout = options.timeout || 600000; // Default 10 minutes
129
+
130
+ // Resolve enabledLevels: prefer explicit option, fall back to skipLevel3 compat
131
+ const enabledLevels = options.enabledLevels
132
+ || { 1: true, 2: true, 3: !skipLevel3 };
73
133
 
74
134
  if (skipRunCreation && !options.runId) {
75
135
  throw new Error('runId is required when skipRunCreation is true');
@@ -91,19 +151,19 @@ class Analyzer {
91
151
  }
92
152
 
93
153
  logger.section('Multi-Level AI Analysis Starting (Parallel Execution)');
94
- logger.info(`PR ID: ${prId}`);
95
- logger.info(`Analysis run ID: ${runId}`);
154
+ logger.info(`${logPrefix}PR ID: ${prId}`);
155
+ logger.info(`${logPrefix}Analysis run ID: ${runId}`);
96
156
  if (analysisId) {
97
- logger.info(`Analysis ID (for cancellation): ${analysisId}`);
157
+ logger.info(`${logPrefix}Analysis ID (for cancellation): ${analysisId}`);
98
158
  }
99
- logger.info(`Worktree path: ${worktreePath}`);
159
+ logger.info(`${logPrefix}Worktree path: ${worktreePath}`);
100
160
 
101
161
  // Extract head_sha from prMetadata for traceability
102
162
  // PR mode: prMetadata.head_sha is the PR head commit
103
163
  // Local mode: prMetadata.head_sha is the local HEAD commit
104
164
  const headSha = prMetadata?.head_sha || null;
105
165
  if (headSha) {
106
- logger.info(`HEAD SHA: ${headSha}`);
166
+ logger.info(`${logPrefix}HEAD SHA: ${headSha}`);
107
167
  }
108
168
 
109
169
  // Create analysis run record in database (skip when caller already created it)
@@ -120,9 +180,9 @@ class Analyzer {
120
180
  requestInstructions,
121
181
  headSha
122
182
  });
123
- logger.info(`Created analysis_run record: ${runId}`);
183
+ logger.info(`${logPrefix}Created analysis_run record: ${runId}`);
124
184
  } catch (createError) {
125
- logger.warn(`Failed to create analysis_run record: ${createError.message}`);
185
+ logger.warn(`${logPrefix}Failed to create analysis_run record: ${createError.message}`);
126
186
  // Continue with analysis even if record creation fails
127
187
  }
128
188
  }
@@ -130,51 +190,46 @@ class Analyzer {
130
190
  // Load generated file patterns to skip during analysis
131
191
  const generatedPatterns = await this.loadGeneratedFilePatterns(worktreePath);
132
192
  if (generatedPatterns.length > 0) {
133
- logger.info(`Found ${generatedPatterns.length} generated file patterns to skip: ${generatedPatterns.join(', ')}`);
193
+ logger.info(`${logPrefix}Found ${generatedPatterns.length} generated file patterns to skip: ${generatedPatterns.join(', ')}`);
134
194
  }
135
195
 
136
196
  // Get changed files for validation (use provided list for local mode, or compute for PR mode)
137
197
  const validFiles = changedFiles || await this.getChangedFilesList(worktreePath, prMetadata);
138
- logger.info(`[Orchestration] Using ${validFiles.length} changed files for path validation`);
198
+ logger.info(`${logPrefix}Using ${validFiles.length} changed files for path validation`);
139
199
 
140
200
  // Build file line count map for line number validation
141
201
  const fileLineCountMap = await buildFileLineCountMap(worktreePath, validFiles);
142
- logger.info(`[Line Validation] Built line count map for ${fileLineCountMap.size} files`);
202
+ logger.info(`${logPrefix}[Line Validation] Built line count map for ${fileLineCountMap.size} files`);
143
203
 
144
204
  try {
145
205
  // Note: We no longer delete old AI suggestions to preserve analysis history.
146
206
  // The API endpoint filters to show only the latest ai_run_id.
147
207
 
148
- // Run analysis levels in parallel (optionally skip level 3)
149
- const levelsToRun = skipLevel3 ? 2 : 3;
150
- logger.info(`Starting ${levelsToRun} analysis levels in parallel${skipLevel3 ? ' (Level 3 skipped)' : ''}...`);
208
+ // Run analysis levels in parallel based on enabledLevels
209
+ const enabledCount = [1, 2, 3].filter(l => enabledLevels[l]).length;
210
+ const skippedNames = [1, 2, 3].filter(l => !enabledLevels[l]).map(l => `L${l}`);
211
+ logger.info(`${logPrefix}Starting ${enabledCount} analysis levels in parallel${skippedNames.length ? ` (${skippedNames.join(', ')} skipped)` : ''}...`);
151
212
  if (mergedInstructions) {
152
- logger.info(`Custom instructions provided: ${mergedInstructions.length} chars`);
213
+ logger.info(`${logPrefix}Custom instructions provided: ${mergedInstructions.length} chars`);
153
214
  }
154
215
  const tier = options.tier || 'balanced';
155
216
 
156
- // Build the promises array - always include levels 1 and 2, optionally include level 3
157
- const analysisPromises = [
158
- this.analyzeLevel1Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier }),
159
- this.analyzeLevel2Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier })
217
+ // Build the promises array for each level based on enabledLevels
218
+ const levelAnalyzers = [
219
+ () => this.analyzeLevel1Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier, timeout: executionTimeout, logPrefix, reviewerNum }),
220
+ () => this.analyzeLevel2Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier, timeout: executionTimeout, logPrefix, reviewerNum }),
221
+ () => this.analyzeLevel3Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier, timeout: executionTimeout, logPrefix, reviewerNum })
160
222
  ];
161
223
 
162
- if (skipLevel3) {
163
- // Return a pre-resolved promise with skipped status (include summary for consistent result shape)
164
- analysisPromises.push(Promise.resolve({ suggestions: [], status: 'skipped', summary: 'Level 3 skipped' }));
165
- // Update progress for level 3 as skipped
166
- if (progressCallback) {
167
- progressCallback({
168
- status: 'skipped',
169
- progress: 'Skipped',
170
- level: 3
171
- });
224
+ const analysisPromises = [1, 2, 3].map((level, idx) => {
225
+ if (!enabledLevels[level]) {
226
+ if (progressCallback) {
227
+ progressCallback({ status: 'skipped', progress: 'Skipped', level });
228
+ }
229
+ return Promise.resolve({ suggestions: [], status: 'skipped', summary: `Level ${level} skipped` });
172
230
  }
173
- } else {
174
- analysisPromises.push(
175
- this.analyzeLevel3Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns, progressCallback, mergedInstructions, validFiles, { analysisId, tier })
176
- );
177
- }
231
+ return levelAnalyzers[idx]();
232
+ });
178
233
 
179
234
  const results = await Promise.allSettled(analysisPromises);
180
235
 
@@ -190,7 +245,7 @@ class Analyzer {
190
245
  r => r.status === 'rejected' && r.reason?.isCancellation
191
246
  );
192
247
  if (hasCancellation) {
193
- logger.info('Analysis cancelled by user');
248
+ logger.info(`${logPrefix}Analysis cancelled by user`);
194
249
  throw new CancellationError('Analysis cancelled by user');
195
250
  }
196
251
 
@@ -203,19 +258,19 @@ class Analyzer {
203
258
  suggestions: [],
204
259
  status: 'skipped'
205
260
  };
206
- logger.info(`Level ${index + 1} skipped`);
261
+ logger.info(`${logPrefix}Level ${index + 1} skipped`);
207
262
  } else {
208
263
  levelResults[levelName] = {
209
264
  suggestions: result.value.suggestions || [],
210
265
  status: 'success',
211
266
  summary: result.value.summary
212
267
  };
213
- logger.success(`Level ${index + 1} completed: ${levelResults[levelName].suggestions.length} suggestions`);
268
+ logger.success(`${logPrefix}Level ${index + 1} completed: ${levelResults[levelName].suggestions.length} suggestions`);
214
269
  }
215
270
  } else {
216
271
  // Don't log cancellation as a warning - it's expected
217
272
  if (!result.reason?.isCancellation) {
218
- logger.warn(`Level ${index + 1} failed: ${result.reason?.message || 'Unknown error'}`);
273
+ logger.warn(`${logPrefix}Level ${index + 1} failed: ${result.reason?.message || 'Unknown error'}`);
219
274
  }
220
275
  }
221
276
  });
@@ -230,11 +285,11 @@ class Analyzer {
230
285
  }
231
286
 
232
287
  // Step 4: Orchestrate all suggestions
233
- logger.info('All levels complete. Starting orchestration...');
288
+ logger.info(`${logPrefix}All levels complete. Starting cross-level consolidation...`);
234
289
  if (progressCallback) {
235
290
  progressCallback({
236
291
  status: 'running',
237
- progress: 'Orchestrating AI suggestions for intelligent curation...',
292
+ progress: 'Consolidating suggestions across analysis levels...',
238
293
  level: 'orchestration'
239
294
  });
240
295
  }
@@ -246,7 +301,12 @@ class Analyzer {
246
301
  level3: levelResults.level3.suggestions
247
302
  };
248
303
 
249
- const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback });
304
+ const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback, timeout: executionTimeout, logPrefix, reviewerNum });
305
+
306
+ // Report orchestration step as completed
307
+ if (progressCallback) {
308
+ progressCallback({ level: 'orchestration', status: 'completed', progress: 'Cross-level consolidation complete' });
309
+ }
250
310
 
251
311
  // Validate and finalize suggestions
252
312
  const finalSuggestions = this.validateAndFinalizeSuggestions(
@@ -256,7 +316,7 @@ class Analyzer {
256
316
  );
257
317
 
258
318
  // Store orchestrated results with ai_level = NULL (final suggestions)
259
- logger.info('Storing orchestrated suggestions in database...');
319
+ logger.info(`${logPrefix}Storing consolidated suggestions in database...`);
260
320
  await this.storeSuggestions(prId, runId, finalSuggestions, null, validFiles);
261
321
 
262
322
  // Update analysis_run record with completion data
@@ -267,12 +327,12 @@ class Analyzer {
267
327
  totalSuggestions: finalSuggestions.length,
268
328
  filesAnalyzed: validFiles.length
269
329
  });
270
- logger.info(`Updated analysis_run record to completed: ${finalSuggestions.length} suggestions, ${validFiles.length} files`);
330
+ logger.info(`${logPrefix}Updated analysis_run record to completed: ${finalSuggestions.length} suggestions, ${validFiles.length} files`);
271
331
  } catch (updateError) {
272
- logger.warn(`Failed to update analysis_run record: ${updateError.message}`);
332
+ logger.warn(`${logPrefix}Failed to update analysis_run record: ${updateError.message}`);
273
333
  }
274
334
 
275
- logger.success(`Analysis complete: ${finalSuggestions.length} final suggestions`);
335
+ logger.success(`${logPrefix}Analysis complete: ${finalSuggestions.length} final suggestions`);
276
336
 
277
337
  return {
278
338
  runId,
@@ -282,8 +342,13 @@ class Analyzer {
282
342
  };
283
343
 
284
344
  } catch (orchestrationError) {
285
- logger.error(`Orchestration failed: ${orchestrationError.message}`);
286
- logger.warn('Falling back to storing all level suggestions without orchestration');
345
+ logger.error(`${logPrefix}Cross-level consolidation failed: ${orchestrationError.message}`);
346
+ logger.warn(`${logPrefix}Falling back to storing all level suggestions without consolidation`);
347
+
348
+ // Report orchestration step as failed
349
+ if (progressCallback) {
350
+ progressCallback({ level: 'orchestration', status: 'failed', progress: 'Cross-level consolidation failed' });
351
+ }
287
352
 
288
353
  // Fallback: store all suggestions as final without orchestration
289
354
  const fallbackSuggestions = [
@@ -302,7 +367,7 @@ class Analyzer {
302
367
  await this.storeSuggestions(prId, runId, finalFallbackSuggestions, null, validFiles);
303
368
 
304
369
  // Update analysis_run record with completion data (even though orchestration failed)
305
- const fallbackSummary = `Analysis complete (orchestration failed): ${finalFallbackSuggestions.length} suggestions`;
370
+ const fallbackSummary = `Analysis complete (consolidation failed): ${finalFallbackSuggestions.length} suggestions`;
306
371
  try {
307
372
  await analysisRunRepo.update(runId, {
308
373
  status: 'completed',
@@ -310,9 +375,9 @@ class Analyzer {
310
375
  totalSuggestions: finalFallbackSuggestions.length,
311
376
  filesAnalyzed: validFiles.length
312
377
  });
313
- logger.info(`Updated analysis_run record to completed (fallback): ${finalFallbackSuggestions.length} suggestions`);
378
+ logger.info(`${logPrefix}Updated analysis_run record to completed (fallback): ${finalFallbackSuggestions.length} suggestions`);
314
379
  } catch (updateError) {
315
- logger.warn(`Failed to update analysis_run record: ${updateError.message}`);
380
+ logger.warn(`${logPrefix}Failed to update analysis_run record: ${updateError.message}`);
316
381
  }
317
382
 
318
383
  return {
@@ -329,21 +394,21 @@ class Analyzer {
329
394
  try {
330
395
  if (error.isCancellation) {
331
396
  await analysisRunRepo.update(runId, { status: 'cancelled' });
332
- logger.info(`Updated analysis_run record to cancelled`);
397
+ logger.info(`${logPrefix}Updated analysis_run record to cancelled`);
333
398
  } else {
334
399
  await analysisRunRepo.update(runId, { status: 'failed' });
335
- logger.info(`Updated analysis_run record to failed`);
400
+ logger.info(`${logPrefix}Updated analysis_run record to failed`);
336
401
  }
337
402
  } catch (updateError) {
338
- logger.warn(`Failed to update analysis_run record on error: ${updateError.message}`);
403
+ logger.warn(`${logPrefix}Failed to update analysis_run record on error: ${updateError.message}`);
339
404
  }
340
405
 
341
406
  // Don't log cancellation as an error - it's expected user behavior
342
407
  if (error.isCancellation) {
343
408
  throw error;
344
409
  }
345
- logger.error(`Analysis failed: ${error.message}`);
346
- logger.error(`Error stack: ${error.stack}`);
410
+ logger.error(`${logPrefix}Analysis failed: ${error.message}`);
411
+ logger.error(`${logPrefix}Error stack: ${error.stack}`);
347
412
  throw error;
348
413
  }
349
414
  }
@@ -601,7 +666,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
601
666
  }
602
667
 
603
668
  if (!validPaths || validPaths.length === 0) {
604
- logger.warn('[Orchestration] No valid paths provided for validation, skipping path filtering');
669
+ logger.warn('[Validation] No valid paths provided for validation, skipping path filtering');
605
670
  return suggestions;
606
671
  }
607
672
 
@@ -630,11 +695,11 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
630
695
 
631
696
  // Log discarded suggestions for debugging
632
697
  if (discardedSuggestions.length > 0) {
633
- logger.warn(`[Orchestration] Discarded ${discardedSuggestions.length} suggestion(s) with invalid file paths:`);
698
+ logger.warn(`[Validation] Discarded ${discardedSuggestions.length} suggestion(s) with invalid file paths:`);
634
699
  for (const discarded of discardedSuggestions) {
635
700
  logger.warn(` - "${discarded.file}" (normalized: "${discarded.normalizedPath}"): ${discarded.type} - ${discarded.title}`);
636
701
  }
637
- logger.info(`[Orchestration] Valid paths in PR diff: ${Array.from(normalizedValidPaths).slice(0, 10).join(', ')}${normalizedValidPaths.size > 10 ? '...' : ''}`);
702
+ logger.info(`[Validation] Valid paths in PR diff: ${Array.from(normalizedValidPaths).slice(0, 10).join(', ')}${normalizedValidPaths.size > 10 ? '...' : ''}`);
638
703
  }
639
704
 
640
705
  return validSuggestions;
@@ -695,8 +760,11 @@ Or simply ignore any changes to files matching these patterns in your analysis.
695
760
  * @returns {Promise<Object>} Analysis results
696
761
  */
697
762
  async analyzeLevel1Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns = [], progressCallback = null, customInstructions = null, changedFiles = null, options = {}) {
698
- const { analysisId, tier = 'balanced' } = options;
699
- logger.info('[Level 1] Analysis Starting');
763
+ const { analysisId, tier = 'balanced', timeout = 600000, logPrefix: lp = '', reviewerNum } = options;
764
+ // Build adapter-level log prefix: when reviewerNum is set (council mode),
765
+ // use compact format like [R1 L1] so concurrent reviewers are disambiguated
766
+ const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} L1]` : '';
767
+ logger.info(`${lp}[Level 1] Analysis Starting`);
700
768
 
701
769
  try {
702
770
  // Check if analysis was cancelled before starting
@@ -708,7 +776,7 @@ Or simply ignore any changes to files matching these patterns in your analysis.
708
776
  const aiProvider = createProvider(this.provider, this.model);
709
777
 
710
778
  const updateProgress = (step) => {
711
- const progress = `[Level 1] ${step}...`;
779
+ const progress = `${lp}[Level 1] ${step}...`;
712
780
 
713
781
  if (progressCallback) {
714
782
  progressCallback({
@@ -728,10 +796,11 @@ Or simply ignore any changes to files matching these patterns in your analysis.
728
796
  updateProgress('Running AI to analyze changes in isolation');
729
797
  const response = await aiProvider.execute(prompt, {
730
798
  cwd: worktreePath,
731
- timeout: 600000, // 10 minutes for Level 1
799
+ timeout,
732
800
  level: 1,
733
801
  analysisId,
734
802
  registerProcess,
803
+ logPrefix: adapterLogPrefix,
735
804
  onStreamEvent: progressCallback ? (event) => {
736
805
  progressCallback({ level: 1, status: 'running', streamEvent: event });
737
806
  } : undefined
@@ -740,20 +809,20 @@ Or simply ignore any changes to files matching these patterns in your analysis.
740
809
  // Parse and validate the response
741
810
  updateProgress('Processing AI results');
742
811
  const parsedSuggestions = this.parseResponse(response, 1);
743
- logger.success(`Parsed ${parsedSuggestions.length} valid Level 1 suggestions`);
812
+ logger.success(`${lp}Parsed ${parsedSuggestions.length} valid Level 1 suggestions`);
744
813
 
745
814
  // Validate suggestion file paths if changedFiles provided
746
815
  const suggestions = (changedFiles && changedFiles.length > 0)
747
816
  ? this.validateSuggestionFilePaths(parsedSuggestions, changedFiles)
748
817
  : parsedSuggestions;
749
818
  if (changedFiles && changedFiles.length > 0) {
750
- logger.success(`After path validation: ${suggestions.length} suggestions`);
819
+ logger.success(`${lp}After path validation: ${suggestions.length} suggestions`);
751
820
  }
752
821
 
753
822
  // Store Level 1 suggestions
754
823
  updateProgress('Storing Level 1 suggestions in database');
755
824
  await this.storeSuggestions(prId, runId, suggestions, 1, changedFiles);
756
- logger.success(`Level 1 complete: ${suggestions.length} suggestions`);
825
+ logger.success(`${lp}Level 1 complete: ${suggestions.length} suggestions`);
757
826
 
758
827
  // Report completion to progress callback
759
828
  if (progressCallback) {
@@ -772,7 +841,7 @@ Or simply ignore any changes to files matching these patterns in your analysis.
772
841
  } catch (error) {
773
842
  // Don't log cancellation as an error - it's expected user behavior
774
843
  if (!error.isCancellation) {
775
- logger.error(`Level 1 analysis failed: ${error.message}`);
844
+ logger.error(`${lp}Level 1 analysis failed: ${error.message}`);
776
845
  }
777
846
 
778
847
  // Report failure to progress callback (unless cancelled)
@@ -1684,8 +1753,9 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1684
1753
  * @returns {Promise<Object>} Analysis results
1685
1754
  */
1686
1755
  async analyzeLevel2Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns = [], progressCallback = null, customInstructions = null, changedFiles = null, options = {}) {
1687
- const { analysisId, tier = 'balanced' } = options;
1688
- logger.info('[Level 2] Analysis Starting');
1756
+ const { analysisId, tier = 'balanced', timeout = 600000, logPrefix: lp = '', reviewerNum } = options;
1757
+ const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} L2]` : '';
1758
+ logger.info(`${lp}[Level 2] Analysis Starting`);
1689
1759
 
1690
1760
  try {
1691
1761
  // Check if analysis was cancelled before starting
@@ -1697,7 +1767,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1697
1767
  const aiProvider = createProvider(this.provider, this.model);
1698
1768
 
1699
1769
  const updateProgress = (step) => {
1700
- const progress = `[Level 2] ${step}...`;
1770
+ const progress = `${lp}[Level 2] ${step}...`;
1701
1771
 
1702
1772
  if (progressCallback) {
1703
1773
  progressCallback({
@@ -1711,7 +1781,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1711
1781
 
1712
1782
  // Get list of changed files for grounding (use provided list for local mode, or compute for PR mode)
1713
1783
  const validFiles = changedFiles || await this.getChangedFilesList(worktreePath, prMetadata);
1714
- logger.info(`[Level 2] Changed files for grounding: ${validFiles.length} files`);
1784
+ logger.info(`${lp}[Level 2] Changed files for grounding: ${validFiles.length} files`);
1715
1785
 
1716
1786
  // Build the Level 2 prompt
1717
1787
  updateProgress('Building prompt for AI to analyze file context');
@@ -1721,10 +1791,11 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1721
1791
  updateProgress('Running AI to analyze files in context');
1722
1792
  const response = await aiProvider.execute(prompt, {
1723
1793
  cwd: worktreePath,
1724
- timeout: 600000, // 10 minutes for Level 2
1794
+ timeout,
1725
1795
  level: 2,
1726
1796
  analysisId,
1727
1797
  registerProcess,
1798
+ logPrefix: adapterLogPrefix,
1728
1799
  onStreamEvent: progressCallback ? (event) => {
1729
1800
  progressCallback({ level: 2, status: 'running', streamEvent: event });
1730
1801
  } : undefined
@@ -1733,16 +1804,16 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1733
1804
  // Parse and validate the response
1734
1805
  updateProgress('Processing AI results');
1735
1806
  let suggestions = this.parseResponse(response, 2);
1736
- logger.success(`Parsed ${suggestions.length} valid Level 2 suggestions`);
1807
+ logger.success(`${lp}Parsed ${suggestions.length} valid Level 2 suggestions`);
1737
1808
 
1738
1809
  // Validate suggestion file paths against changed files
1739
1810
  suggestions = this.validateSuggestionFilePaths(suggestions, validFiles);
1740
- logger.success(`After path validation: ${suggestions.length} suggestions`);
1811
+ logger.success(`${lp}After path validation: ${suggestions.length} suggestions`);
1741
1812
 
1742
1813
  // Store Level 2 suggestions
1743
1814
  updateProgress('Storing Level 2 suggestions in database');
1744
1815
  await this.storeSuggestions(prId, runId, suggestions, 2, validFiles);
1745
- logger.success(`Level 2 complete: ${suggestions.length} suggestions`);
1816
+ logger.success(`${lp}Level 2 complete: ${suggestions.length} suggestions`);
1746
1817
 
1747
1818
  // Report completion to progress callback
1748
1819
  if (progressCallback) {
@@ -1761,7 +1832,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1761
1832
  } catch (error) {
1762
1833
  // Don't log cancellation as an error - it's expected user behavior
1763
1834
  if (!error.isCancellation) {
1764
- logger.error(`Level 2 analysis failed: ${error.message}`);
1835
+ logger.error(`${lp}Level 2 analysis failed: ${error.message}`);
1765
1836
  }
1766
1837
 
1767
1838
  // Report failure to progress callback (unless cancelled)
@@ -1792,8 +1863,9 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1792
1863
  * @returns {Promise<Object>} Analysis results
1793
1864
  */
1794
1865
  async analyzeLevel3Isolated(prId, runId, worktreePath, prMetadata, generatedPatterns = [], progressCallback = null, customInstructions = null, changedFiles = null, options = {}) {
1795
- const { analysisId, tier = 'balanced' } = options;
1796
- logger.info('[Level 3] Analysis Starting');
1866
+ const { analysisId, tier = 'balanced', timeout = 600000, logPrefix: lp = '', reviewerNum } = options;
1867
+ const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} L3]` : '';
1868
+ logger.info(`${lp}[Level 3] Analysis Starting`);
1797
1869
 
1798
1870
  try {
1799
1871
  // Check if analysis was cancelled before starting
@@ -1805,7 +1877,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1805
1877
  const aiProvider = createProvider(this.provider, this.model);
1806
1878
 
1807
1879
  const updateProgress = (step) => {
1808
- const progress = `[Level 3] ${step}...`;
1880
+ const progress = `${lp}[Level 3] ${step}...`;
1809
1881
 
1810
1882
  if (progressCallback) {
1811
1883
  progressCallback({
@@ -1823,7 +1895,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1823
1895
 
1824
1896
  // Get list of changed files for grounding (use provided list for local mode, or compute for PR mode)
1825
1897
  const validFiles = changedFiles || await this.getChangedFilesList(worktreePath, prMetadata);
1826
- logger.info(`[Level 3] Changed files for grounding: ${validFiles.length} files`);
1898
+ logger.info(`${lp}[Level 3] Changed files for grounding: ${validFiles.length} files`);
1827
1899
 
1828
1900
  // Build the Level 3 prompt with test context
1829
1901
  updateProgress('Building prompt for AI to analyze codebase impact');
@@ -1833,10 +1905,11 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1833
1905
  updateProgress('Running AI to analyze codebase-wide implications');
1834
1906
  const response = await aiProvider.execute(prompt, {
1835
1907
  cwd: worktreePath,
1836
- timeout: 600000, // 10 minutes for Level 3
1908
+ timeout,
1837
1909
  level: 3,
1838
1910
  analysisId,
1839
1911
  registerProcess,
1912
+ logPrefix: adapterLogPrefix,
1840
1913
  onStreamEvent: progressCallback ? (event) => {
1841
1914
  progressCallback({ level: 3, status: 'running', streamEvent: event });
1842
1915
  } : undefined
@@ -1845,16 +1918,16 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1845
1918
  // Parse and validate the response
1846
1919
  updateProgress('Processing codebase context results');
1847
1920
  let suggestions = this.parseResponse(response, 3);
1848
- logger.success(`Parsed ${suggestions.length} valid Level 3 suggestions`);
1921
+ logger.success(`${lp}Parsed ${suggestions.length} valid Level 3 suggestions`);
1849
1922
 
1850
1923
  // Validate suggestion file paths against changed files
1851
1924
  suggestions = this.validateSuggestionFilePaths(suggestions, validFiles);
1852
- logger.success(`After path validation: ${suggestions.length} suggestions`);
1925
+ logger.success(`${lp}After path validation: ${suggestions.length} suggestions`);
1853
1926
 
1854
1927
  // Store Level 3 suggestions
1855
1928
  updateProgress('Storing Level 3 suggestions in database');
1856
1929
  await this.storeSuggestions(prId, runId, suggestions, 3, validFiles);
1857
- logger.success(`Level 3 complete: ${suggestions.length} suggestions`);
1930
+ logger.success(`${lp}Level 3 complete: ${suggestions.length} suggestions`);
1858
1931
 
1859
1932
  // Report completion to progress callback
1860
1933
  if (progressCallback) {
@@ -1873,7 +1946,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1873
1946
  } catch (error) {
1874
1947
  // Don't log cancellation as an error - it's expected user behavior
1875
1948
  if (!error.isCancellation) {
1876
- logger.error(`Level 3 analysis failed: ${error.message}`);
1949
+ logger.error(`${lp}Level 3 analysis failed: ${error.message}`);
1877
1950
  }
1878
1951
 
1879
1952
  // Report failure to progress callback (unless cancelled)
@@ -2350,14 +2423,17 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2350
2423
  * @returns {Promise<Array>} Curated suggestions array
2351
2424
  */
2352
2425
  async orchestrateWithAI(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, options = {}) {
2353
- const { analysisId, tier = 'balanced', progressCallback } = options;
2354
- logger.section('[Orchestration] AI Orchestration Starting');
2426
+ const { analysisId, tier = 'balanced', progressCallback, providerOverride, modelOverride, timeout = 600000, logPrefix: lp = '', reviewerNum } = options;
2427
+ // Build adapter-level log prefix: when reviewerNum is set (council mode),
2428
+ // use compact format like [R1 Orch] so concurrent reviewers are disambiguated
2429
+ const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} Orch]` : '';
2430
+ logger.section(`${lp}[Consolidation] Cross-Level Consolidation Starting`);
2355
2431
 
2356
2432
  const totalSuggestions = (allSuggestions.level1?.length || 0) +
2357
2433
  (allSuggestions.level2?.length || 0) +
2358
2434
  (allSuggestions.level3?.length || 0);
2359
2435
 
2360
- logger.info(`[Orchestration] Orchestrating ${totalSuggestions} total suggestions across all levels`);
2436
+ logger.info(`${lp}[Consolidation] Consolidating ${totalSuggestions} total suggestions across all levels`);
2361
2437
 
2362
2438
  try {
2363
2439
  // Check if analysis was cancelled before starting
@@ -2365,19 +2441,21 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2365
2441
  throw new CancellationError('Analysis was cancelled');
2366
2442
  }
2367
2443
 
2368
- // Create provider instance for orchestration
2369
- const aiProvider = createProvider(this.provider, this.model);
2444
+ // Create provider instance for consolidation (use overrides if provided)
2445
+ const aiProvider = createProvider(providerOverride || this.provider, modelOverride || this.model);
2370
2446
 
2371
- // Build the orchestration prompt
2372
- const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier);
2447
+ // Build the consolidation prompt
2448
+ const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp);
2373
2449
 
2374
- // Execute Claude CLI for orchestration
2375
- logger.info('[Orchestration] Running AI orchestration to curate and merge suggestions...');
2450
+ // Execute AI for cross-level consolidation
2451
+ logger.info(`${lp}[Consolidation] Running AI consolidation to curate and merge suggestions...`);
2376
2452
  const response = await aiProvider.execute(prompt, {
2377
- timeout: 600000, // 10 minutes for orchestration
2453
+ cwd: worktreePath,
2454
+ timeout,
2378
2455
  level: 'orchestration',
2379
2456
  analysisId,
2380
2457
  registerProcess,
2458
+ logPrefix: adapterLogPrefix,
2381
2459
  onStreamEvent: progressCallback ? (event) => {
2382
2460
  progressCallback({ level: 'orchestration', status: 'running', streamEvent: event });
2383
2461
  } : undefined
@@ -2386,25 +2464,25 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2386
2464
  // Parse the orchestrated response
2387
2465
  const orchestratedSuggestions = this.parseResponse(response, 'orchestration');
2388
2466
 
2389
- // Debug: If orchestration returned 0 suggestions but there was input, log for investigation
2467
+ // Debug: If consolidation returned 0 suggestions but there was input, log for investigation
2390
2468
  const inputLevel1Count = allSuggestions.level1?.length || 0;
2391
2469
  const inputLevel2Count = allSuggestions.level2?.length || 0;
2392
2470
  const inputLevel3Count = allSuggestions.level3?.length || 0;
2393
2471
  const hadInputSuggestions = inputLevel1Count > 0 || inputLevel2Count > 0 || inputLevel3Count > 0;
2394
2472
 
2395
2473
  if (orchestratedSuggestions.length === 0 && hadInputSuggestions) {
2396
- logger.warn('[Orchestration] WARNING: Orchestration returned 0 suggestions despite input');
2397
- logger.warn(`[Orchestration] Input suggestion counts: Level1=${inputLevel1Count}, Level2=${inputLevel2Count}, Level3=${inputLevel3Count}`);
2474
+ logger.warn(`${lp}[Consolidation] WARNING: Consolidation returned 0 suggestions despite input`);
2475
+ logger.warn(`${lp}[Consolidation] Input suggestion counts: Level1=${inputLevel1Count}, Level2=${inputLevel2Count}, Level3=${inputLevel3Count}`);
2398
2476
  if (response.raw) {
2399
- logger.warn('[Orchestration] Raw AI response for debugging:');
2400
- logger.warn('--- BEGIN RAW ORCHESTRATION RESPONSE ---');
2477
+ logger.warn(`${lp}[Consolidation] Raw AI response for debugging:`);
2478
+ logger.warn('--- BEGIN RAW CONSOLIDATION RESPONSE ---');
2401
2479
  logger.warn(response.raw);
2402
- logger.warn('--- END RAW ORCHESTRATION RESPONSE ---');
2480
+ logger.warn('--- END RAW CONSOLIDATION RESPONSE ---');
2403
2481
  } else if (response.suggestions) {
2404
- logger.warn('[Orchestration] Response had suggestions array but parsing returned 0. Response.suggestions:');
2482
+ logger.warn(`${lp}[Consolidation] Response had suggestions array but parsing returned 0. Response.suggestions:`);
2405
2483
  logger.warn(JSON.stringify(response.suggestions, null, 2));
2406
2484
  } else {
2407
- logger.warn('[Orchestration] Response object keys: ' + Object.keys(response).join(', '));
2485
+ logger.warn(`${lp}[Consolidation] Response object keys: ` + Object.keys(response).join(', '));
2408
2486
  }
2409
2487
  }
2410
2488
 
@@ -2419,7 +2497,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2419
2497
  }
2420
2498
  }
2421
2499
 
2422
- logger.success(`[Orchestration] AI orchestration complete: ${orchestratedSuggestions.length} curated suggestions`);
2500
+ logger.success(`${lp}[Consolidation] Cross-level consolidation complete: ${orchestratedSuggestions.length} curated suggestions`);
2423
2501
 
2424
2502
  return {
2425
2503
  suggestions: orchestratedSuggestions,
@@ -2427,8 +2505,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2427
2505
  };
2428
2506
 
2429
2507
  } catch (error) {
2430
- logger.warn(`AI orchestration failed: ${error.message}`);
2431
- logger.warn('Falling back to storing all original suggestions');
2508
+ logger.warn(`${lp}[Consolidation] Cross-level consolidation failed: ${error.message}`);
2509
+ logger.warn(`${lp}[Consolidation] Falling back to storing all original suggestions`);
2432
2510
 
2433
2511
  // Fallback: combine all suggestions with level labels
2434
2512
  const fallbackSuggestions = [];
@@ -2453,7 +2531,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2453
2531
 
2454
2532
  return {
2455
2533
  suggestions: fallbackSuggestions,
2456
- summary: `Analysis complete (orchestration failed): ${fallbackSuggestions.length} suggestions from all analysis levels`
2534
+ summary: `Analysis complete (consolidation failed): ${fallbackSuggestions.length} suggestions from all analysis levels`
2457
2535
  };
2458
2536
  }
2459
2537
  }
@@ -2477,17 +2555,18 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2477
2555
  * @param {string} customInstructions - Optional custom instructions to guide prioritization/filtering
2478
2556
  * @param {string} worktreePath - Path to the git worktree
2479
2557
  * @param {string} tier - Capability tier: 'fast', 'balanced', or 'thorough' (default: 'balanced')
2558
+ * @param {string} logPrefix - Optional log prefix for reviewer identification in council mode
2480
2559
  * @returns {string} Orchestration prompt
2481
2560
  */
2482
- buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, tier = 'balanced') {
2483
- logger.debug(`[Orchestration] Building prompt with tier: ${tier}`);
2561
+ buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, tier = 'balanced', logPrefix = '') {
2562
+ logger.debug(`${logPrefix}[Consolidation] Building consolidation prompt with tier: ${tier}`);
2484
2563
  const promptBuilder = getPromptBuilder('orchestration', tier, this.provider);
2485
2564
 
2486
2565
  // Build context for the tagged prompt system
2487
2566
  const isLocal = prMetadata.reviewType === 'local';
2488
2567
  const reviewDescription = isLocal
2489
- ? `local changes (review #${prMetadata.number || 'local'})`
2490
- : `pull request #${prMetadata.number}`;
2568
+ ? `local changes (review #${prMetadata.pr_number || 'local'})`
2569
+ : `pull request #${prMetadata.pr_number}`;
2491
2570
 
2492
2571
  const context = {
2493
2572
  reviewIntro: `You are orchestrating AI-powered code review suggestions for ${reviewDescription}.`,
@@ -2505,6 +2584,1078 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2505
2584
  }
2506
2585
 
2507
2586
 
2587
+ /**
2588
+ * Run a reviewer-centric council analysis. Each reviewer independently runs all enabled
2589
+ * levels, producing a complete review. Results are then consolidated cross-voice.
2590
+ *
2591
+ * @param {Object} reviewContext - Context for the review
2592
+ * @param {number} reviewContext.reviewId - Review ID
2593
+ * @param {string} reviewContext.worktreePath - Path to the git worktree
2594
+ * @param {Object} reviewContext.prMetadata - PR/review metadata
2595
+ * @param {Array<string>} [reviewContext.changedFiles] - Changed files list
2596
+ * @param {Object} reviewContext.instructions - Instructions { repoInstructions, requestInstructions }
2597
+ * @param {Object} councilConfig - Reviewer-centric council configuration
2598
+ * @param {Array<Object>} councilConfig.voices - Reviewer configurations (provider, model, tier, customInstructions, timeout)
2599
+ * @param {Object} councilConfig.levels - Which levels to enable, e.g. {1: true, 2: true, 3: false}
2600
+ * @param {Object} [councilConfig.consolidation] - Consolidation provider/model/tier
2601
+ * @param {Object} options - Additional options
2602
+ * @param {string} options.analysisId - Analysis ID for process tracking
2603
+ * @param {string} [options.runId] - Pre-generated parent run ID
2604
+ * @param {Function} [options.progressCallback] - Progress callback
2605
+ * @returns {Promise<Object>} Analysis results { runId, suggestions, summary }
2606
+ */
2607
+ async runReviewerCentricCouncil(reviewContext, councilConfig, options = {}) {
2608
+ const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
2609
+ const { analysisId, progressCallback } = options;
2610
+ const parentRunId = options.runId || uuidv4();
2611
+
2612
+ logger.section('Review Council Analysis Starting (Reviewer-Centric)');
2613
+ logger.info(`Review ID: ${reviewId}, Parent Run ID: ${parentRunId}`);
2614
+
2615
+ // Normalize config: detect levels-format from frontend and extract voices + boolean levels
2616
+ if (!councilConfig.voices && councilConfig.levels) {
2617
+ // Frontend sends levels-format: { levels: { "1": { enabled, voices }, ... }, orchestration }
2618
+ // Convert to voice-centric format expected by this method
2619
+ const normalizedVoices = [];
2620
+ const seenVoices = new Set();
2621
+ const normalizedLevels = {};
2622
+ for (const [key, levelConfig] of Object.entries(councilConfig.levels)) {
2623
+ if (typeof levelConfig === 'object' && levelConfig !== null) {
2624
+ normalizedLevels[key] = levelConfig.enabled !== false;
2625
+ if (levelConfig.enabled !== false && Array.isArray(levelConfig.voices)) {
2626
+ for (const v of levelConfig.voices) {
2627
+ const voiceSig = `${v.provider}|${v.model}|${v.tier || 'balanced'}|${v.customInstructions || ''}`;
2628
+ if (!seenVoices.has(voiceSig)) {
2629
+ seenVoices.add(voiceSig);
2630
+ normalizedVoices.push(v);
2631
+ }
2632
+ }
2633
+ }
2634
+ } else {
2635
+ // Already boolean format
2636
+ normalizedLevels[key] = levelConfig !== false;
2637
+ }
2638
+ }
2639
+ councilConfig = { ...councilConfig, voices: normalizedVoices, levels: normalizedLevels };
2640
+ logger.info(`[ReviewerCouncil] Normalized levels-format config: ${normalizedVoices.length} unique reviewer(s), levels: ${JSON.stringify(normalizedLevels)}`);
2641
+ }
2642
+
2643
+ // Merge instructions
2644
+ let mergedInstructions = null;
2645
+ if (instructions && typeof instructions === 'object') {
2646
+ mergedInstructions = mergeInstructions(instructions.repoInstructions, instructions.requestInstructions);
2647
+ }
2648
+
2649
+ // Resolve enabledLevels from council config
2650
+ const enabledLevels = {};
2651
+ for (const key of ['1', '2', '3']) {
2652
+ const levelVal = councilConfig.levels?.[key];
2653
+ enabledLevels[parseInt(key)] = typeof levelVal === 'object' ? levelVal?.enabled !== false : levelVal !== false;
2654
+ }
2655
+ const enabledLevelsList = Object.entries(enabledLevels).filter(([, v]) => v).map(([k]) => parseInt(k));
2656
+ logger.info(`[ReviewerCouncil] Enabled levels: ${enabledLevelsList.join(', ')}`);
2657
+
2658
+ // Load generated file patterns
2659
+ const generatedPatterns = await this.loadGeneratedFilePatterns(worktreePath);
2660
+
2661
+ // Get changed files for validation
2662
+ const validFiles = changedFiles || await this.getChangedFilesList(worktreePath, prMetadata);
2663
+ logger.info(`[ReviewerCouncil] Using ${validFiles.length} changed files for path validation`);
2664
+
2665
+ // Build file line count map for validation
2666
+ const fileLineCountMap = await buildFileLineCountMap(worktreePath, validFiles);
2667
+
2668
+ const headSha = prMetadata?.head_sha || null;
2669
+ const analysisRunRepo = new AnalysisRunRepository(this.db);
2670
+
2671
+ // Create parent analysis run only if caller didn't already create it
2672
+ // (when runId is passed via options, the route handler has already inserted the record)
2673
+ if (!options.runId) {
2674
+ try {
2675
+ await analysisRunRepo.create({
2676
+ id: parentRunId,
2677
+ reviewId,
2678
+ provider: 'council',
2679
+ model: 'voice-centric',
2680
+ customInstructions: mergedInstructions,
2681
+ repoInstructions: instructions?.repoInstructions || null,
2682
+ requestInstructions: instructions?.requestInstructions || null,
2683
+ headSha,
2684
+ configType: 'council',
2685
+ levelsConfig: enabledLevels
2686
+ });
2687
+ logger.info(`[ReviewerCouncil] Created parent analysis_run: ${parentRunId}`);
2688
+ } catch (err) {
2689
+ logger.warn(`[ReviewerCouncil] Failed to create parent run record: ${err.message}`);
2690
+ }
2691
+ }
2692
+
2693
+ const voices = councilConfig.voices || [];
2694
+ if (voices.length === 0) {
2695
+ throw new Error('No voices configured in council');
2696
+ }
2697
+
2698
+ // Single voice: skip child run entirely, run analysis directly on parent run
2699
+ if (voices.length === 1) {
2700
+ const voice = voices[0];
2701
+ const { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
2702
+ buildVoiceContext(voice, 0, instructions, progressCallback, this.db);
2703
+ logger.info(`[ReviewerCouncil] Single reviewer (${reviewerLabel}) — running directly on parent run, no child run`);
2704
+
2705
+ // Report voice-centric progress structure
2706
+ if (progressCallback) {
2707
+ progressCallback({
2708
+ voiceCentric: true,
2709
+ level: 'voice-init',
2710
+ status: 'running',
2711
+ voices: { [voiceKey]: { status: 'pending', provider: voice.provider, model: voice.model, tier: voiceTier } }
2712
+ });
2713
+ }
2714
+
2715
+ // analyzeAllLevels handles validation, storage, and run status updates internally
2716
+ // (including error/cancellation status), so no error handling needed here
2717
+ const result = await voiceAnalyzer.analyzeAllLevels(
2718
+ reviewId,
2719
+ worktreePath,
2720
+ prMetadata,
2721
+ voiceProgressCallback,
2722
+ { repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
2723
+ changedFiles,
2724
+ {
2725
+ analysisId,
2726
+ runId: parentRunId,
2727
+ skipRunCreation: true,
2728
+ enabledLevels,
2729
+ tier: voiceTier,
2730
+ timeout: voiceTimeout,
2731
+ logPrefix: `[${reviewerLabel}] `,
2732
+ reviewerNum: 1
2733
+ }
2734
+ );
2735
+
2736
+ return {
2737
+ runId: parentRunId,
2738
+ suggestions: result.suggestions,
2739
+ summary: result.summary || `Review council complete: ${result.suggestions?.length || 0} suggestions`
2740
+ };
2741
+ }
2742
+
2743
+ logger.info(`[ReviewerCouncil] Launching ${voices.length} reviewer(s), each running levels: ${enabledLevelsList.join(', ')}`);
2744
+
2745
+ // Report voice-centric progress structure
2746
+ if (progressCallback) {
2747
+ progressCallback({
2748
+ voiceCentric: true,
2749
+ level: 'voice-init',
2750
+ status: 'running',
2751
+ voices: Object.fromEntries(voices.map((v, idx) => {
2752
+ const voiceKey = `${v.provider}-${v.model}${idx > 0 ? `-${idx}` : ''}`;
2753
+ return [voiceKey, {
2754
+ status: 'pending',
2755
+ provider: v.provider,
2756
+ model: v.model,
2757
+ tier: v.tier || 'balanced'
2758
+ }];
2759
+ }))
2760
+ });
2761
+ }
2762
+
2763
+ // For each voice, create a child run and launch analyzeAllLevels
2764
+ const voicePromises = voices.map(async (voice, idx) => {
2765
+ const { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
2766
+ buildVoiceContext(voice, idx, instructions, progressCallback, this.db);
2767
+ const childRunId = uuidv4();
2768
+
2769
+ // Create child analysis run record
2770
+ try {
2771
+ await analysisRunRepo.create({
2772
+ id: childRunId,
2773
+ reviewId,
2774
+ provider: voice.provider,
2775
+ model: voice.model,
2776
+ customInstructions: mergedInstructions,
2777
+ repoInstructions: instructions?.repoInstructions || null,
2778
+ requestInstructions: instructions?.requestInstructions || null,
2779
+ headSha,
2780
+ parentRunId,
2781
+ configType: 'council',
2782
+ levelsConfig: enabledLevels
2783
+ });
2784
+ logger.info(`[ReviewerCouncil] Created child run ${childRunId} for ${reviewerLabel}`);
2785
+ } catch (err) {
2786
+ logger.warn(`[ReviewerCouncil] Failed to create child run record: ${err.message}`);
2787
+ }
2788
+
2789
+ try {
2790
+ const result = await voiceAnalyzer.analyzeAllLevels(
2791
+ reviewId,
2792
+ worktreePath,
2793
+ prMetadata,
2794
+ voiceProgressCallback,
2795
+ { repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
2796
+ changedFiles,
2797
+ {
2798
+ analysisId,
2799
+ runId: childRunId,
2800
+ skipRunCreation: true,
2801
+ enabledLevels,
2802
+ tier: voiceTier,
2803
+ timeout: voiceTimeout,
2804
+ logPrefix: `[${reviewerLabel}] `,
2805
+ reviewerNum: idx + 1
2806
+ }
2807
+ );
2808
+
2809
+ // Update child run to completed
2810
+ try {
2811
+ await analysisRunRepo.update(childRunId, {
2812
+ status: 'completed',
2813
+ summary: result.summary,
2814
+ totalSuggestions: result.suggestions?.length || 0,
2815
+ filesAnalyzed: validFiles.length
2816
+ });
2817
+ } catch (err) {
2818
+ logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
2819
+ }
2820
+
2821
+ return { voiceKey, reviewerLabel, childRunId, result, provider: voice.provider, model: voice.model };
2822
+ } catch (error) {
2823
+ // Update child run to failed/cancelled
2824
+ try {
2825
+ await analysisRunRepo.update(childRunId, {
2826
+ status: error.isCancellation ? 'cancelled' : 'failed'
2827
+ });
2828
+ } catch (err) {
2829
+ logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
2830
+ }
2831
+ throw error;
2832
+ }
2833
+ });
2834
+
2835
+ const voiceResults = await Promise.allSettled(voicePromises);
2836
+
2837
+ // Check for cancellation
2838
+ const hasCancellation = voiceResults.some(
2839
+ r => r.status === 'rejected' && r.reason?.isCancellation
2840
+ );
2841
+ if (hasCancellation) {
2842
+ try {
2843
+ await analysisRunRepo.update(parentRunId, { status: 'cancelled' });
2844
+ } catch (err) {
2845
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
2846
+ }
2847
+ throw new CancellationError('Voice-centric council analysis cancelled by user');
2848
+ }
2849
+
2850
+ // Collect successful results
2851
+ const successfulVoices = [];
2852
+ const allVoiceSuggestions = [];
2853
+ const voiceSummaries = [];
2854
+
2855
+ for (let i = 0; i < voiceResults.length; i++) {
2856
+ const settled = voiceResults[i];
2857
+ if (settled.status === 'fulfilled' && settled.value.result?.suggestions) {
2858
+ successfulVoices.push(settled.value);
2859
+ allVoiceSuggestions.push(...settled.value.result.suggestions);
2860
+ if (settled.value.result.summary) {
2861
+ voiceSummaries.push(settled.value.result.summary);
2862
+ }
2863
+ logger.success(`[ReviewerCouncil] ${settled.value.reviewerLabel}: ${settled.value.result.suggestions.length} suggestions`);
2864
+ } else {
2865
+ const reason = settled.status === 'rejected' ? settled.reason?.message : 'No suggestions';
2866
+ const label = voices[i] ? buildReviewerLabel(i, voices[i]) : `Reviewer ${i + 1}`;
2867
+ logger.warn(`[ReviewerCouncil] ${label} failed: ${reason}`);
2868
+ }
2869
+ }
2870
+
2871
+ if (successfulVoices.length === 0) {
2872
+ try {
2873
+ await analysisRunRepo.update(parentRunId, { status: 'failed' });
2874
+ } catch (err) {
2875
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
2876
+ }
2877
+ throw new Error('All council voices failed');
2878
+ }
2879
+
2880
+ // Single voice: use directly, no consolidation
2881
+ if (successfulVoices.length === 1) {
2882
+ logger.info('[ReviewerCouncil] Single reviewer result — skipping consolidation');
2883
+ const singleResult = successfulVoices[0].result;
2884
+
2885
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
2886
+ singleResult.suggestions, fileLineCountMap, validFiles
2887
+ );
2888
+ await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
2889
+
2890
+ try {
2891
+ await analysisRunRepo.update(parentRunId, {
2892
+ status: 'completed',
2893
+ summary: singleResult.summary,
2894
+ totalSuggestions: finalSuggestions.length,
2895
+ filesAnalyzed: validFiles.length
2896
+ });
2897
+ } catch (err) {
2898
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
2899
+ }
2900
+
2901
+ return {
2902
+ runId: parentRunId,
2903
+ suggestions: finalSuggestions,
2904
+ summary: singleResult.summary || `Review council complete: ${finalSuggestions.length} suggestions`
2905
+ };
2906
+ }
2907
+
2908
+ // Multiple voices: cross-voice consolidation
2909
+ const totalSuggestionCount = allVoiceSuggestions.length;
2910
+
2911
+ // If below consolidation threshold, skip
2912
+ if (totalSuggestionCount < COUNCIL_CONSOLIDATION_THRESHOLD) {
2913
+ logger.info(`[ReviewerCouncil] ${totalSuggestionCount} total suggestions below threshold (${COUNCIL_CONSOLIDATION_THRESHOLD}) — skipping consolidation`);
2914
+ const summary = voiceSummaries.length > 1 ? voiceSummaries.join('\n\n') : voiceSummaries[0];
2915
+
2916
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
2917
+ allVoiceSuggestions, fileLineCountMap, validFiles
2918
+ );
2919
+ await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
2920
+
2921
+ try {
2922
+ await analysisRunRepo.update(parentRunId, {
2923
+ status: 'completed',
2924
+ summary: summary || `Review council complete: ${finalSuggestions.length} suggestions`,
2925
+ totalSuggestions: finalSuggestions.length,
2926
+ filesAnalyzed: validFiles.length
2927
+ });
2928
+ } catch (err) {
2929
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
2930
+ }
2931
+
2932
+ return {
2933
+ runId: parentRunId,
2934
+ suggestions: finalSuggestions,
2935
+ summary: summary || `Reviewer-centric council complete: ${finalSuggestions.length} suggestions`
2936
+ };
2937
+ }
2938
+
2939
+ // Run cross-reviewer consolidation
2940
+ logger.info(`[ReviewerCouncil] Starting cross-reviewer consolidation of ${successfulVoices.length} reviewers (${totalSuggestionCount} total suggestions)`);
2941
+
2942
+ if (progressCallback) {
2943
+ progressCallback({
2944
+ status: 'running',
2945
+ progress: 'Consolidating results across reviewers...',
2946
+ level: 'orchestration',
2947
+ voiceCentric: true
2948
+ });
2949
+ }
2950
+
2951
+ const consolConfig = councilConfig.consolidation || this._defaultConsolidation(councilConfig);
2952
+ const consolProvider = consolConfig.provider;
2953
+ const consolModel = consolConfig.model;
2954
+ const consolTier = consolConfig.tier || 'balanced';
2955
+
2956
+ // Merge consolidation-specific custom instructions with global instructions
2957
+ let consolInstructions = mergedInstructions;
2958
+ if (consolConfig.customInstructions) {
2959
+ consolInstructions = consolInstructions
2960
+ ? `${consolInstructions}\n\n## Orchestration-Specific Instructions\n${consolConfig.customInstructions}`
2961
+ : consolConfig.customInstructions;
2962
+ }
2963
+
2964
+ try {
2965
+ // Build per-voice review summaries for the consolidation prompt
2966
+ const voiceReviews = successfulVoices.map(v => ({
2967
+ voiceKey: v.voiceKey,
2968
+ provider: v.provider,
2969
+ model: v.model,
2970
+ suggestionCount: v.result.suggestions.length + (v.result.fileLevelSuggestions?.length || 0),
2971
+ suggestions: v.result.suggestions,
2972
+ fileLevelSuggestions: v.result.fileLevelSuggestions || [],
2973
+ summary: v.result.summary
2974
+ }));
2975
+
2976
+ const consolidated = await this._crossVoiceConsolidate(
2977
+ voiceReviews, prMetadata, consolInstructions, worktreePath,
2978
+ { provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback }
2979
+ );
2980
+
2981
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
2982
+ consolidated.suggestions, fileLineCountMap, validFiles
2983
+ );
2984
+
2985
+ await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
2986
+
2987
+ const summary = consolidated.summary || `Review council complete: ${finalSuggestions.length} suggestions from ${successfulVoices.length} reviewers`;
2988
+
2989
+ try {
2990
+ await analysisRunRepo.update(parentRunId, {
2991
+ status: 'completed',
2992
+ summary,
2993
+ totalSuggestions: finalSuggestions.length,
2994
+ filesAnalyzed: validFiles.length
2995
+ });
2996
+ } catch (err) {
2997
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
2998
+ }
2999
+
3000
+ logger.success(`[ReviewerCouncil] Analysis complete: ${finalSuggestions.length} consolidated suggestions`);
3001
+
3002
+ return {
3003
+ runId: parentRunId,
3004
+ suggestions: finalSuggestions,
3005
+ summary
3006
+ };
3007
+ } catch (error) {
3008
+ logger.error(`[ReviewerCouncil] Cross-reviewer consolidation failed: ${error.message}`);
3009
+
3010
+ // Fallback: use all voice suggestions combined
3011
+ const fallbackSuggestions = this.validateAndFinalizeSuggestions(
3012
+ allVoiceSuggestions, fileLineCountMap, validFiles
3013
+ );
3014
+ await this.storeSuggestions(reviewId, parentRunId, fallbackSuggestions, null, validFiles);
3015
+
3016
+ const fallbackSummary = `Review council complete (consolidation failed): ${fallbackSuggestions.length} suggestions`;
3017
+
3018
+ try {
3019
+ await analysisRunRepo.update(parentRunId, {
3020
+ status: 'completed',
3021
+ summary: fallbackSummary,
3022
+ totalSuggestions: fallbackSuggestions.length,
3023
+ filesAnalyzed: validFiles.length
3024
+ });
3025
+ } catch (err) {
3026
+ logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
3027
+ }
3028
+
3029
+ return {
3030
+ runId: parentRunId,
3031
+ suggestions: fallbackSuggestions,
3032
+ summary: fallbackSummary,
3033
+ orchestrationFailed: true
3034
+ };
3035
+ }
3036
+ }
3037
+
3038
+ /**
3039
+ * Run a council analysis with multiple voices across configurable levels
3040
+ *
3041
+ * @param {Object} reviewContext - Context for the review
3042
+ * @param {number} reviewContext.reviewId - Review ID (from reviews table)
3043
+ * @param {string} reviewContext.worktreePath - Path to the git worktree
3044
+ * @param {Object} reviewContext.prMetadata - PR/review metadata
3045
+ * @param {Array<string>} [reviewContext.changedFiles] - Changed files list
3046
+ * @param {Object} reviewContext.instructions - Instructions object { repoInstructions, requestInstructions }
3047
+ * @param {Object} councilConfig - Council configuration
3048
+ * @param {Object} councilConfig.levels - Level configurations keyed by '1', '2', '3'
3049
+ * @param {Object} [councilConfig.consolidation] - Consolidation provider/model/tier
3050
+ * @param {Object} options - Additional options
3051
+ * @param {string} options.analysisId - Analysis ID for process tracking
3052
+ * @param {string} [options.runId] - Pre-generated run ID
3053
+ * @param {Function} [options.progressCallback] - Progress callback
3054
+ * @returns {Promise<Object>} Analysis results
3055
+ */
3056
+ async runCouncilAnalysis(reviewContext, councilConfig, options = {}) {
3057
+ const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
3058
+ const { analysisId, progressCallback } = options;
3059
+ const runId = options.runId || uuidv4();
3060
+
3061
+ logger.section('Review Council Analysis Starting');
3062
+ logger.info(`Review ID: ${reviewId}, Run ID: ${runId}`);
3063
+
3064
+ // Merge instructions
3065
+ const { mergeInstructions } = require('../utils/instructions');
3066
+ let mergedInstructions = null;
3067
+ if (instructions && typeof instructions === 'object') {
3068
+ mergedInstructions = mergeInstructions(instructions.repoInstructions, instructions.requestInstructions);
3069
+ }
3070
+
3071
+ // Load generated file patterns
3072
+ const generatedPatterns = await this.loadGeneratedFilePatterns(worktreePath);
3073
+
3074
+ // Get changed files for validation
3075
+ const validFiles = changedFiles || await this.getChangedFilesList(worktreePath, prMetadata);
3076
+ logger.info(`[Council] Using ${validFiles.length} changed files for path validation`);
3077
+
3078
+ // Build file line count map for validation
3079
+ const { buildFileLineCountMap } = require('../utils/line-validation');
3080
+ const fileLineCountMap = await buildFileLineCountMap(worktreePath, validFiles);
3081
+
3082
+ // Collect all voice tasks across enabled levels
3083
+ const voiceTasks = [];
3084
+ const enabledLevels = [];
3085
+
3086
+ for (const [levelKey, levelConfig] of Object.entries(councilConfig.levels)) {
3087
+ if (!levelConfig.enabled) continue;
3088
+ enabledLevels.push(parseInt(levelKey));
3089
+
3090
+ for (const [voiceIdx, voice] of levelConfig.voices.entries()) {
3091
+ const voiceId = `L${levelKey}-${voice.provider}-${voice.model}${voiceIdx > 0 ? `-${voiceIdx}` : ''}`;
3092
+ const reviewerLabel = `L${levelKey} ${buildReviewerLabel(voiceIdx, voice)}`;
3093
+ const reviewerLogPrefix = `[L${levelKey} R${voiceIdx + 1}]`;
3094
+ const tier = voice.tier || 'balanced';
3095
+
3096
+ // Merge per-voice custom instructions with base instructions
3097
+ let voiceInstructions = mergedInstructions;
3098
+ if (voice.customInstructions) {
3099
+ voiceInstructions = voiceInstructions
3100
+ ? `${voiceInstructions}\n\n${voice.customInstructions}`
3101
+ : voice.customInstructions;
3102
+ }
3103
+
3104
+ voiceTasks.push({
3105
+ voiceId,
3106
+ reviewerLabel,
3107
+ reviewerLogPrefix,
3108
+ level: parseInt(levelKey),
3109
+ provider: voice.provider,
3110
+ model: voice.model,
3111
+ tier,
3112
+ timeout: voice.timeout || 600000,
3113
+ customInstructions: voiceInstructions
3114
+ });
3115
+ }
3116
+ }
3117
+
3118
+ if (voiceTasks.length === 0) {
3119
+ throw new Error('No enabled levels with voices in council config');
3120
+ }
3121
+
3122
+ logger.info(`[Council] Launching ${voiceTasks.length} reviewer(s) across levels: ${enabledLevels.join(', ')}`);
3123
+
3124
+ // Execute all voices in parallel
3125
+ const voicePromises = voiceTasks.map(task => {
3126
+ return this._executeCouncilVoice(task, {
3127
+ reviewId, runId, worktreePath, prMetadata,
3128
+ generatedPatterns, validFiles, analysisId, progressCallback
3129
+ });
3130
+ });
3131
+
3132
+ const voiceResults = await Promise.allSettled(voicePromises);
3133
+
3134
+ // Check for cancellation
3135
+ const hasCancellation = voiceResults.some(
3136
+ r => r.status === 'rejected' && r.reason?.isCancellation
3137
+ );
3138
+ if (hasCancellation) {
3139
+ throw new CancellationError('Council analysis cancelled by user');
3140
+ }
3141
+
3142
+ // Collect results per level, including voice summaries
3143
+ const levelSuggestions = {};
3144
+ const voiceSummaries = [];
3145
+ const rawSuggestions = [];
3146
+ let voiceSuccessCount = 0;
3147
+
3148
+ for (let i = 0; i < voiceTasks.length; i++) {
3149
+ const task = voiceTasks[i];
3150
+ const result = voiceResults[i];
3151
+
3152
+ if (result.status === 'fulfilled' && result.value.suggestions) {
3153
+ const suggestions = result.value.suggestions;
3154
+ voiceSuccessCount++;
3155
+
3156
+ // Capture voice summary (voices may produce summaries even with 0 suggestions)
3157
+ if (result.value.summary) {
3158
+ voiceSummaries.push(result.value.summary);
3159
+ }
3160
+
3161
+ // Tag each suggestion with voice_id, level, and mark as raw
3162
+ const taggedSuggestions = suggestions.map(s => ({
3163
+ ...s,
3164
+ voice_id: task.voiceId,
3165
+ level: task.level,
3166
+ is_raw: 1
3167
+ }));
3168
+
3169
+ rawSuggestions.push(...taggedSuggestions);
3170
+
3171
+ if (!levelSuggestions[task.level]) {
3172
+ levelSuggestions[task.level] = [];
3173
+ }
3174
+ levelSuggestions[task.level].push(...suggestions);
3175
+
3176
+ logger.success(`[Council] ${task.reviewerLabel}: ${suggestions.length} suggestions`);
3177
+ } else {
3178
+ const reason = result.status === 'rejected' ? result.reason?.message : 'No suggestions';
3179
+ logger.warn(`[Council] ${task.reviewerLabel} failed: ${reason}`);
3180
+ }
3181
+ }
3182
+
3183
+ if (voiceSuccessCount === 0) {
3184
+ throw new Error('All council voices failed');
3185
+ }
3186
+
3187
+ // Pick the best available voice summary for bypass paths.
3188
+ // When consolidation/orchestration is skipped, we use the first real voice summary
3189
+ // rather than a generic placeholder, so AI-generated summaries aren't lost.
3190
+ const bestVoiceSummary = voiceSummaries.length > 0 ? voiceSummaries[0] : null;
3191
+
3192
+ // Check if we can skip ALL consolidation
3193
+ // Use voiceSuccessCount (not just voices with >0 suggestions) so a single voice
3194
+ // returning 0 suggestions but a summary still takes the fast path
3195
+ const enabledLevelsWithResults = Object.keys(levelSuggestions).map(Number);
3196
+ const successfulVoiceLevels = new Set();
3197
+ for (let i = 0; i < voiceTasks.length; i++) {
3198
+ if (voiceResults[i].status === 'fulfilled' && voiceResults[i].value.suggestions) {
3199
+ successfulVoiceLevels.add(voiceTasks[i].level);
3200
+ }
3201
+ }
3202
+
3203
+ if (voiceSuccessCount === 1 && successfulVoiceLevels.size === 1) {
3204
+ // Single voice, single level — skip all consolidation
3205
+ logger.info('[Council] Single reviewer result — skipping consolidation');
3206
+ const singleLevel = [...successfulVoiceLevels][0];
3207
+ const singleLevelSuggestions = levelSuggestions[singleLevel] || [];
3208
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
3209
+ singleLevelSuggestions, fileLineCountMap, validFiles
3210
+ );
3211
+ await this.storeSuggestions(reviewId, runId, finalSuggestions, null, validFiles);
3212
+
3213
+ return {
3214
+ runId,
3215
+ suggestions: finalSuggestions,
3216
+ summary: bestVoiceSummary || `Council analysis complete: ${finalSuggestions.length} suggestions from single reviewer`
3217
+ };
3218
+ }
3219
+
3220
+ // Check if total suggestion count is below consolidation threshold
3221
+ const totalSuggestionCount = Object.values(levelSuggestions).reduce((sum, arr) => sum + arr.length, 0);
3222
+ if (totalSuggestionCount < COUNCIL_CONSOLIDATION_THRESHOLD) {
3223
+ logger.info(`[Council] ${totalSuggestionCount} total suggestions below threshold (${COUNCIL_CONSOLIDATION_THRESHOLD}) — skipping consolidation`);
3224
+ const allSuggestions = Object.values(levelSuggestions).flat();
3225
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
3226
+ allSuggestions, fileLineCountMap, validFiles
3227
+ );
3228
+ await this.storeSuggestions(reviewId, runId, finalSuggestions, null, validFiles);
3229
+
3230
+ // Prefer voice summaries over generic placeholders. When multiple voices
3231
+ // produced summaries, join them so no insight is lost.
3232
+ const thresholdSummary = voiceSummaries.length > 1
3233
+ ? voiceSummaries.join('\n\n')
3234
+ : bestVoiceSummary;
3235
+
3236
+ return {
3237
+ runId,
3238
+ suggestions: finalSuggestions,
3239
+ summary: thresholdSummary || `Council analysis complete: ${finalSuggestions.length} suggestions (consolidation skipped — below threshold)`
3240
+ };
3241
+ }
3242
+
3243
+ // Store raw per-reviewer suggestions (only when consolidation will occur)
3244
+ logger.info(`[Council] Storing ${rawSuggestions.length} raw reviewer suggestions`);
3245
+ await this._storeCouncilSuggestions(reviewId, runId, rawSuggestions, validFiles);
3246
+
3247
+ // Determine orchestration provider
3248
+ const orchConfig = councilConfig.consolidation || councilConfig.orchestration || this._defaultOrchestration(councilConfig);
3249
+ const orchProvider = orchConfig.provider;
3250
+ const orchModel = orchConfig.model;
3251
+ const orchTier = orchConfig.tier || 'balanced';
3252
+
3253
+ // Merge orchestration-specific custom instructions with global instructions
3254
+ let orchInstructions = mergedInstructions;
3255
+ if (orchConfig.customInstructions) {
3256
+ orchInstructions = orchInstructions
3257
+ ? `${orchInstructions}\n\n## Orchestration-Specific Instructions\n${orchConfig.customInstructions}`
3258
+ : orchConfig.customInstructions;
3259
+ }
3260
+
3261
+ logger.info(`[Council] Consolidation: ${orchProvider}/${orchModel} (${orchTier})`);
3262
+
3263
+ if (progressCallback) {
3264
+ progressCallback({
3265
+ status: 'running',
3266
+ progress: 'Consolidating council results...',
3267
+ level: 'orchestration'
3268
+ });
3269
+ }
3270
+
3271
+ // Pass 1: Intra-level consolidation (for levels with >1 reviewer)
3272
+ const consolidatedPerLevel = {};
3273
+ for (const [levelStr, suggestions] of Object.entries(levelSuggestions)) {
3274
+ const level = parseInt(levelStr);
3275
+ const successfulVoicesForLevel = voiceTasks
3276
+ .map((t, idx) => ({ t, idx }))
3277
+ .filter(({ t, idx }) =>
3278
+ t.level === level &&
3279
+ voiceResults[idx].status === 'fulfilled' &&
3280
+ voiceResults[idx].value.suggestions?.length > 0
3281
+ );
3282
+
3283
+ if (successfulVoicesForLevel.length <= 1) {
3284
+ // Single reviewer or no results — no intra-level consolidation needed
3285
+ consolidatedPerLevel[level] = suggestions;
3286
+ } else {
3287
+ // Multiple reviewers — run intra-level consolidation
3288
+ logger.info(`[Council] Pass 1: Consolidating ${successfulVoicesForLevel.length} reviewers for Level ${level}`);
3289
+ try {
3290
+ const consolidated = await this._intraLevelConsolidate(
3291
+ level, suggestions, prMetadata, orchInstructions, worktreePath,
3292
+ { provider: orchProvider, model: orchModel, tier: orchTier, timeout: orchConfig.timeout, analysisId, progressCallback, reviewerCount: successfulVoicesForLevel.length }
3293
+ );
3294
+ consolidatedPerLevel[level] = consolidated;
3295
+ // Report intra-level consolidation step as completed
3296
+ if (progressCallback) {
3297
+ progressCallback({ level: `consolidation-L${level}`, status: 'completed', progress: `Level ${level} consolidation complete` });
3298
+ }
3299
+ } catch (error) {
3300
+ logger.warn(`[Council] Intra-level consolidation failed for Level ${level}: ${error.message}`);
3301
+ consolidatedPerLevel[level] = suggestions; // Fallback to raw
3302
+ // Report intra-level consolidation step as failed
3303
+ if (progressCallback) {
3304
+ progressCallback({ level: `consolidation-L${level}`, status: 'failed', progress: `Level ${level} consolidation failed` });
3305
+ }
3306
+ }
3307
+ }
3308
+ }
3309
+
3310
+ // Pass 2: Cross-level orchestration
3311
+ logger.info('[Council] Pass 2: Cross-level consolidation');
3312
+ try {
3313
+ const allSuggestions = {
3314
+ level1: consolidatedPerLevel[1] || [],
3315
+ level2: consolidatedPerLevel[2] || [],
3316
+ level3: consolidatedPerLevel[3] || []
3317
+ };
3318
+
3319
+ const orchestrationResult = await this.orchestrateWithAI(
3320
+ allSuggestions, prMetadata, orchInstructions, worktreePath,
3321
+ { analysisId, tier: orchTier, progressCallback, providerOverride: orchProvider, modelOverride: orchModel, timeout: orchConfig.timeout || 600000 }
3322
+ );
3323
+
3324
+ // Report cross-level orchestration step as completed
3325
+ if (progressCallback) {
3326
+ progressCallback({ level: 'orchestration', status: 'completed', progress: 'Cross-level consolidation complete' });
3327
+ }
3328
+
3329
+ const finalSuggestions = this.validateAndFinalizeSuggestions(
3330
+ orchestrationResult.suggestions, fileLineCountMap, validFiles
3331
+ );
3332
+
3333
+ // Store final consolidated suggestions (is_raw=0, voice_id=null)
3334
+ await this.storeSuggestions(reviewId, runId, finalSuggestions, null, validFiles);
3335
+
3336
+ logger.success(`[Council] Analysis complete: ${finalSuggestions.length} final suggestions`);
3337
+
3338
+ return {
3339
+ runId,
3340
+ suggestions: finalSuggestions,
3341
+ summary: orchestrationResult.summary
3342
+ };
3343
+ } catch (error) {
3344
+ logger.error(`[Council] Cross-level consolidation failed: ${error.message}`);
3345
+
3346
+ // Report cross-level orchestration step as failed
3347
+ if (progressCallback) {
3348
+ progressCallback({ level: 'orchestration', status: 'failed', progress: 'Cross-level consolidation failed' });
3349
+ }
3350
+
3351
+ // Fallback: combine all consolidated suggestions
3352
+ const fallbackSuggestions = Object.values(consolidatedPerLevel).flat();
3353
+ const finalFallback = this.validateAndFinalizeSuggestions(
3354
+ fallbackSuggestions, fileLineCountMap, validFiles
3355
+ );
3356
+ await this.storeSuggestions(reviewId, runId, finalFallback, null, validFiles);
3357
+
3358
+ return {
3359
+ runId,
3360
+ suggestions: finalFallback,
3361
+ summary: `Council analysis complete (consolidation failed): ${finalFallback.length} suggestions`,
3362
+ orchestrationFailed: true
3363
+ };
3364
+ }
3365
+ }
3366
+
3367
+ /**
3368
+ * Execute a single council voice
3369
+ * @param {Object} task - Voice task configuration
3370
+ * @param {Object} context - Shared context
3371
+ * @returns {Promise<Object>} Voice results { suggestions, summary }
3372
+ * @private
3373
+ */
3374
+ async _executeCouncilVoice(task, context) {
3375
+ const { voiceId, reviewerLabel, reviewerLogPrefix, level, provider, model, tier, timeout = 600000, customInstructions } = task;
3376
+ const { reviewId, runId, worktreePath, prMetadata, generatedPatterns, validFiles, analysisId, progressCallback } = context;
3377
+ const displayLabel = reviewerLabel || voiceId;
3378
+
3379
+ logger.info(`[Council] Starting ${displayLabel} (${tier})`);
3380
+
3381
+ if (analysisId && isAnalysisCancelled(analysisId)) {
3382
+ throw new CancellationError('Analysis was cancelled');
3383
+ }
3384
+
3385
+ // Create provider instance for this voice
3386
+ const aiProvider = createProvider(provider, model);
3387
+
3388
+ // Build prompt based on level
3389
+ let prompt;
3390
+ if (level === 1) {
3391
+ prompt = this.buildLevel1Prompt(reviewId, worktreePath, prMetadata, generatedPatterns, customInstructions, validFiles, tier);
3392
+ } else if (level === 2) {
3393
+ prompt = this.buildLevel2Prompt(reviewId, worktreePath, prMetadata, generatedPatterns, customInstructions, validFiles, tier);
3394
+ } else if (level === 3) {
3395
+ // Level 3 is async and has a testingContext parameter (null for council voices)
3396
+ prompt = await this.buildLevel3Prompt(reviewId, worktreePath, prMetadata, null, generatedPatterns, customInstructions, validFiles, tier);
3397
+ }
3398
+
3399
+ // Execute
3400
+ try {
3401
+ const response = await aiProvider.execute(prompt, {
3402
+ cwd: worktreePath,
3403
+ timeout,
3404
+ level,
3405
+ analysisId,
3406
+ registerProcess,
3407
+ logPrefix: reviewerLogPrefix,
3408
+ onStreamEvent: progressCallback ? (event) => {
3409
+ progressCallback({ level, status: 'running', streamEvent: event, voiceId });
3410
+ } : undefined
3411
+ });
3412
+
3413
+ // Parse response
3414
+ const suggestions = this.parseResponse(response, level);
3415
+ logger.success(`[Council] ${displayLabel}: parsed ${suggestions.length} suggestions`);
3416
+
3417
+ // Report per-voice completion to progress callback
3418
+ if (progressCallback) {
3419
+ progressCallback({
3420
+ status: 'completed',
3421
+ progress: `${suggestions.length} suggestions`,
3422
+ level,
3423
+ voiceId
3424
+ });
3425
+ }
3426
+
3427
+ return {
3428
+ suggestions,
3429
+ summary: response.summary || `${displayLabel}: ${suggestions.length} suggestions`
3430
+ };
3431
+ } catch (error) {
3432
+ if (!error.isCancellation) {
3433
+ logger.error(`[Council] ${displayLabel} failed: ${error.message}`);
3434
+ }
3435
+
3436
+ // Report per-voice failure to progress callback (unless cancelled)
3437
+ if (progressCallback && !error.isCancellation) {
3438
+ progressCallback({
3439
+ status: 'failed',
3440
+ progress: `Failed: ${error.message}`,
3441
+ level,
3442
+ voiceId
3443
+ });
3444
+ }
3445
+
3446
+ throw error;
3447
+ }
3448
+ }
3449
+
3450
+ /**
3451
+ * Run intra-level consolidation for a level with multiple voices
3452
+ * @param {number} level - Analysis level
3453
+ * @param {Array} suggestions - Combined suggestions from all voices at this level
3454
+ * @param {Object} prMetadata - PR metadata
3455
+ * @param {string} customInstructions - Custom instructions
3456
+ * @param {string} worktreePath - Worktree path
3457
+ * @param {Object} orchConfig - Orchestration config { provider, model, tier, timeout, analysisId, progressCallback }
3458
+ * @returns {Promise<Array>} Consolidated suggestions
3459
+ * @private
3460
+ */
3461
+ async _intraLevelConsolidate(level, suggestions, prMetadata, customInstructions, worktreePath, orchConfig) {
3462
+ const { provider, model, tier, timeout, analysisId, progressCallback, reviewerCount } = orchConfig;
3463
+
3464
+ const aiProvider = createProvider(provider, model);
3465
+
3466
+ const isLocal = prMetadata.reviewType === 'local';
3467
+ const reviewDescription = isLocal
3468
+ ? `local changes (review #${prMetadata.pr_number || 'local'})`
3469
+ : `pull request #${prMetadata.pr_number}`;
3470
+
3471
+ const promptBuilder = getPromptBuilder('consolidation', tier || 'balanced', provider);
3472
+ const prompt = promptBuilder.build({
3473
+ reviewIntro: `You are consolidating Level ${level} code review suggestions from multiple independent AI reviewers for ${reviewDescription}.`,
3474
+ customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
3475
+ lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
3476
+ reviewerSuggestions: JSON.stringify(suggestions, null, 2),
3477
+ suggestionCount: suggestions.length,
3478
+ reviewerCount: reviewerCount || 'multiple'
3479
+ });
3480
+
3481
+ const response = await aiProvider.execute(prompt, {
3482
+ cwd: worktreePath,
3483
+ timeout: timeout || 300000,
3484
+ level: `consolidation-L${level}`,
3485
+ analysisId,
3486
+ registerProcess,
3487
+ onStreamEvent: progressCallback ? (event) => {
3488
+ progressCallback({ level: `consolidation-L${level}`, status: 'running', streamEvent: event });
3489
+ } : undefined
3490
+ });
3491
+
3492
+ return this.parseResponse(response, level);
3493
+ }
3494
+
3495
+ /**
3496
+ * Store council suggestions with voice_id and is_raw fields
3497
+ * @param {number} reviewId - Review ID
3498
+ * @param {string} runId - Analysis run ID
3499
+ * @param {Array} suggestions - Suggestions with voice_id and is_raw fields
3500
+ * @param {Array<string>} validFiles - Valid file paths
3501
+ * @private
3502
+ */
3503
+ async _storeCouncilSuggestions(reviewId, runId, suggestions, validFiles) {
3504
+ const { run: dbRun } = require('../database');
3505
+
3506
+ // FAILSAFE: Filter suggestions to only those with valid file paths (same as storeSuggestions)
3507
+ const validPathsSet = new Set(validFiles.map(f => normalizePath(resolveRenamedFile(f))));
3508
+ let filteredCount = 0;
3509
+
3510
+ for (const suggestion of suggestions) {
3511
+ // Validate file path against changed files
3512
+ if (!this.isValidSuggestionPath(suggestion.file, validPathsSet)) {
3513
+ filteredCount++;
3514
+ continue;
3515
+ }
3516
+
3517
+ const body = suggestion.description +
3518
+ (suggestion.suggestion ? '\n\n**Suggestion:** ' + suggestion.suggestion : '');
3519
+
3520
+ const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
3521
+ const side = suggestion.old_or_new === 'OLD' ? 'LEFT' : 'RIGHT';
3522
+
3523
+ await dbRun(this.db, `
3524
+ INSERT INTO comments (
3525
+ review_id, source, author, ai_run_id, ai_level, ai_confidence,
3526
+ file, line_start, line_end, side, type, title, body, status, is_file_level,
3527
+ voice_id, is_raw
3528
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3529
+ `, [
3530
+ reviewId,
3531
+ 'ai',
3532
+ 'AI Assistant',
3533
+ runId,
3534
+ suggestion.level || null, // ai_level
3535
+ suggestion.confidence,
3536
+ suggestion.file,
3537
+ suggestion.line_start,
3538
+ suggestion.line_end,
3539
+ side,
3540
+ suggestion.type,
3541
+ suggestion.title,
3542
+ body,
3543
+ 'active',
3544
+ isFileLevel,
3545
+ suggestion.voice_id || null,
3546
+ suggestion.is_raw || 0
3547
+ ]);
3548
+ }
3549
+
3550
+ if (filteredCount > 0) {
3551
+ logger.warn(`[Council] Filtered ${filteredCount} raw suggestions with invalid file paths`);
3552
+ }
3553
+ logger.info(`[Council] Stored ${suggestions.length - filteredCount} raw reviewer suggestions`);
3554
+ }
3555
+
3556
+ /**
3557
+ * Derive default orchestration config from the council config
3558
+ * @param {Object} councilConfig - Council configuration
3559
+ * @returns {Object} Default orchestration { provider, model, tier }
3560
+ * @private
3561
+ */
3562
+ _defaultOrchestration(councilConfig) {
3563
+ // Find the first enabled level with voices
3564
+ for (const levelKey of ['1', '2', '3']) {
3565
+ const level = councilConfig.levels[levelKey];
3566
+ if (level?.enabled && level.voices?.length > 0) {
3567
+ const firstVoice = level.voices[0];
3568
+ return {
3569
+ provider: firstVoice.provider,
3570
+ model: firstVoice.model,
3571
+ tier: firstVoice.tier || 'balanced'
3572
+ };
3573
+ }
3574
+ }
3575
+
3576
+ // Fallback to claude/sonnet
3577
+ return { provider: 'claude', model: 'sonnet', tier: 'balanced' };
3578
+ }
3579
+
3580
+ /**
3581
+ * Derive default consolidation config from voice-centric council config
3582
+ * @param {Object} councilConfig - Voice-centric council configuration
3583
+ * @returns {Object} Default consolidation { provider, model, tier }
3584
+ * @private
3585
+ */
3586
+ _defaultConsolidation(councilConfig) {
3587
+ // Use the first voice as consolidation model
3588
+ const voices = councilConfig.voices || [];
3589
+ if (voices.length > 0) {
3590
+ return {
3591
+ provider: voices[0].provider,
3592
+ model: voices[0].model,
3593
+ tier: voices[0].tier || 'balanced'
3594
+ };
3595
+ }
3596
+ return { provider: 'claude', model: 'sonnet', tier: 'balanced' };
3597
+ }
3598
+
3599
+ /**
3600
+ * Consolidate suggestions across multiple complete voice reviews.
3601
+ * Unlike intra-level consolidation which merges duplicates within a level,
3602
+ * this merges complete, independent reviews from different AI models.
3603
+ *
3604
+ * @param {Array<Object>} voiceReviews - Array of { voiceKey, provider, model, suggestionCount, suggestions, summary }
3605
+ * @param {Object} prMetadata - PR metadata
3606
+ * @param {string} customInstructions - Merged custom instructions
3607
+ * @param {string} worktreePath - Worktree path
3608
+ * @param {Object} config - { provider, model, tier, timeout, analysisId, progressCallback }
3609
+ * @returns {Promise<Object>} { suggestions, summary }
3610
+ * @private
3611
+ */
3612
+ async _crossVoiceConsolidate(voiceReviews, prMetadata, customInstructions, worktreePath, config) {
3613
+ const { provider, model, tier, timeout, analysisId, progressCallback } = config;
3614
+
3615
+ const aiProvider = createProvider(provider, model);
3616
+
3617
+ const voiceDescriptions = voiceReviews.map(v => {
3618
+ let desc = `### Reviewer: ${v.voiceKey} (${v.provider}/${v.model}) — ${v.suggestionCount} suggestions\n`;
3619
+ if (v.summary) desc += `Summary: ${v.summary}\n`;
3620
+ desc += `Suggestions:\n${JSON.stringify(v.suggestions, null, 2)}`;
3621
+ if (v.fileLevelSuggestions?.length > 0) {
3622
+ desc += `\nFile-Level Suggestions:\n${JSON.stringify(v.fileLevelSuggestions, null, 2)}`;
3623
+ }
3624
+ return desc;
3625
+ }).join('\n\n');
3626
+
3627
+ const isLocal = prMetadata.reviewType === 'local';
3628
+ const reviewDescription = isLocal
3629
+ ? `local changes (review #${prMetadata.pr_number || 'local'})`
3630
+ : `pull request #${prMetadata.pr_number}`;
3631
+
3632
+ const promptBuilder = getPromptBuilder('consolidation', tier || 'balanced', provider);
3633
+ const prompt = promptBuilder.build({
3634
+ reviewIntro: `You are consolidating code review results from ${voiceReviews.length} independent AI reviewers for ${reviewDescription}. Each reviewer independently analyzed the same code changes and produced a complete review.`,
3635
+ customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
3636
+ lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
3637
+ reviewerSuggestions: voiceDescriptions,
3638
+ suggestionCount: voiceReviews.reduce((sum, v) => sum + v.suggestionCount, 0),
3639
+ reviewerCount: voiceReviews.length
3640
+ });
3641
+
3642
+ const response = await aiProvider.execute(prompt, {
3643
+ cwd: worktreePath,
3644
+ timeout: timeout || 300000,
3645
+ level: 'cross-voice-consolidation',
3646
+ analysisId,
3647
+ registerProcess,
3648
+ onStreamEvent: progressCallback ? (event) => {
3649
+ progressCallback({ level: 'orchestration', status: 'running', streamEvent: event, voiceCentric: true });
3650
+ } : undefined
3651
+ });
3652
+
3653
+ const suggestions = this.parseResponse(response, 'consolidation');
3654
+ const summary = response.summary || `Consolidated ${voiceReviews.length} reviewer outputs into ${suggestions.length} suggestions`;
3655
+
3656
+ return { suggestions, summary };
3657
+ }
3658
+
2508
3659
  /**
2509
3660
  * Update suggestion status (adopt, dismiss, etc)
2510
3661
  */