@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
@@ -113,7 +113,7 @@ function determineCompletionInfo(result) {
113
113
  // We have orchestrated suggestions - use those as the final count
114
114
  totalSuggestions = result.level2Result.orchestratedSuggestions.length;
115
115
  progressMessage = `Analysis complete: ${totalSuggestions} orchestrated suggestions stored`;
116
- logger.success(`Orchestration successful: ${totalSuggestions} curated suggestions from all levels`);
116
+ logger.success(`Consolidation successful: ${totalSuggestions} curated suggestions from all levels`);
117
117
  } else {
118
118
  // Fall back to individual level counts
119
119
  const level1Count = result.suggestions.length;
@@ -272,7 +272,11 @@ function createProgressCallback(analysisId) {
272
272
  if (!currentStatus) return;
273
273
 
274
274
  const level = progressUpdate.level;
275
- const levelKey = level === 'orchestration' ? 4 : level;
275
+ // Map consolidation-L* and orchestration to level 4 (consolidation phase)
276
+ const consolidationMatch = typeof level === 'string' && level.match(/^consolidation-L(\d+)$/);
277
+ const levelKey = level === 'orchestration' ? 4
278
+ : consolidationMatch ? 4
279
+ : level;
276
280
 
277
281
  // Stream event: store latest and throttle broadcasts
278
282
  if (progressUpdate.streamEvent && levelKey) {
@@ -292,6 +296,16 @@ function createProgressCallback(analysisId) {
292
296
  }
293
297
 
294
298
  currentStatus.levels[levelKey].streamEvent = evt;
299
+ // Propagate voiceId so council progress modal can identify active voice
300
+ if (progressUpdate.voiceId) {
301
+ currentStatus.levels[levelKey].voiceId = progressUpdate.voiceId;
302
+ }
303
+ // Propagate consolidation step so frontend can identify active consolidation child
304
+ if (consolidationMatch) {
305
+ currentStatus.levels[levelKey].consolidationStep = `L${consolidationMatch[1]}`;
306
+ } else if (level === 'orchestration') {
307
+ currentStatus.levels[levelKey].consolidationStep = 'orchestration';
308
+ }
295
309
  activeAnalyses.set(analysisId, currentStatus);
296
310
 
297
311
  // Throttle: only broadcast if enough time has elapsed
@@ -306,20 +320,88 @@ function createProgressCallback(analysisId) {
306
320
  // Regular status update (not a stream event)
307
321
  // Update the specific level's status, clearing any stale streamEvent
308
322
  if (level && level >= 1 && level <= 3) {
309
- currentStatus.levels[level] = {
310
- status: progressUpdate.status || 'running',
311
- progress: progressUpdate.progress || 'In progress...',
312
- streamEvent: undefined
313
- };
323
+ if (progressUpdate.voiceId) {
324
+ // Per-voice update (council mode): track in voices map so concurrent
325
+ // completions on the same level don't clobber each other
326
+ if (!currentStatus.levels[level].voices) {
327
+ currentStatus.levels[level].voices = {};
328
+ }
329
+ currentStatus.levels[level].voices[progressUpdate.voiceId] = {
330
+ status: progressUpdate.status || 'running',
331
+ progress: progressUpdate.progress || 'In progress...'
332
+ };
333
+ // Last-writer-wins: set top-level voiceId for backward compat with frontend
334
+ // routing. The per-voice detail lives in the `voices` map above.
335
+ currentStatus.levels[level].voiceId = progressUpdate.voiceId;
336
+ currentStatus.levels[level].status = progressUpdate.status || 'running';
337
+ currentStatus.levels[level].progress = progressUpdate.progress || 'In progress...';
338
+ currentStatus.levels[level].streamEvent = undefined;
339
+ } else {
340
+ // Non-voice update (single-model mode)
341
+ currentStatus.levels[level] = {
342
+ status: progressUpdate.status || 'running',
343
+ progress: progressUpdate.progress || 'In progress...',
344
+ streamEvent: undefined,
345
+ voiceId: undefined
346
+ };
347
+ }
314
348
  }
315
349
 
316
- // Handle orchestration as level 4
317
- if (level === 'orchestration') {
318
- currentStatus.levels[4] = {
350
+ // Handle orchestration and consolidation as level 4.
351
+ //
352
+ // levels[4] is intentionally denormalized with two parallel maps:
353
+ // - `steps`: keyed by consolidation sub-step ("L1", "L2", "L3", "orchestration").
354
+ // Tracks which consolidation phases have run/completed. Used to derive the
355
+ // aggregate status so one step completing doesn't mark the whole phase done.
356
+ // - `voices`: keyed by reviewer voiceId (council mode only). Tracks per-reviewer
357
+ // orchestration progress so the frontend can render individual reviewer rows.
358
+ //
359
+ // Both maps must be preserved across updates since each progress event only
360
+ // reports on a single step or voice at a time.
361
+ if (level === 'orchestration' || consolidationMatch) {
362
+ const step = consolidationMatch ? `L${consolidationMatch[1]}` : 'orchestration';
363
+ // Preserve existing consolidation steps when updating level 4
364
+ const existing = currentStatus.levels[4] || {};
365
+ const steps = { ...(existing.steps || {}) };
366
+ steps[step] = {
319
367
  status: progressUpdate.status || 'running',
320
- progress: progressUpdate.progress || 'Finalizing results...',
321
- streamEvent: undefined
368
+ progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...')
369
+ };
370
+ // Derive the top-level consolidation status from the aggregate of step statuses
371
+ // so that a single step completing doesn't mark the whole phase as completed
372
+ const stepStatuses = Object.values(steps).map(s => s.status);
373
+ const derivedStatus = stepStatuses.every(s => s === 'completed') ? 'completed'
374
+ : stepStatuses.some(s => s === 'failed') ? 'failed'
375
+ : stepStatuses.some(s => s === 'running') ? 'running'
376
+ : progressUpdate.status || 'running';
377
+ // Preserve existing per-voice orchestration states when rebuilding level 4
378
+ const existingVoices = existing.voices ? { ...existing.voices } : undefined;
379
+ currentStatus.levels[4] = {
380
+ status: derivedStatus,
381
+ progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...'),
382
+ streamEvent: undefined,
383
+ consolidationStep: step,
384
+ steps,
385
+ voices: existingVoices
322
386
  };
387
+
388
+ // Track per-voice orchestration state (voice-centric council mode):
389
+ // When a voiceId is present, store per-voice status in levels[4].voices
390
+ // so the frontend can update individual reviewer's consolidation row.
391
+ if (progressUpdate.voiceId) {
392
+ if (!currentStatus.levels[4].voices) {
393
+ currentStatus.levels[4].voices = {};
394
+ }
395
+ currentStatus.levels[4].voices[progressUpdate.voiceId] = {
396
+ status: progressUpdate.status || 'running',
397
+ progress: progressUpdate.progress || 'Consolidating...'
398
+ };
399
+ // Last-writer-wins: reflects whichever voice reported most recently.
400
+ // Intentional — mirrors levels 1-3 behavior (line ~334) and the frontend
401
+ // uses per-voice detail from the `voices` map, not this top-level field.
402
+ // This field exists for backward compat with single-model progress routing.
403
+ currentStatus.levels[4].voiceId = progressUpdate.voiceId;
404
+ }
323
405
  }
324
406
 
325
407
  // Update overall progress message if provided
@@ -332,6 +414,36 @@ function createProgressCallback(analysisId) {
332
414
  };
333
415
  }
334
416
 
417
+ /**
418
+ * Parse enabledLevels from a request body into a normalized { 1: bool, 2: bool, 3: bool } object.
419
+ *
420
+ * Accepts three formats:
421
+ * - Array: e.g. [1, 3] -> { 1: true, 2: false, 3: true }
422
+ * - Object: e.g. { 1: true, 2: false, 3: true } -> filtered to known keys only
423
+ * - Null/undefined: falls back to skipLevel3 flag -> { 1: true, 2: true, 3: !skipLevel3 }
424
+ *
425
+ * @param {Array|Object|null} requestEnabledLevels - Raw enabledLevels from request body
426
+ * @param {boolean} [skipLevel3=false] - Fallback flag when enabledLevels is not provided
427
+ * @returns {Object} Normalized levels config { 1: boolean, 2: boolean, 3: boolean }
428
+ */
429
+ function parseEnabledLevels(requestEnabledLevels, skipLevel3 = false) {
430
+ const levelsConfig = {};
431
+ if (Array.isArray(requestEnabledLevels)) {
432
+ for (const key of [1, 2, 3]) {
433
+ levelsConfig[key] = requestEnabledLevels.includes(key);
434
+ }
435
+ } else if (requestEnabledLevels && typeof requestEnabledLevels === 'object') {
436
+ for (const key of [1, 2, 3]) {
437
+ levelsConfig[key] = !!requestEnabledLevels[key];
438
+ }
439
+ } else {
440
+ levelsConfig[1] = true;
441
+ levelsConfig[2] = true;
442
+ levelsConfig[3] = !skipLevel3;
443
+ }
444
+ return levelsConfig;
445
+ }
446
+
335
447
  module.exports = {
336
448
  CancellationError,
337
449
  activeAnalyses,
@@ -351,5 +463,6 @@ module.exports = {
351
463
  registerProcess,
352
464
  killProcesses,
353
465
  isAnalysisCancelled,
354
- createProgressCallback
466
+ createProgressCallback,
467
+ parseEnabledLevels
355
468
  };
package/src/server.js CHANGED
@@ -2,9 +2,10 @@
2
2
  const express = require('express');
3
3
  const path = require('path');
4
4
  const { loadConfig, getGitHubToken } = require('./config');
5
- const { initializeDatabase, getDatabaseStatus, queryOne } = require('./database');
5
+ const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./database');
6
6
  const { normalizeRepository } = require('./utils/paths');
7
7
  const { applyConfigOverrides, checkAllProviders } = require('./ai');
8
+ const logger = require('./utils/logger');
8
9
 
9
10
  let db = null;
10
11
  let server = null;
@@ -122,7 +123,24 @@ async function startServer(sharedDb = null) {
122
123
  } catch (error) {
123
124
  console.log('Could not check database status:', error.message);
124
125
  }
125
-
126
+
127
+ // Clean up stale analysis runs that have been "running" for over 30 minutes.
128
+ // We use a time threshold rather than blanket cleanup because multiple server
129
+ // processes (e.g. Express + MCP) may share the same database, and a naive
130
+ // UPDATE would kill legitimately running analyses owned by another process.
131
+ // TODO: A more robust approach would be to record the owning PID in
132
+ // analysis_runs and only clean up records whose process is no longer alive.
133
+ // This would require a schema migration and updating the PID throughout the
134
+ // analysis lifecycle (since analysis spawns child processes).
135
+ try {
136
+ const result = await run(db, "UPDATE analysis_runs SET status = 'failed', completed_at = CURRENT_TIMESTAMP WHERE status = 'running' AND started_at < datetime('now', '-30 minutes')");
137
+ if (result.changes > 0) {
138
+ logger.info(`Cleaned up ${result.changes} stale analysis run(s) (running > 30 minutes)`);
139
+ }
140
+ } catch (error) {
141
+ logger.warn(`Failed to clean up orphaned analysis runs: ${error.message}`);
142
+ }
143
+
126
144
  // Check if public directory exists
127
145
  const publicDir = path.join(__dirname, '..', 'public');
128
146
  try {
@@ -176,12 +194,22 @@ async function startServer(sharedDb = null) {
176
194
  try {
177
195
  const existing = await queryOne(db, 'SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
178
196
  if (existing) {
179
- res.sendFile(path.join(__dirname, '..', 'public', 'pr.html'));
197
+ // PR metadata exists, but verify the worktree is still present.
198
+ // When a user deletes a worktree, metadata is preserved but the
199
+ // worktree record is removed. Without this check the route serves
200
+ // pr.html for a missing worktree, causing 404s on file fetches.
201
+ const worktree = await queryOne(db, 'SELECT id FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
202
+ if (worktree) {
203
+ res.sendFile(path.join(__dirname, '..', 'public', 'pr.html'));
204
+ } else {
205
+ logger.info(`PR metadata exists but no worktree for ${repository} #${prNumber}, serving setup page`);
206
+ res.sendFile(path.join(__dirname, '..', 'public', 'setup.html'));
207
+ }
180
208
  } else {
181
209
  res.sendFile(path.join(__dirname, '..', 'public', 'setup.html'));
182
210
  }
183
211
  } catch (error) {
184
- console.error('Failed to query pr_metadata for PR route, falling back to pr.html:', error.message);
212
+ logger.error('Failed to query pr_metadata for PR route, falling back to pr.html:', error.message);
185
213
  res.sendFile(path.join(__dirname, '..', 'public', 'pr.html'));
186
214
  }
187
215
  });
@@ -225,9 +253,11 @@ async function startServer(sharedDb = null) {
225
253
  const localRoutes = require('./routes/local');
226
254
  const setupRoutes = require('./routes/setup');
227
255
  const mcpRoutes = require('./routes/mcp');
256
+ const councilRoutes = require('./routes/councils');
228
257
 
229
258
  // Mount specific routes first to ensure they match before general PR routes
230
259
  app.use('/', analysisRoutes);
260
+ app.use('/', councilRoutes);
231
261
  app.use('/', commentsRoutes);
232
262
  app.use('/', configRoutes);
233
263
  app.use('/', worktreesRoutes);
@@ -56,6 +56,7 @@ function getStatsQuery(runId = null) {
56
56
  query: `
57
57
  SELECT type, COUNT(*) as count FROM comments
58
58
  WHERE review_id = ? AND source = 'ai' AND ai_level IS NULL
59
+ AND (is_raw = 0 OR is_raw IS NULL)
59
60
  AND ai_run_id = ?
60
61
  GROUP BY type
61
62
  `,
@@ -67,6 +68,7 @@ function getStatsQuery(runId = null) {
67
68
  query: `
68
69
  SELECT type, COUNT(*) as count FROM comments
69
70
  WHERE review_id = ? AND source = 'ai' AND ai_level IS NULL
71
+ AND (is_raw = 0 OR is_raw IS NULL)
70
72
  AND ai_run_id = (
71
73
  SELECT ai_run_id FROM comments
72
74
  WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL