@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.
@@ -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
@@ -423,7 +423,13 @@ class GitWorktreeManager {
423
423
  child.stdout.on('data', (data) => {
424
424
  const chunk = data.toString();
425
425
  stdout += chunk;
426
- process.stdout.write(chunk);
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
+ };
@@ -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
- summaryGenerator.kickOffSummaryJob({
840
- db,
841
- config,
842
- reviewId: sessionId,
843
- diffText: diff,
844
- worktreePath: repoPath,
845
- reviewContext: { prTitle: branch },
846
- trigger: 'auto'
847
- })?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
848
-
849
- tourGenerator.kickOffTourJob({
850
- db,
851
- config,
852
- reviewId: sessionId,
853
- diffText: diff,
854
- worktreePath: repoPath,
855
- reviewContext: { prTitle: branch },
856
- trigger: 'auto'
857
- })?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
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
- if (flags.model) {
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
- if (flags.ai) {
921
- url += '?analyze=true';
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);