@in-the-loop-labs/pair-review 3.7.2 → 3.8.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.
@@ -113,9 +113,13 @@ class AIProvider {
113
113
 
114
114
  /**
115
115
  * Test if the provider's CLI is available
116
+ * @param {number} [timeoutMs] - Optional timeout in milliseconds for the
117
+ * availability probe. Subclasses that spawn a child process should use this
118
+ * to bound the check (the value is resolved per-provider by
119
+ * testProviderAvailability). Defaults to DEFAULT_AVAILABILITY_TIMEOUT_MS.
116
120
  * @returns {Promise<boolean>}
117
121
  */
118
- async testAvailability() {
122
+ async testAvailability(timeoutMs) {
119
123
  throw new Error('testAvailability() must be implemented by subclass');
120
124
  }
121
125
 
@@ -353,6 +357,28 @@ const providerRegistry = new Map();
353
357
  */
354
358
  const providerConfigOverrides = new Map();
355
359
 
360
+ /**
361
+ * Default timeout (ms) for a provider availability probe when no
362
+ * per-provider `availability_timeout_seconds` is configured.
363
+ */
364
+ const DEFAULT_AVAILABILITY_TIMEOUT_MS = 10000;
365
+
366
+ /**
367
+ * Convert a configured `availability_timeout_seconds` value to milliseconds.
368
+ * Single source of truth for the "valid positive seconds" predicate shared by
369
+ * every availability-probe timeout (AI providers, executable providers, and
370
+ * chat providers); falls back to `defaultMs` when the value is unset,
371
+ * non-numeric, non-finite, or <= 0.
372
+ * @param {*} seconds - Raw config value, expected to be a number of seconds
373
+ * @param {number} [defaultMs=DEFAULT_AVAILABILITY_TIMEOUT_MS] - Fallback in ms
374
+ * @returns {number} Timeout in milliseconds
375
+ */
376
+ function secondsToTimeoutMs(seconds, defaultMs = DEFAULT_AVAILABILITY_TIMEOUT_MS) {
377
+ return (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0)
378
+ ? seconds * 1000
379
+ : defaultMs;
380
+ }
381
+
356
382
  /**
357
383
  * Whether yolo mode is enabled (skips fine-grained provider permissions)
358
384
  */
@@ -534,6 +560,7 @@ function applyConfigOverrides(config) {
534
560
  env: providerConfig.env,
535
561
  load_skills: providerConfig.load_skills,
536
562
  app_extensions: providerConfig.app_extensions,
563
+ availability_timeout_seconds: providerConfig.availability_timeout_seconds,
537
564
  models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null
538
565
  });
539
566
  logger.debug(`Registered aliased provider: ${providerId} (base: ${providerConfig.type})`);
@@ -561,6 +588,7 @@ function applyConfigOverrides(config) {
561
588
  env: providerConfig.env,
562
589
  load_skills: providerConfig.load_skills,
563
590
  app_extensions: providerConfig.app_extensions,
591
+ availability_timeout_seconds: providerConfig.availability_timeout_seconds,
564
592
  models: processedModels
565
593
  });
566
594
  }
@@ -724,27 +752,54 @@ function createProvider(providerId, model = null, overrides = {}) {
724
752
  return new ProviderClass(actualModel, { ...(configOverrides || {}), ...overrides, yolo: yoloMode });
725
753
  }
726
754
 
755
+ /**
756
+ * Resolve the availability-probe timeout (ms) for a provider.
757
+ * Reads the per-provider `availability_timeout_seconds` config override
758
+ * (in seconds, mirroring `checkout_timeout_seconds`); falls back to
759
+ * DEFAULT_AVAILABILITY_TIMEOUT_MS when unset, non-numeric, or <= 0.
760
+ * @param {string} providerId - Provider ID
761
+ * @returns {number} Timeout in milliseconds
762
+ */
763
+ function resolveAvailabilityTimeoutMs(providerId) {
764
+ const seconds = providerConfigOverrides.get(providerId)?.availability_timeout_seconds;
765
+ return secondsToTimeoutMs(seconds);
766
+ }
767
+
727
768
  /**
728
769
  * Test availability of a provider with timeout
729
770
  * @param {string} providerId - Provider ID
730
- * @param {number} timeout - Timeout in milliseconds (default 10 seconds)
771
+ * @param {number} [timeoutMs] - Timeout in milliseconds. When omitted, resolved
772
+ * per-provider from `availability_timeout_seconds` (default 10 seconds).
731
773
  * @returns {Promise<{available: boolean, error?: string}>}
732
774
  */
733
- async function testProviderAvailability(providerId, timeout = 10000) {
775
+ async function testProviderAvailability(providerId, timeoutMs) {
776
+ const effectiveTimeoutMs = timeoutMs != null ? timeoutMs : resolveAvailabilityTimeoutMs(providerId);
734
777
  try {
735
778
  const provider = createProvider(providerId);
736
779
 
737
- // Race between availability test and timeout
780
+ // Race between availability test and timeout. The provider's own probe
781
+ // receives the same timeout so it can kill its child process on expiry;
782
+ // this race is the only guard for providers without an internal timeout.
783
+ // Capture the timer handle so we can clear it once the race settles —
784
+ // otherwise a fast probe with a large configured timeout would keep the
785
+ // Node event loop alive (and its rejection unobserved) until the timer fires.
786
+ let timeoutId;
738
787
  const timeoutPromise = new Promise((_, reject) => {
739
- setTimeout(() => reject(new Error('Provider test timed out')), timeout);
788
+ timeoutId = setTimeout(
789
+ () => reject(new Error(`Provider test timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`)),
790
+ effectiveTimeoutMs
791
+ );
740
792
  });
741
793
 
742
- const available = await Promise.race([
743
- provider.testAvailability(),
744
- timeoutPromise
745
- ]);
746
-
747
- return { available };
794
+ try {
795
+ const available = await Promise.race([
796
+ provider.testAvailability(effectiveTimeoutMs),
797
+ timeoutPromise
798
+ ]);
799
+ return { available };
800
+ } finally {
801
+ clearTimeout(timeoutId);
802
+ }
748
803
  } catch (error) {
749
804
  const ProviderClass = providerRegistry.get(providerId);
750
805
  const installInstructions = ProviderClass?.getInstallInstructions() || 'Check the provider documentation.';
@@ -791,6 +846,9 @@ module.exports = {
791
846
  getAllProvidersInfo,
792
847
  createProvider,
793
848
  testProviderAvailability,
849
+ resolveAvailabilityTimeoutMs,
850
+ secondsToTimeoutMs,
851
+ DEFAULT_AVAILABILITY_TIMEOUT_MS,
794
852
  // Config override support
795
853
  applyConfigOverrides,
796
854
  createAliasedProviderClass,
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const { spawn } = require('child_process');
10
- const { getCachedAvailability } = require('../ai');
10
+ const { getCachedAvailability, secondsToTimeoutMs, DEFAULT_AVAILABILITY_TIMEOUT_MS } = require('../ai');
11
11
  const logger = require('../utils/logger');
12
12
 
13
13
  // Default dependencies (overridable for testing)
@@ -123,6 +123,9 @@ function getChatProvider(id) {
123
123
  if (overrides.availability_command !== undefined) {
124
124
  provider.availability_command = overrides.availability_command;
125
125
  }
126
+ if (overrides.availability_timeout_seconds !== undefined) {
127
+ provider.availability_timeout_seconds = overrides.availability_timeout_seconds;
128
+ }
126
129
  if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
127
130
  provider.args = [...provider.args, ...overrides.extra_args];
128
131
  }
@@ -147,6 +150,9 @@ function getChatProvider(id) {
147
150
  if (overrides.availability_command !== undefined) {
148
151
  merged.availability_command = overrides.availability_command;
149
152
  }
153
+ if (overrides.availability_timeout_seconds !== undefined) {
154
+ merged.availability_timeout_seconds = overrides.availability_timeout_seconds;
155
+ }
150
156
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
151
157
  if (overrides.args) {
152
158
  merged.args = overrides.args;
@@ -230,9 +236,10 @@ function isCodexProvider(id) {
230
236
  /**
231
237
  * Check availability of a single chat provider.
232
238
  * Providers with `availability_command` run that command first.
233
- * Without an availability command, Pi delegates to the existing AI provider
234
- * availability cache and other providers spawn `<command> --version` to verify
235
- * the binary exists.
239
+ * Without an availability command, the built-in Pi provider (no `command`
240
+ * override) delegates to the existing AI provider availability cache; every
241
+ * other provider — including custom `type: 'pi'` providers and built-in Pi with
242
+ * a `command` override — spawns `<command> --version` to verify the binary exists.
236
243
  * @param {string} id - Provider ID
237
244
  * @param {Object} [_deps] - Dependency overrides for testing
238
245
  * @returns {Promise<{available: boolean, error?: string}>}
@@ -245,6 +252,12 @@ async function checkChatProviderAvailability(id, _deps) {
245
252
 
246
253
  const deps = { ...defaults, ..._deps };
247
254
 
255
+ // Per-provider availability-probe timeout. Configured in seconds (mirrors
256
+ // checkout_timeout_seconds); falls back to the shared default when
257
+ // unset/invalid. Build-based availability commands can raise this via
258
+ // `availability_timeout_seconds`.
259
+ const timeout = secondsToTimeoutMs(provider.availability_timeout_seconds);
260
+
248
261
  if (provider.availability_command) {
249
262
  return runCommandAvailabilityCheck({
250
263
  deps,
@@ -253,11 +266,18 @@ async function checkChatProviderAvailability(id, _deps) {
253
266
  displayCommand: 'availability command',
254
267
  shell: true,
255
268
  env: provider.env,
269
+ timeout,
256
270
  });
257
271
  }
258
272
 
259
- // Pi delegates to existing AI provider availability
260
- if (provider.type === 'pi') {
273
+ // Delegate to the AI provider's cached availability only for the built-in Pi
274
+ // chat provider with no command override — i.e. the same binary the AI
275
+ // provider already probed. The built-in `pi` definition intentionally omits
276
+ // `command` (PiBridge resolves its own default at runtime), so `!provider.command`
277
+ // cleanly distinguishes it. Custom `type: 'pi'` providers and built-in Pi
278
+ // overridden with a different `command` point at a different binary, so they
279
+ // fall through to the `<command> --version` probe below (which honors `timeout`).
280
+ if (provider.type === 'pi' && !provider.command) {
261
281
  const cached = getCachedAvailability('pi');
262
282
  return { available: cached?.available || false, error: cached?.error };
263
283
  }
@@ -278,6 +298,7 @@ async function checkChatProviderAvailability(id, _deps) {
278
298
  displayCommand: `${command} --version`,
279
299
  shell: useShell,
280
300
  env: provider.env,
301
+ timeout,
281
302
  });
282
303
  }
283
304
 
@@ -296,15 +317,15 @@ async function checkChatProviderAvailability(id, _deps) {
296
317
  * - `displayCommand` is used in error messages so user-configured shell strings
297
318
  * do not need to be printed verbatim.
298
319
  *
299
- * @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object}} opts
320
+ * @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object, timeout?: number}} opts
300
321
  * @returns {Promise<{available: boolean, error?: string}>}
301
322
  */
302
- function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env }) {
323
+ function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env, timeout = DEFAULT_AVAILABILITY_TIMEOUT_MS }) {
303
324
  return new Promise((resolve) => {
304
325
  try {
305
326
  const proc = deps.spawn(command, args, {
306
327
  stdio: ['ignore', 'ignore', 'ignore'],
307
- timeout: 10000,
328
+ timeout,
308
329
  shell,
309
330
  env: { ...process.env, ...(env || {}) },
310
331
  });
@@ -0,0 +1,118 @@
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 {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
40
+ * (requestInstructions may be null)
41
+ * @param {Object} [params.githubClient] - GitHub client passed through to the analyzer
42
+ * @returns {Promise<Object>} The analyzer result ({ suggestions, summary, levelOutcomes, ... })
43
+ */
44
+ async function runHeadlessCouncilAnalysis(db, params) {
45
+ const {
46
+ analyzer,
47
+ reviewId,
48
+ council,
49
+ configType,
50
+ councilConfig,
51
+ worktreePath,
52
+ prMetadata,
53
+ instructions,
54
+ githubClient,
55
+ } = params;
56
+
57
+ const runId = uuidv4();
58
+
59
+ // Compute levelsConfig the same way launchCouncilAnalysis (analyses.js:533-541) does.
60
+ let levelsConfig = null;
61
+ if (configType === 'council') {
62
+ levelsConfig = councilConfig.levels || null;
63
+ } else if (councilConfig.levels) {
64
+ levelsConfig = {};
65
+ for (const [key, val] of Object.entries(councilConfig.levels)) {
66
+ levelsConfig[key] = val?.enabled !== false;
67
+ }
68
+ }
69
+
70
+ const runRepo = new AnalysisRunRepository(db);
71
+ await runRepo.create({
72
+ id: runId,
73
+ reviewId,
74
+ provider: 'council',
75
+ model: council.id,
76
+ tier: null,
77
+ globalInstructions: instructions.globalInstructions || null,
78
+ repoInstructions: instructions.repoInstructions || null,
79
+ requestInstructions: instructions.requestInstructions || null,
80
+ headSha: prMetadata?.head_sha || null,
81
+ configType,
82
+ levelsConfig
83
+ });
84
+
85
+ new CouncilRepository(db).touchLastUsedAt(council.id).catch(err => {
86
+ logger.warn(`Failed to update council last_used_at: ${err.message}`);
87
+ });
88
+
89
+ const reviewContext = {
90
+ reviewId,
91
+ worktreePath,
92
+ prMetadata,
93
+ changedFiles: null,
94
+ instructions
95
+ };
96
+
97
+ try {
98
+ const result = configType === 'council'
99
+ ? await analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { runId, progressCallback: null, githubClient })
100
+ : await analyzer.runCouncilAnalysis(reviewContext, councilConfig, { runId, progressCallback: null, githubClient });
101
+
102
+ await runRepo.update(runId, {
103
+ status: 'completed',
104
+ summary: result.summary,
105
+ totalSuggestions: result.suggestions.length,
106
+ ...(result.levelOutcomes ? { levelOutcomes: result.levelOutcomes } : {})
107
+ }).catch(err => {
108
+ logger.warn(`Failed to update analysis_run: ${err.message}`);
109
+ });
110
+
111
+ return result;
112
+ } catch (error) {
113
+ await runRepo.update(runId, { status: 'failed' }).catch(() => {});
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ 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
+ };
@@ -13,6 +13,7 @@ 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');
16
17
  const { startServer } = require('./server');
17
18
  const { localReviewDiffs } = require('./routes/shared');
18
19
  const summaryGenerator = require('./ai/summary-generator');
@@ -906,10 +907,18 @@ async function handleLocalReview(targetPath, flags = {}) {
906
907
  console.log('Initializing database...');
907
908
  db = await initializeDatabase(resolveDbName(config));
908
909
 
910
+ // Resolve the council handle FAIL-FAST before any further setup, so a bad
911
+ // handle surfaces as a clean error instead of after the browser opens.
912
+ let councilSelection = null;
913
+ if (flags.council) {
914
+ councilSelection = await resolveCouncilHandle(db, flags.council);
915
+ }
916
+
909
917
  const session = await setupLocalReviewSession({ db, config, repoPath, flags });
910
918
  setupComplete = true;
911
919
 
912
- if (flags.model) {
920
+ // Skipped when --council is set: council voices carry their own per-voice models.
921
+ if (flags.model && !flags.council) {
913
922
  process.env.PAIR_REVIEW_MODEL = flags.model;
914
923
  }
915
924
 
@@ -917,9 +926,9 @@ async function handleLocalReview(targetPath, flags = {}) {
917
926
  const port = await startServer(db);
918
927
 
919
928
  let url = `http://localhost:${port}/local/${session.sessionId}`;
920
- if (flags.ai) {
921
- url += '?analyze=true';
922
- }
929
+ const shouldAnalyze = flags.ai || flags.council;
930
+ if (shouldAnalyze) url += '?analyze=true';
931
+ if (councilSelection) url += `&council=${councilSelection.id}`;
923
932
  console.log(`\nOpening browser to: ${url}`);
924
933
  await open(url);
925
934