@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- package/src/utils/stats-calculator.js +2 -0
package/src/ai/analyzer.js
CHANGED
|
@@ -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(
|
|
95
|
-
logger.info(
|
|
154
|
+
logger.info(`${logPrefix}PR ID: ${prId}`);
|
|
155
|
+
logger.info(`${logPrefix}Analysis run ID: ${runId}`);
|
|
96
156
|
if (analysisId) {
|
|
97
|
-
logger.info(
|
|
157
|
+
logger.info(`${logPrefix}Analysis ID (for cancellation): ${analysisId}`);
|
|
98
158
|
}
|
|
99
|
-
logger.info(
|
|
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(
|
|
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(
|
|
183
|
+
logger.info(`${logPrefix}Created analysis_run record: ${runId}`);
|
|
124
184
|
} catch (createError) {
|
|
125
|
-
logger.warn(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
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(
|
|
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
|
|
157
|
-
const
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
288
|
+
logger.info(`${logPrefix}All levels complete. Starting cross-level consolidation...`);
|
|
234
289
|
if (progressCallback) {
|
|
235
290
|
progressCallback({
|
|
236
291
|
status: 'running',
|
|
237
|
-
progress: '
|
|
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(
|
|
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(
|
|
330
|
+
logger.info(`${logPrefix}Updated analysis_run record to completed: ${finalSuggestions.length} suggestions, ${validFiles.length} files`);
|
|
271
331
|
} catch (updateError) {
|
|
272
|
-
logger.warn(
|
|
332
|
+
logger.warn(`${logPrefix}Failed to update analysis_run record: ${updateError.message}`);
|
|
273
333
|
}
|
|
274
334
|
|
|
275
|
-
logger.success(
|
|
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(
|
|
286
|
-
logger.warn(
|
|
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 (
|
|
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(
|
|
378
|
+
logger.info(`${logPrefix}Updated analysis_run record to completed (fallback): ${finalFallbackSuggestions.length} suggestions`);
|
|
314
379
|
} catch (updateError) {
|
|
315
|
-
logger.warn(
|
|
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(
|
|
397
|
+
logger.info(`${logPrefix}Updated analysis_run record to cancelled`);
|
|
333
398
|
} else {
|
|
334
399
|
await analysisRunRepo.update(runId, { status: 'failed' });
|
|
335
|
-
logger.info(
|
|
400
|
+
logger.info(`${logPrefix}Updated analysis_run record to failed`);
|
|
336
401
|
}
|
|
337
402
|
} catch (updateError) {
|
|
338
|
-
logger.warn(
|
|
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(
|
|
346
|
-
logger.error(
|
|
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('[
|
|
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(`[
|
|
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(`[
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
2375
|
-
logger.info(
|
|
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
|
-
|
|
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
|
|
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(
|
|
2397
|
-
logger.warn(
|
|
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(
|
|
2400
|
-
logger.warn('--- BEGIN RAW
|
|
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
|
|
2480
|
+
logger.warn('--- END RAW CONSOLIDATION RESPONSE ---');
|
|
2403
2481
|
} else if (response.suggestions) {
|
|
2404
|
-
logger.warn(
|
|
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(
|
|
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(
|
|
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(
|
|
2431
|
-
logger.warn(
|
|
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 (
|
|
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(
|
|
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.
|
|
2490
|
-
: `pull request #${prMetadata.
|
|
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
|
*/
|