@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- package/src/utils/stats-calculator.js +2 -0
package/src/routes/shared.js
CHANGED
|
@@ -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(`
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|