@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.
Files changed (79) hide show
  1. package/package.json +2 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +83 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +83 -8
  21. package/public/js/components/AnalysisConfigModal.js +155 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +239 -22
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +179 -12
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +141 -47
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +534 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +89 -4
  59. package/src/chat/prompt-builder.js +17 -1
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +133 -52
  64. package/src/local-review.js +15 -9
  65. package/src/main.js +3 -2
  66. package/src/routes/analyses.js +34 -8
  67. package/src/routes/chat.js +15 -8
  68. package/src/routes/config.js +3 -120
  69. package/src/routes/councils.js +15 -6
  70. package/src/routes/executable-analysis.js +494 -0
  71. package/src/routes/local.js +160 -26
  72. package/src/routes/mcp.js +9 -4
  73. package/src/routes/pr.js +166 -29
  74. package/src/routes/reviews.js +31 -5
  75. package/src/routes/shared.js +72 -5
  76. package/src/routes/worktrees.js +4 -2
  77. package/src/utils/comment-formatter.js +28 -11
  78. package/src/utils/instructions.js +22 -8
  79. package/src/utils/logger.js +20 -10
@@ -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 voiceAnalyzer = new Analyzer(db, voice.model, voice.provider);
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: mergedInstructions, // Keep for backward compat
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
- const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback, timeout: executionTimeout, logPrefix, reviewerNum });
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: mergedInstructions,
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 voiceKey = `${v.provider}-${v.model}${idx > 0 ? `-${idx}` : ''}`;
2800
- return [voiceKey, {
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 analyzeAllLevels
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: mergedInstructions,
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 voiceSummaries = [];
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.summary) {
2910
- voiceSummaries.push(settled.value.result.summary);
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 (successfulVoices.length === 1) {
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
- if (voiceSuccessCount === 1 && successfulVoiceLevels.size === 1) {
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
- // Check if total suggestion count is below consolidation threshold
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, suggestions, prMetadata, orchInstructions, worktreePath,
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} suggestions - Combined suggestions from all voices at this level
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, suggestions, prMetadata, customInstructions, worktreePath, orchConfig) {
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: JSON.stringify(suggestions, null, 2),
3526
- suggestionCount: suggestions.length,
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
- if (voices.length > 0) {
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: voices[0].provider,
3643
- model: voices[0].model,
3644
- tier: voices[0].tier || 'balanced'
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} (${v.provider}/${v.model}) — ${v.suggestionCount} suggestions\n`;
3670
- if (v.summary) desc += `Summary: ${v.summary}\n`;
3671
- desc += `Suggestions:\n${JSON.stringify(v.suggestions, null, 2)}`;
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 += `\nFile-Level Suggestions:\n${JSON.stringify(v.fileLevelSuggestions, null, 2)}`;
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
- const summary = response.summary || `Consolidated ${voiceReviews.length} reviewer outputs into ${suggestions.length} suggestions`;
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;