@in-the-loop-labs/pair-review 3.7.1 → 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 };
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
19
19
  const { mergeInstructions } = require('../utils/instructions');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { GitHubClient } = require('../github/client');
22
- const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
22
+ const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveRepoOptions, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
23
23
  const { setupStackPR } = require('../setup/stack-setup');
24
24
  const Analyzer = require('../ai/analyzer');
25
25
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -166,6 +166,7 @@ const defaults = {
166
166
  getGitHubToken,
167
167
  resolveHostBinding,
168
168
  resolveBindingRepositoryFromPR,
169
+ resolveRepoOptions,
169
170
  setupStackPR,
170
171
  Analyzer,
171
172
  getProviderClass,
@@ -205,8 +206,23 @@ async function executeStackAnalysis(params) {
205
206
  if (!state) return;
206
207
 
207
208
  try {
209
+ // 0. Resolve the config-binding key once. It drives both the per-repo
210
+ // worktree config (just below) and the host binding (step 3). For
211
+ // monorepo-style `url_pattern` configs this differs from the PR
212
+ // identity `${owner}/${repo}`.
213
+ const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
214
+ // Honor the repo's configured worktree options so stack worktrees match the
215
+ // non-stack path. This carries through:
216
+ // - worktreeConfig (worktree_name_template / worktree_directory) so they
217
+ // don't fall back to pair-review's default naming and location, and
218
+ // - the checkout script + timeout and sparse-checkout inheritance used
219
+ // during per-PR worktree creation (forwarded to createWorktreeForPR).
220
+ // These derive purely from file config, so DB repo_settings (pool config)
221
+ // are not needed here.
222
+ const { worktreeConfig, checkoutScript, checkoutTimeout } = deps.resolveRepoOptions(config, bindingRepository);
223
+
208
224
  // 1. Resolve repositoryPath from trigger worktree
209
- const worktreeManager = new deps.GitWorktreeManager(db);
225
+ const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
210
226
  let repositoryPath;
211
227
  try {
212
228
  const owningRepoGit = await worktreeManager.resolveOwningRepo(triggerWorktreePath);
@@ -230,13 +246,13 @@ async function executeStackAnalysis(params) {
230
246
  logger.warn(`Bulk git fetch failed, will fetch per-PR: ${fetchError.message}`);
231
247
  }
232
248
 
233
- // 3. Fetch all PR data from GitHub in parallel
234
- // Use the per-repo binding so alt-host stack analyses target the right host.
235
- // The PR identity (`${owner}/${repo}`) is used for DB rows and worktree
236
- // identity. For host-binding lookups we MUST use the config-binding key,
237
- // which differs from the PR identity for monorepo-style `url_pattern`
238
- // configs (one `repos[...]` entry serves many captured owner/repo pairs).
239
- const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
249
+ // 3. Fetch all PR data from GitHub in parallel.
250
+ // `bindingRepository` (resolved in step 0) is the config-binding key. We use
251
+ // it — not the PR identity `${owner}/${repo}` for host-binding lookups so
252
+ // alt-host stack analyses target the right host. The two differ for
253
+ // monorepo-style `url_pattern` configs (one `repos[...]` entry serves many
254
+ // captured owner/repo pairs). The PR identity is still used for DB rows and
255
+ // worktree identity.
240
256
  const stackBinding = deps.resolveHostBinding(bindingRepository, config);
241
257
  const githubToken = stackBinding.token;
242
258
  const prDataMap = new Map();
@@ -275,7 +291,16 @@ async function executeStackAnalysis(params) {
275
291
 
276
292
  const prInfo = { owner, repo, number: prNum };
277
293
  const { path: perPRWorktreePath } = await worktreeManager.createWorktreeForPR(
278
- prInfo, prData, repositoryPath
294
+ prInfo, prData, repositoryPath,
295
+ {
296
+ checkoutScript,
297
+ checkoutTimeout,
298
+ // No checkout script → inherit the trigger worktree's sparse-checkout
299
+ // layout instead of a full checkout from the repo root. When a script is
300
+ // configured it sets up sparse-checkout itself, so worktreeSourcePath is
301
+ // unused (and omitted) in that case.
302
+ ...(checkoutScript ? {} : { worktreeSourcePath: triggerWorktreePath }),
303
+ }
279
304
  );
280
305
  worktreePathMap.set(prNum, perPRWorktreePath);
281
306
  } catch (wtError) {
@@ -307,6 +332,7 @@ async function executeStackAnalysis(params) {
307
332
  worktreePath: worktreePathMap.get(prNum),
308
333
  analysisConfig, stackAnalysisId, state,
309
334
  githubToken, binding: stackBinding, prData: prDataMap.get(prNum),
335
+ worktreeConfig, checkoutScript,
310
336
  onAnalysisIdReady
311
337
  }).then(result => {
312
338
  state.prStatuses.set(prNum, {
@@ -346,6 +372,7 @@ async function executeStackAnalysis(params) {
346
372
  async function analyzeStackPR(deps, db, config, {
347
373
  owner, repo, repository, bindingRepository, prNum, worktreePath,
348
374
  analysisConfig, stackAnalysisId, state, githubToken, binding, prData,
375
+ worktreeConfig, checkoutScript,
349
376
  onAnalysisIdReady
350
377
  }) {
351
378
  // Build a GitHubClient for analyzer-side dedup pre-fetch. The stack
@@ -357,12 +384,17 @@ async function analyzeStackPR(deps, db, config, {
357
384
  if (stackGithubClient) {
358
385
  logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNum} (stack)`);
359
386
  }
360
- // 1. Setup PR (generates diff, stores metadata)
361
- const worktreeManager = new deps.GitWorktreeManager(db);
387
+ // 1. Setup PR (expands sparse-checkout, generates diff, stores metadata)
388
+ // Construct with the repo's resolved worktreeConfig for consistency with the
389
+ // creation manager. setupStackPR operates against the explicit worktreePath
390
+ // (diff generation + sparse-cone expansion), so the worktreeConfig naming /
391
+ // directory options are not exercised today — threading them through guards
392
+ // against silent latent regressions if that changes.
393
+ const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
362
394
  await deps.setupStackPR({
363
395
  db, owner, repo, prNumber: prNum,
364
396
  githubToken, binding, bindingRepository,
365
- worktreePath, worktreeManager, prData
397
+ worktreePath, worktreeManager, prData, checkoutScript
366
398
  });
367
399
 
368
400
  // 2. Fetch prMetadata from DB
@@ -32,9 +32,12 @@ const logger = require('../utils/logger');
32
32
  * @param {string} params.worktreePath - Path to the per-PR worktree
33
33
  * @param {import('../git/worktree').GitWorktreeManager} params.worktreeManager - Worktree manager instance
34
34
  * @param {Object} [params.prData] - Pre-fetched PR data from GitHub (skips API call when provided)
35
+ * @param {string|null} [params.checkoutScript] - Repo's configured checkout script, if any. When set,
36
+ * the script owns all sparse-checkout setup, so built-in sparse-cone expansion is skipped (mirrors
37
+ * the non-stack `pr-setup.js` contract).
35
38
  * @returns {Promise<{ reviewId: number, prMetadata: Object, prData: Object, isNew: boolean }>}
36
39
  */
37
- async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData }) {
40
+ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData, checkoutScript }) {
38
41
  // `bindingRepository` is accepted so callers (e.g. `executeStackAnalysis`)
39
42
  // can thread the resolved config-binding key through to any downstream
40
43
  // per-repo lookups added in this function. Currently unused inside this
@@ -56,13 +59,38 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
56
59
  const prFiles = await githubClient.fetchPullRequestFiles(owner, repo, prNumber);
57
60
  logger.info(`PR #${prNumber} has ${prFiles.length} changed files`);
58
61
 
59
- // 3. Generate diff in the worktree (SHA-based, works after checkout)
62
+ // 3. Expand sparse-checkout for PR-changed directories (mirrors pr-setup.js).
63
+ // Stack worktrees inherit the trigger worktree's sparse-checkout layout, which
64
+ // may omit directories a sibling PR touches. The SHA-based diff below reads
65
+ // commit objects (not the working tree) so it is unaffected, but the later
66
+ // file-context and codebase-context analysis steps DO read files from disk —
67
+ // an unexpanded cone would silently under-review those files. Expanding here
68
+ // ensures every PR-changed directory is present on disk.
69
+ //
70
+ // IMPORTANT: when a checkout_script is configured the script owns all
71
+ // sparse-checkout setup, so we must NOT auto-expand — doing so would override
72
+ // the cone the script just configured. This matches the pr-setup.js contract.
73
+ if (!checkoutScript && prFiles.length > 0) {
74
+ const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
75
+ if (isSparse) {
76
+ try {
77
+ const addedDirs = await worktreeManager.ensurePRDirectoriesInSparseCheckout(worktreePath, prFiles);
78
+ if (addedDirs.length > 0) {
79
+ logger.info(`Stack PR #${prNumber}: expanded sparse-checkout for: ${addedDirs.join(', ')}`);
80
+ }
81
+ } catch (sparseError) {
82
+ logger.warn(`Stack PR #${prNumber}: sparse-checkout expansion failed (non-fatal): ${sparseError.message}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ // 4. Generate diff in the worktree (SHA-based, works after checkout)
60
88
  const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
61
89
 
62
- // 4. Get changed files with stats
90
+ // 5. Get changed files with stats
63
91
  const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
64
92
 
65
- // 5. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
93
+ // 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
66
94
  const prInfo = { owner, repo, number: prNumber };
67
95
  const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
68
96
 
@@ -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
  }