@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.
package/src/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
4
- const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
3
+ const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
4
+ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
7
7
  const { GitWorktreeManager } = require('./git/worktree');
@@ -11,6 +11,9 @@ const { startServer } = require('./server');
11
11
  const Analyzer = require('./ai/analyzer');
12
12
  const { applyConfigOverrides } = require('./ai');
13
13
  const { handleLocalReview, findMainGitRoot } = require('./local-review');
14
+ const { resolveCouncilHandle, getCouncilLastUsedRepos, shortId } = require('./councils/resolve-council');
15
+ const { runHeadlessCouncilAnalysis } = require('./councils/headless-council');
16
+ const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
14
17
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
15
18
  const { fireReviewStartedHook } = require('./hooks/payloads');
16
19
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
@@ -164,6 +167,9 @@ OPTIONS:
164
167
  opus-4.6-high, opus-4.6-1m, sonnet-4.6
165
168
  (opus is Opus 4.7 XHigh, the default)
166
169
  or use provider-specific models with Gemini/Codex
170
+ --council <handle> Run analysis with a saved council (multi-voice). Handle is a
171
+ council name, name-slug, or id (prefix). See --list-councils.
172
+ --list-councils List saved councils (handles to use with --council) and exit
167
173
  --use-checkout Use current directory instead of creating worktree
168
174
  (automatic in GitHub Actions)
169
175
  --yolo Allow AI providers full system access (skip read-only
@@ -179,6 +185,8 @@ EXAMPLES:
179
185
  pair-review --local # Review uncommitted local changes
180
186
  pair-review 123 --ai # Auto-run AI analysis
181
187
  pair-review --ai-review # CI mode: auto-detect PR, submit review
188
+ pair-review 123 --ai-draft --council security-review # Headless council draft
189
+ pair-review --list-councils # List saved councils and their handles
182
190
  pair-review --register # Register URL scheme handler
183
191
  pair-review --register --command "node bin/pair-review.js" # Custom command
184
192
 
@@ -207,6 +215,74 @@ MORE INFO:
207
215
  `);
208
216
  }
209
217
 
218
+ /**
219
+ * Print the list of saved councils (handles, names, types, and "last used"
220
+ * metadata) as an aligned table, then exit guidance. Used by --list-councils.
221
+ *
222
+ * @param {Object} db - Database instance
223
+ */
224
+ async function printCouncilList(db) {
225
+ const councils = await new CouncilRepository(db).list();
226
+
227
+ if (!councils || councils.length === 0) {
228
+ console.log('No councils found. Create one in the web UI under Analysis settings.');
229
+ return;
230
+ }
231
+
232
+ const repoMap = await getCouncilLastUsedRepos(db);
233
+
234
+ const rows = councils.map(c => {
235
+ const entry = repoMap.get(c.id);
236
+ const lastTs = entry?.last_started || c.last_used_at;
237
+ const lastUsed = lastTs ? String(lastTs).slice(0, 10) : 'never';
238
+ let lastUsedWith = '—';
239
+ if (entry) {
240
+ lastUsedWith = `${entry.repository}${entry.pr_number ? `#${entry.pr_number}` : ''}`;
241
+ }
242
+ return {
243
+ handle: shortId(c.id),
244
+ name: c.name || '',
245
+ type: c.type || '',
246
+ lastUsed,
247
+ lastUsedWith
248
+ };
249
+ });
250
+
251
+ const headers = {
252
+ handle: 'HANDLE',
253
+ name: 'NAME',
254
+ type: 'TYPE',
255
+ lastUsed: 'LAST USED',
256
+ lastUsedWith: 'LAST USED WITH'
257
+ };
258
+
259
+ const widths = {};
260
+ for (const key of Object.keys(headers)) {
261
+ widths[key] = Math.max(
262
+ headers[key].length,
263
+ ...rows.map(r => String(r[key]).length)
264
+ );
265
+ }
266
+
267
+ const formatRow = r =>
268
+ [
269
+ String(r.handle).padEnd(widths.handle),
270
+ String(r.name).padEnd(widths.name),
271
+ String(r.type).padEnd(widths.type),
272
+ String(r.lastUsed).padEnd(widths.lastUsed),
273
+ String(r.lastUsedWith).padEnd(widths.lastUsedWith)
274
+ ].join(' ');
275
+
276
+ console.log(formatRow(headers));
277
+ for (const r of rows) {
278
+ console.log(formatRow(r));
279
+ }
280
+
281
+ console.log('');
282
+ console.log('Pass a handle with --council, e.g.:');
283
+ console.log(' pair-review <pr> --ai-draft --council ' + shortId(councils[0].id));
284
+ }
285
+
210
286
  /**
211
287
  * Print version and exit
212
288
  */
@@ -279,6 +355,8 @@ const KNOWN_FLAGS = new Set([
279
355
  '-l', '--local',
280
356
  '--mcp',
281
357
  '--model',
358
+ '--council',
359
+ '--list-councils',
282
360
  '--register',
283
361
  '--unregister',
284
362
  '--command',
@@ -328,6 +406,16 @@ function parseArgs(args) {
328
406
  } else {
329
407
  throw new Error('--model flag requires a model name (e.g., --model sonnet)');
330
408
  }
409
+ } else if (arg === '--council') {
410
+ // Next argument is the council handle (name, name-slug, or id prefix)
411
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
412
+ flags.council = args[i + 1];
413
+ i++; // Skip next argument since we consumed it
414
+ } else {
415
+ throw new Error('--council flag requires a council handle (e.g., --council my-council)');
416
+ }
417
+ } else if (arg === '--list-councils') {
418
+ flags.listCouncils = true;
331
419
  } else if (arg === '-l' || arg === '--local') {
332
420
  // -l/--local flag is always a boolean
333
421
  flags.local = true;
@@ -497,26 +585,84 @@ AI PROVIDERS:
497
585
  process.env.PAIR_REVIEW_YOLO = 'true';
498
586
  }
499
587
 
588
+ // --model is ignored when --council is set: council voices use their own
589
+ // per-voice models, so a top-level --model has no effect on the analysis.
590
+ if (flags.council && flags.model) {
591
+ console.warn('Warning: --model is ignored when --council is set; council voices use their own per-voice models.');
592
+ }
593
+
500
594
  // Apply provider config overrides (including yolo) for all code paths
501
595
  // (interactive, headless, local). server.js calls this independently on
502
596
  // startup, but headless paths (--ai-draft, --ai-review) never start the
503
597
  // server, so we must also apply here.
504
598
  applyConfigOverrides(config);
505
599
 
600
+ // --list-councils: print the saved councils and exit. Needs the database
601
+ // but no server, no PR/local context, and no single-port delegation.
602
+ if (flags.listCouncils) {
603
+ const listDb = await initializeDatabase(resolveDbName(config));
604
+ try {
605
+ await printCouncilList(listDb);
606
+ } finally {
607
+ try { listDb.close(); } catch { /* ignore */ }
608
+ }
609
+ process.exit(0);
610
+ }
611
+
612
+ // --council requires something to analyze: a PR identifier or --local.
613
+ // Reject early (before single-port delegation) so a contextless `--council`
614
+ // fails fast with a clear message instead of silently delegating to — or
615
+ // starting — the generic landing page with the council ignored.
616
+ //
617
+ // Exception: in GitHub Actions, `--ai-review --council <handle>` is a
618
+ // documented headless flow where the PR is auto-detected from the
619
+ // environment further below (detectPRFromGitHubEnvironment). At this point
620
+ // prArgs is still empty, so guarding on it alone would reject that valid CI
621
+ // invocation. Defer to the later `--ai-review` PR check, which reports a
622
+ // clear error if no PR can be detected.
623
+ if (
624
+ flags.council &&
625
+ prArgs.length === 0 &&
626
+ !flags.local &&
627
+ !(flags.aiReview && process.env.GITHUB_ACTIONS === 'true')
628
+ ) {
629
+ throw new Error('--council flag requires a pull request number/URL or --local to run analysis');
630
+ }
631
+
632
+ // Resolve the council handle FAIL-FAST, before single-port delegation, so
633
+ // that (a) a bad handle errors out for every mode (interactive, headless,
634
+ // and delegated) rather than only on a cold start, and (b) the resolved id
635
+ // can be threaded into the delegated URL — the browser-side council
636
+ // auto-analysis is keyed on the `council` query param. Resolution needs the
637
+ // DB, so when --council is set we open the shared connection now and the
638
+ // downstream handlers reuse it (they re-resolve for their own purposes;
639
+ // that is a cheap in-memory match against the same connection).
640
+ let cliCouncil = null;
641
+ if (flags.council) {
642
+ db = await initializeDatabase(resolveDbName(config));
643
+ cliCouncil = await resolveCouncilHandle(db, flags.council);
644
+ }
645
+
506
646
  // Single-port delegation: if a pair-review server is already running on the
507
647
  // configured port, delegate to it (open browser URL) and exit immediately.
508
648
  // Skipped for: headless modes (no browser), single_port: false (dev mode).
509
649
  if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
510
- const delegated = await attemptDelegation(config, flags, prArgs);
650
+ const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
651
+ councilId: cliCouncil?.id || null
652
+ });
511
653
  if (delegated) {
654
+ if (db) { try { db.close(); } catch { /* ignore */ } }
512
655
  process.exit(0);
513
656
  }
514
657
  // Not delegated — no server running, proceed to start one
515
658
  }
516
659
 
517
- // Initialize database
518
- console.log('Initializing database...');
519
- db = await initializeDatabase(resolveDbName(config));
660
+ // Initialize database (reuse the connection already opened for council
661
+ // resolution above, if any).
662
+ if (!db) {
663
+ console.log('Initializing database...');
664
+ db = await initializeDatabase(resolveDbName(config));
665
+ }
520
666
 
521
667
  // Migrate existing worktrees to database (if any)
522
668
  const path = require('path');
@@ -649,11 +795,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
649
795
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
650
796
  }
651
797
 
652
- // Set model override if provided via CLI flag
653
- if (flags.model) {
798
+ // Set model override if provided via CLI flag. Skipped when --council is
799
+ // set: council voices carry their own per-voice models.
800
+ if (flags.model && !flags.council) {
654
801
  process.env.PAIR_REVIEW_MODEL = flags.model;
655
802
  }
656
803
 
804
+ // Resolve the council handle FAIL-FAST before starting the server, so a bad
805
+ // handle surfaces as a clean error (caught below) instead of after the
806
+ // browser has opened.
807
+ let councilSelection = null;
808
+ if (flags.council) {
809
+ councilSelection = await resolveCouncilHandle(db, flags.council);
810
+ }
811
+
657
812
  // Start server and open browser to setup page
658
813
  const port = await startServer(db, poolLifecycle);
659
814
 
@@ -663,9 +818,9 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
663
818
  startPoolBackgroundFetches(db, config);
664
819
 
665
820
  let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
666
- if (flags.ai) {
667
- url += '?analyze=true';
668
- }
821
+ const shouldAnalyze = flags.ai || flags.council;
822
+ if (shouldAnalyze) url += '?analyze=true';
823
+ if (councilSelection) url += `&council=${councilSelection.id}`;
669
824
 
670
825
  console.log(`Opening browser to: ${url}`);
671
826
  await open(url);
@@ -742,6 +897,21 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
742
897
  const parser = new PRArgumentParser(config);
743
898
  prInfo = await parser.parsePRArguments(args);
744
899
 
900
+ // Resolve + validate the council FAIL-FAST, before any worktree/network
901
+ // work, so a bad handle or invalid config surfaces immediately.
902
+ let councilSelection = null;
903
+ if (flags.council) {
904
+ const council = await resolveCouncilHandle(db, flags.council);
905
+ const configType = council.type || 'advanced';
906
+ const councilConfig = normalizeCouncilConfig(council.config, configType);
907
+ // validateCouncilConfig returns an error string, or null when valid.
908
+ const validationError = validateCouncilConfig(councilConfig, configType);
909
+ if (validationError) {
910
+ throw new Error(`Invalid council "${council.name}": ${validationError}`);
911
+ }
912
+ councilSelection = { council, configType, councilConfig };
913
+ }
914
+
745
915
  // Resolve host binding for the target repo (handles alt-host).
746
916
  // Prefer `bindingRepository` (from url_pattern match) over the raw
747
917
  // `${owner}/${repo}` since they can differ when one config entry
@@ -972,13 +1142,11 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
972
1142
  const globalInstructions = config.globalInstructions || null;
973
1143
 
974
1144
  // Run AI analysis
975
- console.log('Running AI analysis (all 3 levels)...');
976
1145
  const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
977
1146
  const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
978
1147
  const providerLoadSkills = config.providers?.[cliProvider]?.load_skills;
979
1148
  const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
980
1149
  const providerOverrides = { load_skills: loadSkills };
981
- const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
982
1150
 
983
1151
  let analysisSummary = null;
984
1152
  try {
@@ -986,7 +1154,33 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
986
1154
  // githubClient is forwarded so the analyzer can pre-fetch existing PR review
987
1155
  // comments when callers opt in via excludePrevious.github.
988
1156
  logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
989
- const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
1157
+ let analysisResult;
1158
+ if (councilSelection) {
1159
+ console.log(`Running council analysis: ${councilSelection.council.name} (${councilSelection.configType})...`);
1160
+ // Council voices can span multiple providers, so resolve a per-provider
1161
+ // load_skills map (tier-3 resolution per provider) the same way every
1162
+ // other council invoker does (pr.js / local.js / stack-analysis.js).
1163
+ // Passing only the shared `providerOverrides` would collapse every voice
1164
+ // onto the default provider's load_skills — see buildVoiceContext.
1165
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
1166
+ buildCouncilProviderOverrides(config, repository, repoSettings);
1167
+ const analyzer = new Analyzer(db, 'council', 'council', councilProviderOverrides, councilProviderOverridesMap);
1168
+ analysisResult = await runHeadlessCouncilAnalysis(db, {
1169
+ analyzer,
1170
+ reviewId: review.id,
1171
+ council: councilSelection.council,
1172
+ configType: councilSelection.configType,
1173
+ councilConfig: councilSelection.councilConfig,
1174
+ worktreePath,
1175
+ prMetadata: storedPRData,
1176
+ instructions: { globalInstructions, repoInstructions, requestInstructions: null },
1177
+ githubClient
1178
+ });
1179
+ } else {
1180
+ console.log('Running AI analysis (all 3 levels)...');
1181
+ const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
1182
+ analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
1183
+ }
990
1184
  analysisSummary = analysisResult.summary;
991
1185
  console.log('AI analysis completed successfully');
992
1186
  } catch (analysisError) {
@@ -1402,4 +1596,4 @@ if (require.main === module) {
1402
1596
  main();
1403
1597
  }
1404
1598
 
1405
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
1599
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList };
@@ -95,18 +95,24 @@ function notifyVersion(port, currentVersion, _deps) {
95
95
  * @param {number} [context.number]
96
96
  * @param {string} [context.localPath]
97
97
  * @param {boolean} [context.analyze] - Whether to trigger auto-analysis
98
+ * @param {string} [context.councilId] - Resolved council id for council auto-analysis
98
99
  * @returns {string} Full URL
99
100
  */
100
101
  function buildDelegationUrl(port, mode, context = {}) {
101
102
  const base = `http://localhost:${port}`;
102
103
  if (mode === 'pr') {
103
104
  let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
104
- if (context.analyze) url += '?analyze=true';
105
+ const query = [];
106
+ if (context.analyze) query.push('analyze=true');
107
+ if (context.councilId) query.push(`council=${encodeURIComponent(context.councilId)}`);
108
+ if (query.length) url += `?${query.join('&')}`;
105
109
  return url;
106
110
  }
107
111
  if (mode === 'local') {
112
+ // The `?path=` segment is always present, so analyze/council append with `&`.
108
113
  let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
109
114
  if (context.analyze) url += '&analyze=true';
115
+ if (context.councilId) url += `&council=${encodeURIComponent(context.councilId)}`;
110
116
  return url;
111
117
  }
112
118
  return `${base}/`;
@@ -137,11 +143,21 @@ async function parsePRArgsForDelegation(prArgs, config = null, _deps) {
137
143
  * @param {object} flags - Parsed CLI flags
138
144
  * @param {string[]} prArgs - PR arguments from CLI
139
145
  * @param {object} [_deps] - Dependency overrides for testing
146
+ * @param {object} [options] - Pre-resolved values from the caller
147
+ * @param {string|null} [options.councilId] - Resolved council id (the caller
148
+ * resolves `flags.council` against the DB before delegation, since this
149
+ * module has no DB access). When set, the delegated URL carries
150
+ * `&council=<id>` and auto-analysis is forced on.
140
151
  * @returns {Promise<boolean>} true if delegated, false if should start fresh
141
152
  */
142
- async function attemptDelegation(config, flags, prArgs, _deps) {
153
+ async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
143
154
  const deps = { ...defaults, ..._deps };
144
155
  const port = config.port;
156
+ const councilId = options.councilId || null;
157
+ // A council selection implies analysis: the browser-side council
158
+ // auto-analysis is gated on the `council` param + `analyze=true`, mirroring
159
+ // the cold-start URL built in handlePullRequest/handleLocalReview.
160
+ const analyze = !!(flags.ai || flags.council);
145
161
 
146
162
  const result = await detectRunningServer(port, _deps);
147
163
 
@@ -164,10 +180,10 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
164
180
  if (flags.local) {
165
181
  rejectUrlLikeLocalReviewPath(flags.localPath);
166
182
  const targetPath = path.resolve(flags.localPath || process.cwd());
167
- url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
183
+ url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId });
168
184
  } else if (prArgs.length > 0) {
169
185
  const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
170
- url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
186
+ url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId });
171
187
  } else {
172
188
  url = buildDelegationUrl(port, 'server');
173
189
  }