@in-the-loop-labs/pair-review 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  const express = require('express');
13
- const { RepoSettingsRepository, ReviewRepository } = require('../database');
13
+ const { RepoSettingsRepository, ReviewRepository, queryOne } = require('../database');
14
14
  const {
15
15
  getAllProvidersInfo,
16
16
  testProviderAvailability,
@@ -82,7 +82,7 @@ router.patch('/api/config', async (req, res) => {
82
82
  });
83
83
 
84
84
  } catch (error) {
85
- console.error('Error updating config:', error);
85
+ logger.error('Error updating config:', error);
86
86
  res.status(500).json({
87
87
  error: 'Failed to update configuration'
88
88
  });
@@ -109,7 +109,9 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
109
109
  default_instructions: null,
110
110
  default_provider: null,
111
111
  default_model: null,
112
- local_path: null
112
+ local_path: null,
113
+ default_council_id: null,
114
+ default_tab: null
113
115
  });
114
116
  }
115
117
 
@@ -119,12 +121,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
119
121
  default_provider: settings.default_provider,
120
122
  default_model: settings.default_model,
121
123
  local_path: settings.local_path,
124
+ default_council_id: settings.default_council_id,
125
+ default_tab: settings.default_tab,
122
126
  created_at: settings.created_at,
123
127
  updated_at: settings.updated_at
124
128
  });
125
129
 
126
130
  } catch (error) {
127
- console.error('Error fetching repo settings:', error);
131
+ logger.error('Error fetching repo settings:', error);
128
132
  res.status(500).json({
129
133
  error: 'Failed to fetch repository settings'
130
134
  });
@@ -138,14 +142,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
138
142
  router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
139
143
  try {
140
144
  const { owner, repo } = req.params;
141
- const { default_instructions, default_provider, default_model, local_path } = req.body;
145
+ const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab } = req.body;
142
146
  const repository = normalizeRepository(owner, repo);
143
147
  const db = req.app.get('db');
144
148
 
145
149
  // Validate that at least one setting is provided
146
- if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined) {
150
+ if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined) {
147
151
  return res.status(400).json({
148
- error: 'At least one setting (default_instructions, default_provider, default_model, or local_path) must be provided'
152
+ error: 'At least one setting (default_instructions, default_provider, default_model, local_path, default_council_id, or default_tab) must be provided'
149
153
  });
150
154
  }
151
155
 
@@ -154,7 +158,9 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
154
158
  default_instructions,
155
159
  default_provider,
156
160
  default_model,
157
- local_path
161
+ local_path,
162
+ default_council_id,
163
+ default_tab
158
164
  });
159
165
 
160
166
  logger.info(`Saved repo settings for ${repository}`);
@@ -167,12 +173,14 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
167
173
  default_provider: settings.default_provider,
168
174
  default_model: settings.default_model,
169
175
  local_path: settings.local_path,
176
+ default_council_id: settings.default_council_id,
177
+ default_tab: settings.default_tab,
170
178
  updated_at: settings.updated_at
171
179
  }
172
180
  });
173
181
 
174
182
  } catch (error) {
175
- console.error('Error saving repo settings:', error);
183
+ logger.error('Error saving repo settings:', error);
176
184
  res.status(500).json({
177
185
  error: 'Failed to save repository settings'
178
186
  });
@@ -202,16 +210,29 @@ router.get('/api/pr/:owner/:repo/:number/review-settings', async (req, res) => {
202
210
 
203
211
  if (!review) {
204
212
  return res.json({
205
- custom_instructions: null
213
+ custom_instructions: null,
214
+ last_council_id: null
206
215
  });
207
216
  }
208
217
 
218
+ // Find the last council used for this review
219
+ let last_council_id = null;
220
+ const lastCouncilRun = await queryOne(db, `
221
+ SELECT model FROM analysis_runs
222
+ WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
223
+ ORDER BY started_at DESC LIMIT 1
224
+ `, [review.id]);
225
+ if (lastCouncilRun) {
226
+ last_council_id = lastCouncilRun.model;
227
+ }
228
+
209
229
  res.json({
210
- custom_instructions: review.custom_instructions || null
230
+ custom_instructions: review.custom_instructions || null,
231
+ last_council_id
211
232
  });
212
233
 
213
234
  } catch (error) {
214
- console.error('Error fetching review settings:', error);
235
+ logger.error('Error fetching review settings:', error);
215
236
  res.status(500).json({
216
237
  error: 'Failed to fetch review settings'
217
238
  });
@@ -238,7 +259,7 @@ router.get('/api/providers', (req, res) => {
238
259
  checkInProgress: isCheckInProgress()
239
260
  });
240
261
  } catch (error) {
241
- console.error('Error fetching providers:', error);
262
+ logger.error('Error fetching providers:', error);
242
263
  res.status(500).json({
243
264
  error: 'Failed to fetch AI providers'
244
265
  });
@@ -261,7 +282,7 @@ router.get('/api/providers/:providerId/test', async (req, res) => {
261
282
  installInstructions: result.installInstructions || null
262
283
  });
263
284
  } catch (error) {
264
- console.error('Error testing provider:', error);
285
+ logger.error('Error testing provider:', error);
265
286
  res.status(500).json({
266
287
  error: 'Failed to test provider availability'
267
288
  });
@@ -298,7 +319,7 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
298
319
  checkInProgress: true
299
320
  });
300
321
  } catch (error) {
301
- console.error('Error refreshing provider availability:', error);
322
+ logger.error('Error refreshing provider availability:', error);
302
323
  res.status(500).json({
303
324
  error: 'Failed to refresh provider availability'
304
325
  });
@@ -0,0 +1,351 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Council Routes
4
+ *
5
+ * CRUD endpoints for managing Review Council configurations.
6
+ * Councils define multi-voice, multi-provider analysis configurations
7
+ * that run in parallel and consolidate results.
8
+ */
9
+
10
+ const express = require('express');
11
+ const { v4: uuidv4 } = require('uuid');
12
+ const logger = require('../utils/logger');
13
+ const { CouncilRepository } = require('../database');
14
+
15
+ const router = express.Router();
16
+
17
+ /**
18
+ * Normalize a council config to match the expected shape for its type.
19
+ *
20
+ * When type is 'council' (voice-centric) but the config is in the levels-based
21
+ * (advanced) format — e.g. from a previously saved council or a migration — this
22
+ * extracts the voices and converts the levels to booleans so it passes validation.
23
+ *
24
+ * When type is anything else, or the config already matches, returns the config
25
+ * as-is.
26
+ *
27
+ * @param {Object} config - Council configuration
28
+ * @param {string} [type] - The council type ('council' or 'advanced')
29
+ * @returns {Object} Normalized config (may be the original object if no changes needed)
30
+ */
31
+ function normalizeCouncilConfig(config, type) {
32
+ if (!config || typeof config !== 'object' || type !== 'council') {
33
+ return config;
34
+ }
35
+
36
+ // If it already has a voices array, it's already in voice-centric format
37
+ if (Array.isArray(config.voices) && config.voices.length > 0) {
38
+ return config;
39
+ }
40
+
41
+ // Check if levels are in the advanced format (objects with enabled/voices)
42
+ if (!config.levels || typeof config.levels !== 'object') {
43
+ return config;
44
+ }
45
+
46
+ const hasAdvancedLevels = Object.values(config.levels).some(
47
+ val => typeof val === 'object' && val !== null && 'enabled' in val
48
+ );
49
+
50
+ if (!hasAdvancedLevels) {
51
+ return config;
52
+ }
53
+
54
+ // Convert from advanced (levels-based) to voice-centric format
55
+ const normalizedVoices = [];
56
+ const seenVoices = new Set();
57
+ const normalizedLevels = {};
58
+
59
+ for (const [key, levelConfig] of Object.entries(config.levels)) {
60
+ if (typeof levelConfig === 'object' && levelConfig !== null) {
61
+ normalizedLevels[key] = levelConfig.enabled !== false;
62
+ if (levelConfig.enabled !== false && Array.isArray(levelConfig.voices)) {
63
+ for (const v of levelConfig.voices) {
64
+ const voiceSig = JSON.stringify(v, Object.keys(v).sort());
65
+ if (!seenVoices.has(voiceSig)) {
66
+ seenVoices.add(voiceSig);
67
+ normalizedVoices.push(v);
68
+ }
69
+ }
70
+ }
71
+ } else {
72
+ // Already boolean — keep as-is
73
+ normalizedLevels[key] = levelConfig !== false;
74
+ }
75
+ }
76
+
77
+ // Destructure out orchestration so it does not leak into the normalized output
78
+ const { orchestration, ...rest } = config;
79
+ return {
80
+ ...rest,
81
+ voices: normalizedVoices,
82
+ levels: normalizedLevels,
83
+ consolidation: config.consolidation || orchestration || undefined
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Validate a council config object
89
+ * @param {Object} config - Council configuration
90
+ * @param {string} [type] - The council type ('council' or 'advanced'), provided as a sibling field from req.body
91
+ * @returns {string|null} Error message or null if valid
92
+ */
93
+ function validateCouncilConfig(config, type) {
94
+ if (!config || typeof config !== 'object') {
95
+ return 'config must be an object';
96
+ }
97
+
98
+ // Dispatch based on explicit type parameter (from req.body.type, not config.type)
99
+ if (type === 'council') {
100
+ return validateCouncilFormat(config);
101
+ }
102
+
103
+ // Legacy configs (no type) and type === 'advanced' use level-centric format
104
+ return validateAdvancedFormat(config);
105
+ }
106
+
107
+ /**
108
+ * Validate the voice-centric council format (type: 'council')
109
+ * @param {Object} config
110
+ * @returns {string|null} Error message or null if valid
111
+ */
112
+ function validateCouncilFormat(config) {
113
+ // Validate voices array
114
+ if (!Array.isArray(config.voices) || config.voices.length === 0) {
115
+ return 'config.voices must be a non-empty array';
116
+ }
117
+
118
+ for (const [i, voice] of config.voices.entries()) {
119
+ if (!voice.provider) {
120
+ return `voices[${i}].provider is required`;
121
+ }
122
+ if (!voice.model) {
123
+ return `voices[${i}].model is required`;
124
+ }
125
+ }
126
+
127
+ // Validate levels
128
+ if (!config.levels || typeof config.levels !== 'object') {
129
+ return 'config.levels is required and must be an object';
130
+ }
131
+
132
+ const validLevels = ['1', '2', '3'];
133
+ const hasEnabled = Object.entries(config.levels).some(([key, val]) =>
134
+ validLevels.includes(key) && val === true
135
+ );
136
+ if (!hasEnabled) {
137
+ return 'At least one level (1, 2, or 3) must be enabled';
138
+ }
139
+
140
+ // Validate consolidation (optional)
141
+ if (config.consolidation) {
142
+ if (!config.consolidation.provider || !config.consolidation.model) {
143
+ return 'consolidation.provider and consolidation.model are required when consolidation is specified';
144
+ }
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Validate the level-centric advanced format (type: 'advanced' or legacy no-type)
152
+ * @param {Object} config
153
+ * @returns {string|null} Error message or null if valid
154
+ */
155
+ function validateAdvancedFormat(config) {
156
+ // Validate levels
157
+ if (!config.levels || typeof config.levels !== 'object') {
158
+ return 'config.levels is required and must be an object';
159
+ }
160
+
161
+ const validLevels = ['1', '2', '3'];
162
+ for (const [levelKey, level] of Object.entries(config.levels)) {
163
+ if (!validLevels.includes(levelKey)) {
164
+ return `Invalid level key: "${levelKey}". Valid keys: ${validLevels.join(', ')}`;
165
+ }
166
+
167
+ if (typeof level.enabled !== 'boolean') {
168
+ return `levels.${levelKey}.enabled must be a boolean`;
169
+ }
170
+
171
+ if (level.enabled) {
172
+ if (!Array.isArray(level.voices) || level.voices.length === 0) {
173
+ return `levels.${levelKey}.voices must be a non-empty array when enabled`;
174
+ }
175
+
176
+ for (const [i, voice] of level.voices.entries()) {
177
+ if (!voice.provider) {
178
+ return `levels.${levelKey}.voices[${i}].provider is required`;
179
+ }
180
+ if (!voice.model) {
181
+ return `levels.${levelKey}.voices[${i}].model is required`;
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // Ensure at least one level is enabled with voices
188
+ const hasEnabledLevel = Object.values(config.levels).some(l => l.enabled);
189
+ if (!hasEnabledLevel) {
190
+ return 'At least one level must be enabled';
191
+ }
192
+
193
+ // Validate orchestration (optional — defaults will be applied at runtime)
194
+ if (config.orchestration) {
195
+ if (!config.orchestration.provider || !config.orchestration.model) {
196
+ return 'orchestration.provider and orchestration.model are required when orchestration is specified';
197
+ }
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * GET /api/councils — List all saved councils
205
+ */
206
+ router.get('/api/councils', async (req, res) => {
207
+ try {
208
+ const db = req.app.get('db');
209
+ const councilRepo = new CouncilRepository(db);
210
+ const councils = await councilRepo.list();
211
+
212
+ res.json({ councils });
213
+ } catch (error) {
214
+ logger.error('Error listing councils:', error);
215
+ res.status(500).json({ error: 'Failed to list councils' });
216
+ }
217
+ });
218
+
219
+ /**
220
+ * GET /api/councils/:id — Get a specific council
221
+ */
222
+ router.get('/api/councils/:id', async (req, res) => {
223
+ try {
224
+ const { id } = req.params;
225
+ const db = req.app.get('db');
226
+ const councilRepo = new CouncilRepository(db);
227
+ const council = await councilRepo.getById(id);
228
+
229
+ if (!council) {
230
+ return res.status(404).json({ error: 'Council not found' });
231
+ }
232
+
233
+ res.json({ council });
234
+ } catch (error) {
235
+ logger.error('Error fetching council:', error);
236
+ res.status(500).json({ error: 'Failed to fetch council' });
237
+ }
238
+ });
239
+
240
+ /**
241
+ * POST /api/councils — Create a new council
242
+ */
243
+ router.post('/api/councils', async (req, res) => {
244
+ try {
245
+ const { name, config, type } = req.body || {};
246
+
247
+ if (!name || !name.trim()) {
248
+ return res.status(400).json({ error: 'name is required' });
249
+ }
250
+
251
+ if (!config) {
252
+ return res.status(400).json({ error: 'config is required' });
253
+ }
254
+
255
+ const effectiveType = type || 'advanced';
256
+ const validationError = validateCouncilConfig(config, effectiveType);
257
+ if (validationError) {
258
+ return res.status(400).json({ error: validationError });
259
+ }
260
+
261
+ const db = req.app.get('db');
262
+ const councilRepo = new CouncilRepository(db);
263
+ const id = uuidv4();
264
+ const council = await councilRepo.create({ id, name: name.trim(), config, type: effectiveType });
265
+
266
+ res.status(201).json({ council });
267
+ } catch (error) {
268
+ logger.error('Error creating council:', error);
269
+ res.status(500).json({ error: 'Failed to create council' });
270
+ }
271
+ });
272
+
273
+ /**
274
+ * PUT /api/councils/:id — Update a council
275
+ */
276
+ router.put('/api/councils/:id', async (req, res) => {
277
+ try {
278
+ const { id } = req.params;
279
+ const { name, config, type } = req.body || {};
280
+
281
+ const db = req.app.get('db');
282
+ const councilRepo = new CouncilRepository(db);
283
+
284
+ // Verify council exists
285
+ const existing = await councilRepo.getById(id);
286
+ if (!existing) {
287
+ return res.status(404).json({ error: 'Council not found' });
288
+ }
289
+
290
+ // Validate config if provided
291
+ if (config) {
292
+ // A PUT might update config without changing type, so use the effective type:
293
+ // prefer the explicitly provided type, fall back to the existing record's type
294
+ const effectiveType = type !== undefined ? type : existing.type;
295
+ const validationError = validateCouncilConfig(config, effectiveType);
296
+ if (validationError) {
297
+ return res.status(400).json({ error: validationError });
298
+ }
299
+ } else if (type !== undefined && type !== existing.type) {
300
+ // Type is changing without a new config — validate existing config against the new type
301
+ const validationError = validateCouncilConfig(existing.config, type);
302
+ if (validationError) {
303
+ return res.status(400).json({ error: `Existing config is incompatible with type '${type}': ${validationError}` });
304
+ }
305
+ }
306
+
307
+ const updates = {};
308
+ if (name !== undefined) {
309
+ const trimmed = name.trim();
310
+ if (!trimmed) {
311
+ return res.status(400).json({ error: 'name cannot be empty' });
312
+ }
313
+ updates.name = trimmed;
314
+ }
315
+ if (config !== undefined) updates.config = config;
316
+ if (type !== undefined) updates.type = type;
317
+
318
+ await councilRepo.update(id, updates);
319
+ const updated = await councilRepo.getById(id);
320
+
321
+ res.json({ council: updated });
322
+ } catch (error) {
323
+ logger.error('Error updating council:', error);
324
+ res.status(500).json({ error: 'Failed to update council' });
325
+ }
326
+ });
327
+
328
+ /**
329
+ * DELETE /api/councils/:id — Delete a council
330
+ */
331
+ router.delete('/api/councils/:id', async (req, res) => {
332
+ try {
333
+ const { id } = req.params;
334
+ const db = req.app.get('db');
335
+ const councilRepo = new CouncilRepository(db);
336
+
337
+ const existed = await councilRepo.delete(id);
338
+ if (!existed) {
339
+ return res.status(404).json({ error: 'Council not found' });
340
+ }
341
+
342
+ res.json({ success: true });
343
+ } catch (error) {
344
+ logger.error('Error deleting council:', error);
345
+ res.status(500).json({ error: 'Failed to delete council' });
346
+ }
347
+ });
348
+
349
+ module.exports = router;
350
+ module.exports.validateCouncilConfig = validateCouncilConfig;
351
+ module.exports.normalizeCouncilConfig = normalizeCouncilConfig;
@@ -33,7 +33,8 @@ const {
33
33
  determineCompletionInfo,
34
34
  broadcastProgress,
35
35
  CancellationError,
36
- createProgressCallback
36
+ createProgressCallback,
37
+ parseEnabledLevels
37
38
  } = require('./shared');
38
39
 
39
40
  const router = express.Router();
@@ -603,7 +604,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
603
604
  }
604
605
 
605
606
  // Extract optional provider, model, tier, customInstructions and skipLevel3 from request body
606
- const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3 } = req.body || {};
607
+ const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
607
608
 
608
609
  // Trim and validate custom instructions
609
610
  const MAX_INSTRUCTIONS_LENGTH = 5000;
@@ -680,6 +681,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
680
681
 
681
682
  // Create DB analysis_runs record immediately so it's queryable for polling
682
683
  const analysisRunRepo = new AnalysisRunRepository(db);
684
+ const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
683
685
  try {
684
686
  await analysisRunRepo.create({
685
687
  id: runId,
@@ -688,7 +690,9 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
688
690
  model: selectedModel,
689
691
  repoInstructions,
690
692
  requestInstructions,
691
- headSha: review.local_head_sha || null
693
+ headSha: review.local_head_sha || null,
694
+ configType: 'single',
695
+ levelsConfig
692
696
  });
693
697
  } catch (error) {
694
698
  logger.error('Failed to create analysis run record:', error);
@@ -705,9 +709,9 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
705
709
  startedAt: new Date().toISOString(),
706
710
  progress: 'Starting analysis...',
707
711
  levels: {
708
- 1: { status: 'running', progress: 'Starting...' },
709
- 2: { status: 'running', progress: 'Starting...' },
710
- 3: requestSkipLevel3 ? { status: 'skipped', progress: 'Skipped' } : { status: 'running', progress: 'Starting...' },
712
+ 1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
713
+ 2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
714
+ 3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
711
715
  4: { status: 'pending', progress: 'Pending' }
712
716
  },
713
717
  filesAnalyzed: 0,
@@ -760,7 +764,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
760
764
  const progressCallback = createProgressCallback(analysisId);
761
765
 
762
766
  // Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag)
763
- analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3 })
767
+ analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
764
768
  .then(async result => {
765
769
  logger.section('Local Analysis Results');
766
770
  logger.success(`Analysis complete for local review #${reviewId}`);
@@ -979,6 +983,7 @@ router.get('/api/local/:reviewId/suggestions', async (req, res) => {
979
983
  AND source = 'ai'
980
984
  AND ${levelFilter}
981
985
  AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
986
+ AND (is_raw = 0 OR is_raw IS NULL)
982
987
  AND ${runIdFilter}
983
988
  ORDER BY
984
989
  CASE
@@ -1741,10 +1746,11 @@ router.get('/api/local/:reviewId/has-ai-suggestions', async (req, res) => {
1741
1746
  }
1742
1747
 
1743
1748
  // Check if any AI suggestions exist for this review
1749
+ // Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
1744
1750
  const result = await queryOne(db, `
1745
1751
  SELECT EXISTS(
1746
1752
  SELECT 1 FROM comments
1747
- WHERE review_id = ? AND source = 'ai'
1753
+ WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
1748
1754
  ) as has_suggestions
1749
1755
  `, [reviewId]);
1750
1756
 
@@ -1997,12 +2003,25 @@ router.get('/api/local/:reviewId/review-settings', async (req, res) => {
1997
2003
 
1998
2004
  if (!review) {
1999
2005
  return res.json({
2000
- custom_instructions: null
2006
+ custom_instructions: null,
2007
+ last_council_id: null
2001
2008
  });
2002
2009
  }
2003
2010
 
2011
+ // Find the last council used for this review
2012
+ let last_council_id = null;
2013
+ const lastCouncilRun = await queryOne(db, `
2014
+ SELECT model FROM analysis_runs
2015
+ WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
2016
+ ORDER BY started_at DESC LIMIT 1
2017
+ `, [review.id]);
2018
+ if (lastCouncilRun) {
2019
+ last_council_id = lastCouncilRun.model;
2020
+ }
2021
+
2004
2022
  res.json({
2005
- custom_instructions: review.custom_instructions || null
2023
+ custom_instructions: review.custom_instructions || null,
2024
+ last_council_id
2006
2025
  });
2007
2026
 
2008
2027
  } catch (error) {
@@ -2072,7 +2091,10 @@ router.get('/api/local/:reviewId/analysis-runs', async (req, res) => {
2072
2091
  const analysisRunRepo = new AnalysisRunRepository(db);
2073
2092
  const runs = await analysisRunRepo.getByReviewId(reviewId);
2074
2093
 
2075
- res.json({ runs });
2094
+ res.json({ runs: runs.map(r => ({
2095
+ ...r,
2096
+ levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
2097
+ })) });
2076
2098
  } catch (error) {
2077
2099
  logger.error('Error fetching analysis runs:', error);
2078
2100
  res.status(500).json({ error: 'Failed to fetch analysis runs' });
package/src/routes/mcp.js CHANGED
@@ -273,6 +273,8 @@ function createMCPServer(db, options = {}) {
273
273
  ...reviewLookupSchema,
274
274
  limit: z.number().int().positive().optional()
275
275
  .describe('Maximum number of runs to return (most recent first). Use limit=1 to poll for the latest run.'),
276
+ includeChildRuns: z.boolean().optional()
277
+ .describe('Include child reviewer runs from council analyses. Defaults to false (only top-level runs).'),
276
278
  },
277
279
  async (args) => {
278
280
  const { review, error } = await resolveReview(args, db);
@@ -281,7 +283,12 @@ function createMCPServer(db, options = {}) {
281
283
  }
282
284
 
283
285
  const runRepo = new AnalysisRunRepository(db);
284
- const runs = await runRepo.getByReviewId(review.id, { limit: args.limit });
286
+ let runs = await runRepo.getByReviewId(review.id, { limit: args.limit });
287
+
288
+ // By default, exclude child runs (they're an internal implementation detail)
289
+ if (!args.includeChildRuns) {
290
+ runs = runs.filter(r => !r.parent_run_id);
291
+ }
285
292
 
286
293
  return {
287
294
  content: [{
@@ -368,7 +375,7 @@ function createMCPServer(db, options = {}) {
368
375
 
369
376
  // Build parameterized WHERE conditions
370
377
  const params = [runId];
371
- const conditions = ["ai_run_id = ?", "source = 'ai'", 'ai_level IS NULL'];
378
+ const conditions = ["ai_run_id = ?", "source = 'ai'", 'ai_level IS NULL', '(is_raw = 0 OR is_raw IS NULL)'];
372
379
 
373
380
  if (args.status) {
374
381
  conditions.push('status = ?');
@@ -91,7 +91,9 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
91
91
  return res.json({ setupId: existing.setupId });
92
92
  }
93
93
 
94
- // Check if we already have data for this PR in the database
94
+ // Check if we already have data AND a worktree for this PR in the database.
95
+ // When a user deletes a worktree, PR metadata is preserved but the worktree
96
+ // record is removed. We must re-run setup to recreate the worktree.
95
97
  const repository = normalizeRepository(owner, repo);
96
98
  const existingPR = await queryOne(
97
99
  db,
@@ -99,7 +101,15 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
99
101
  [prNumber, repository]
100
102
  );
101
103
  if (existingPR) {
102
- return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
104
+ const worktree = await queryOne(
105
+ db,
106
+ 'SELECT id FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE',
107
+ [prNumber, repository]
108
+ );
109
+ if (worktree) {
110
+ return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
111
+ }
112
+ logger.info(`PR metadata exists but worktree missing for ${repository} #${prNumber}, re-running setup`);
103
113
  }
104
114
 
105
115
  // Start the async setup