@in-the-loop-labs/pair-review 3.7.2 → 3.9.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/README.md +188 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/local.js +28 -8
- package/public/js/pr.js +92 -7
- package/public/js/utils/provider-model.js +9 -2
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +31 -30
- package/src/ai/codex-provider.js +4 -3
- package/src/ai/copilot-provider.js +15 -1
- package/src/ai/cursor-agent-provider.js +4 -3
- package/src/ai/executable-provider.js +12 -4
- package/src/ai/gemini-provider.js +15 -1
- package/src/ai/index.js +14 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +294 -38
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +128 -0
- package/src/councils/resolve-council.js +167 -0
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +54 -23
- package/src/main.js +1125 -25
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +131 -5
- package/src/utils/logger.js +25 -4
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
const { AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a council analysis without any server infrastructure.
|
|
9
|
+
*
|
|
10
|
+
* This is the server-free twin of `launchCouncilAnalysis` in
|
|
11
|
+
* `src/routes/analyses.js`. It exists for the CLI headless path, where the
|
|
12
|
+
* SSE broadcast helpers (`broadcastProgress`/`broadcastReviewEvent`), the
|
|
13
|
+
* `activeAnalyses` map, the worktree pool lifecycle, and the hook firing of
|
|
14
|
+
* the route are all absent. It reuses the SAME analyzer methods
|
|
15
|
+
* (`runReviewerCentricCouncil` / `runCouncilAnalysis`) and the SAME
|
|
16
|
+
* run-record semantics as the route.
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: The two functions MUST stay in parity for run-record fields and
|
|
19
|
+
* completion semantics. Specifically:
|
|
20
|
+
* - The parent `analysis_runs` row is PRE-CREATED here with
|
|
21
|
+
* `model = council.id` and `runId` is passed into the analyzer. This is
|
|
22
|
+
* what attributes the run to the council. The analyzer methods do not
|
|
23
|
+
* create the parent row when a `runId` is supplied.
|
|
24
|
+
* - The analyzer methods do NOT mark the parent run completed/failed; this
|
|
25
|
+
* helper does it (mirroring the route's `.then()` / `.catch()`).
|
|
26
|
+
* Any change to run-record fields or completion handling must be applied to
|
|
27
|
+
* BOTH this function and `launchCouncilAnalysis`.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} db - Database instance
|
|
30
|
+
* @param {Object} params - Analysis parameters
|
|
31
|
+
* @param {Object} params.analyzer - Analyzer instance exposing
|
|
32
|
+
* `runReviewerCentricCouncil` and `runCouncilAnalysis`
|
|
33
|
+
* @param {number} params.reviewId - Review ID (PR or local)
|
|
34
|
+
* @param {Object} params.council - Full council row (must have `.id`)
|
|
35
|
+
* @param {string} params.configType - 'council' (voice-centric) or 'advanced'
|
|
36
|
+
* @param {Object} params.councilConfig - The council's parsed config object
|
|
37
|
+
* @param {string} params.worktreePath - Path to the checked-out worktree
|
|
38
|
+
* @param {Object} [params.prMetadata] - PR metadata (head_sha used for the run row)
|
|
39
|
+
* @param {Array<Object|string>|null} [params.changedFiles] - Precomputed scope-aware
|
|
40
|
+
* changed-file list (local mode) so council validation uses the same file set as
|
|
41
|
+
* the generated diff; `null` in PR mode (the analyzer derives it from Git). Mirrors
|
|
42
|
+
* the route contract (`src/routes/local.js` passes the list, `src/routes/pr.js`
|
|
43
|
+
* passes `null`).
|
|
44
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
45
|
+
* (requestInstructions may be null)
|
|
46
|
+
* @param {Object} [params.githubClient] - GitHub client passed through to the analyzer
|
|
47
|
+
* @returns {Promise<Object>} `{ runId, ...result }` — the parent run id created
|
|
48
|
+
* here, spread together with the analyzer result
|
|
49
|
+
* ({ suggestions, summary, levelOutcomes, ... }).
|
|
50
|
+
*/
|
|
51
|
+
async function runHeadlessCouncilAnalysis(db, params) {
|
|
52
|
+
const {
|
|
53
|
+
analyzer,
|
|
54
|
+
reviewId,
|
|
55
|
+
council,
|
|
56
|
+
configType,
|
|
57
|
+
councilConfig,
|
|
58
|
+
worktreePath,
|
|
59
|
+
prMetadata,
|
|
60
|
+
changedFiles,
|
|
61
|
+
instructions,
|
|
62
|
+
githubClient,
|
|
63
|
+
} = params;
|
|
64
|
+
|
|
65
|
+
const runId = uuidv4();
|
|
66
|
+
|
|
67
|
+
// Compute levelsConfig the same way launchCouncilAnalysis (analyses.js:533-541) does.
|
|
68
|
+
let levelsConfig = null;
|
|
69
|
+
if (configType === 'council') {
|
|
70
|
+
levelsConfig = councilConfig.levels || null;
|
|
71
|
+
} else if (councilConfig.levels) {
|
|
72
|
+
levelsConfig = {};
|
|
73
|
+
for (const [key, val] of Object.entries(councilConfig.levels)) {
|
|
74
|
+
levelsConfig[key] = val?.enabled !== false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const runRepo = new AnalysisRunRepository(db);
|
|
79
|
+
await runRepo.create({
|
|
80
|
+
id: runId,
|
|
81
|
+
reviewId,
|
|
82
|
+
provider: 'council',
|
|
83
|
+
model: council.id,
|
|
84
|
+
tier: null,
|
|
85
|
+
globalInstructions: instructions.globalInstructions || null,
|
|
86
|
+
repoInstructions: instructions.repoInstructions || null,
|
|
87
|
+
requestInstructions: instructions.requestInstructions || null,
|
|
88
|
+
headSha: prMetadata?.head_sha || null,
|
|
89
|
+
configType,
|
|
90
|
+
levelsConfig
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
new CouncilRepository(db).touchLastUsedAt(council.id).catch(err => {
|
|
94
|
+
logger.warn(`Failed to update council last_used_at: ${err.message}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const reviewContext = {
|
|
98
|
+
reviewId,
|
|
99
|
+
worktreePath,
|
|
100
|
+
prMetadata,
|
|
101
|
+
// Local headless runs forward a scope-aware list; PR mode passes null and
|
|
102
|
+
// the analyzer derives the file set from Git (matches src/routes/pr.js).
|
|
103
|
+
changedFiles: changedFiles || null,
|
|
104
|
+
instructions
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const result = configType === 'council'
|
|
109
|
+
? await analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { runId, progressCallback: null, githubClient })
|
|
110
|
+
: await analyzer.runCouncilAnalysis(reviewContext, councilConfig, { runId, progressCallback: null, githubClient });
|
|
111
|
+
|
|
112
|
+
await runRepo.update(runId, {
|
|
113
|
+
status: 'completed',
|
|
114
|
+
summary: result.summary,
|
|
115
|
+
totalSuggestions: result.suggestions.length,
|
|
116
|
+
...(result.levelOutcomes ? { levelOutcomes: result.levelOutcomes } : {})
|
|
117
|
+
}).catch(err => {
|
|
118
|
+
logger.warn(`Failed to update analysis_run: ${err.message}`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { runId, ...result };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await runRepo.update(runId, { status: 'failed' }).catch(() => {});
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { runHeadlessCouncilAnalysis };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Council handle resolution helpers.
|
|
4
|
+
*
|
|
5
|
+
* Resolves user-supplied CLI handles (id, id-prefix, name, normalized name) to a
|
|
6
|
+
* saved council row, and gathers "Last Used With" metadata for the council list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { query, CouncilRepository } = require('../database');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a string for fuzzy name matching: lowercase, trim, collapse any run
|
|
13
|
+
* of non-alphanumeric characters to a single dash, and strip leading/trailing dashes.
|
|
14
|
+
* @param {string} s - Input string
|
|
15
|
+
* @returns {string} Normalized slug-like string
|
|
16
|
+
*/
|
|
17
|
+
function normalizeForMatch(s) {
|
|
18
|
+
return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Truncate an id to its first 8 characters for display.
|
|
23
|
+
* @param {string} id - Full council id (UUID)
|
|
24
|
+
* @returns {string} Short id
|
|
25
|
+
*/
|
|
26
|
+
function shortId(id) {
|
|
27
|
+
return String(id || '').slice(0, 8);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a clear, multi-line ambiguity error for a handle that matched several councils.
|
|
32
|
+
* @param {string} handle - The user-supplied handle
|
|
33
|
+
* @param {Array<Object>} matches - The matching council rows
|
|
34
|
+
* @returns {Error} Error with a readable, aligned candidate list
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
function _ambiguityError(handle, matches) {
|
|
38
|
+
const padTo = Math.max(...matches.map(c => String(c.name || '').length));
|
|
39
|
+
const lines = matches.map(c => {
|
|
40
|
+
const name = String(c.name || '').padEnd(padTo);
|
|
41
|
+
// Show the FULL id, not shortId: when the collision is on the 8-char prefix,
|
|
42
|
+
// every shortId would be identical and could not disambiguate.
|
|
43
|
+
return ` ${name} (${c.id})`;
|
|
44
|
+
});
|
|
45
|
+
return new Error(
|
|
46
|
+
`Ambiguous council "${handle}" matches ${matches.length} councils. Disambiguate with the id:\n` +
|
|
47
|
+
lines.join('\n')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a user-supplied council handle to a full council row.
|
|
53
|
+
*
|
|
54
|
+
* Matching order (first unambiguous match wins):
|
|
55
|
+
* 1. Exact id
|
|
56
|
+
* 2. UUID-prefix (only for hex-ish handles of length >= 4)
|
|
57
|
+
* 3. Exact name (case-insensitive)
|
|
58
|
+
* 4. Normalized name
|
|
59
|
+
* 5. Partial (substring) name fragment (last resort, never shadows the above)
|
|
60
|
+
*
|
|
61
|
+
* @param {Database} db - Database instance
|
|
62
|
+
* @param {string} handle - The handle to resolve (id, id-prefix, or name)
|
|
63
|
+
* @returns {Promise<Object>} The matching council row
|
|
64
|
+
* @throws {Error} If the handle is missing, ambiguous, or matches nothing
|
|
65
|
+
*/
|
|
66
|
+
async function resolveCouncilHandle(db, handle) {
|
|
67
|
+
const all = await new CouncilRepository(db).list();
|
|
68
|
+
|
|
69
|
+
if (!handle) {
|
|
70
|
+
throw new Error('A council handle is required.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 1. Exact id
|
|
74
|
+
const exactId = all.find(c => c.id === handle);
|
|
75
|
+
if (exactId) return exactId;
|
|
76
|
+
|
|
77
|
+
// 2. UUID-prefix match (only for hex-ish handles of meaningful length)
|
|
78
|
+
if (handle.length >= 4 && /^[0-9a-f-]+$/i.test(handle)) {
|
|
79
|
+
const m = all.filter(c => c.id.toLowerCase().startsWith(handle.toLowerCase()));
|
|
80
|
+
if (m.length === 1) return m[0];
|
|
81
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Exact name (case-insensitive)
|
|
85
|
+
{
|
|
86
|
+
const m = all.filter(c => c.name.toLowerCase() === handle.toLowerCase());
|
|
87
|
+
if (m.length === 1) return m[0];
|
|
88
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Normalized name
|
|
92
|
+
{
|
|
93
|
+
const hn = normalizeForMatch(handle);
|
|
94
|
+
const m = all.filter(c => normalizeForMatch(c.name) === hn);
|
|
95
|
+
if (m.length === 1) return m[0];
|
|
96
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Partial (substring) name fragment — last resort. A council matches if its
|
|
100
|
+
// name contains the handle (case-insensitive) OR its normalized name contains
|
|
101
|
+
// the normalized handle. Union both, de-duplicated by id so a council matched
|
|
102
|
+
// both ways isn't double-counted.
|
|
103
|
+
{
|
|
104
|
+
const hl = handle.toLowerCase();
|
|
105
|
+
const hn = normalizeForMatch(handle);
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const m = [];
|
|
108
|
+
for (const c of all) {
|
|
109
|
+
const byName = String(c.name || '').toLowerCase().includes(hl);
|
|
110
|
+
const byNorm = hn !== '' && normalizeForMatch(c.name).includes(hn);
|
|
111
|
+
if ((byName || byNorm) && !seen.has(c.id)) {
|
|
112
|
+
seen.add(c.id);
|
|
113
|
+
m.push(c);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (m.length === 1) return m[0];
|
|
117
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// No match
|
|
121
|
+
throw new Error(
|
|
122
|
+
`No council matches "${handle}". Run \`pair-review --list-councils\` to see available councils.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a map of the most recent council RUN per saved council, for the
|
|
128
|
+
* "Last Used With" column in the council list.
|
|
129
|
+
*
|
|
130
|
+
* Only counts true council runs (provider = 'council', model != 'inline-config').
|
|
131
|
+
* Councils with no council run simply won't appear in the map.
|
|
132
|
+
*
|
|
133
|
+
* @param {Database} db - Database instance
|
|
134
|
+
* @returns {Promise<Map<string, {repository: string, review_type: string, pr_number: number, last_started: string}>>}
|
|
135
|
+
* Map keyed by council id.
|
|
136
|
+
*/
|
|
137
|
+
async function getCouncilLastUsedRepos(db) {
|
|
138
|
+
const rows = await query(db, `
|
|
139
|
+
SELECT ar.model AS council_id,
|
|
140
|
+
r.repository AS repository,
|
|
141
|
+
r.review_type AS review_type,
|
|
142
|
+
r.pr_number AS pr_number,
|
|
143
|
+
MAX(ar.started_at) AS last_started
|
|
144
|
+
FROM analysis_runs ar
|
|
145
|
+
JOIN reviews r ON r.id = ar.review_id
|
|
146
|
+
WHERE ar.provider = 'council' AND ar.model != 'inline-config'
|
|
147
|
+
GROUP BY ar.model
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
const map = new Map();
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
map.set(row.council_id, {
|
|
153
|
+
repository: row.repository,
|
|
154
|
+
review_type: row.review_type,
|
|
155
|
+
pr_number: row.pr_number,
|
|
156
|
+
last_started: row.last_started
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
normalizeForMatch,
|
|
164
|
+
shortId,
|
|
165
|
+
resolveCouncilHandle,
|
|
166
|
+
getCouncilLastUsedRepos
|
|
167
|
+
};
|
package/src/database.js
CHANGED
|
@@ -3679,6 +3679,58 @@ class CommentRepository {
|
|
|
3679
3679
|
`, [reviewId]);
|
|
3680
3680
|
}
|
|
3681
3681
|
|
|
3682
|
+
/**
|
|
3683
|
+
* Get the consolidated final AI suggestions for a single analysis run.
|
|
3684
|
+
*
|
|
3685
|
+
* This returns the run-scoped, orchestrated (consolidated) layer: the final
|
|
3686
|
+
* suggestions the app shows by default for a run — `source='ai'`,
|
|
3687
|
+
* `ai_level IS NULL` (not per-level), non-raw, and (by default) not dismissed.
|
|
3688
|
+
* It is the shared retrieval used by both the MCP `get_ai_suggestions` tool
|
|
3689
|
+
* and the CLI JSON output for agents.
|
|
3690
|
+
*
|
|
3691
|
+
* INTENTIONALLY DISTINCT from the review-scoped submit query in
|
|
3692
|
+
* `performHeadlessReview` (src/main.js ~1195-1207), which must NOT be migrated
|
|
3693
|
+
* to this method. That query has different semantics on purpose:
|
|
3694
|
+
* - review-scoped (latest-review-wide), not single-run-scoped;
|
|
3695
|
+
* - `status = 'active'` only (no adopted);
|
|
3696
|
+
* - thin columns tailored for GitHub submission.
|
|
3697
|
+
* Keep the two queries separate.
|
|
3698
|
+
*
|
|
3699
|
+
* @param {string} runId - Analysis run ID (`ai_run_id`)
|
|
3700
|
+
* @param {Object} [options] - Query options
|
|
3701
|
+
* @param {string[]} [options.statuses=['active','adopted']] - Statuses to include
|
|
3702
|
+
* @param {string|null} [options.file=null] - Restrict to a single file path
|
|
3703
|
+
* @returns {Promise<Array<Object>>} Consolidated final suggestion rows
|
|
3704
|
+
*/
|
|
3705
|
+
async getFinalSuggestionsByRunId(runId, { statuses = ['active', 'adopted'], file = null } = {}) {
|
|
3706
|
+
const params = [runId];
|
|
3707
|
+
const conditions = [
|
|
3708
|
+
'ai_run_id = ?',
|
|
3709
|
+
"source = 'ai'",
|
|
3710
|
+
'ai_level IS NULL',
|
|
3711
|
+
'(is_raw = 0 OR is_raw IS NULL)'
|
|
3712
|
+
];
|
|
3713
|
+
|
|
3714
|
+
const placeholders = statuses.map(() => '?').join(', ');
|
|
3715
|
+
conditions.push(`status IN (${placeholders})`);
|
|
3716
|
+
params.push(...statuses);
|
|
3717
|
+
|
|
3718
|
+
if (file) {
|
|
3719
|
+
conditions.push('file = ?');
|
|
3720
|
+
params.push(file);
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
return await query(this.db, `
|
|
3724
|
+
SELECT
|
|
3725
|
+
id, ai_run_id, ai_level, ai_confidence,
|
|
3726
|
+
file, line_start, line_end, type, title, body,
|
|
3727
|
+
reasoning, status, is_file_level, severity, created_at
|
|
3728
|
+
FROM comments
|
|
3729
|
+
WHERE ${conditions.join('\n AND ')}
|
|
3730
|
+
ORDER BY file, line_start
|
|
3731
|
+
`, params);
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3682
3734
|
/**
|
|
3683
3735
|
* Restore a soft-deleted user comment (set status from 'inactive' back to 'active')
|
|
3684
3736
|
* @param {number} id - Comment ID
|
package/src/git/worktree.js
CHANGED
|
@@ -423,7 +423,13 @@ class GitWorktreeManager {
|
|
|
423
423
|
child.stdout.on('data', (data) => {
|
|
424
424
|
const chunk = data.toString();
|
|
425
425
|
stdout += chunk;
|
|
426
|
-
|
|
426
|
+
// When PAIR_REVIEW_QUIET_STDOUT is set (headless --json or MCP stdio
|
|
427
|
+
// mode, via redirectConsoleToStderr in src/mcp-stdio.js), stdout is
|
|
428
|
+
// reserved for a machine-readable document (JSON / JSON-RPC). Mirror the
|
|
429
|
+
// child's stdout to stderr in that case so it doesn't corrupt the
|
|
430
|
+
// reserved stream; otherwise mirror it to stdout as before.
|
|
431
|
+
const sink = process.env.PAIR_REVIEW_QUIET_STDOUT ? process.stderr : process.stdout;
|
|
432
|
+
sink.write(chunk);
|
|
427
433
|
});
|
|
428
434
|
child.stderr.on('data', (data) => {
|
|
429
435
|
const chunk = data.toString();
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for threading per-run CLI instructions into analysis.
|
|
4
|
+
*
|
|
5
|
+
* `resolveCliInstructions` reads `--instructions` / `--instructions-file`.
|
|
6
|
+
*
|
|
7
|
+
* `buildInteractiveAnalysisConfig` / `prepareInteractiveAnalysisConfig` bridge the
|
|
8
|
+
* CLI to the browser-side auto-analyze: interactive `--ai` / `--council` runs open
|
|
9
|
+
* the web UI and trigger analysis via the `?analyze=true` URL, which has no slot
|
|
10
|
+
* for custom instructions. To honor `--instructions` there too (instead of
|
|
11
|
+
* silently dropping it), the CLI resolves the full review config + instructions
|
|
12
|
+
* and threads a short id through the URL as `analysisConfigId`. The PR/local
|
|
13
|
+
* browser code already resolves that id before starting analysis (see
|
|
14
|
+
* `_fetchAutoAnalysisConfigFromUrl` in public/js/pr.js and the local auto-analyze
|
|
15
|
+
* in public/js/local.js).
|
|
16
|
+
*
|
|
17
|
+
* The resolution is split so the same config object serves two storage targets:
|
|
18
|
+
* - `buildInteractiveAnalysisConfig` — PURE, returns the resolved config object
|
|
19
|
+
* (no storage). Reused by the delegation path, which POSTs it to a server
|
|
20
|
+
* running in ANOTHER process (the in-memory store below is per-process).
|
|
21
|
+
* - `prepareInteractiveAnalysisConfig` — the cold-start wrapper that stashes the
|
|
22
|
+
* built config in the in-memory bulk-analysis-config store (same process that
|
|
23
|
+
* then starts the server) and returns the id.
|
|
24
|
+
*
|
|
25
|
+
* Living in its own module keeps both CLI entry points — `handlePullRequest`
|
|
26
|
+
* (src/main.js) and `handleLocalReview` (src/local-review.js) — able to import
|
|
27
|
+
* it without a require cycle through main.js.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const { resolveReviewConfig } = require('./review-config');
|
|
32
|
+
const { createBulkAnalysisConfig } = require('./routes/bulk-analysis-configs');
|
|
33
|
+
|
|
34
|
+
const MAX_INSTRUCTIONS_CHARS = 5000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve per-run custom instructions ("requestInstructions") from CLI flags.
|
|
38
|
+
*
|
|
39
|
+
* Precedence: `--instructions <text>` is used directly; otherwise
|
|
40
|
+
* `--instructions-file <path>` is read from disk. The result is trimmed and
|
|
41
|
+
* capped at 5000 chars (parity with the web analyze routes). Returns `null`
|
|
42
|
+
* when neither flag is supplied.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} flags - Parsed CLI flags
|
|
45
|
+
* @returns {Promise<string|null>}
|
|
46
|
+
* @throws {Error} On unreadable instructions file or when the text exceeds the cap.
|
|
47
|
+
*/
|
|
48
|
+
async function resolveCliInstructions(flags) {
|
|
49
|
+
let text = null;
|
|
50
|
+
|
|
51
|
+
if (typeof flags.instructions === 'string') {
|
|
52
|
+
text = flags.instructions;
|
|
53
|
+
} else if (typeof flags.instructionsFile === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
text = await fs.promises.readFile(flags.instructionsFile, 'utf8');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new Error(`Failed to read --instructions-file "${flags.instructionsFile}": ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (text == null) return null;
|
|
62
|
+
|
|
63
|
+
text = text.trim();
|
|
64
|
+
if (text.length === 0) return null;
|
|
65
|
+
|
|
66
|
+
if (text.length > MAX_INSTRUCTIONS_CHARS) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Custom instructions exceed the ${MAX_INSTRUCTIONS_CHARS}-character limit (got ${text.length}).`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* For an interactive `--ai` / `--council` run that ALSO carries
|
|
77
|
+
* `--instructions[-file]`, resolve the full review config (single provider/model
|
|
78
|
+
* or council) plus the instruction text into the analysis-config object the
|
|
79
|
+
* browser-side analyze code consumes. PURE: it performs no storage, so it serves
|
|
80
|
+
* both the in-process cold-start path (via `prepareInteractiveAnalysisConfig`)
|
|
81
|
+
* and the cross-process delegation path (which POSTs the object to the running
|
|
82
|
+
* server — see `storeAnalysisConfigRemote` in src/single-port.js).
|
|
83
|
+
*
|
|
84
|
+
* Returns `null` when no instructions were supplied, so callers keep their prior
|
|
85
|
+
* URL shape (bare `?analyze=true` + optional `&council=`) unchanged.
|
|
86
|
+
*
|
|
87
|
+
* The returned config is shape-compatible with what the browser-side analyze code
|
|
88
|
+
* consumes: a single pick becomes `{ provider, model, customInstructions }`; a
|
|
89
|
+
* council becomes an inline snapshot `{ isCouncil, configType, councilConfig,
|
|
90
|
+
* councilName, customInstructions }` (the snapshot forces the analysis route to
|
|
91
|
+
* use the exact resolved council rather than re-fetching by id).
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} params
|
|
94
|
+
* @param {Object} params.db - Database instance
|
|
95
|
+
* @param {Object} params.config - Loaded global config
|
|
96
|
+
* @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
|
|
97
|
+
* @param {string} params.repository - owner/repo (for repo-default resolution)
|
|
98
|
+
* @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
|
|
99
|
+
*/
|
|
100
|
+
async function buildInteractiveAnalysisConfig({ db, config, flags, repository }) {
|
|
101
|
+
const requestInstructions = await resolveCliInstructions(flags);
|
|
102
|
+
if (!requestInstructions) return null;
|
|
103
|
+
|
|
104
|
+
const reviewConfig = await resolveReviewConfig(
|
|
105
|
+
db,
|
|
106
|
+
repository,
|
|
107
|
+
{ council: flags.council, model: flags.model },
|
|
108
|
+
config
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return reviewConfig.type === 'council'
|
|
112
|
+
? {
|
|
113
|
+
isCouncil: true,
|
|
114
|
+
configType: reviewConfig.configType,
|
|
115
|
+
councilConfig: reviewConfig.councilConfig,
|
|
116
|
+
councilName: reviewConfig.council.name || null,
|
|
117
|
+
customInstructions: requestInstructions
|
|
118
|
+
}
|
|
119
|
+
: {
|
|
120
|
+
provider: reviewConfig.provider,
|
|
121
|
+
model: reviewConfig.model,
|
|
122
|
+
customInstructions: requestInstructions
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Thin in-process wrapper around `buildInteractiveAnalysisConfig`: resolve the
|
|
128
|
+
* analysis config and, when present, stash it in the in-memory
|
|
129
|
+
* bulk-analysis-config store, returning the id to thread through the browser URL
|
|
130
|
+
* as `analysisConfigId`. Used by the cold-start handlers (`handlePullRequest`,
|
|
131
|
+
* `handleLocalReview`) that go on to start the server in THIS process, so the
|
|
132
|
+
* in-process store is the one the browser tab will read from.
|
|
133
|
+
*
|
|
134
|
+
* Returns `null` when no instructions were supplied (callers keep their prior URL
|
|
135
|
+
* shape unchanged).
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} params - See `buildInteractiveAnalysisConfig`.
|
|
138
|
+
* @returns {Promise<string|null>} The analysisConfigId, or null when no instructions.
|
|
139
|
+
*/
|
|
140
|
+
async function prepareInteractiveAnalysisConfig({ db, config, flags, repository }) {
|
|
141
|
+
const analysisConfig = await buildInteractiveAnalysisConfig({ db, config, flags, repository });
|
|
142
|
+
if (!analysisConfig) return null;
|
|
143
|
+
|
|
144
|
+
const { id } = createBulkAnalysisConfig(analysisConfig);
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
resolveCliInstructions,
|
|
150
|
+
buildInteractiveAnalysisConfig,
|
|
151
|
+
prepareInteractiveAnalysisConfig
|
|
152
|
+
};
|
package/src/local-review.js
CHANGED
|
@@ -13,6 +13,8 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = r
|
|
|
13
13
|
const execAsync = promisify(exec);
|
|
14
14
|
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
|
|
15
15
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
16
|
+
const { resolveCouncilHandle } = require('./councils/resolve-council');
|
|
17
|
+
const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
|
|
16
18
|
const { startServer } = require('./server');
|
|
17
19
|
const { localReviewDiffs } = require('./routes/shared');
|
|
18
20
|
const summaryGenerator = require('./ai/summary-generator');
|
|
@@ -702,9 +704,14 @@ async function generateLocalDiff(repoPath, options = {}) {
|
|
|
702
704
|
* @param {Object} params.config - Loaded config
|
|
703
705
|
* @param {string} params.repoPath - Path to the working tree (already validated)
|
|
704
706
|
* @param {Object} [params.flags] - CLI flags / options
|
|
707
|
+
* @param {boolean} [params.startBackgroundJobs=true] - When false, skip the
|
|
708
|
+
* fire-and-forget summary/tour generation jobs. The interactive UI wants them
|
|
709
|
+
* (they populate the review while the human reads the diff), but a one-shot
|
|
710
|
+
* headless run does not: they would spend provider budget after the JSON
|
|
711
|
+
* result is already printed and keep the event loop alive (CLI hang).
|
|
705
712
|
* @returns {Promise<{sessionId: number, repoPath: string, branch: string, repository: string, headSha: string, diff: string, stats: Object, digest: string|null, branchInfo: Object|null, scopeStart: string, scopeEnd: string, baseBranch: string|null}>}
|
|
706
713
|
*/
|
|
707
|
-
async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
|
|
714
|
+
async function setupLocalReviewSession({ db, config, repoPath, flags = {}, startBackgroundJobs = true }) {
|
|
708
715
|
const headSha = await module.exports.getHeadSha(repoPath);
|
|
709
716
|
console.log(`HEAD SHA: ${headSha}`);
|
|
710
717
|
|
|
@@ -836,25 +843,30 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
|
|
|
836
843
|
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
837
844
|
}
|
|
838
845
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
846
|
+
// Fire-and-forget summary/tour generation. Skipped for headless one-shot runs
|
|
847
|
+
// (startBackgroundJobs:false) — they would spend provider budget after the
|
|
848
|
+
// result is reported and keep the event loop alive, hanging the CLI.
|
|
849
|
+
if (startBackgroundJobs) {
|
|
850
|
+
summaryGenerator.kickOffSummaryJob({
|
|
851
|
+
db,
|
|
852
|
+
config,
|
|
853
|
+
reviewId: sessionId,
|
|
854
|
+
diffText: diff,
|
|
855
|
+
worktreePath: repoPath,
|
|
856
|
+
reviewContext: { prTitle: branch },
|
|
857
|
+
trigger: 'auto'
|
|
858
|
+
})?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
|
|
859
|
+
|
|
860
|
+
tourGenerator.kickOffTourJob({
|
|
861
|
+
db,
|
|
862
|
+
config,
|
|
863
|
+
reviewId: sessionId,
|
|
864
|
+
diffText: diff,
|
|
865
|
+
worktreePath: repoPath,
|
|
866
|
+
reviewContext: { prTitle: branch },
|
|
867
|
+
trigger: 'auto'
|
|
868
|
+
})?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
|
|
869
|
+
}
|
|
858
870
|
|
|
859
871
|
return {
|
|
860
872
|
sessionId,
|
|
@@ -906,10 +918,18 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
906
918
|
console.log('Initializing database...');
|
|
907
919
|
db = await initializeDatabase(resolveDbName(config));
|
|
908
920
|
|
|
921
|
+
// Resolve the council handle FAIL-FAST before any further setup, so a bad
|
|
922
|
+
// handle surfaces as a clean error instead of after the browser opens.
|
|
923
|
+
let councilSelection = null;
|
|
924
|
+
if (flags.council) {
|
|
925
|
+
councilSelection = await resolveCouncilHandle(db, flags.council);
|
|
926
|
+
}
|
|
927
|
+
|
|
909
928
|
const session = await setupLocalReviewSession({ db, config, repoPath, flags });
|
|
910
929
|
setupComplete = true;
|
|
911
930
|
|
|
912
|
-
|
|
931
|
+
// Skipped when --council is set: council voices carry their own per-voice models.
|
|
932
|
+
if (flags.model && !flags.council) {
|
|
913
933
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
914
934
|
}
|
|
915
935
|
|
|
@@ -917,8 +937,19 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
917
937
|
const port = await startServer(db);
|
|
918
938
|
|
|
919
939
|
let url = `http://localhost:${port}/local/${session.sessionId}`;
|
|
920
|
-
|
|
921
|
-
|
|
940
|
+
const shouldAnalyze = flags.ai || flags.council;
|
|
941
|
+
if (shouldAnalyze) url += '?analyze=true';
|
|
942
|
+
// When the run carries --instructions, stash the resolved analysis config and
|
|
943
|
+
// thread its id so the browser-side auto-analyze honors the instructions
|
|
944
|
+
// instead of silently dropping them (mirrors handlePullRequest). The id
|
|
945
|
+
// encodes the council selection, so prefer it over the bare &council= param.
|
|
946
|
+
const analysisConfigId = shouldAnalyze
|
|
947
|
+
? await prepareInteractiveAnalysisConfig({ db, config, flags, repository: session.repository })
|
|
948
|
+
: null;
|
|
949
|
+
if (analysisConfigId) {
|
|
950
|
+
url += `&analysisConfigId=${analysisConfigId}`;
|
|
951
|
+
} else if (councilSelection) {
|
|
952
|
+
url += `&council=${councilSelection.id}`;
|
|
922
953
|
}
|
|
923
954
|
console.log(`\nOpening browser to: ${url}`);
|
|
924
955
|
await open(url);
|