@in-the-loop-labs/pair-review 1.4.4 → 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
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  const express = require('express');
14
- const { query, queryOne, withTransaction, RepoSettingsRepository, ReviewRepository, CommentRepository, AnalysisRunRepository, PRMetadataRepository } = require('../database');
14
+ const { query, queryOne, withTransaction, RepoSettingsRepository, ReviewRepository, CommentRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
15
15
  const { GitWorktreeManager } = require('../git/worktree');
16
16
  const Analyzer = require('../ai/analyzer');
17
17
  const { v4: uuidv4 } = require('uuid');
@@ -23,18 +23,22 @@ const { normalizeRepository } = require('../utils/paths');
23
23
  const {
24
24
  activeAnalyses,
25
25
  prToAnalysisId,
26
+ localReviewToAnalysisId,
26
27
  progressClients,
27
28
  localReviewDiffs,
28
29
  getPRKey,
30
+ getLocalReviewKey,
29
31
  getModel,
30
32
  determineCompletionInfo,
31
33
  broadcastProgress,
32
34
  killProcesses,
33
35
  isAnalysisCancelled,
34
36
  CancellationError,
35
- createProgressCallback
37
+ createProgressCallback,
38
+ parseEnabledLevels
36
39
  } = require('./shared');
37
40
  const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
41
+ const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
38
42
 
39
43
  const router = express.Router();
40
44
 
@@ -47,7 +51,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
47
51
  const prNumber = parseInt(pr);
48
52
 
49
53
  // Extract optional provider, model, tier, customInstructions and skipLevel3 from request body
50
- const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3 } = req.body || {};
54
+ const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
51
55
 
52
56
  // Trim and validate custom instructions
53
57
  const MAX_INSTRUCTIONS_LENGTH = 5000;
@@ -156,6 +160,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
156
160
 
157
161
  // Create DB analysis_runs record immediately so it's queryable for polling
158
162
  const analysisRunRepo = new AnalysisRunRepository(db);
163
+ const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
159
164
  await analysisRunRepo.create({
160
165
  id: runId,
161
166
  reviewId: review.id,
@@ -163,7 +168,9 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
163
168
  model,
164
169
  repoInstructions,
165
170
  requestInstructions,
166
- headSha: prMetadata.head_sha || null
171
+ headSha: prMetadata.head_sha || null,
172
+ configType: 'single',
173
+ levelsConfig
167
174
  });
168
175
 
169
176
  // Store analysis status with separate tracking for each level
@@ -176,9 +183,9 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
176
183
  progress: 'Starting analysis...',
177
184
  // Track each level separately for parallel execution
178
185
  levels: {
179
- 1: { status: 'running', progress: 'Starting...' },
180
- 2: { status: 'running', progress: 'Starting...' },
181
- 3: requestSkipLevel3 ? { status: 'skipped', progress: 'Skipped' } : { status: 'running', progress: 'Starting...' },
186
+ 1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
187
+ 2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
188
+ 3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
182
189
  4: { status: 'pending', progress: 'Pending' }
183
190
  },
184
191
  filesAnalyzed: 0,
@@ -214,7 +221,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
214
221
  const progressCallback = createProgressCallback(analysisId);
215
222
 
216
223
  // Start analysis asynchronously (skipRunCreation since we created the record above; tier for prompt selection, skipLevel3 flag)
217
- analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3 })
224
+ analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
218
225
  .then(async result => {
219
226
  logger.section('Analysis Results');
220
227
  logger.success(`Analysis complete for PR #${prNumber}`);
@@ -587,10 +594,11 @@ router.get('/api/pr/:owner/:repo/:number/has-ai-suggestions', async (req, res) =
587
594
  }
588
595
 
589
596
  // Check if any AI suggestions exist for this PR using review.id
597
+ // Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
590
598
  const result = await queryOne(db, `
591
599
  SELECT EXISTS(
592
600
  SELECT 1 FROM comments
593
- WHERE review_id = ? AND source = 'ai'
601
+ WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
594
602
  ) as has_suggestions
595
603
  `, [review.id]);
596
604
 
@@ -772,6 +780,7 @@ router.get('/api/pr/:owner/:repo/:number/ai-suggestions', async (req, res) => {
772
780
  AND source = 'ai'
773
781
  AND ${levelFilter}
774
782
  AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
783
+ AND (is_raw = 0 OR is_raw IS NULL)
775
784
  AND ${runIdFilter}
776
785
  ORDER BY
777
786
  CASE
@@ -863,7 +872,10 @@ router.get('/api/analysis-runs/:reviewId', async (req, res) => {
863
872
  const analysisRunRepo = new AnalysisRunRepository(db);
864
873
  const runs = await analysisRunRepo.getByReviewId(parseInt(reviewId, 10));
865
874
 
866
- res.json({ runs });
875
+ res.json({ runs: runs.map(r => ({
876
+ ...r,
877
+ levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
878
+ })) });
867
879
  } catch (error) {
868
880
  logger.error('Error fetching analysis runs:', error);
869
881
  res.status(500).json({ error: 'Failed to fetch analysis runs' });
@@ -1087,4 +1099,477 @@ router.post('/api/analysis-results', async (req, res) => {
1087
1099
  }
1088
1100
  });
1089
1101
 
1102
+ /**
1103
+ * Launch a council analysis, shared by both PR and local mode.
1104
+ *
1105
+ * This helper encapsulates all the common logic: council config resolution/validation,
1106
+ * analysis run record creation, progress tracking setup, async analyzer invocation,
1107
+ * completion/failure status broadcasting, and tracking map cleanup.
1108
+ *
1109
+ * Mode-specific differences are injected via the `modeContext` parameter.
1110
+ *
1111
+ * @param {Object} db - Database handle
1112
+ * @param {Object} modeContext - Mode-specific values:
1113
+ * @param {number} modeContext.reviewId - Review record ID
1114
+ * @param {string} modeContext.worktreePath - Path to the worktree or local directory
1115
+ * @param {Object} modeContext.prMetadata - PR metadata (from DB for PR mode, synthetic for local mode)
1116
+ * @param {Array|null} modeContext.changedFiles - Changed files list (null for PR mode)
1117
+ * @param {string} modeContext.repository - Repository identifier (e.g., "owner/repo")
1118
+ * @param {string} modeContext.headSha - HEAD SHA for the analysis run record
1119
+ * @param {Object} modeContext.trackingMap - Map instance for tracking (prToAnalysisId or localReviewToAnalysisId)
1120
+ * @param {string} modeContext.trackingKey - Key for the tracking map (getPRKey() or getLocalReviewKey())
1121
+ * @param {string} modeContext.logLabel - Human-readable label for logs (e.g., "PR #42" or "local review #7")
1122
+ * @param {Object} [modeContext.initialStatusExtra] - Extra fields merged into the initial status object
1123
+ * @param {Array<string>} [modeContext.extraBroadcastKeys] - Additional SSE keys to broadcast completion to
1124
+ * @param {Function} [modeContext.onSuccess] - Optional async callback invoked after successful completion
1125
+ * with signature (result, analysisRunRepo, runId). Used for mode-specific side effects like saving
1126
+ * summary to the review record.
1127
+ * @param {Object} [modeContext.runUpdateExtra] - Extra fields merged into the analysisRunRepo.update call on success
1128
+ * @param {Object} councilConfig - Validated council configuration
1129
+ * @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
1130
+ * @param {Object} instructions - { repoInstructions, requestInstructions }
1131
+ * @returns {{ analysisId: string, runId: string }} IDs for the caller to return in the response
1132
+ */
1133
+ /**
1134
+ * Check if a level is enabled in a council config, handling both formats:
1135
+ * - Voice-centric: levels values are booleans (e.g. { '1': true })
1136
+ * - Advanced: levels values are objects (e.g. { '1': { enabled: true, voices: [...] } })
1137
+ */
1138
+ function isLevelEnabled(councilConfig, levelKey) {
1139
+ const val = councilConfig.levels?.[levelKey];
1140
+ if (typeof val === 'boolean') return val;
1141
+ return val?.enabled === true;
1142
+ }
1143
+
1144
+ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId, instructions, configType = 'advanced') {
1145
+ const {
1146
+ reviewId,
1147
+ worktreePath,
1148
+ prMetadata,
1149
+ changedFiles,
1150
+ repository,
1151
+ headSha,
1152
+ trackingMap,
1153
+ trackingKey,
1154
+ logLabel,
1155
+ initialStatusExtra,
1156
+ extraBroadcastKeys,
1157
+ onSuccess,
1158
+ runUpdateExtra
1159
+ } = modeContext;
1160
+
1161
+ const { repoInstructions, requestInstructions } = instructions;
1162
+
1163
+ // Determine if voice-centric mode
1164
+ const isVoiceCentric = configType === 'council';
1165
+
1166
+ // Create run/analysis ID
1167
+ const runId = uuidv4();
1168
+ const analysisId = runId;
1169
+
1170
+ // Compute levelsConfig for the run record
1171
+ let levelsConfig = null;
1172
+ if (isVoiceCentric && councilConfig.levels) {
1173
+ levelsConfig = councilConfig.levels;
1174
+ } else if (councilConfig.levels) {
1175
+ levelsConfig = {};
1176
+ for (const [key, val] of Object.entries(councilConfig.levels)) {
1177
+ levelsConfig[key] = val?.enabled !== false;
1178
+ }
1179
+ }
1180
+
1181
+ // Create DB analysis_runs record
1182
+ const analysisRunRepo = new AnalysisRunRepository(db);
1183
+ await analysisRunRepo.create({
1184
+ id: runId,
1185
+ reviewId,
1186
+ provider: 'council',
1187
+ model: councilId || 'inline-config',
1188
+ repoInstructions,
1189
+ requestInstructions,
1190
+ headSha: headSha || null,
1191
+ configType,
1192
+ levelsConfig
1193
+ });
1194
+
1195
+ // Touch council MRU timestamp (if using a saved council, not inline-config)
1196
+ if (councilId) {
1197
+ const councilRepo = new CouncilRepository(db);
1198
+ councilRepo.touchLastUsedAt(councilId).catch(err => {
1199
+ logger.warn(`Failed to update council last_used_at: ${err.message}`);
1200
+ });
1201
+ }
1202
+
1203
+ // Setup progress tracking
1204
+ const initialStatus = {
1205
+ id: analysisId,
1206
+ repository,
1207
+ status: 'running',
1208
+ startedAt: new Date().toISOString(),
1209
+ progress: 'Starting council analysis...',
1210
+ levels: {
1211
+ 1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1212
+ 2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1213
+ 3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1214
+ 4: { status: 'pending', progress: 'Pending' }
1215
+ },
1216
+ isCouncil: true,
1217
+ councilConfig,
1218
+ configType,
1219
+ filesAnalyzed: 0,
1220
+ filesRemaining: 0,
1221
+ ...initialStatusExtra
1222
+ };
1223
+ activeAnalyses.set(analysisId, initialStatus);
1224
+
1225
+ // Store tracking map entry
1226
+ trackingMap.set(trackingKey, analysisId);
1227
+
1228
+ broadcastProgress(analysisId, initialStatus);
1229
+
1230
+ // Create analyzer (provider/model don't matter for council — voices have their own)
1231
+ const analyzer = new Analyzer(db, 'council', 'council');
1232
+
1233
+ logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
1234
+ logger.log('API', `Repository: ${repository}`, 'magenta');
1235
+ logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
1236
+ logger.log('API', `Config type: ${configType}`, 'magenta');
1237
+
1238
+ const progressCallback = createProgressCallback(analysisId);
1239
+
1240
+ // Route to voice-centric or level-centric council based on configType
1241
+ const reviewContext = {
1242
+ reviewId,
1243
+ worktreePath,
1244
+ prMetadata,
1245
+ changedFiles,
1246
+ instructions: { repoInstructions, requestInstructions }
1247
+ };
1248
+
1249
+ const analysisPromise = isVoiceCentric
1250
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
1251
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
1252
+
1253
+ analysisPromise
1254
+ .then(async result => {
1255
+ logger.success(`Council analysis complete for ${logLabel}: ${result.suggestions.length} suggestions`);
1256
+
1257
+ // Update analysis run record
1258
+ try {
1259
+ await analysisRunRepo.update(runId, {
1260
+ status: 'completed',
1261
+ summary: result.summary,
1262
+ totalSuggestions: result.suggestions.length,
1263
+ ...runUpdateExtra
1264
+ });
1265
+ } catch (updateError) {
1266
+ logger.warn(`Failed to update analysis_run: ${updateError.message}`);
1267
+ }
1268
+
1269
+ // Mode-specific success callback (e.g., saving summary to review)
1270
+ if (onSuccess) {
1271
+ try {
1272
+ await onSuccess(result, analysisRunRepo, runId);
1273
+ } catch (callbackError) {
1274
+ logger.warn(`Council onSuccess callback failed: ${callbackError.message}`);
1275
+ }
1276
+ }
1277
+
1278
+ // Use robust fallback: if activeAnalyses entry was removed (e.g. by cancel), use empty object
1279
+ const currentStatus = activeAnalyses.get(analysisId);
1280
+ if (!currentStatus) return;
1281
+
1282
+ const completedStatus = {
1283
+ ...currentStatus,
1284
+ status: 'completed',
1285
+ completedAt: new Date().toISOString(),
1286
+ progress: `Council analysis complete — ${result.suggestions.length} suggestions`,
1287
+ suggestionsCount: result.suggestions.length,
1288
+ levels: {
1289
+ ...currentStatus.levels,
1290
+ 4: { status: 'completed', progress: 'Results finalized' }
1291
+ }
1292
+ };
1293
+ // Mark all enabled levels as completed
1294
+ for (const levelKey of ['1', '2', '3']) {
1295
+ if (currentStatus.levels?.[levelKey]?.status === 'running') {
1296
+ completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
1297
+ }
1298
+ }
1299
+ activeAnalyses.set(analysisId, completedStatus);
1300
+ broadcastProgress(analysisId, completedStatus);
1301
+
1302
+ // Broadcast to additional SSE keys (e.g., local mode broadcasts to `local-${reviewId}`)
1303
+ if (extraBroadcastKeys) {
1304
+ for (const key of extraBroadcastKeys) {
1305
+ broadcastProgress(key, { ...completedStatus, source: 'council' });
1306
+ }
1307
+ }
1308
+ })
1309
+ .catch(error => {
1310
+ if (error.isCancellation) {
1311
+ logger.info(`Council analysis cancelled for ${logLabel}`);
1312
+ return;
1313
+ }
1314
+ logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
1315
+
1316
+ // Use robust fallback: if activeAnalyses entry was removed, use empty object
1317
+ // This prevents orphaned DB records in "running" status
1318
+ const failedStatus = {
1319
+ ...(activeAnalyses.get(analysisId) || {}),
1320
+ status: 'failed',
1321
+ completedAt: new Date().toISOString(),
1322
+ error: error.message,
1323
+ progress: 'Council analysis failed'
1324
+ };
1325
+ activeAnalyses.set(analysisId, failedStatus);
1326
+ broadcastProgress(analysisId, failedStatus);
1327
+
1328
+ // Update analysis run record
1329
+ analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
1330
+ })
1331
+ .finally(() => {
1332
+ // Clean up tracking map entry (always runs regardless of success/failure)
1333
+ trackingMap.delete(trackingKey);
1334
+ });
1335
+
1336
+ return { analysisId, runId };
1337
+ }
1338
+
1339
+ /**
1340
+ * Trigger council analysis for a PR
1341
+ * Uses multiple voices/providers across configurable levels
1342
+ */
1343
+ router.post('/api/analyze/council/:owner/:repo/:pr', async (req, res) => {
1344
+ try {
1345
+ const { owner, repo, pr } = req.params;
1346
+ const prNumber = parseInt(pr);
1347
+ const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
1348
+
1349
+ if (isNaN(prNumber) || prNumber <= 0) {
1350
+ return res.status(400).json({ error: 'Invalid pull request number' });
1351
+ }
1352
+
1353
+ if (!councilId && !inlineConfig) {
1354
+ return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
1355
+ }
1356
+
1357
+ const repository = normalizeRepository(owner, repo);
1358
+ const db = req.app.get('db');
1359
+
1360
+ // Resolve council config and determine config type
1361
+ // Priority: request body configType > saved council type > default 'advanced'
1362
+ let councilConfig;
1363
+ let configType;
1364
+ if (councilId) {
1365
+ const councilRepo = new CouncilRepository(db);
1366
+ const council = await councilRepo.getById(councilId);
1367
+ if (!council) {
1368
+ return res.status(404).json({ error: 'Council not found' });
1369
+ }
1370
+ councilConfig = council.config;
1371
+ configType = requestConfigType || council.type || 'advanced';
1372
+ } else {
1373
+ councilConfig = inlineConfig;
1374
+ configType = requestConfigType || 'advanced';
1375
+ }
1376
+
1377
+ // Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
1378
+ councilConfig = normalizeCouncilConfig(councilConfig, configType);
1379
+
1380
+ // Validate council config (saved configs are validated on save; inline configs need runtime validation)
1381
+ const configError = validateCouncilConfig(councilConfig, configType);
1382
+ if (configError) {
1383
+ return res.status(400).json({ error: `Invalid council config: ${configError}` });
1384
+ }
1385
+
1386
+ // Get PR metadata
1387
+ const prMetadataRepo = new PRMetadataRepository(db);
1388
+ const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
1389
+ if (!prMetadata) {
1390
+ return res.status(404).json({ error: `Pull request #${prNumber} not found. Please load the PR first.` });
1391
+ }
1392
+
1393
+ // Get worktree path
1394
+ const worktreeManager = new GitWorktreeManager(db);
1395
+ const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
1396
+ if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
1397
+ return res.status(404).json({ error: 'Worktree not found for this PR. Please reload the PR.' });
1398
+ }
1399
+
1400
+ // Resolve instructions
1401
+ const reviewRepo = new ReviewRepository(db);
1402
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1403
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1404
+ const repoInstructions = repoSettings?.default_instructions || null;
1405
+ const requestInstructions = rawInstructions?.trim() || null;
1406
+
1407
+ // Get or create review record
1408
+ const review = await reviewRepo.getOrCreate({ prNumber, repository });
1409
+
1410
+ // Save custom instructions to the review record (same as single-model endpoint)
1411
+ if (requestInstructions) {
1412
+ await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
1413
+ }
1414
+
1415
+ const { analysisId, runId } = await launchCouncilAnalysis(
1416
+ db,
1417
+ {
1418
+ reviewId: review.id,
1419
+ worktreePath,
1420
+ prMetadata,
1421
+ changedFiles: null,
1422
+ repository,
1423
+ headSha: prMetadata.head_sha,
1424
+ trackingMap: prToAnalysisId,
1425
+ trackingKey: getPRKey(owner, repo, prNumber),
1426
+ logLabel: `PR #${prNumber}`,
1427
+ initialStatusExtra: { prNumber },
1428
+ extraBroadcastKeys: null,
1429
+ onSuccess: async (result) => {
1430
+ if (result.summary) {
1431
+ await reviewRepo.upsertSummary(prNumber, repository, result.summary);
1432
+ }
1433
+ }
1434
+ },
1435
+ councilConfig,
1436
+ councilId,
1437
+ { repoInstructions, requestInstructions },
1438
+ configType
1439
+ );
1440
+
1441
+ res.json({
1442
+ analysisId,
1443
+ runId,
1444
+ status: 'started',
1445
+ message: 'Council analysis started in background',
1446
+ isCouncil: true
1447
+ });
1448
+ } catch (error) {
1449
+ logger.error('Error starting council analysis:', error);
1450
+ res.status(500).json({ error: 'Failed to start council analysis' });
1451
+ }
1452
+ });
1453
+
1454
+ /**
1455
+ * Trigger council analysis for a local review
1456
+ */
1457
+ router.post('/api/local/:reviewId/analyze/council', async (req, res) => {
1458
+ try {
1459
+ const { reviewId } = req.params;
1460
+ const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
1461
+
1462
+ if (!councilId && !inlineConfig) {
1463
+ return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
1464
+ }
1465
+
1466
+ const db = req.app.get('db');
1467
+
1468
+ // Get review record
1469
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ? AND review_type = ?', [reviewId, 'local']);
1470
+ if (!review) {
1471
+ return res.status(404).json({ error: 'Local review not found' });
1472
+ }
1473
+
1474
+ // Resolve council config and determine config type
1475
+ // Priority: request body configType > saved council type > default 'advanced'
1476
+ let councilConfig;
1477
+ let configType;
1478
+ if (councilId) {
1479
+ const councilRepo = new CouncilRepository(db);
1480
+ const council = await councilRepo.getById(councilId);
1481
+ if (!council) {
1482
+ return res.status(404).json({ error: 'Council not found' });
1483
+ }
1484
+ councilConfig = council.config;
1485
+ configType = requestConfigType || council.type || 'advanced';
1486
+ } else {
1487
+ councilConfig = inlineConfig;
1488
+ configType = requestConfigType || 'advanced';
1489
+ }
1490
+
1491
+ // Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
1492
+ councilConfig = normalizeCouncilConfig(councilConfig, configType);
1493
+
1494
+ // Validate council config (saved configs are validated on save; inline configs need runtime validation)
1495
+ const configError = validateCouncilConfig(councilConfig, configType);
1496
+ if (configError) {
1497
+ return res.status(400).json({ error: `Invalid council config: ${configError}` });
1498
+ }
1499
+
1500
+ const localPath = review.local_path;
1501
+
1502
+ // Build metadata for local mode
1503
+ const prMetadata = {
1504
+ reviewType: 'local',
1505
+ repository: review.repository,
1506
+ title: review.name || 'Local changes',
1507
+ description: '',
1508
+ head_sha: review.local_head_sha
1509
+ };
1510
+
1511
+ // Get changed files
1512
+ const analyzer = new Analyzer(db, 'council', 'council');
1513
+ const changedFiles = await analyzer.getLocalChangedFiles(localPath);
1514
+
1515
+ // Generate and cache diff so the web UI can display it (same as regular analysis endpoint)
1516
+ try {
1517
+ const diffResult = await generateLocalDiff(localPath);
1518
+ const digest = await computeLocalDiffDigest(localPath);
1519
+ localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
1520
+ } catch (diffError) {
1521
+ logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
1522
+ }
1523
+
1524
+ // Resolve instructions
1525
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1526
+ const reviewRepo = new ReviewRepository(db);
1527
+ const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
1528
+ const repoInstructions = repoSettings?.default_instructions || null;
1529
+ const requestInstructions = rawInstructions?.trim() || null;
1530
+
1531
+ const parsedReviewId = parseInt(reviewId, 10);
1532
+
1533
+ // Save custom instructions to the review record (same as single-model endpoint)
1534
+ if (requestInstructions) {
1535
+ await reviewRepo.updateReview(parsedReviewId, {
1536
+ customInstructions: requestInstructions
1537
+ });
1538
+ }
1539
+
1540
+ const { analysisId, runId } = await launchCouncilAnalysis(
1541
+ db,
1542
+ {
1543
+ reviewId: parsedReviewId,
1544
+ worktreePath: localPath,
1545
+ prMetadata,
1546
+ changedFiles,
1547
+ repository: review.repository,
1548
+ headSha: review.local_head_sha,
1549
+ trackingMap: localReviewToAnalysisId,
1550
+ trackingKey: getLocalReviewKey(reviewId),
1551
+ logLabel: `local review #${reviewId}`,
1552
+ initialStatusExtra: { reviewId: parsedReviewId, reviewType: 'local' },
1553
+ extraBroadcastKeys: [`local-${reviewId}`],
1554
+ runUpdateExtra: { filesAnalyzed: changedFiles.length }
1555
+ },
1556
+ councilConfig,
1557
+ councilId,
1558
+ { repoInstructions, requestInstructions },
1559
+ configType
1560
+ );
1561
+
1562
+ res.json({
1563
+ analysisId,
1564
+ runId,
1565
+ status: 'started',
1566
+ message: 'Council analysis started in background',
1567
+ isCouncil: true
1568
+ });
1569
+ } catch (error) {
1570
+ logger.error('Error starting local council analysis:', error);
1571
+ res.status(500).json({ error: 'Failed to start council analysis' });
1572
+ }
1573
+ });
1574
+
1090
1575
  module.exports = router;