@in-the-loop-labs/pair-review 3.0.5 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
- package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
- package/public/css/analysis-config.css +83 -0
- package/public/css/pr.css +191 -4
- package/public/index.html +20 -0
- package/public/js/components/AIPanel.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +83 -8
- package/public/js/components/AnalysisConfigModal.js +155 -5
- package/public/js/components/ChatPanel.js +22 -5
- package/public/js/components/CouncilProgressModal.js +239 -22
- package/public/js/components/TimeoutSelect.js +2 -0
- package/public/js/components/VoiceCentricConfigTab.js +179 -12
- package/public/js/index.js +119 -1
- package/public/js/local.js +141 -47
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +71 -12
- package/public/js/repo-settings.js +2 -2
- package/public/local.html +32 -11
- package/public/pr.html +2 -0
- package/src/ai/analyzer.js +371 -111
- package/src/ai/claude-provider.js +2 -0
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +2 -0
- package/src/ai/executable-provider.js +534 -0
- package/src/ai/gemini-provider.js +2 -0
- package/src/ai/index.js +9 -1
- package/src/ai/pi-provider.js +10 -8
- package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
- package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
- package/src/ai/prompts/baseline/level1/balanced.js +12 -0
- package/src/ai/prompts/baseline/level1/fast.js +11 -0
- package/src/ai/prompts/baseline/level1/thorough.js +12 -0
- package/src/ai/prompts/baseline/level2/balanced.js +13 -0
- package/src/ai/prompts/baseline/level2/fast.js +12 -0
- package/src/ai/prompts/baseline/level2/thorough.js +13 -0
- package/src/ai/prompts/baseline/level3/balanced.js +13 -0
- package/src/ai/prompts/baseline/level3/fast.js +12 -0
- package/src/ai/prompts/baseline/level3/thorough.js +13 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
- package/src/ai/prompts/render-for-skill.js +3 -0
- package/src/ai/prompts/shared/output-schema.js +8 -0
- package/src/ai/provider.js +89 -4
- package/src/chat/prompt-builder.js +17 -1
- package/src/chat/session-manager.js +32 -28
- package/src/config.js +15 -2
- package/src/database.js +59 -15
- package/src/git/base-branch.js +133 -52
- package/src/local-review.js +15 -9
- package/src/main.js +3 -2
- package/src/routes/analyses.js +34 -8
- package/src/routes/chat.js +15 -8
- package/src/routes/config.js +3 -120
- package/src/routes/councils.js +15 -6
- package/src/routes/executable-analysis.js +494 -0
- package/src/routes/local.js +160 -26
- package/src/routes/mcp.js +9 -4
- package/src/routes/pr.js +166 -29
- package/src/routes/reviews.js +31 -5
- package/src/routes/shared.js +72 -5
- package/src/routes/worktrees.js +4 -2
- package/src/utils/comment-formatter.js +28 -11
- package/src/utils/instructions.js +22 -8
- package/src/utils/logger.js +20 -10
package/src/ai/analyzer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const { createProvider } = require('./index');
|
|
2
|
+
const { createProvider, getProviderClass } = require('./index');
|
|
3
|
+
const os = require('os');
|
|
3
4
|
const { v4: uuidv4 } = require('uuid');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const fs = require('fs').promises;
|
|
@@ -24,9 +25,8 @@ const { AnalysisRunRepository, CommentRepository } = require('../database');
|
|
|
24
25
|
const { mergeInstructions } = require('../utils/instructions');
|
|
25
26
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
26
27
|
const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guidance');
|
|
28
|
+
const { generateDiffForExecutable } = require('../routes/executable-analysis');
|
|
27
29
|
|
|
28
|
-
/** Minimum total suggestion count across all voices before consolidation is applied */
|
|
29
|
-
const COUNCIL_CONSOLIDATION_THRESHOLD = 8;
|
|
30
30
|
|
|
31
31
|
// GIT_DIFF_FLAGS imported from ../git/diff-flags
|
|
32
32
|
|
|
@@ -87,7 +87,14 @@ function buildVoiceContext(voice, idx, instructions, progressCallback, db) {
|
|
|
87
87
|
: voice.customInstructions;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const
|
|
90
|
+
const ProviderClass = getProviderClass(voice.provider);
|
|
91
|
+
const isExecutable = ProviderClass?.isExecutable || false;
|
|
92
|
+
|
|
93
|
+
// Only create Analyzer for native voices
|
|
94
|
+
const voiceAnalyzer = isExecutable ? null : new Analyzer(db, voice.model, voice.provider);
|
|
95
|
+
// Create provider instance for executable voices (used directly)
|
|
96
|
+
const voiceProvider = isExecutable ? createProvider(voice.provider, voice.model) : null;
|
|
97
|
+
|
|
91
98
|
const voiceTier = voice.tier || 'balanced';
|
|
92
99
|
const voiceTimeout = voice.timeout || 600000;
|
|
93
100
|
|
|
@@ -100,7 +107,159 @@ function buildVoiceContext(voice, idx, instructions, progressCallback, db) {
|
|
|
100
107
|
});
|
|
101
108
|
} : null;
|
|
102
109
|
|
|
103
|
-
return { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout };
|
|
110
|
+
return { voiceAnalyzer, voiceProvider, isExecutable, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run an executable provider as a council voice.
|
|
115
|
+
* Mirrors the pattern in src/routes/executable-analysis.js but without Express/HTTP concerns.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} voiceProvider - Provider instance from createProvider()
|
|
118
|
+
* @param {number|string} reviewId - Review ID
|
|
119
|
+
* @param {string} worktreePath - Path to the git worktree
|
|
120
|
+
* @param {Object} prMetadata - PR metadata
|
|
121
|
+
* @param {Object} options - { analysisId, timeout, requestInstructions, progressCallback, logPrefix }
|
|
122
|
+
* @returns {Promise<Object>} { suggestions, summary }
|
|
123
|
+
*/
|
|
124
|
+
async function runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetadata, options) {
|
|
125
|
+
const { analysisId, timeout, requestInstructions, progressCallback, logPrefix } = options;
|
|
126
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pair-review-exec-'));
|
|
127
|
+
try {
|
|
128
|
+
const executableContext = {
|
|
129
|
+
title: prMetadata.title || '',
|
|
130
|
+
description: prMetadata.description || '',
|
|
131
|
+
cwd: worktreePath,
|
|
132
|
+
outputDir: tmpDir,
|
|
133
|
+
model: voiceProvider.resolvedModel !== undefined ? voiceProvider.resolvedModel : (voiceProvider.model || null),
|
|
134
|
+
baseSha: prMetadata.base_sha || null,
|
|
135
|
+
headSha: prMetadata.head_sha || null,
|
|
136
|
+
baseBranch: prMetadata.base_branch || null,
|
|
137
|
+
headBranch: prMetadata.head_branch || null,
|
|
138
|
+
scopeStart: prMetadata.scopeStart || null,
|
|
139
|
+
scopeEnd: prMetadata.scopeEnd || null,
|
|
140
|
+
customInstructions: requestInstructions || null,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Generate scoped diff file when provider expects diff_path
|
|
144
|
+
if (voiceProvider.contextArgs?.diff_path) {
|
|
145
|
+
const diffPath = path.join(tmpDir, 'review.diff');
|
|
146
|
+
try {
|
|
147
|
+
await generateDiffForExecutable(
|
|
148
|
+
worktreePath,
|
|
149
|
+
executableContext,
|
|
150
|
+
voiceProvider.diffArgs || [],
|
|
151
|
+
diffPath
|
|
152
|
+
);
|
|
153
|
+
executableContext.diffPath = diffPath;
|
|
154
|
+
} catch (diffError) {
|
|
155
|
+
logger.warn(
|
|
156
|
+
`${logPrefix || ''}Failed to generate diff for executable voice: ${diffError.message} — continuing without diff`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Emit initial running status (non-stream) so the progress modal
|
|
162
|
+
// populates exec.voices[voiceId] and transitions from Pending to Running
|
|
163
|
+
if (progressCallback) {
|
|
164
|
+
progressCallback({ level: 'exec', status: 'running', progress: 'Starting external tool...' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = await voiceProvider.execute(null, {
|
|
168
|
+
executableContext,
|
|
169
|
+
cwd: worktreePath,
|
|
170
|
+
timeout: timeout || voiceProvider.timeout || 600000,
|
|
171
|
+
analysisId,
|
|
172
|
+
registerProcess,
|
|
173
|
+
onStreamEvent: progressCallback ? (event) => {
|
|
174
|
+
progressCallback({ level: 'exec', status: 'running', streamEvent: event });
|
|
175
|
+
} : null
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!result?.success || !result?.data) {
|
|
179
|
+
throw new Error(`${logPrefix || ''}Executable provider returned no data`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
suggestions: result.data.suggestions || [],
|
|
184
|
+
summary: result.data.summary || ''
|
|
185
|
+
};
|
|
186
|
+
} finally {
|
|
187
|
+
if (logger.isStreamDebugEnabled()) {
|
|
188
|
+
logger.info(`[ExecutableVoice] Keeping output dir for debug: ${tmpDir}`);
|
|
189
|
+
} else {
|
|
190
|
+
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build the dedup context object from PR metadata and run identifiers.
|
|
197
|
+
*
|
|
198
|
+
* @param {Object} prMetadata - PR metadata with repository and pr_number
|
|
199
|
+
* @param {Object} ids - { reviewId, serverPort, runId, excludeRunIds }
|
|
200
|
+
* @param {string} [ids.runId] - Single run ID to exclude (backward compat)
|
|
201
|
+
* @param {string[]} [ids.excludeRunIds] - Array of run IDs to exclude (takes precedence over runId)
|
|
202
|
+
* @returns {Object} { owner, repo, pullNumber, reviewId, serverPort, runId, excludeRunIds }
|
|
203
|
+
*/
|
|
204
|
+
function buildDedupContext(prMetadata, { reviewId, serverPort, runId, excludeRunIds }) {
|
|
205
|
+
const [owner, repo] = prMetadata.repository?.split('/') || [];
|
|
206
|
+
return { owner, repo, pullNumber: prMetadata.pr_number, reviewId, serverPort, runId, excludeRunIds };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build dedup instructions text for excluding previously identified issues.
|
|
211
|
+
*
|
|
212
|
+
* @param {Object|null} excludePrevious - { github: bool, feedback: bool } (or falsy if disabled)
|
|
213
|
+
* @param {Object} context - { owner, repo, pullNumber, reviewId, serverPort, runId, excludeRunIds }
|
|
214
|
+
* @param {string} [context.runId] - Single run ID to exclude (backward compat)
|
|
215
|
+
* @param {string[]} [context.excludeRunIds] - Array of run IDs to exclude (takes precedence over runId)
|
|
216
|
+
* @returns {string} Instruction text for the dedup-instructions prompt section, or empty string
|
|
217
|
+
*/
|
|
218
|
+
function buildDedupInstructions(excludePrevious, context) {
|
|
219
|
+
if (!excludePrevious || (!excludePrevious.github && !excludePrevious.feedback)) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
context = context || {};
|
|
223
|
+
|
|
224
|
+
const sections = [];
|
|
225
|
+
|
|
226
|
+
sections.push(`## Exclude Previously Identified Issues
|
|
227
|
+
|
|
228
|
+
After consolidating suggestions, check your results against previously identified issues and remove any that are duplicates or substantially similar. If you have zero suggestions after consolidation, skip this step entirely.`);
|
|
229
|
+
|
|
230
|
+
if (excludePrevious.github && context.owner && context.repo && context.pullNumber) {
|
|
231
|
+
sections.push(`### GitHub PR Review Comments
|
|
232
|
+
Fetch inline review comments:
|
|
233
|
+
\`\`\`
|
|
234
|
+
gh api repos/${context.owner}/${context.repo}/pulls/${context.pullNumber}/comments --paginate
|
|
235
|
+
\`\`\`
|
|
236
|
+
Each comment has \`path\` (file), \`line\`/\`original_line\` (line number), and \`body\` (content).
|
|
237
|
+
A suggestion is a duplicate if it matches on **all three** of: (1) same file, (2) overlapping or adjacent line range (within 5 lines), and (3) substantially similar issue — i.e., the same category of issue (error handling, validation, naming, etc.) applied to the same code. If a previous comment partially overlaps your suggestion — e.g., it flags missing error handling while your suggestion flags missing error handling *and* input validation — keep only the novel portion that the previous comment does not address. If there is no novel portion, exclude it entirely.`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (excludePrevious.feedback && context.reviewId && context.serverPort) {
|
|
241
|
+
const excludeRunIds = context.excludeRunIds?.length ? context.excludeRunIds : (context.runId ? [context.runId] : []);
|
|
242
|
+
const excludeParam = excludeRunIds.length ? `&excludeRunId=${excludeRunIds.join(',')}` : '';
|
|
243
|
+
sections.push(`### Existing Pair-Review Feedback
|
|
244
|
+
Fetch previous AI suggestions:
|
|
245
|
+
\`\`\`
|
|
246
|
+
curl -s "http://localhost:${context.serverPort}/api/reviews/${context.reviewId}/suggestions?allRuns=true&levels=final${excludeParam}"
|
|
247
|
+
\`\`\`
|
|
248
|
+
Fetch previous user comments:
|
|
249
|
+
\`\`\`
|
|
250
|
+
curl -s "http://localhost:${context.serverPort}/api/reviews/${context.reviewId}/comments?includeDismissed=true"
|
|
251
|
+
\`\`\`
|
|
252
|
+
A suggestion is a duplicate if it targets the same file and overlapping lines and raises the same category of issue as a previous suggestion or comment. For partial overlaps — where your suggestion covers a superset of what was previously flagged — narrow your suggestion to address only the novel aspect. If nothing novel remains, exclude it.`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (sections.length <= 1) {
|
|
256
|
+
// Only the header was added, no actual sources — return empty
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
sections.push('Report how many suggestions were excluded in your summary.');
|
|
261
|
+
|
|
262
|
+
return sections.join('\n\n');
|
|
104
263
|
}
|
|
105
264
|
|
|
106
265
|
class Analyzer {
|
|
@@ -136,6 +295,7 @@ class Analyzer {
|
|
|
136
295
|
* @param {Object} prMetadata - PR metadata with base branch info
|
|
137
296
|
* @param {Function} progressCallback - Callback for progress updates
|
|
138
297
|
* @param {Object|string} instructions - Instructions object or legacy string for backward compatibility
|
|
298
|
+
* @param {string} [instructions.globalInstructions] - Global instructions from ~/.pair-review/global-instructions.md
|
|
139
299
|
* @param {string} [instructions.repoInstructions] - Repository-level instructions from repo_settings
|
|
140
300
|
* @param {string} [instructions.requestInstructions] - Request-level instructions from the analyze request
|
|
141
301
|
* @param {Array<string>} changedFiles - Optional list of changed files for local mode validation
|
|
@@ -146,11 +306,13 @@ class Analyzer {
|
|
|
146
306
|
* @param {boolean} [options.skipLevel3] - Skip Level 3 codebase context analysis (deprecated: use enabledLevels)
|
|
147
307
|
* @param {Object} [options.enabledLevels] - Which levels to run, e.g. {1: true, 2: true, 3: false}
|
|
148
308
|
* @param {string} [options.tier='balanced'] - Analysis tier (fast, balanced, thorough)
|
|
309
|
+
* @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
|
|
310
|
+
* @param {number} [options.serverPort] - Server port for dedup API calls
|
|
149
311
|
* @returns {Promise<Object>} Analysis results
|
|
150
312
|
*/
|
|
151
313
|
async analyzeAllLevels(prId, worktreePath, prMetadata, progressCallback = null, instructions = null, changedFiles = null, options = {}) {
|
|
152
314
|
const runId = options.runId || uuidv4();
|
|
153
|
-
const { analysisId, skipRunCreation, skipLevel3, reviewerNum } = options;
|
|
315
|
+
const { analysisId, skipRunCreation, skipLevel3, reviewerNum, excludePrevious, serverPort } = options;
|
|
154
316
|
const logPrefix = options.logPrefix || '';
|
|
155
317
|
const executionTimeout = options.timeout || 600000; // Default 10 minutes
|
|
156
318
|
|
|
@@ -163,6 +325,7 @@ class Analyzer {
|
|
|
163
325
|
}
|
|
164
326
|
|
|
165
327
|
// Handle both new object format and legacy string format for backward compatibility
|
|
328
|
+
let globalInstructions = null;
|
|
166
329
|
let repoInstructions = null;
|
|
167
330
|
let requestInstructions = null;
|
|
168
331
|
let mergedInstructions = null;
|
|
@@ -172,9 +335,10 @@ class Analyzer {
|
|
|
172
335
|
mergedInstructions = instructions;
|
|
173
336
|
} else if (instructions && typeof instructions === 'object') {
|
|
174
337
|
// New format: object with separate instruction fields
|
|
338
|
+
globalInstructions = instructions.globalInstructions || null;
|
|
175
339
|
repoInstructions = instructions.repoInstructions || null;
|
|
176
340
|
requestInstructions = instructions.requestInstructions || null;
|
|
177
|
-
mergedInstructions = mergeInstructions(repoInstructions, requestInstructions);
|
|
341
|
+
mergedInstructions = mergeInstructions({ globalInstructions, repoInstructions, requestInstructions });
|
|
178
342
|
}
|
|
179
343
|
|
|
180
344
|
logger.section('Multi-Level AI Analysis Starting (Parallel Execution)');
|
|
@@ -207,7 +371,8 @@ class Analyzer {
|
|
|
207
371
|
provider: this.provider,
|
|
208
372
|
model: this.model,
|
|
209
373
|
tier: options.tier ? resolveTier(options.tier) : 'balanced',
|
|
210
|
-
customInstructions:
|
|
374
|
+
customInstructions: requestInstructions, // Only request-level; global/repo stored in their own columns
|
|
375
|
+
globalInstructions,
|
|
211
376
|
repoInstructions,
|
|
212
377
|
requestInstructions,
|
|
213
378
|
headSha,
|
|
@@ -342,7 +507,10 @@ class Analyzer {
|
|
|
342
507
|
level3: levelResults.level3.suggestions
|
|
343
508
|
};
|
|
344
509
|
|
|
345
|
-
|
|
510
|
+
// Build dedup context from prMetadata and options
|
|
511
|
+
const dedupContext = buildDedupContext(prMetadata, { reviewId: prId, serverPort, runId });
|
|
512
|
+
|
|
513
|
+
const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback, timeout: executionTimeout, logPrefix, reviewerNum, excludePrevious, dedupContext });
|
|
346
514
|
|
|
347
515
|
// Report orchestration step as completed
|
|
348
516
|
if (progressCallback) {
|
|
@@ -798,6 +966,7 @@ Or simply ignore any changes to files matching these patterns in your analysis.
|
|
|
798
966
|
* @param {Object} prMetadata - PR metadata with base branch info
|
|
799
967
|
* @param {Function} progressCallback - Callback for progress updates
|
|
800
968
|
* @param {Object|string} instructions - Instructions object or legacy string for backward compatibility
|
|
969
|
+
* @param {string} [instructions.globalInstructions] - Global instructions from ~/.pair-review/global-instructions.md
|
|
801
970
|
* @param {string} [instructions.repoInstructions] - Repository-level instructions from repo_settings
|
|
802
971
|
* @param {string} [instructions.requestInstructions] - Request-level instructions from the analyze request
|
|
803
972
|
* @param {Array<string>} changedFiles - Optional list of changed files for local mode validation
|
|
@@ -2454,10 +2623,12 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2454
2623
|
* @param {string} worktreePath - Path to the git worktree
|
|
2455
2624
|
* @param {Object} options - Additional options
|
|
2456
2625
|
* @param {string} options.analysisId - Analysis ID for process tracking (enables cancellation)
|
|
2626
|
+
* @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
|
|
2627
|
+
* @param {Object} [options.dedupContext] - { owner, repo, pullNumber, reviewId, serverPort }
|
|
2457
2628
|
* @returns {Promise<Array>} Curated suggestions array
|
|
2458
2629
|
*/
|
|
2459
2630
|
async orchestrateWithAI(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, options = {}) {
|
|
2460
|
-
const { analysisId, tier = 'balanced', progressCallback, providerOverride, modelOverride, timeout = 600000, logPrefix: lp = '', reviewerNum } = options;
|
|
2631
|
+
const { analysisId, tier = 'balanced', progressCallback, providerOverride, modelOverride, timeout = 600000, logPrefix: lp = '', reviewerNum, excludePrevious, dedupContext } = options;
|
|
2461
2632
|
// Build adapter-level log prefix: when reviewerNum is set (council mode),
|
|
2462
2633
|
// use compact format like [R1 Orch] so concurrent reviewers are disambiguated
|
|
2463
2634
|
const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} Orch]` : '';
|
|
@@ -2479,7 +2650,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2479
2650
|
const aiProvider = createProvider(providerOverride || this.provider, modelOverride || this.model);
|
|
2480
2651
|
|
|
2481
2652
|
// Build the consolidation prompt
|
|
2482
|
-
const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp);
|
|
2653
|
+
const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp, { excludePrevious, dedupContext });
|
|
2483
2654
|
|
|
2484
2655
|
// Execute AI for cross-level consolidation
|
|
2485
2656
|
logger.info(`${lp}[Consolidation] Running AI consolidation to curate and merge suggestions...`);
|
|
@@ -2590,9 +2761,12 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2590
2761
|
* @param {string} worktreePath - Path to the git worktree
|
|
2591
2762
|
* @param {string} tier - Capability tier: 'fast', 'balanced', or 'thorough' (default: 'balanced')
|
|
2592
2763
|
* @param {string} logPrefix - Optional log prefix for reviewer identification in council mode
|
|
2764
|
+
* @param {Object} [dedupOptions] - Dedup options
|
|
2765
|
+
* @param {Object} [dedupOptions.excludePrevious] - { github: bool, feedback: bool }
|
|
2766
|
+
* @param {Object} [dedupOptions.dedupContext] - { owner, repo, pullNumber, reviewId, serverPort }
|
|
2593
2767
|
* @returns {string} Orchestration prompt
|
|
2594
2768
|
*/
|
|
2595
|
-
buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, tier = 'balanced', logPrefix = '') {
|
|
2769
|
+
buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, tier = 'balanced', logPrefix = '', dedupOptions = {}) {
|
|
2596
2770
|
logger.debug(`${logPrefix}[Consolidation] Building consolidation prompt with tier: ${tier}`);
|
|
2597
2771
|
const promptBuilder = getPromptBuilder('orchestration', tier, this.provider);
|
|
2598
2772
|
|
|
@@ -2606,6 +2780,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2606
2780
|
reviewIntro: `You are orchestrating AI-powered code review suggestions for ${reviewDescription}.`,
|
|
2607
2781
|
customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
|
|
2608
2782
|
lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
|
|
2783
|
+
dedupInstructions: buildDedupInstructions(dedupOptions.excludePrevious, dedupOptions.dedupContext || {}),
|
|
2609
2784
|
level1Count: allSuggestions.level1?.length || 0,
|
|
2610
2785
|
level2Count: allSuggestions.level2?.length || 0,
|
|
2611
2786
|
level3Count: allSuggestions.level3?.length || 0,
|
|
@@ -2627,7 +2802,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2627
2802
|
* @param {string} reviewContext.worktreePath - Path to the git worktree
|
|
2628
2803
|
* @param {Object} reviewContext.prMetadata - PR/review metadata
|
|
2629
2804
|
* @param {Array<string>} [reviewContext.changedFiles] - Changed files list
|
|
2630
|
-
* @param {Object} reviewContext.instructions - Instructions { repoInstructions, requestInstructions }
|
|
2805
|
+
* @param {Object} reviewContext.instructions - Instructions { globalInstructions, repoInstructions, requestInstructions }
|
|
2631
2806
|
* @param {Object} councilConfig - Reviewer-centric council configuration
|
|
2632
2807
|
* @param {Array<Object>} councilConfig.voices - Reviewer configurations (provider, model, tier, customInstructions, timeout)
|
|
2633
2808
|
* @param {Object} councilConfig.levels - Which levels to enable, e.g. {1: true, 2: true, 3: false}
|
|
@@ -2636,11 +2811,13 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2636
2811
|
* @param {string} options.analysisId - Analysis ID for process tracking
|
|
2637
2812
|
* @param {string} [options.runId] - Pre-generated parent run ID
|
|
2638
2813
|
* @param {Function} [options.progressCallback] - Progress callback
|
|
2814
|
+
* @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
|
|
2815
|
+
* @param {number} [options.serverPort] - Server port for dedup API calls
|
|
2639
2816
|
* @returns {Promise<Object>} Analysis results { runId, suggestions, summary }
|
|
2640
2817
|
*/
|
|
2641
2818
|
async runReviewerCentricCouncil(reviewContext, councilConfig, options = {}) {
|
|
2642
2819
|
const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
|
|
2643
|
-
const { analysisId, progressCallback } = options;
|
|
2820
|
+
const { analysisId, progressCallback, excludePrevious, serverPort } = options;
|
|
2644
2821
|
const parentRunId = options.runId || uuidv4();
|
|
2645
2822
|
|
|
2646
2823
|
logger.section('Review Council Analysis Starting (Reviewer-Centric)');
|
|
@@ -2677,7 +2854,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2677
2854
|
// Merge instructions
|
|
2678
2855
|
let mergedInstructions = null;
|
|
2679
2856
|
if (instructions && typeof instructions === 'object') {
|
|
2680
|
-
mergedInstructions = mergeInstructions(instructions.repoInstructions, instructions.requestInstructions);
|
|
2857
|
+
mergedInstructions = mergeInstructions({ globalInstructions: instructions.globalInstructions, repoInstructions: instructions.repoInstructions, requestInstructions: instructions.requestInstructions });
|
|
2681
2858
|
}
|
|
2682
2859
|
|
|
2683
2860
|
// Resolve enabledLevels from council config
|
|
@@ -2715,7 +2892,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2715
2892
|
provider: 'council',
|
|
2716
2893
|
model: 'voice-centric',
|
|
2717
2894
|
tier: null,
|
|
2718
|
-
customInstructions:
|
|
2895
|
+
customInstructions: instructions?.requestInstructions || null, // Only request-level; global/repo stored in their own columns
|
|
2896
|
+
globalInstructions: instructions?.globalInstructions || null,
|
|
2719
2897
|
repoInstructions: instructions?.repoInstructions || null,
|
|
2720
2898
|
requestInstructions: instructions?.requestInstructions || null,
|
|
2721
2899
|
headSha,
|
|
@@ -2745,7 +2923,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2745
2923
|
// Single voice: skip child run entirely, run analysis directly on parent run
|
|
2746
2924
|
if (voices.length === 1) {
|
|
2747
2925
|
const voice = voices[0];
|
|
2748
|
-
const { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
|
|
2926
|
+
const { voiceAnalyzer, voiceProvider, isExecutable, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
|
|
2749
2927
|
buildVoiceContext(voice, 0, instructions, progressCallback, this.db);
|
|
2750
2928
|
logger.info(`[ReviewerCouncil] Single reviewer (${reviewerLabel}) — running directly on parent run, no child run`);
|
|
2751
2929
|
|
|
@@ -2755,10 +2933,39 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2755
2933
|
voiceCentric: true,
|
|
2756
2934
|
level: 'voice-init',
|
|
2757
2935
|
status: 'running',
|
|
2758
|
-
voices: { [voiceKey]: { status: 'pending', provider: voice.provider, model: voice.model, tier: voiceTier } }
|
|
2936
|
+
voices: { [voiceKey]: { status: 'pending', provider: voice.provider, model: voice.model, tier: voiceTier, isExecutable } }
|
|
2759
2937
|
});
|
|
2760
2938
|
}
|
|
2761
2939
|
|
|
2940
|
+
if (isExecutable) {
|
|
2941
|
+
const result = await runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetadata, {
|
|
2942
|
+
analysisId, timeout: voiceTimeout,
|
|
2943
|
+
requestInstructions: voiceRequestInstructions,
|
|
2944
|
+
progressCallback: voiceProgressCallback,
|
|
2945
|
+
logPrefix: `[${reviewerLabel}] `
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
const finalSuggestions = this.validateAndFinalizeSuggestions(result.suggestions, fileLineCountMap, validFiles);
|
|
2949
|
+
await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
|
|
2950
|
+
|
|
2951
|
+
try {
|
|
2952
|
+
await analysisRunRepo.update(parentRunId, {
|
|
2953
|
+
status: 'completed',
|
|
2954
|
+
summary: result.summary,
|
|
2955
|
+
totalSuggestions: finalSuggestions.length,
|
|
2956
|
+
filesAnalyzed: validFiles.length
|
|
2957
|
+
});
|
|
2958
|
+
} catch (err) {
|
|
2959
|
+
logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
return {
|
|
2963
|
+
runId: parentRunId,
|
|
2964
|
+
suggestions: finalSuggestions,
|
|
2965
|
+
summary: result.summary || `Review council complete: ${finalSuggestions.length} suggestions`
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2762
2969
|
// analyzeAllLevels handles validation, storage, and run status updates internally
|
|
2763
2970
|
// (including error/cancellation status), so no error handling needed here
|
|
2764
2971
|
const result = await voiceAnalyzer.analyzeAllLevels(
|
|
@@ -2766,7 +2973,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2766
2973
|
worktreePath,
|
|
2767
2974
|
prMetadata,
|
|
2768
2975
|
voiceProgressCallback,
|
|
2769
|
-
{ repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
|
|
2976
|
+
{ globalInstructions: instructions?.globalInstructions, repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
|
|
2770
2977
|
changedFiles,
|
|
2771
2978
|
{
|
|
2772
2979
|
analysisId,
|
|
@@ -2776,7 +2983,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2776
2983
|
tier: voiceTier,
|
|
2777
2984
|
timeout: voiceTimeout,
|
|
2778
2985
|
logPrefix: `[${reviewerLabel}] `,
|
|
2779
|
-
reviewerNum: 1
|
|
2986
|
+
reviewerNum: 1,
|
|
2987
|
+
excludePrevious,
|
|
2988
|
+
serverPort
|
|
2780
2989
|
}
|
|
2781
2990
|
);
|
|
2782
2991
|
|
|
@@ -2796,20 +3005,23 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2796
3005
|
level: 'voice-init',
|
|
2797
3006
|
status: 'running',
|
|
2798
3007
|
voices: Object.fromEntries(voices.map((v, idx) => {
|
|
2799
|
-
const
|
|
2800
|
-
|
|
3008
|
+
const vKey = `${v.provider}-${v.model}${idx > 0 ? `-${idx}` : ''}`;
|
|
3009
|
+
const VoiceProviderClass = getProviderClass(v.provider);
|
|
3010
|
+
return [vKey, {
|
|
2801
3011
|
status: 'pending',
|
|
2802
3012
|
provider: v.provider,
|
|
2803
3013
|
model: v.model,
|
|
2804
|
-
tier: v.tier || 'balanced'
|
|
3014
|
+
tier: v.tier || 'balanced',
|
|
3015
|
+
isExecutable: VoiceProviderClass?.isExecutable || false
|
|
2805
3016
|
}];
|
|
2806
3017
|
}))
|
|
2807
3018
|
});
|
|
2808
3019
|
}
|
|
2809
3020
|
|
|
2810
|
-
// For each voice, create a child run and launch
|
|
3021
|
+
// For each voice, create a child run and launch analysis
|
|
3022
|
+
const commentRepo = new CommentRepository(this.db);
|
|
2811
3023
|
const voicePromises = voices.map(async (voice, idx) => {
|
|
2812
|
-
const { voiceAnalyzer, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
|
|
3024
|
+
const { voiceAnalyzer, voiceProvider, isExecutable, voiceKey, reviewerLabel, voiceRequestInstructions, voiceProgressCallback, voiceTier, voiceTimeout } =
|
|
2813
3025
|
buildVoiceContext(voice, idx, instructions, progressCallback, this.db);
|
|
2814
3026
|
const childRunId = uuidv4();
|
|
2815
3027
|
|
|
@@ -2821,7 +3033,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2821
3033
|
provider: voice.provider,
|
|
2822
3034
|
model: voice.model,
|
|
2823
3035
|
tier: voiceTier,
|
|
2824
|
-
customInstructions:
|
|
3036
|
+
customInstructions: instructions?.requestInstructions || null, // Only request-level; global/repo stored in their own columns
|
|
3037
|
+
globalInstructions: instructions?.globalInstructions || null,
|
|
2825
3038
|
repoInstructions: instructions?.repoInstructions || null,
|
|
2826
3039
|
requestInstructions: instructions?.requestInstructions || null,
|
|
2827
3040
|
headSha,
|
|
@@ -2836,12 +3049,43 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2836
3049
|
}
|
|
2837
3050
|
|
|
2838
3051
|
try {
|
|
3052
|
+
if (isExecutable) {
|
|
3053
|
+
const result = await runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetadata, {
|
|
3054
|
+
analysisId, timeout: voiceTimeout,
|
|
3055
|
+
requestInstructions: voiceRequestInstructions,
|
|
3056
|
+
progressCallback: voiceProgressCallback,
|
|
3057
|
+
logPrefix: `[${reviewerLabel}] `
|
|
3058
|
+
});
|
|
3059
|
+
|
|
3060
|
+
// Validate suggestions before storage (matches single-voice path)
|
|
3061
|
+
const validatedSuggestions = this.validateAndFinalizeSuggestions(result.suggestions, fileLineCountMap, validFiles);
|
|
3062
|
+
|
|
3063
|
+
// Update child run
|
|
3064
|
+
try {
|
|
3065
|
+
await analysisRunRepo.update(childRunId, {
|
|
3066
|
+
status: 'completed',
|
|
3067
|
+
summary: result.summary,
|
|
3068
|
+
totalSuggestions: validatedSuggestions.length
|
|
3069
|
+
});
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// Store validated voice suggestions
|
|
3075
|
+
await commentRepo.bulkInsertAISuggestions(reviewId, childRunId, validatedSuggestions, null);
|
|
3076
|
+
|
|
3077
|
+
const validatedResult = { ...result, suggestions: validatedSuggestions };
|
|
3078
|
+
return { voiceKey, reviewerLabel, childRunId, result: validatedResult, provider: voice.provider, model: voice.model, isExecutable: true, customInstructions: voice.customInstructions || null };
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// Note: excludePrevious/serverPort omitted intentionally — dedup runs once
|
|
3082
|
+
// during cross-voice consolidation, not per-voice.
|
|
2839
3083
|
const result = await voiceAnalyzer.analyzeAllLevels(
|
|
2840
3084
|
reviewId,
|
|
2841
3085
|
worktreePath,
|
|
2842
3086
|
prMetadata,
|
|
2843
3087
|
voiceProgressCallback,
|
|
2844
|
-
{ repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
|
|
3088
|
+
{ globalInstructions: instructions?.globalInstructions, repoInstructions: instructions?.repoInstructions, requestInstructions: voiceRequestInstructions },
|
|
2845
3089
|
changedFiles,
|
|
2846
3090
|
{
|
|
2847
3091
|
analysisId,
|
|
@@ -2867,7 +3111,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2867
3111
|
logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
|
|
2868
3112
|
}
|
|
2869
3113
|
|
|
2870
|
-
return { voiceKey, reviewerLabel, childRunId, result, provider: voice.provider, model: voice.model };
|
|
3114
|
+
return { voiceKey, reviewerLabel, childRunId, result, provider: voice.provider, model: voice.model, isExecutable: false, customInstructions: voice.customInstructions || null };
|
|
2871
3115
|
} catch (error) {
|
|
2872
3116
|
// Update child run to failed/cancelled
|
|
2873
3117
|
try {
|
|
@@ -2899,15 +3143,15 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2899
3143
|
// Collect successful results
|
|
2900
3144
|
const successfulVoices = [];
|
|
2901
3145
|
const allVoiceSuggestions = [];
|
|
2902
|
-
const
|
|
3146
|
+
const allVoiceFileLevelSuggestions = [];
|
|
2903
3147
|
|
|
2904
3148
|
for (let i = 0; i < voiceResults.length; i++) {
|
|
2905
3149
|
const settled = voiceResults[i];
|
|
2906
3150
|
if (settled.status === 'fulfilled' && settled.value.result?.suggestions) {
|
|
2907
3151
|
successfulVoices.push(settled.value);
|
|
2908
3152
|
allVoiceSuggestions.push(...settled.value.result.suggestions);
|
|
2909
|
-
if (settled.value.result.
|
|
2910
|
-
|
|
3153
|
+
if (settled.value.result.fileLevelSuggestions) {
|
|
3154
|
+
allVoiceFileLevelSuggestions.push(...settled.value.result.fileLevelSuggestions);
|
|
2911
3155
|
}
|
|
2912
3156
|
logger.success(`[ReviewerCouncil] ${settled.value.reviewerLabel}: ${settled.value.result.suggestions.length} suggestions`);
|
|
2913
3157
|
} else {
|
|
@@ -2927,7 +3171,10 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2927
3171
|
}
|
|
2928
3172
|
|
|
2929
3173
|
// Single voice: use directly, no consolidation
|
|
2930
|
-
if (
|
|
3174
|
+
// But if dedup (exclude-previous) is enabled, we must go through consolidation
|
|
3175
|
+
// so that previous suggestions are filtered out.
|
|
3176
|
+
const hasExcludePrevious = excludePrevious && (excludePrevious.github || excludePrevious.feedback);
|
|
3177
|
+
if (successfulVoices.length === 1 && !hasExcludePrevious) {
|
|
2931
3178
|
logger.info('[ReviewerCouncil] Single reviewer result — skipping consolidation');
|
|
2932
3179
|
const singleResult = successfulVoices[0].result;
|
|
2933
3180
|
|
|
@@ -2957,34 +3204,6 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2957
3204
|
// Multiple voices: cross-voice consolidation
|
|
2958
3205
|
const totalSuggestionCount = allVoiceSuggestions.length;
|
|
2959
3206
|
|
|
2960
|
-
// If below consolidation threshold, skip
|
|
2961
|
-
if (totalSuggestionCount < COUNCIL_CONSOLIDATION_THRESHOLD) {
|
|
2962
|
-
logger.info(`[ReviewerCouncil] ${totalSuggestionCount} total suggestions below threshold (${COUNCIL_CONSOLIDATION_THRESHOLD}) — skipping consolidation`);
|
|
2963
|
-
const summary = voiceSummaries.length > 1 ? voiceSummaries.join('\n\n') : voiceSummaries[0];
|
|
2964
|
-
|
|
2965
|
-
const finalSuggestions = this.validateAndFinalizeSuggestions(
|
|
2966
|
-
allVoiceSuggestions, fileLineCountMap, validFiles
|
|
2967
|
-
);
|
|
2968
|
-
await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
|
|
2969
|
-
|
|
2970
|
-
try {
|
|
2971
|
-
await analysisRunRepo.update(parentRunId, {
|
|
2972
|
-
status: 'completed',
|
|
2973
|
-
summary: summary || `Review council complete: ${finalSuggestions.length} suggestions`,
|
|
2974
|
-
totalSuggestions: finalSuggestions.length,
|
|
2975
|
-
filesAnalyzed: validFiles.length
|
|
2976
|
-
});
|
|
2977
|
-
} catch (err) {
|
|
2978
|
-
logger.warn(`[ReviewerCouncil] Failed to update parent run: ${err.message}`);
|
|
2979
|
-
}
|
|
2980
|
-
|
|
2981
|
-
return {
|
|
2982
|
-
runId: parentRunId,
|
|
2983
|
-
suggestions: finalSuggestions,
|
|
2984
|
-
summary: summary || `Reviewer-centric council complete: ${finalSuggestions.length} suggestions`
|
|
2985
|
-
};
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
3207
|
// Run cross-reviewer consolidation
|
|
2989
3208
|
logger.info(`[ReviewerCouncil] Starting cross-reviewer consolidation of ${successfulVoices.length} reviewers (${totalSuggestionCount} total suggestions)`);
|
|
2990
3209
|
|
|
@@ -3016,15 +3235,22 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3016
3235
|
voiceKey: v.voiceKey,
|
|
3017
3236
|
provider: v.provider,
|
|
3018
3237
|
model: v.model,
|
|
3238
|
+
isExecutable: v.isExecutable || false,
|
|
3239
|
+
customInstructions: v.customInstructions || null,
|
|
3019
3240
|
suggestionCount: v.result.suggestions.length + (v.result.fileLevelSuggestions?.length || 0),
|
|
3020
3241
|
suggestions: v.result.suggestions,
|
|
3021
3242
|
fileLevelSuggestions: v.result.fileLevelSuggestions || [],
|
|
3022
3243
|
summary: v.result.summary
|
|
3023
3244
|
}));
|
|
3024
3245
|
|
|
3246
|
+
// Build dedup context for cross-voice consolidation
|
|
3247
|
+
// Exclude both parent and child run IDs so the dedup fetch doesn't include the current run's results
|
|
3248
|
+
const childRunIds = successfulVoices.map(v => v.childRunId).filter(Boolean);
|
|
3249
|
+
const dedupContext = buildDedupContext(prMetadata, { reviewId, serverPort, runId: parentRunId, excludeRunIds: [parentRunId, ...childRunIds] });
|
|
3250
|
+
|
|
3025
3251
|
const consolidated = await this._crossVoiceConsolidate(
|
|
3026
3252
|
voiceReviews, prMetadata, consolInstructions, worktreePath,
|
|
3027
|
-
{ provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback }
|
|
3253
|
+
{ provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback, excludePrevious, dedupContext }
|
|
3028
3254
|
);
|
|
3029
3255
|
|
|
3030
3256
|
const finalSuggestions = this.validateAndFinalizeSuggestions(
|
|
@@ -3058,7 +3284,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3058
3284
|
|
|
3059
3285
|
// Fallback: use all voice suggestions combined
|
|
3060
3286
|
const fallbackSuggestions = this.validateAndFinalizeSuggestions(
|
|
3061
|
-
allVoiceSuggestions, fileLineCountMap, validFiles
|
|
3287
|
+
[...allVoiceSuggestions, ...allVoiceFileLevelSuggestions], fileLineCountMap, validFiles
|
|
3062
3288
|
);
|
|
3063
3289
|
await this.storeSuggestions(reviewId, parentRunId, fallbackSuggestions, null, validFiles);
|
|
3064
3290
|
|
|
@@ -3092,7 +3318,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3092
3318
|
* @param {string} reviewContext.worktreePath - Path to the git worktree
|
|
3093
3319
|
* @param {Object} reviewContext.prMetadata - PR/review metadata
|
|
3094
3320
|
* @param {Array<string>} [reviewContext.changedFiles] - Changed files list
|
|
3095
|
-
* @param {Object} reviewContext.instructions - Instructions object { repoInstructions, requestInstructions }
|
|
3321
|
+
* @param {Object} reviewContext.instructions - Instructions object { globalInstructions, repoInstructions, requestInstructions }
|
|
3096
3322
|
* @param {Object} councilConfig - Council configuration
|
|
3097
3323
|
* @param {Object} councilConfig.levels - Level configurations keyed by '1', '2', '3'
|
|
3098
3324
|
* @param {Object} [councilConfig.consolidation] - Consolidation provider/model/tier
|
|
@@ -3100,11 +3326,13 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3100
3326
|
* @param {string} options.analysisId - Analysis ID for process tracking
|
|
3101
3327
|
* @param {string} [options.runId] - Pre-generated run ID
|
|
3102
3328
|
* @param {Function} [options.progressCallback] - Progress callback
|
|
3329
|
+
* @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
|
|
3330
|
+
* @param {number} [options.serverPort] - Server port for dedup API calls
|
|
3103
3331
|
* @returns {Promise<Object>} Analysis results
|
|
3104
3332
|
*/
|
|
3105
3333
|
async runCouncilAnalysis(reviewContext, councilConfig, options = {}) {
|
|
3106
3334
|
const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
|
|
3107
|
-
const { analysisId, progressCallback } = options;
|
|
3335
|
+
const { analysisId, progressCallback, excludePrevious, serverPort } = options;
|
|
3108
3336
|
const runId = options.runId || uuidv4();
|
|
3109
3337
|
|
|
3110
3338
|
logger.section('Review Council Analysis Starting');
|
|
@@ -3114,7 +3342,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3114
3342
|
const { mergeInstructions } = require('../utils/instructions');
|
|
3115
3343
|
let mergedInstructions = null;
|
|
3116
3344
|
if (instructions && typeof instructions === 'object') {
|
|
3117
|
-
mergedInstructions = mergeInstructions(instructions.repoInstructions, instructions.requestInstructions);
|
|
3345
|
+
mergedInstructions = mergeInstructions({ globalInstructions: instructions.globalInstructions, repoInstructions: instructions.repoInstructions, requestInstructions: instructions.requestInstructions });
|
|
3118
3346
|
}
|
|
3119
3347
|
|
|
3120
3348
|
// Load generated file patterns
|
|
@@ -3159,7 +3387,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3159
3387
|
model: voice.model,
|
|
3160
3388
|
tier,
|
|
3161
3389
|
timeout: voice.timeout || 600000,
|
|
3162
|
-
customInstructions: voiceInstructions
|
|
3390
|
+
customInstructions: voiceInstructions,
|
|
3391
|
+
voiceCustomInstructions: voice.customInstructions || null
|
|
3163
3392
|
});
|
|
3164
3393
|
}
|
|
3165
3394
|
}
|
|
@@ -3249,7 +3478,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3249
3478
|
}
|
|
3250
3479
|
}
|
|
3251
3480
|
|
|
3252
|
-
|
|
3481
|
+
const hasExcludePrevious = excludePrevious && (excludePrevious.github || excludePrevious.feedback);
|
|
3482
|
+
if (voiceSuccessCount === 1 && successfulVoiceLevels.size === 1 && !hasExcludePrevious) {
|
|
3253
3483
|
// Single voice, single level — skip all consolidation
|
|
3254
3484
|
logger.info('[Council] Single reviewer result — skipping consolidation');
|
|
3255
3485
|
const singleLevel = [...successfulVoiceLevels][0];
|
|
@@ -3266,30 +3496,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3266
3496
|
};
|
|
3267
3497
|
}
|
|
3268
3498
|
|
|
3269
|
-
//
|
|
3270
|
-
const totalSuggestionCount = Object.values(levelSuggestions).reduce((sum, arr) => sum + arr.length, 0);
|
|
3271
|
-
if (totalSuggestionCount < COUNCIL_CONSOLIDATION_THRESHOLD) {
|
|
3272
|
-
logger.info(`[Council] ${totalSuggestionCount} total suggestions below threshold (${COUNCIL_CONSOLIDATION_THRESHOLD}) — skipping consolidation`);
|
|
3273
|
-
const allSuggestions = Object.values(levelSuggestions).flat();
|
|
3274
|
-
const finalSuggestions = this.validateAndFinalizeSuggestions(
|
|
3275
|
-
allSuggestions, fileLineCountMap, validFiles
|
|
3276
|
-
);
|
|
3277
|
-
await this.storeSuggestions(reviewId, runId, finalSuggestions, null, validFiles);
|
|
3278
|
-
|
|
3279
|
-
// Prefer voice summaries over generic placeholders. When multiple voices
|
|
3280
|
-
// produced summaries, join them so no insight is lost.
|
|
3281
|
-
const thresholdSummary = voiceSummaries.length > 1
|
|
3282
|
-
? voiceSummaries.join('\n\n')
|
|
3283
|
-
: bestVoiceSummary;
|
|
3284
|
-
|
|
3285
|
-
return {
|
|
3286
|
-
runId,
|
|
3287
|
-
suggestions: finalSuggestions,
|
|
3288
|
-
summary: thresholdSummary || `Council analysis complete: ${finalSuggestions.length} suggestions (consolidation skipped — below threshold)`
|
|
3289
|
-
};
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
// Store raw per-reviewer suggestions (only when consolidation will occur)
|
|
3499
|
+
// Store raw per-reviewer suggestions before consolidation
|
|
3293
3500
|
logger.info(`[Council] Storing ${rawSuggestions.length} raw reviewer suggestions`);
|
|
3294
3501
|
await this._storeCouncilSuggestions(reviewId, runId, rawSuggestions, validFiles);
|
|
3295
3502
|
|
|
@@ -3336,8 +3543,16 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3336
3543
|
// Multiple reviewers — run intra-level consolidation
|
|
3337
3544
|
logger.info(`[Council] Pass 1: Consolidating ${successfulVoicesForLevel.length} reviewers for Level ${level}`);
|
|
3338
3545
|
try {
|
|
3546
|
+
const voiceGroups = successfulVoicesForLevel.map(({ t: task, idx }) => ({
|
|
3547
|
+
voiceId: task.voiceId,
|
|
3548
|
+
provider: task.provider,
|
|
3549
|
+
model: task.model,
|
|
3550
|
+
customInstructions: task.voiceCustomInstructions,
|
|
3551
|
+
summary: voiceResults[idx].value.summary,
|
|
3552
|
+
suggestions: voiceResults[idx].value.suggestions
|
|
3553
|
+
}));
|
|
3339
3554
|
const consolidated = await this._intraLevelConsolidate(
|
|
3340
|
-
level,
|
|
3555
|
+
level, voiceGroups, prMetadata, orchInstructions, worktreePath,
|
|
3341
3556
|
{ provider: orchProvider, model: orchModel, tier: orchTier, timeout: orchConfig.timeout, analysisId, progressCallback, reviewerCount: successfulVoicesForLevel.length }
|
|
3342
3557
|
);
|
|
3343
3558
|
consolidatedPerLevel[level] = consolidated;
|
|
@@ -3365,9 +3580,12 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3365
3580
|
level3: consolidatedPerLevel[3] || []
|
|
3366
3581
|
};
|
|
3367
3582
|
|
|
3583
|
+
// Build dedup context for cross-level orchestration
|
|
3584
|
+
const dedupContext = buildDedupContext(prMetadata, { reviewId, serverPort, runId });
|
|
3585
|
+
|
|
3368
3586
|
const orchestrationResult = await this.orchestrateWithAI(
|
|
3369
3587
|
allSuggestions, prMetadata, orchInstructions, worktreePath,
|
|
3370
|
-
{ analysisId, tier: orchTier, progressCallback, providerOverride: orchProvider, modelOverride: orchModel, timeout: orchConfig.timeout || 600000 }
|
|
3588
|
+
{ analysisId, tier: orchTier, progressCallback, providerOverride: orchProvider, modelOverride: orchModel, timeout: orchConfig.timeout || 600000, excludePrevious, dedupContext }
|
|
3371
3589
|
);
|
|
3372
3590
|
|
|
3373
3591
|
// Report cross-level orchestration step as completed
|
|
@@ -3499,7 +3717,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3499
3717
|
/**
|
|
3500
3718
|
* Run intra-level consolidation for a level with multiple voices
|
|
3501
3719
|
* @param {number} level - Analysis level
|
|
3502
|
-
* @param {Array}
|
|
3720
|
+
* @param {Array<Object>} voiceGroups - Per-reviewer groups: { voiceId, provider, model, customInstructions, suggestions }
|
|
3503
3721
|
* @param {Object} prMetadata - PR metadata
|
|
3504
3722
|
* @param {string} customInstructions - Custom instructions
|
|
3505
3723
|
* @param {string} worktreePath - Worktree path
|
|
@@ -3507,7 +3725,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3507
3725
|
* @returns {Promise<Array>} Consolidated suggestions
|
|
3508
3726
|
* @private
|
|
3509
3727
|
*/
|
|
3510
|
-
async _intraLevelConsolidate(level,
|
|
3728
|
+
async _intraLevelConsolidate(level, voiceGroups, prMetadata, customInstructions, worktreePath, orchConfig) {
|
|
3511
3729
|
const { provider, model, tier, timeout, analysisId, progressCallback, reviewerCount } = orchConfig;
|
|
3512
3730
|
|
|
3513
3731
|
const aiProvider = createProvider(provider, model);
|
|
@@ -3517,13 +3735,26 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3517
3735
|
? `local changes (review #${prMetadata.pr_number || 'local'})`
|
|
3518
3736
|
: `pull request #${prMetadata.pr_number}`;
|
|
3519
3737
|
|
|
3738
|
+
const reviewerSuggestions = voiceGroups.map(g => {
|
|
3739
|
+
let desc = `### Reviewer: ${g.voiceId} (${g.provider}/${g.model})\n`;
|
|
3740
|
+
if (g.customInstructions) {
|
|
3741
|
+
desc += `\n**Review Focus:**\n${g.customInstructions}\n`;
|
|
3742
|
+
}
|
|
3743
|
+
if (g.summary) desc += `\n**Summary:** ${g.summary}\n`;
|
|
3744
|
+
desc += `\n**Suggestions:**\n${JSON.stringify(g.suggestions, null, 2)}`;
|
|
3745
|
+
return desc;
|
|
3746
|
+
}).join('\n\n---\n\n');
|
|
3747
|
+
|
|
3748
|
+
const suggestionCount = voiceGroups.reduce((sum, g) => sum + g.suggestions.length, 0);
|
|
3749
|
+
|
|
3520
3750
|
const promptBuilder = getPromptBuilder('consolidation', tier || 'balanced', provider);
|
|
3521
3751
|
const prompt = promptBuilder.build({
|
|
3522
3752
|
reviewIntro: `You are consolidating Level ${level} code review suggestions from multiple independent AI reviewers for ${reviewDescription}.`,
|
|
3523
3753
|
customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
|
|
3754
|
+
dedupInstructions: '',
|
|
3524
3755
|
lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
|
|
3525
|
-
reviewerSuggestions
|
|
3526
|
-
suggestionCount
|
|
3756
|
+
reviewerSuggestions,
|
|
3757
|
+
suggestionCount,
|
|
3527
3758
|
reviewerCount: reviewerCount || 'multiple'
|
|
3528
3759
|
});
|
|
3529
3760
|
|
|
@@ -3635,15 +3866,17 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3635
3866
|
* @private
|
|
3636
3867
|
*/
|
|
3637
3868
|
_defaultConsolidation(councilConfig) {
|
|
3638
|
-
// Use the first voice as consolidation model
|
|
3639
3869
|
const voices = councilConfig.voices || [];
|
|
3640
|
-
|
|
3870
|
+
// Prefer first non-executable voice for consolidation
|
|
3871
|
+
const nativeVoice = voices.find(v => !getProviderClass(v.provider)?.isExecutable);
|
|
3872
|
+
if (nativeVoice) {
|
|
3641
3873
|
return {
|
|
3642
|
-
provider:
|
|
3643
|
-
model:
|
|
3644
|
-
tier:
|
|
3874
|
+
provider: nativeVoice.provider,
|
|
3875
|
+
model: nativeVoice.model,
|
|
3876
|
+
tier: nativeVoice.tier || 'balanced'
|
|
3645
3877
|
};
|
|
3646
3878
|
}
|
|
3879
|
+
// All-executable council: fall back to claude/sonnet-4.6
|
|
3647
3880
|
return { provider: 'claude', model: 'sonnet-4.6', tier: 'balanced' };
|
|
3648
3881
|
}
|
|
3649
3882
|
|
|
@@ -3656,24 +3889,29 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3656
3889
|
* @param {Object} prMetadata - PR metadata
|
|
3657
3890
|
* @param {string} customInstructions - Merged custom instructions
|
|
3658
3891
|
* @param {string} worktreePath - Worktree path
|
|
3659
|
-
* @param {Object} config - { provider, model, tier, timeout, analysisId, progressCallback }
|
|
3892
|
+
* @param {Object} config - { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext }
|
|
3660
3893
|
* @returns {Promise<Object>} { suggestions, summary }
|
|
3661
3894
|
* @private
|
|
3662
3895
|
*/
|
|
3663
3896
|
async _crossVoiceConsolidate(voiceReviews, prMetadata, customInstructions, worktreePath, config) {
|
|
3664
|
-
const { provider, model, tier, timeout, analysisId, progressCallback } = config;
|
|
3897
|
+
const { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext } = config;
|
|
3665
3898
|
|
|
3666
3899
|
const aiProvider = createProvider(provider, model);
|
|
3667
3900
|
|
|
3668
3901
|
const voiceDescriptions = voiceReviews.map(v => {
|
|
3669
|
-
let desc = `### Reviewer: ${v.voiceKey}
|
|
3670
|
-
if (v.
|
|
3671
|
-
desc += `
|
|
3902
|
+
let desc = `### Reviewer: ${v.voiceKey}`;
|
|
3903
|
+
if (v.isExecutable) desc += ' [external tool]';
|
|
3904
|
+
desc += ` (${v.provider}/${v.model}) — ${v.suggestionCount} suggestions\n`;
|
|
3905
|
+
if (v.customInstructions) {
|
|
3906
|
+
desc += `\n**Review Focus:**\n${v.customInstructions}\n`;
|
|
3907
|
+
}
|
|
3908
|
+
if (v.summary) desc += `\n**Summary:** ${v.summary}\n`;
|
|
3909
|
+
desc += `\n**Suggestions:**\n${JSON.stringify(v.suggestions, null, 2)}`;
|
|
3672
3910
|
if (v.fileLevelSuggestions?.length > 0) {
|
|
3673
|
-
desc += `\
|
|
3911
|
+
desc += `\n**File-Level Suggestions:**\n${JSON.stringify(v.fileLevelSuggestions, null, 2)}`;
|
|
3674
3912
|
}
|
|
3675
3913
|
return desc;
|
|
3676
|
-
}).join('\n\n');
|
|
3914
|
+
}).join('\n\n---\n\n');
|
|
3677
3915
|
|
|
3678
3916
|
const isLocal = prMetadata.reviewType === 'local';
|
|
3679
3917
|
const reviewDescription = isLocal
|
|
@@ -3685,6 +3923,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3685
3923
|
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.`,
|
|
3686
3924
|
customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
|
|
3687
3925
|
lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
|
|
3926
|
+
dedupInstructions: buildDedupInstructions(excludePrevious, dedupContext || {}),
|
|
3688
3927
|
reviewerSuggestions: voiceDescriptions,
|
|
3689
3928
|
suggestionCount: voiceReviews.reduce((sum, v) => sum + v.suggestionCount, 0),
|
|
3690
3929
|
reviewerCount: voiceReviews.length
|
|
@@ -3702,7 +3941,26 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3702
3941
|
});
|
|
3703
3942
|
|
|
3704
3943
|
const suggestions = this.parseResponse(response, 'consolidation');
|
|
3705
|
-
|
|
3944
|
+
|
|
3945
|
+
// Extract summary from response, falling back to raw JSON extraction (same pattern as orchestrateWithAI)
|
|
3946
|
+
let summary;
|
|
3947
|
+
if (response.summary) {
|
|
3948
|
+
summary = response.summary;
|
|
3949
|
+
} else if (response.raw) {
|
|
3950
|
+
const extracted = extractJSON(response.raw, 'consolidation');
|
|
3951
|
+
if (extracted.success && extracted.data.summary) {
|
|
3952
|
+
summary = extracted.data.summary;
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
if (!summary) {
|
|
3956
|
+
// Fall back to individual reviewer summaries rather than generic consolidation text
|
|
3957
|
+
const reviewerSummaries = voiceReviews
|
|
3958
|
+
.filter(v => v.summary)
|
|
3959
|
+
.map(v => v.summary);
|
|
3960
|
+
summary = reviewerSummaries.length > 0
|
|
3961
|
+
? reviewerSummaries.join('\n\n')
|
|
3962
|
+
: `Review complete: ${suggestions.length} suggestions`;
|
|
3963
|
+
}
|
|
3706
3964
|
|
|
3707
3965
|
return { suggestions, summary };
|
|
3708
3966
|
}
|
|
@@ -3728,4 +3986,6 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3728
3986
|
}
|
|
3729
3987
|
}
|
|
3730
3988
|
|
|
3731
|
-
module.exports = Analyzer;
|
|
3989
|
+
module.exports = Analyzer;
|
|
3990
|
+
module.exports.buildDedupContext = buildDedupContext;
|
|
3991
|
+
module.exports.buildDedupInstructions = buildDedupInstructions;
|