@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

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 (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,721 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * AI Analysis Routes (shared, ID-based endpoints)
4
+ *
5
+ * Provides endpoints that work across both PR mode and Local mode:
6
+ * - GET /api/analyses/runs — list analysis runs for a review
7
+ * - GET /api/analyses/runs/latest — get most recent run for a review
8
+ * - GET /api/analyses/runs/:runId — get a specific run
9
+ * - POST /api/analyses/results — import external analysis results
10
+ * - GET /api/analyses/:id/status — get in-memory analysis status
11
+ * - POST /api/analyses/:id/cancel — cancel an active analysis
12
+ * - GET /api/analyses/:id/progress — SSE progress stream (unified)
13
+ *
14
+ * Routes that are PR-specific or local-specific live in pr.js and local.js
15
+ * respectively (e.g., starting an analysis).
16
+ */
17
+
18
+ const express = require('express');
19
+ const { queryOne, withTransaction, ReviewRepository, CommentRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
20
+ const Analyzer = require('../ai/analyzer');
21
+ const { getTierForModel } = require('../ai/provider');
22
+ const { v4: uuidv4 } = require('uuid');
23
+ const logger = require('../utils/logger');
24
+ const { broadcastReviewEvent } = require('../sse/review-events');
25
+ const path = require('path');
26
+ const { normalizeRepository } = require('../utils/paths');
27
+ const {
28
+ activeAnalyses,
29
+ reviewToAnalysisId,
30
+ progressClients,
31
+ localReviewDiffs,
32
+ broadcastProgress,
33
+ killProcesses,
34
+ createProgressCallback
35
+ } = require('./shared');
36
+ const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
37
+ const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
38
+ const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
39
+
40
+ const router = express.Router();
41
+
42
+ /**
43
+ * Enrich a raw analysis run record for API responses.
44
+ * Applies backward-compatible tier fallback and parses levels_config JSON.
45
+ */
46
+ function enrichRun(run) {
47
+ if (!run) return null;
48
+ return {
49
+ ...run,
50
+ levels_config: run.levels_config ? JSON.parse(run.levels_config) : null,
51
+ tier: run.tier ?? (run.provider && run.model ? getTierForModel(run.provider, run.model) : null)
52
+ };
53
+ }
54
+
55
+ // ==========================================================================
56
+ // Static path routes — registered BEFORE :id param routes to avoid clashes
57
+ // ==========================================================================
58
+
59
+ /**
60
+ * Get all analysis runs for a review
61
+ * Query param: reviewId (integer)
62
+ */
63
+ router.get('/api/analyses/runs', async (req, res) => {
64
+ try {
65
+ const reviewId = parseInt(req.query.reviewId, 10);
66
+
67
+ if (!reviewId || isNaN(reviewId) || reviewId <= 0) {
68
+ return res.status(400).json({ error: 'Missing or invalid reviewId query parameter' });
69
+ }
70
+
71
+ const db = req.app.get('db');
72
+ const analysisRunRepo = new AnalysisRunRepository(db);
73
+ const runs = await analysisRunRepo.getByReviewId(reviewId);
74
+
75
+ res.json({ runs: runs.map(enrichRun) });
76
+ } catch (error) {
77
+ logger.error('Error fetching analysis runs:', error);
78
+ res.status(500).json({ error: 'Failed to fetch analysis runs' });
79
+ }
80
+ });
81
+
82
+ /**
83
+ * Get the most recent analysis run for a review
84
+ * Query param: reviewId (integer)
85
+ */
86
+ router.get('/api/analyses/runs/latest', async (req, res) => {
87
+ try {
88
+ const reviewId = parseInt(req.query.reviewId, 10);
89
+
90
+ if (!reviewId || isNaN(reviewId) || reviewId <= 0) {
91
+ return res.status(400).json({ error: 'Missing or invalid reviewId query parameter' });
92
+ }
93
+
94
+ const db = req.app.get('db');
95
+ const analysisRunRepo = new AnalysisRunRepository(db);
96
+ const run = await analysisRunRepo.getLatestByReviewId(reviewId);
97
+
98
+ if (!run) {
99
+ return res.status(404).json({ error: 'No analysis runs found' });
100
+ }
101
+
102
+ res.json({ run: enrichRun(run) });
103
+ } catch (error) {
104
+ logger.error('Error fetching latest analysis run:', error);
105
+ res.status(500).json({ error: 'Failed to fetch latest analysis run' });
106
+ }
107
+ });
108
+
109
+ /**
110
+ * Get a specific analysis run by ID
111
+ */
112
+ router.get('/api/analyses/runs/:runId', async (req, res) => {
113
+ try {
114
+ const { runId } = req.params;
115
+ const db = req.app.get('db');
116
+
117
+ const analysisRunRepo = new AnalysisRunRepository(db);
118
+ const run = await analysisRunRepo.getById(runId);
119
+
120
+ if (!run) {
121
+ return res.status(404).json({ error: 'Analysis run not found' });
122
+ }
123
+
124
+ res.json({ run: enrichRun(run) });
125
+ } catch (error) {
126
+ logger.error('Error fetching analysis run:', error);
127
+ res.status(500).json({ error: 'Failed to fetch analysis run' });
128
+ }
129
+ });
130
+
131
+ /**
132
+ * Import externally-produced analysis results
133
+ *
134
+ * Accepts suggestions generated outside pair-review (e.g. by a coding agent's
135
+ * analyze skill) and stores them as a completed analysis run so they appear
136
+ * inline in the web UI.
137
+ */
138
+ router.post('/api/analyses/results', async (req, res) => {
139
+ try {
140
+ const {
141
+ path: localPath,
142
+ headSha,
143
+ repo,
144
+ prNumber,
145
+ provider = null,
146
+ model = null,
147
+ summary = null,
148
+ suggestions = [],
149
+ fileLevelSuggestions = [],
150
+ tier = null
151
+ } = req.body || {};
152
+
153
+ // --- Validate tier ---
154
+ let resolvedTier = tier;
155
+ if (tier != null) {
156
+ if (!VALID_TIERS.includes(tier)) {
157
+ return res.status(400).json({
158
+ error: `Invalid tier: "${tier}". Valid tiers: ${VALID_TIERS.join(', ')}`
159
+ });
160
+ }
161
+ resolvedTier = resolveTier(tier);
162
+ }
163
+
164
+ // --- Validate identification pair ---
165
+ const hasLocal = localPath && headSha;
166
+ const hasPR = repo && prNumber != null;
167
+
168
+ if (!hasLocal && !hasPR) {
169
+ return res.status(400).json({
170
+ error: 'Must provide either (path + headSha) for local mode or (repo + prNumber) for PR mode'
171
+ });
172
+ }
173
+ if (hasLocal && hasPR) {
174
+ return res.status(400).json({
175
+ error: 'Provide only one identification pair: (path + headSha) or (repo + prNumber), not both'
176
+ });
177
+ }
178
+
179
+ // --- Validate suggestions ---
180
+ if (!Array.isArray(suggestions)) {
181
+ return res.status(400).json({ error: 'suggestions must be an array' });
182
+ }
183
+ if (!Array.isArray(fileLevelSuggestions)) {
184
+ return res.status(400).json({ error: 'fileLevelSuggestions must be an array' });
185
+ }
186
+
187
+ const REQUIRED_SUGGESTION_FIELDS = ['file', 'type', 'title', 'description'];
188
+ for (const [idx, s] of suggestions.entries()) {
189
+ for (const field of REQUIRED_SUGGESTION_FIELDS) {
190
+ if (!s[field]) {
191
+ return res.status(400).json({
192
+ error: `suggestions[${idx}] missing required field: ${field}`
193
+ });
194
+ }
195
+ }
196
+ }
197
+ for (const [idx, s] of fileLevelSuggestions.entries()) {
198
+ for (const field of REQUIRED_SUGGESTION_FIELDS) {
199
+ if (!s[field]) {
200
+ return res.status(400).json({
201
+ error: `fileLevelSuggestions[${idx}] missing required field: ${field}`
202
+ });
203
+ }
204
+ }
205
+ }
206
+
207
+ const db = req.app.get('db');
208
+ const reviewRepo = new ReviewRepository(db);
209
+ const analysisRunRepo = new AnalysisRunRepository(db);
210
+
211
+ // --- Resolve review ---
212
+ let reviewId;
213
+ if (hasLocal) {
214
+ // Local mode: derive repository name from the directory basename
215
+ const repository = path.basename(localPath) || 'local';
216
+ reviewId = await reviewRepo.upsertLocalReview({
217
+ localPath,
218
+ localHeadSha: headSha,
219
+ repository
220
+ });
221
+
222
+ // Generate and store diff so the web UI can display it
223
+ try {
224
+ const diffResult = await generateLocalDiff(localPath);
225
+ const digest = await computeLocalDiffDigest(localPath);
226
+ localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
227
+ } catch (diffError) {
228
+ logger.warn(`Could not generate diff for local review ${reviewId}: ${diffError.message}`);
229
+ }
230
+ } else {
231
+ const repoParts = repo.split('/');
232
+ if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
233
+ return res.status(400).json({ error: 'repo must be in format owner/repo' });
234
+ }
235
+ const parsedPR = parseInt(prNumber, 10);
236
+ if (isNaN(parsedPR) || parsedPR <= 0) {
237
+ return res.status(400).json({ error: 'Invalid pull request number' });
238
+ }
239
+ const repository = normalizeRepository(repoParts[0], repoParts[1]);
240
+ const review = await reviewRepo.getOrCreate({
241
+ prNumber: parsedPR,
242
+ repository
243
+ });
244
+ reviewId = review.id;
245
+ }
246
+
247
+ // --- Create completed analysis run, insert suggestions, update stats ---
248
+ const runId = uuidv4();
249
+ const allSuggestions = [
250
+ ...suggestions.map(s => ({ ...s, is_file_level: false })),
251
+ ...fileLevelSuggestions.map(s => ({ ...s, is_file_level: true }))
252
+ ];
253
+ const totalSuggestions = allSuggestions.length;
254
+ const filesAnalyzed = new Set(allSuggestions.map(s => s.file)).size;
255
+
256
+ const commentRepo = new CommentRepository(db);
257
+
258
+ await withTransaction(db, async () => {
259
+ await analysisRunRepo.create({
260
+ id: runId,
261
+ reviewId,
262
+ provider,
263
+ model,
264
+ tier: resolvedTier,
265
+ headSha: headSha || null,
266
+ status: 'completed'
267
+ });
268
+
269
+ await commentRepo.bulkInsertAISuggestions(reviewId, runId, allSuggestions);
270
+
271
+ await analysisRunRepo.update(runId, {
272
+ summary,
273
+ totalSuggestions,
274
+ filesAnalyzed
275
+ });
276
+ });
277
+
278
+ // --- Broadcast SSE completion event (after transaction completes) ---
279
+ const completionEvent = {
280
+ id: runId,
281
+ status: 'completed',
282
+ completedAt: new Date().toISOString(),
283
+ progress: `Analysis complete — ${totalSuggestions} suggestion${totalSuggestions !== 1 ? 's' : ''}`,
284
+ suggestionsCount: totalSuggestions,
285
+ filesAnalyzed,
286
+ levels: {
287
+ 1: { status: 'completed', progress: 'Complete' },
288
+ 2: { status: 'completed', progress: 'Complete' },
289
+ 3: { status: 'completed', progress: 'Complete' },
290
+ 4: { status: 'completed', progress: 'Complete' }
291
+ }
292
+ };
293
+ broadcastProgress(runId, completionEvent);
294
+
295
+ // Broadcast on the review-level key so the frontend auto-refreshes.
296
+ broadcastProgress(`review-${reviewId}`, { ...completionEvent, source: 'external' });
297
+
298
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
299
+
300
+ logger.success(`Imported ${totalSuggestions} external analysis suggestions (run ${runId})`);
301
+
302
+ res.status(201).json({
303
+ runId,
304
+ reviewId,
305
+ totalSuggestions,
306
+ status: 'completed'
307
+ });
308
+ } catch (error) {
309
+ logger.error('Error importing analysis results:', error);
310
+ res.status(500).json({ error: 'Failed to import analysis results' });
311
+ }
312
+ });
313
+
314
+ // ==========================================================================
315
+ // Parameterised :id routes — registered AFTER static paths
316
+ // ==========================================================================
317
+
318
+ /**
319
+ * Get AI analysis status
320
+ */
321
+ router.get('/api/analyses/:id/status', async (req, res) => {
322
+ try {
323
+ const { id } = req.params;
324
+
325
+ const analysis = activeAnalyses.get(id);
326
+
327
+ if (!analysis) {
328
+ return res.status(404).json({
329
+ error: 'Analysis not found'
330
+ });
331
+ }
332
+
333
+ res.json(analysis);
334
+
335
+ } catch (error) {
336
+ logger.error('Error fetching analysis status:', error);
337
+ res.status(500).json({
338
+ error: 'Failed to fetch analysis status'
339
+ });
340
+ }
341
+ });
342
+
343
+ /**
344
+ * Cancel an active AI analysis
345
+ */
346
+ router.post('/api/analyses/:id/cancel', async (req, res) => {
347
+ try {
348
+ const { id } = req.params;
349
+
350
+ const analysis = activeAnalyses.get(id);
351
+
352
+ if (!analysis) {
353
+ return res.status(404).json({
354
+ error: 'Analysis not found'
355
+ });
356
+ }
357
+
358
+ // Check if already completed/failed/cancelled
359
+ if (['completed', 'failed', 'cancelled'].includes(analysis.status)) {
360
+ return res.json({
361
+ success: true,
362
+ message: `Analysis already ${analysis.status}`,
363
+ status: analysis.status
364
+ });
365
+ }
366
+
367
+ logger.section(`Cancelling Analysis: ${id}`);
368
+ // Log context based on review type (PR mode vs local mode)
369
+ if (analysis.reviewType === 'local') {
370
+ logger.log('API', `Local review #${analysis.reviewId} in ${analysis.repository}`, 'yellow');
371
+ } else {
372
+ logger.log('API', `PR #${analysis.prNumber} in ${analysis.repository}`, 'yellow');
373
+ }
374
+
375
+ // Kill all running child processes for this analysis
376
+ const killedCount = killProcesses(id);
377
+ logger.info(`Killed ${killedCount} running process(es)`);
378
+
379
+ // Update database record to cancelled
380
+ if (analysis.runId) {
381
+ try {
382
+ const db = req.app.get('db');
383
+ const analysisRunRepo = new AnalysisRunRepository(db);
384
+ await analysisRunRepo.update(analysis.runId, { status: 'cancelled' });
385
+ logger.info(`Updated analysis_run DB record to cancelled: ${analysis.runId}`);
386
+ } catch (dbError) {
387
+ logger.warn(`Failed to update analysis_run DB record: ${dbError.message}`);
388
+ }
389
+ }
390
+
391
+ // Update analysis status to cancelled
392
+ const cancelledStatus = {
393
+ ...analysis,
394
+ status: 'cancelled',
395
+ cancelledAt: new Date().toISOString(),
396
+ progress: 'Analysis cancelled by user',
397
+ levels: {
398
+ ...analysis.levels,
399
+ 1: analysis.levels?.[1]?.status === 'running'
400
+ ? { status: 'cancelled', progress: 'Cancelled' }
401
+ : analysis.levels?.[1],
402
+ 2: analysis.levels?.[2]?.status === 'running'
403
+ ? { status: 'cancelled', progress: 'Cancelled' }
404
+ : analysis.levels?.[2],
405
+ 3: analysis.levels?.[3]?.status === 'running'
406
+ ? { status: 'cancelled', progress: 'Cancelled' }
407
+ : analysis.levels?.[3],
408
+ 4: analysis.levels?.[4]?.status === 'running'
409
+ ? { status: 'cancelled', progress: 'Cancelled' }
410
+ : analysis.levels?.[4]
411
+ }
412
+ };
413
+
414
+ activeAnalyses.set(id, cancelledStatus);
415
+
416
+ // Broadcast cancelled status to SSE clients
417
+ broadcastProgress(id, cancelledStatus);
418
+
419
+ // Clean up review to analysis ID mapping
420
+ if (analysis.reviewId) {
421
+ reviewToAnalysisId.delete(analysis.reviewId);
422
+ }
423
+
424
+ logger.success(`Analysis ${id} cancelled successfully`);
425
+
426
+ res.json({
427
+ success: true,
428
+ message: 'Analysis cancelled',
429
+ processesKilled: killedCount,
430
+ status: 'cancelled'
431
+ });
432
+
433
+ } catch (error) {
434
+ logger.error(`Error cancelling analysis: ${error.message}`);
435
+ res.status(500).json({
436
+ error: 'Failed to cancel analysis'
437
+ });
438
+ }
439
+ });
440
+
441
+ /**
442
+ * Server-Sent Events endpoint for AI analysis progress (unified)
443
+ *
444
+ * Clients connect with an analysis UUID. The handler also registers
445
+ * the client under `review-${reviewId}` when the analysis status
446
+ * carries a reviewId, so that external result broadcasts reach them.
447
+ */
448
+ router.get('/api/analyses/:id/progress', (req, res) => {
449
+ const analysisId = req.params.id;
450
+
451
+ // Set up SSE headers
452
+ res.writeHead(200, {
453
+ 'Content-Type': 'text/event-stream',
454
+ 'Cache-Control': 'no-cache',
455
+ 'Connection': 'keep-alive',
456
+ 'Access-Control-Allow-Origin': '*',
457
+ 'Access-Control-Allow-Headers': 'Cache-Control'
458
+ });
459
+
460
+ // Send initial connection message
461
+ res.write('data: {"type":"connected","message":"Connected to progress stream"}\n\n');
462
+
463
+ // Store client for this analysis
464
+ if (!progressClients.has(analysisId)) {
465
+ progressClients.set(analysisId, new Set());
466
+ }
467
+ progressClients.get(analysisId).add(res);
468
+
469
+ // Also register under the review-level key so external broadcasts reach this client
470
+ const currentStatus = activeAnalyses.get(analysisId);
471
+ const reviewId = currentStatus?.reviewId;
472
+ const reviewKey = reviewId ? `review-${reviewId}` : null;
473
+ if (reviewKey) {
474
+ if (!progressClients.has(reviewKey)) {
475
+ progressClients.set(reviewKey, new Set());
476
+ }
477
+ progressClients.get(reviewKey).add(res);
478
+ }
479
+
480
+ // Send current status if analysis exists
481
+ if (currentStatus) {
482
+ res.write(`data: ${JSON.stringify({
483
+ type: 'progress',
484
+ ...currentStatus
485
+ })}\n\n`);
486
+ }
487
+
488
+ // Handle client disconnect
489
+ const cleanup = () => {
490
+ const clients = progressClients.get(analysisId);
491
+ if (clients) {
492
+ clients.delete(res);
493
+ if (clients.size === 0) {
494
+ progressClients.delete(analysisId);
495
+ }
496
+ }
497
+ if (reviewKey) {
498
+ const reviewClients = progressClients.get(reviewKey);
499
+ if (reviewClients) {
500
+ reviewClients.delete(res);
501
+ if (reviewClients.size === 0) {
502
+ progressClients.delete(reviewKey);
503
+ }
504
+ }
505
+ }
506
+ };
507
+
508
+ req.on('close', cleanup);
509
+ req.on('error', cleanup);
510
+ });
511
+
512
+ // ==========================================================================
513
+ // Shared helper: launch council analysis
514
+ // ==========================================================================
515
+
516
+ /**
517
+ * Launch a council analysis, shared by both PR and local mode.
518
+ *
519
+ * This helper encapsulates all the common logic: council config resolution/validation,
520
+ * analysis run record creation, progress tracking setup, async analyzer invocation,
521
+ * completion/failure status broadcasting, and tracking map cleanup.
522
+ *
523
+ * @param {Object} db - Database handle
524
+ * @param {Object} modeContext - Mode-specific values
525
+ * @param {Object} councilConfig - Validated council configuration
526
+ * @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
527
+ * @param {Object} instructions - { repoInstructions, requestInstructions }
528
+ * @param {string} [configType='advanced'] - Config type
529
+ * @returns {{ analysisId: string, runId: string }}
530
+ */
531
+ function isLevelEnabled(councilConfig, levelKey) {
532
+ const val = councilConfig.levels?.[levelKey];
533
+ if (typeof val === 'boolean') return val;
534
+ return val?.enabled === true;
535
+ }
536
+
537
+ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId, instructions, configType = 'advanced') {
538
+ const {
539
+ reviewId,
540
+ worktreePath,
541
+ prMetadata,
542
+ changedFiles,
543
+ repository,
544
+ headSha,
545
+ logLabel,
546
+ initialStatusExtra,
547
+ extraBroadcastKeys,
548
+ onSuccess,
549
+ runUpdateExtra
550
+ } = modeContext;
551
+
552
+ const { repoInstructions, requestInstructions } = instructions;
553
+
554
+ const isVoiceCentric = configType === 'council';
555
+
556
+ const runId = uuidv4();
557
+ const analysisId = runId;
558
+
559
+ let levelsConfig = null;
560
+ if (isVoiceCentric && councilConfig.levels) {
561
+ levelsConfig = councilConfig.levels;
562
+ } else if (councilConfig.levels) {
563
+ levelsConfig = {};
564
+ for (const [key, val] of Object.entries(councilConfig.levels)) {
565
+ levelsConfig[key] = val?.enabled !== false;
566
+ }
567
+ }
568
+
569
+ const analysisRunRepo = new AnalysisRunRepository(db);
570
+ await analysisRunRepo.create({
571
+ id: runId,
572
+ reviewId,
573
+ provider: 'council',
574
+ model: councilId || 'inline-config',
575
+ tier: null,
576
+ repoInstructions,
577
+ requestInstructions,
578
+ headSha: headSha || null,
579
+ configType,
580
+ levelsConfig
581
+ });
582
+
583
+ if (councilId) {
584
+ const councilRepo = new CouncilRepository(db);
585
+ councilRepo.touchLastUsedAt(councilId).catch(err => {
586
+ logger.warn(`Failed to update council last_used_at: ${err.message}`);
587
+ });
588
+ }
589
+
590
+ const initialStatus = {
591
+ id: analysisId,
592
+ reviewId,
593
+ repository,
594
+ status: 'running',
595
+ startedAt: new Date().toISOString(),
596
+ progress: 'Starting council analysis...',
597
+ levels: {
598
+ 1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
599
+ 2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
600
+ 3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
601
+ 4: { status: 'pending', progress: 'Pending' }
602
+ },
603
+ isCouncil: true,
604
+ councilConfig,
605
+ configType,
606
+ filesAnalyzed: 0,
607
+ filesRemaining: 0,
608
+ ...initialStatusExtra
609
+ };
610
+ activeAnalyses.set(analysisId, initialStatus);
611
+
612
+ // Store unified tracking map entry (integer reviewId -> analysis UUID)
613
+ reviewToAnalysisId.set(reviewId, analysisId);
614
+
615
+ broadcastProgress(analysisId, initialStatus);
616
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
617
+
618
+ const analyzer = new Analyzer(db, 'council', 'council');
619
+
620
+ logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
621
+ logger.log('API', `Repository: ${repository}`, 'magenta');
622
+ logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
623
+ logger.log('API', `Config type: ${configType}`, 'magenta');
624
+
625
+ const progressCallback = createProgressCallback(analysisId);
626
+
627
+ const reviewContext = {
628
+ reviewId,
629
+ worktreePath,
630
+ prMetadata,
631
+ changedFiles,
632
+ instructions: { repoInstructions, requestInstructions }
633
+ };
634
+
635
+ const analysisPromise = isVoiceCentric
636
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
637
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
638
+
639
+ analysisPromise
640
+ .then(async result => {
641
+ logger.success(`Council analysis complete for ${logLabel}: ${result.suggestions.length} suggestions`);
642
+
643
+ try {
644
+ await analysisRunRepo.update(runId, {
645
+ status: 'completed',
646
+ summary: result.summary,
647
+ totalSuggestions: result.suggestions.length,
648
+ ...runUpdateExtra
649
+ });
650
+ } catch (updateError) {
651
+ logger.warn(`Failed to update analysis_run: ${updateError.message}`);
652
+ }
653
+
654
+ if (onSuccess) {
655
+ try {
656
+ await onSuccess(result, analysisRunRepo, runId);
657
+ } catch (callbackError) {
658
+ logger.warn(`Council onSuccess callback failed: ${callbackError.message}`);
659
+ }
660
+ }
661
+
662
+ const currentStatus = activeAnalyses.get(analysisId);
663
+ if (!currentStatus) return;
664
+
665
+ const completedStatus = {
666
+ ...currentStatus,
667
+ status: 'completed',
668
+ completedAt: new Date().toISOString(),
669
+ progress: `Council analysis complete — ${result.suggestions.length} suggestions`,
670
+ suggestionsCount: result.suggestions.length,
671
+ levels: {
672
+ ...currentStatus.levels,
673
+ 4: { status: 'completed', progress: 'Results finalized' }
674
+ }
675
+ };
676
+ for (const levelKey of ['1', '2', '3']) {
677
+ if (currentStatus.levels?.[levelKey]?.status === 'running') {
678
+ completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
679
+ }
680
+ }
681
+ activeAnalyses.set(analysisId, completedStatus);
682
+ broadcastProgress(analysisId, completedStatus);
683
+ broadcastReviewEvent(initialStatus.reviewId, { type: 'review:analysis_completed' });
684
+
685
+ if (extraBroadcastKeys) {
686
+ for (const key of extraBroadcastKeys) {
687
+ broadcastProgress(key, { ...completedStatus, source: 'council' });
688
+ }
689
+ }
690
+ })
691
+ .catch(error => {
692
+ if (error.isCancellation) {
693
+ logger.info(`Council analysis cancelled for ${logLabel}`);
694
+ return;
695
+ }
696
+ logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
697
+
698
+ const failedStatus = {
699
+ ...(activeAnalyses.get(analysisId) || {}),
700
+ status: 'failed',
701
+ completedAt: new Date().toISOString(),
702
+ error: error.message,
703
+ progress: 'Council analysis failed'
704
+ };
705
+ activeAnalyses.set(analysisId, failedStatus);
706
+ broadcastProgress(analysisId, failedStatus);
707
+
708
+ analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
709
+ })
710
+ .finally(() => {
711
+ // Clean up unified tracking map entry
712
+ reviewToAnalysisId.delete(reviewId);
713
+ });
714
+
715
+ return { analysisId, runId };
716
+ }
717
+
718
+ // Export the helper for pr.js and local.js to use
719
+ router.launchCouncilAnalysis = launchCouncilAnalysis;
720
+
721
+ module.exports = router;