@in-the-loop-labs/pair-review 3.7.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.js CHANGED
@@ -1,7 +1,8 @@
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 { v4: uuidv4 } = require('uuid');
4
+ const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
5
+ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository, CommentRepository, AnalysisRunRepository } = require('./database');
5
6
  const { PRArgumentParser } = require('./github/parser');
6
7
  const { GitHubClient } = require('./github/client');
7
8
  const { GitWorktreeManager } = require('./git/worktree');
@@ -10,7 +11,16 @@ const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
10
11
  const { startServer } = require('./server');
11
12
  const Analyzer = require('./ai/analyzer');
12
13
  const { applyConfigOverrides } = require('./ai');
13
- const { handleLocalReview, findMainGitRoot } = require('./local-review');
14
+ const { handleLocalReview, findMainGitRoot, setupLocalReviewSession, findGitRoot, findMergeBase, getRepositoryName } = require('./local-review');
15
+ const { resolveCliInstructions, prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
16
+ const { resolveCouncilHandle, getCouncilLastUsedRepos, shortId } = require('./councils/resolve-council');
17
+ const { runHeadlessCouncilAnalysis } = require('./councils/headless-council');
18
+ const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
19
+ const { resolveReviewConfig } = require('./review-config');
20
+ const { redirectConsoleToStderr } = require('./mcp-stdio');
21
+ const { getChangedFiles } = require('./routes/executable-analysis');
22
+ const { safeParseJson } = require('./utils/safe-parse-json');
23
+ const { includesBranch } = require('./local-scope');
14
24
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
15
25
  const { fireReviewStartedHook } = require('./hooks/payloads');
16
26
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
@@ -149,6 +159,31 @@ OPTIONS:
149
159
  review on GitHub (headless mode)
150
160
  --ai-review Run AI analysis and submit review to GitHub (CI mode)
151
161
  Auto-detects PR in GitHub Actions environment
162
+ --headless Run AI analysis, store it in the local database, and
163
+ report the results to stdout/stderr, then exit. No
164
+ server, no browser, no GitHub post. Works with a
165
+ <pr> argument or --local.
166
+ --json With --headless, emit the completed run plus its
167
+ consolidated final suggestions as a single JSON
168
+ document on a clean stdout. For any outcome of the
169
+ headless run — success, zero findings, or an error
170
+ raised while running it — stdout carries a JSON
171
+ document with an "ok" field; on failure that envelope
172
+ is { "ok": false, "error": ... } and the exit code is
173
+ non-zero. Pre-flight config/startup failures are the
174
+ exception: they exit non-zero with a stderr message
175
+ and no JSON envelope, so branch on the exit code
176
+ first. stderr is quiet by default (only
177
+ warnings/errors); add --debug for verbose progress
178
+ logs. Only valid together with --headless.
179
+ --instructions <text> Per-run custom instructions for the analysis.
180
+ Applies to any mode that runs analysis: --headless,
181
+ --ai, --ai-draft, --ai-review, or --council (with
182
+ --ai/--council the instructions are carried into the
183
+ browser-triggered analysis). Default: none.
184
+ --instructions-file <path>
185
+ Read per-run custom instructions from a file.
186
+ Mutually exclusive with --instructions.
152
187
  --configure Show setup instructions and configuration options
153
188
  -d, --debug Enable verbose debug logging
154
189
  --debug-stream Log AI provider streaming events (tool calls, text chunks)
@@ -162,8 +197,11 @@ OPTIONS:
162
197
  also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
163
198
  opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
164
199
  opus-4.6-high, opus-4.6-1m, sonnet-4.6
165
- (opus is Opus 4.7 XHigh, the default)
200
+ (opus is Opus 4.8 XHigh, the default)
166
201
  or use provider-specific models with Gemini/Codex
202
+ --council <handle> Run analysis with a saved council (multi-voice). Handle is a
203
+ council name, name-slug, or id (prefix). See --list-councils.
204
+ --list-councils List saved councils (handles to use with --council) and exit
167
205
  --use-checkout Use current directory instead of creating worktree
168
206
  (automatic in GitHub Actions)
169
207
  --yolo Allow AI providers full system access (skip read-only
@@ -179,6 +217,11 @@ EXAMPLES:
179
217
  pair-review --local # Review uncommitted local changes
180
218
  pair-review 123 --ai # Auto-run AI analysis
181
219
  pair-review --ai-review # CI mode: auto-detect PR, submit review
220
+ pair-review 123 --ai-draft --council security-review # Headless council draft
221
+ pair-review --local --headless --json # Analyze local changes, emit JSON, exit
222
+ pair-review 123 --headless # Analyze a PR, print a summary, exit
223
+ pair-review --local --headless --council security # Headless council analysis
224
+ pair-review --list-councils # List saved councils and their handles
182
225
  pair-review --register # Register URL scheme handler
183
226
  pair-review --register --command "node bin/pair-review.js" # Custom command
184
227
 
@@ -207,6 +250,74 @@ MORE INFO:
207
250
  `);
208
251
  }
209
252
 
253
+ /**
254
+ * Print the list of saved councils (handles, names, types, and "last used"
255
+ * metadata) as an aligned table, then exit guidance. Used by --list-councils.
256
+ *
257
+ * @param {Object} db - Database instance
258
+ */
259
+ async function printCouncilList(db) {
260
+ const councils = await new CouncilRepository(db).list();
261
+
262
+ if (!councils || councils.length === 0) {
263
+ console.log('No councils found. Create one in the web UI under Analysis settings.');
264
+ return;
265
+ }
266
+
267
+ const repoMap = await getCouncilLastUsedRepos(db);
268
+
269
+ const rows = councils.map(c => {
270
+ const entry = repoMap.get(c.id);
271
+ const lastTs = entry?.last_started || c.last_used_at;
272
+ const lastUsed = lastTs ? String(lastTs).slice(0, 10) : 'never';
273
+ let lastUsedWith = '—';
274
+ if (entry) {
275
+ lastUsedWith = `${entry.repository}${entry.pr_number ? `#${entry.pr_number}` : ''}`;
276
+ }
277
+ return {
278
+ handle: shortId(c.id),
279
+ name: c.name || '',
280
+ type: c.type || '',
281
+ lastUsed,
282
+ lastUsedWith
283
+ };
284
+ });
285
+
286
+ const headers = {
287
+ handle: 'HANDLE',
288
+ name: 'NAME',
289
+ type: 'TYPE',
290
+ lastUsed: 'LAST USED',
291
+ lastUsedWith: 'LAST USED WITH'
292
+ };
293
+
294
+ const widths = {};
295
+ for (const key of Object.keys(headers)) {
296
+ widths[key] = Math.max(
297
+ headers[key].length,
298
+ ...rows.map(r => String(r[key]).length)
299
+ );
300
+ }
301
+
302
+ const formatRow = r =>
303
+ [
304
+ String(r.handle).padEnd(widths.handle),
305
+ String(r.name).padEnd(widths.name),
306
+ String(r.type).padEnd(widths.type),
307
+ String(r.lastUsed).padEnd(widths.lastUsed),
308
+ String(r.lastUsedWith).padEnd(widths.lastUsedWith)
309
+ ].join(' ');
310
+
311
+ console.log(formatRow(headers));
312
+ for (const r of rows) {
313
+ console.log(formatRow(r));
314
+ }
315
+
316
+ console.log('');
317
+ console.log('Pass a handle with --council, e.g.:');
318
+ console.log(' pair-review <pr> --ai-draft --council ' + shortId(councils[0].id));
319
+ }
320
+
210
321
  /**
211
322
  * Print version and exit
212
323
  */
@@ -272,6 +383,10 @@ const KNOWN_FLAGS = new Set([
272
383
  '--ai',
273
384
  '--ai-draft',
274
385
  '--ai-review',
386
+ '--headless',
387
+ '--json',
388
+ '--instructions',
389
+ '--instructions-file',
275
390
  '--configure',
276
391
  '-d', '--debug',
277
392
  '--debug-stream',
@@ -279,6 +394,8 @@ const KNOWN_FLAGS = new Set([
279
394
  '-l', '--local',
280
395
  '--mcp',
281
396
  '--model',
397
+ '--council',
398
+ '--list-councils',
282
399
  '--register',
283
400
  '--unregister',
284
401
  '--command',
@@ -316,6 +433,29 @@ function parseArgs(args) {
316
433
  flags.aiDraft = true;
317
434
  } else if (arg === '--ai-review') {
318
435
  flags.aiReview = true;
436
+ } else if (arg === '--headless') {
437
+ flags.headless = true;
438
+ } else if (arg === '--json') {
439
+ flags.json = true;
440
+ } else if (arg === '--instructions') {
441
+ // Next argument is free-text instructions. Unlike --model/--council, the
442
+ // value may legitimately begin with '-' (free text), so we only require
443
+ // that a token follows — we do NOT apply the !startsWith('-') guard.
444
+ if (i + 1 < args.length) {
445
+ flags.instructions = args[i + 1];
446
+ i++; // Skip next argument since we consumed it
447
+ } else {
448
+ throw new Error('--instructions flag requires a text value (e.g., --instructions "focus on error handling")');
449
+ }
450
+ } else if (arg === '--instructions-file') {
451
+ // Next argument is a file path. A path won't legitimately start with '-',
452
+ // so apply the same guard as --model to catch a missing value early.
453
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
454
+ flags.instructionsFile = args[i + 1];
455
+ i++; // Skip next argument since we consumed it
456
+ } else {
457
+ throw new Error('--instructions-file flag requires a file path (e.g., --instructions-file ./review-notes.txt)');
458
+ }
319
459
  } else if (arg === '--use-checkout') {
320
460
  flags.useCheckout = true;
321
461
  } else if (arg === '--yolo') {
@@ -328,6 +468,16 @@ function parseArgs(args) {
328
468
  } else {
329
469
  throw new Error('--model flag requires a model name (e.g., --model sonnet)');
330
470
  }
471
+ } else if (arg === '--council') {
472
+ // Next argument is the council handle (name, name-slug, or id prefix)
473
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
474
+ flags.council = args[i + 1];
475
+ i++; // Skip next argument since we consumed it
476
+ } else {
477
+ throw new Error('--council flag requires a council handle (e.g., --council my-council)');
478
+ }
479
+ } else if (arg === '--list-councils') {
480
+ flags.listCouncils = true;
331
481
  } else if (arg === '-l' || arg === '--local') {
332
482
  // -l/--local flag is always a boolean
333
483
  flags.local = true;
@@ -471,11 +621,33 @@ AI PROVIDERS:
471
621
  process.exit(0);
472
622
  }
473
623
 
624
+ // In headless --json mode, stdout is reserved for the single JSON document,
625
+ // so lock it down as EARLY as possible — before loadConfig() (which on a
626
+ // genuine first run creates the config dir and can log) and before the
627
+ // first-run welcome box below, both of which would otherwise corrupt the
628
+ // JSON. Peek the raw args here: neither flag takes a value, parseArgs() still
629
+ // runs afterwards for full validation, and redirectConsoleToStderr() is
630
+ // idempotent (it only reassigns console.* / the logger stream), so the later
631
+ // gated call is unnecessary once this fires.
632
+ //
633
+ // Quiet by default: --headless --json is consumed mostly by coding agents,
634
+ // whose shell tools capture stderr into context — so we DROP progress
635
+ // narration rather than relocating it to stderr (warnings/errors still emit).
636
+ // --debug/-d restores the full verbose stderr stream for humans diagnosing a
637
+ // run. parseArgs() hasn't run yet, so peek raw args for the debug flags too.
638
+ const isHeadlessJson = args.includes('--headless') && args.includes('--json');
639
+ if (isHeadlessJson) {
640
+ const wantsDebug = args.includes('-d') || args.includes('--debug');
641
+ redirectConsoleToStderr({ quiet: !wantsDebug });
642
+ }
643
+
474
644
  // Load configuration
475
645
  const { config, isFirstRun } = await loadConfig();
476
646
 
477
- // Show welcome message on first run (after early-exit flags are handled above)
478
- if (isFirstRun) {
647
+ // Show welcome message on first run (after early-exit flags are handled
648
+ // above). Suppressed in headless --json mode so nothing reaches stdout but
649
+ // the JSON document.
650
+ if (isFirstRun && !isHeadlessJson) {
479
651
  showWelcomeMessage();
480
652
  }
481
653
 
@@ -483,6 +655,40 @@ AI PROVIDERS:
483
655
  // single-port delegation can skip DB entirely)
484
656
  const { prArgs, flags } = parseArgs(args);
485
657
 
658
+ // Headless-mode flag validation (fail fast, before any DB/server work).
659
+ // --instructions and --instructions-file are mutually exclusive.
660
+ if (flags.instructions && flags.instructionsFile) {
661
+ throw new Error('--instructions and --instructions-file are mutually exclusive; provide only one.');
662
+ }
663
+ // --json only makes sense as the machine-readable sink for --headless.
664
+ if (flags.json && !flags.headless) {
665
+ throw new Error('--json requires --headless (it emits the headless run as JSON).');
666
+ }
667
+ // --headless needs something to analyze: a PR identifier or --local.
668
+ if (flags.headless && prArgs.length === 0 && !flags.local) {
669
+ throw new Error('--headless flag requires a pull request number/URL or --local to run analysis.');
670
+ }
671
+ // --instructions[-file] only takes effect in a mode that actually runs
672
+ // analysis. Plain interactive mode never auto-analyzes, so reject the
673
+ // orphaned case with a clear message rather than silently dropping the text
674
+ // (the failure mode this flag's parity is meant to avoid). The consuming
675
+ // modes are: --headless / --ai-draft / --ai-review (consume directly) and
676
+ // --ai / --council (threaded to the browser via analysisConfigId).
677
+ if (
678
+ (flags.instructions || flags.instructionsFile) &&
679
+ !(flags.headless || flags.aiDraft || flags.aiReview || flags.ai || flags.council)
680
+ ) {
681
+ throw new Error(
682
+ '--instructions/--instructions-file require a mode that runs analysis: ' +
683
+ '--headless, --ai, --ai-draft, --ai-review, or --council.'
684
+ );
685
+ }
686
+
687
+ // NOTE: in headless --json mode, stdout was already redirected to stderr
688
+ // above (before loadConfig + the first-run welcome) — see `isHeadlessJson`.
689
+ // No redirect is repeated here: it would be too late to matter and is
690
+ // redundant given the early call.
691
+
486
692
  // Apply debug_stream from config if not already enabled by CLI flag
487
693
  if (!flags.debugStream && config.debug_stream) {
488
694
  flags.debugStream = true;
@@ -497,26 +703,108 @@ AI PROVIDERS:
497
703
  process.env.PAIR_REVIEW_YOLO = 'true';
498
704
  }
499
705
 
706
+ // --model is ignored when --council is set: council voices use their own
707
+ // per-voice models, so a top-level --model has no effect on the analysis.
708
+ if (flags.council && flags.model) {
709
+ console.warn('Warning: --model is ignored when --council is set; council voices use their own per-voice models.');
710
+ }
711
+
500
712
  // Apply provider config overrides (including yolo) for all code paths
501
713
  // (interactive, headless, local). server.js calls this independently on
502
714
  // startup, but headless paths (--ai-draft, --ai-review) never start the
503
715
  // server, so we must also apply here.
504
716
  applyConfigOverrides(config);
505
717
 
718
+ // --list-councils: print the saved councils and exit. Needs the database
719
+ // but no server, no PR/local context, and no single-port delegation.
720
+ if (flags.listCouncils) {
721
+ const listDb = await initializeDatabase(resolveDbName(config));
722
+ try {
723
+ await printCouncilList(listDb);
724
+ } finally {
725
+ try { listDb.close(); } catch { /* ignore */ }
726
+ }
727
+ process.exit(0);
728
+ }
729
+
730
+ // --council requires something to analyze: a PR identifier or --local.
731
+ // Reject early (before single-port delegation) so a contextless `--council`
732
+ // fails fast with a clear message instead of silently delegating to — or
733
+ // starting — the generic landing page with the council ignored.
734
+ //
735
+ // Exception: in GitHub Actions, `--ai-review --council <handle>` is a
736
+ // documented headless flow where the PR is auto-detected from the
737
+ // environment further below (detectPRFromGitHubEnvironment). At this point
738
+ // prArgs is still empty, so guarding on it alone would reject that valid CI
739
+ // invocation. Defer to the later `--ai-review` PR check, which reports a
740
+ // clear error if no PR can be detected.
741
+ if (
742
+ flags.council &&
743
+ prArgs.length === 0 &&
744
+ !flags.local &&
745
+ !(flags.aiReview && process.env.GITHUB_ACTIONS === 'true')
746
+ ) {
747
+ throw new Error('--council flag requires a pull request number/URL or --local to run analysis');
748
+ }
749
+
750
+ // Resolve the council handle FAIL-FAST, before single-port delegation, so
751
+ // that (a) a bad handle errors out for every mode (interactive, headless,
752
+ // and delegated) rather than only on a cold start, and (b) the resolved id
753
+ // can be threaded into the delegated URL — the browser-side council
754
+ // auto-analysis is keyed on the `council` query param. Resolution needs the
755
+ // DB, so when --council is set we open the shared connection now and the
756
+ // downstream handlers reuse it (they re-resolve for their own purposes;
757
+ // that is a cheap in-memory match against the same connection).
758
+ let cliCouncil = null;
759
+ if (flags.council) {
760
+ db = await initializeDatabase(resolveDbName(config));
761
+ cliCouncil = await resolveCouncilHandle(db, flags.council);
762
+ }
763
+
506
764
  // Single-port delegation: if a pair-review server is already running on the
507
765
  // configured port, delegate to it (open browser URL) and exit immediately.
508
766
  // Skipped for: headless modes (no browser), single_port: false (dev mode).
509
- if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
510
- const delegated = await attemptDelegation(config, flags, prArgs);
767
+ // --headless never delegates/opens a browser it runs analysis locally and
768
+ // reports to stdout/stderr.
769
+ if (config.single_port !== false && !flags.aiReview && !flags.aiDraft && !flags.headless) {
770
+ // Carry --instructions across delegation. When an analyzing mode
771
+ // (--ai/--council) ALSO supplies per-run instructions and a server is
772
+ // already running, attemptDelegation resolves the analysis config and POSTs
773
+ // it to that (separate) process, threading the returned id as
774
+ // `analysisConfigId`. That handoff needs an open DB (repo-default/council
775
+ // resolution) and, for local mode, the repository name — PR mode derives it
776
+ // from the args inside attemptDelegation. Without instructions this is a
777
+ // no-op and the prior councilId/analyze URL shape is preserved.
778
+ const wantsInstructionHandoff =
779
+ (flags.instructions || flags.instructionsFile) && (flags.ai || flags.council);
780
+ if (wantsInstructionHandoff && !db) {
781
+ db = await initializeDatabase(resolveDbName(config));
782
+ }
783
+ let localRepository = null;
784
+ if (wantsInstructionHandoff && flags.local) {
785
+ const localTarget = require('path').resolve(flags.localPath || process.cwd());
786
+ const localRoot = await findGitRoot(localTarget);
787
+ localRepository = await getRepositoryName(localRoot);
788
+ }
789
+
790
+ const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
791
+ councilId: cliCouncil?.id || null,
792
+ db,
793
+ localRepository
794
+ });
511
795
  if (delegated) {
796
+ if (db) { try { db.close(); } catch { /* ignore */ } }
512
797
  process.exit(0);
513
798
  }
514
799
  // Not delegated — no server running, proceed to start one
515
800
  }
516
801
 
517
- // Initialize database
518
- console.log('Initializing database...');
519
- db = await initializeDatabase(resolveDbName(config));
802
+ // Initialize database (reuse the connection already opened for council
803
+ // resolution above, if any).
804
+ if (!db) {
805
+ console.log('Initializing database...');
806
+ db = await initializeDatabase(resolveDbName(config));
807
+ }
520
808
 
521
809
  // Migrate existing worktrees to database (if any)
522
810
  const path = require('path');
@@ -533,6 +821,14 @@ AI PROVIDERS:
533
821
  const poolLifecycle = new WorktreePoolLifecycle(db, config);
534
822
  await poolLifecycle.resetAndRehydrate();
535
823
 
824
+ // Headless analysis mode: run analysis (single or council), store in the DB,
825
+ // report results to stdout/stderr, and exit. No server, no browser, no GitHub
826
+ // post. Works with a PR arg or --local. Handled before the local/PR split.
827
+ if (flags.headless) {
828
+ await handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle);
829
+ return;
830
+ }
831
+
536
832
  // Check for local mode (review uncommitted local changes)
537
833
  if (flags.local) {
538
834
  // Resolve localPath, defaulting to cwd if not provided
@@ -598,8 +894,33 @@ AI PROVIDERS:
598
894
  console.log('No pull request specified. Starting server...');
599
895
  await startServerOnly(config, poolLifecycle);
600
896
  }
601
-
897
+
602
898
  } catch (error) {
899
+ // In headless --json mode the consumer (typically a coding agent) parses
900
+ // stdout as JSON. A prose error on stderr + empty stdout forces it to scrape
901
+ // English to learn what broke, so emit a structured failure envelope on
902
+ // stdout instead — one stream, JSON for success and failure alike. Read
903
+ // process.argv directly: `args`/`flags` are block-scoped to the try and
904
+ // unavailable here, and the throw may even predate parseArgs(). Exit stays
905
+ // non-zero so exit-code consumers are unaffected.
906
+ const argv = process.argv.slice(2);
907
+ const jsonMode = argv.includes('--headless') && argv.includes('--json');
908
+ if (jsonMode) {
909
+ // Canonical source for local-mode detection is parseArgs' -l/--local
910
+ // handling; we re-check argv here only because `flags` is block-scoped to
911
+ // the try and may not exist yet if the throw predates parseArgs. Keep any
912
+ // future local-mode alias in sync with that handling. Mode is best-effort
913
+ // (agents branch on `ok`, not `mode`).
914
+ const mode = (argv.includes('--local') || argv.includes('-l')) ? 'local' : 'pr';
915
+ // stdout may be a pipe (e.g. `pair-review … | jq`); a synchronous
916
+ // process.exit() can truncate a buffered write, so exit only once the
917
+ // envelope has flushed. The non-JSON branch below keeps the immediate exit.
918
+ process.stdout.write(
919
+ JSON.stringify(buildHeadlessErrorJson({ mode, error }), null, 2) + '\n',
920
+ () => process.exit(1)
921
+ );
922
+ return;
923
+ }
603
924
  console.error(`\n❌ Error: ${error.message}\n`);
604
925
  process.exit(1);
605
926
  }
@@ -649,11 +970,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
649
970
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
650
971
  }
651
972
 
652
- // Set model override if provided via CLI flag
653
- if (flags.model) {
973
+ // Set model override if provided via CLI flag. Skipped when --council is
974
+ // set: council voices carry their own per-voice models.
975
+ if (flags.model && !flags.council) {
654
976
  process.env.PAIR_REVIEW_MODEL = flags.model;
655
977
  }
656
978
 
979
+ // Resolve the council handle FAIL-FAST before starting the server, so a bad
980
+ // handle surfaces as a clean error (caught below) instead of after the
981
+ // browser has opened.
982
+ let councilSelection = null;
983
+ if (flags.council) {
984
+ councilSelection = await resolveCouncilHandle(db, flags.council);
985
+ }
986
+
657
987
  // Start server and open browser to setup page
658
988
  const port = await startServer(db, poolLifecycle);
659
989
 
@@ -663,8 +993,21 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
663
993
  startPoolBackgroundFetches(db, config);
664
994
 
665
995
  let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
666
- if (flags.ai) {
667
- url += '?analyze=true';
996
+ const shouldAnalyze = flags.ai || flags.council;
997
+ if (shouldAnalyze) url += '?analyze=true';
998
+ // When the run carries --instructions, stash the resolved analysis config
999
+ // (provider/model or council snapshot + instructions) and thread its id so
1000
+ // the browser-side auto-analyze honors the instructions instead of silently
1001
+ // dropping them. The id already encodes the council selection, so prefer it
1002
+ // over the bare &council= param; fall back to &council= otherwise.
1003
+ const repository = normalizeRepository(prInfo.owner, prInfo.repo);
1004
+ const analysisConfigId = shouldAnalyze
1005
+ ? await prepareInteractiveAnalysisConfig({ db, config, flags, repository })
1006
+ : null;
1007
+ if (analysisConfigId) {
1008
+ url += `&analysisConfigId=${analysisConfigId}`;
1009
+ } else if (councilSelection) {
1010
+ url += `&council=${councilSelection.id}`;
668
1011
  }
669
1012
 
670
1013
  console.log(`Opening browser to: ${url}`);
@@ -742,6 +1085,21 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
742
1085
  const parser = new PRArgumentParser(config);
743
1086
  prInfo = await parser.parsePRArguments(args);
744
1087
 
1088
+ // Resolve + validate the council FAIL-FAST, before any worktree/network
1089
+ // work, so a bad handle or invalid config surfaces immediately. The actual
1090
+ // council selection used for analysis is produced below by
1091
+ // resolveReviewConfig; this is a pre-flight validation only.
1092
+ if (flags.council) {
1093
+ const council = await resolveCouncilHandle(db, flags.council);
1094
+ const configType = council.type || 'advanced';
1095
+ const councilConfig = normalizeCouncilConfig(council.config, configType);
1096
+ // validateCouncilConfig returns an error string, or null when valid.
1097
+ const validationError = validateCouncilConfig(councilConfig, configType);
1098
+ if (validationError) {
1099
+ throw new Error(`Invalid council "${council.name}": ${validationError}`);
1100
+ }
1101
+ }
1102
+
745
1103
  // Resolve host binding for the target repo (handles alt-host).
746
1104
  // Prefer `bindingRepository` (from url_pattern match) over the raw
747
1105
  // `${owner}/${repo}` since they can differ when one config entry
@@ -971,14 +1329,28 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
971
1329
  const repoInstructions = repoSettings?.default_instructions || null;
972
1330
  const globalInstructions = config.globalInstructions || null;
973
1331
 
974
- // Run AI analysis
975
- console.log('Running AI analysis (all 3 levels)...');
976
- const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
977
- const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
978
- const providerLoadSkills = config.providers?.[cliProvider]?.load_skills;
1332
+ // Resolve per-run custom instructions from --instructions/--instructions-file.
1333
+ // Default stays null, preserving prior --ai-draft/--ai-review behavior.
1334
+ const requestInstructions = await resolveCliInstructions(flags);
1335
+ const instructions = { globalInstructions, repoInstructions, requestInstructions };
1336
+
1337
+ // Resolve the review configuration (council vs single provider/model) via the
1338
+ // shared resolver, so --ai-draft/--ai-review honor repo default councils too.
1339
+ // The early councilSelection above already fail-fast validated an explicit
1340
+ // --council; resolveReviewConfig re-resolves the same handle (cheap in-memory
1341
+ // match) and unifies single/council selection with the headless path.
1342
+ const reviewConfig = await resolveReviewConfig(
1343
+ db,
1344
+ repository,
1345
+ { council: flags.council, model: flags.model },
1346
+ config
1347
+ );
1348
+
1349
+ // Single-provider runs honor the repo/global tier-3 load_skills resolution.
1350
+ const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
1351
+ const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
979
1352
  const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
980
1353
  const providerOverrides = { load_skills: loadSkills };
981
- const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
982
1354
 
983
1355
  let analysisSummary = null;
984
1356
  try {
@@ -986,7 +1358,18 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
986
1358
  // githubClient is forwarded so the analyzer can pre-fetch existing PR review
987
1359
  // comments when callers opt in via excludePrevious.github.
988
1360
  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 });
1361
+ const { result: analysisResult } = await runHeadlessAnalysis(db, config, {
1362
+ reviewId: review.id,
1363
+ worktreePath,
1364
+ prMetadata: storedPRData,
1365
+ changedFiles: null,
1366
+ repository,
1367
+ repoSettings,
1368
+ instructions,
1369
+ reviewConfig,
1370
+ providerOverrides,
1371
+ githubClient
1372
+ });
990
1373
  analysisSummary = analysisResult.summary;
991
1374
  console.log('AI analysis completed successfully');
992
1375
  } catch (analysisError) {
@@ -1227,6 +1610,716 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
1227
1610
  }
1228
1611
  }
1229
1612
 
1613
+ /**
1614
+ * Shared single-vs-council analysis dispatch for headless runs.
1615
+ *
1616
+ * Both `performHeadlessReview` (GitHub-submit modes) and `handleHeadlessAnalysis`
1617
+ * (analysis-only headless mode) call this so the single/council branching — and
1618
+ * the instruction object threaded into every analyzer path — lives in exactly one
1619
+ * place. It only RUNS analysis and returns the run id + analyzer result; it never
1620
+ * submits to GitHub or releases pool worktrees (callers own that).
1621
+ *
1622
+ * The SAME `instructions` object ({ globalInstructions, repoInstructions,
1623
+ * requestInstructions }) is passed to both branches, centralizing the
1624
+ * three-analyzer-paths instruction hazard (analyzeAllLevels vs the two council
1625
+ * paths inside runHeadlessCouncilAnalysis).
1626
+ *
1627
+ * @param {Object} db - Database instance
1628
+ * @param {Object} config - Application configuration
1629
+ * @param {Object} params
1630
+ * @param {number} params.reviewId - Review id comments are stored under
1631
+ * @param {string} params.worktreePath - Checked-out worktree / local repo path
1632
+ * @param {Object} params.prMetadata - PR/local metadata (head_sha, base_sha, etc.)
1633
+ * @param {Array|null} params.changedFiles - Changed-file list (local mode) or null (PR mode computes it)
1634
+ * @param {string} params.repository - owner/repo
1635
+ * @param {Object|null} params.repoSettings - Repo settings row
1636
+ * @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
1637
+ * @param {Object} params.reviewConfig - Result of resolveReviewConfig (single|council)
1638
+ * @param {Object} params.providerOverrides - Single-provider overrides (e.g. { load_skills })
1639
+ * @param {Object} [params.githubClient] - GitHub client (PR mode) or undefined (local)
1640
+ * @returns {Promise<{ runId: string, result: Object }>}
1641
+ */
1642
+ async function runHeadlessAnalysis(db, config, {
1643
+ reviewId,
1644
+ worktreePath,
1645
+ prMetadata,
1646
+ changedFiles,
1647
+ repository,
1648
+ repoSettings,
1649
+ instructions,
1650
+ reviewConfig,
1651
+ providerOverrides,
1652
+ githubClient
1653
+ }) {
1654
+ if (reviewConfig.type === 'council') {
1655
+ console.log(`Running council analysis: ${reviewConfig.council.name} (${reviewConfig.configType})...`);
1656
+ // Council voices can span multiple providers, so resolve a per-provider
1657
+ // load_skills map (tier-3 resolution per provider) the same way every other
1658
+ // council invoker does (pr.js / local.js / stack-analysis.js). Passing only
1659
+ // the shared providerOverrides would collapse every voice onto the default
1660
+ // provider's load_skills — see buildVoiceContext.
1661
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
1662
+ buildCouncilProviderOverrides(config, repository, repoSettings);
1663
+ const analyzer = new Analyzer(db, 'council', 'council', councilProviderOverrides, councilProviderOverridesMap);
1664
+ const result = await runHeadlessCouncilAnalysis(db, {
1665
+ analyzer,
1666
+ reviewId,
1667
+ council: reviewConfig.council,
1668
+ configType: reviewConfig.configType,
1669
+ councilConfig: reviewConfig.councilConfig,
1670
+ worktreePath,
1671
+ prMetadata,
1672
+ changedFiles,
1673
+ instructions,
1674
+ githubClient
1675
+ });
1676
+ return { runId: result.runId, result };
1677
+ }
1678
+
1679
+ console.log('Running AI analysis (all 3 levels)...');
1680
+ const runId = uuidv4();
1681
+ const analyzer = new Analyzer(db, reviewConfig.model, reviewConfig.provider, providerOverrides);
1682
+ const result = await analyzer.analyzeAllLevels(
1683
+ reviewId,
1684
+ worktreePath,
1685
+ prMetadata,
1686
+ null,
1687
+ instructions,
1688
+ changedFiles,
1689
+ { githubClient, runId }
1690
+ );
1691
+ return { runId, result };
1692
+ }
1693
+
1694
+ /**
1695
+ * Prepare a PR for headless analysis-only mode (no GitHub submit).
1696
+ *
1697
+ * This is the analysis-only twin of the prep block inside `performHeadlessReview`.
1698
+ * It is intentionally a STANDALONE function rather than an extraction from
1699
+ * `performHeadlessReview`: the latter's prep and submit blocks share many locals
1700
+ * and that function is CI-critical, so duplicating the minimal prep here (the
1701
+ * documented fallback in the plan) keeps the submit path byte-identical and
1702
+ * untouched. The prep mirrors `performHeadlessReview` (parse → token/binding →
1703
+ * client → fetch PR → worktree/pool acquire → diff → storePRData → metadata →
1704
+ * review → repoSettings → reviewConfig), minus anything only the submit path
1705
+ * needs.
1706
+ *
1707
+ * @param {Object} db - Database instance
1708
+ * @param {Object} config - Application configuration
1709
+ * @param {Object} flags - Parsed CLI flags
1710
+ * @param {Array<string>} prArgs - PR identifier args
1711
+ * @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [externalPoolLifecycle]
1712
+ * - The session's already-rehydrated pool lifecycle. Reused for pool
1713
+ * acquisition (mirrors `performHeadlessReview`'s `externalPoolLifecycle`
1714
+ * convention) so the in-memory usage tracker populated by
1715
+ * `resetAndRehydrate()` is the one acquisitions go through. Falls back to a
1716
+ * fresh instance only when not supplied.
1717
+ * @returns {Promise<Object>} { githubClient, worktreePath, storedPRData, review,
1718
+ * repository, repoSettings, poolWorktreeId, poolLifecycle, reviewConfig,
1719
+ * providerOverrides }
1720
+ */
1721
+ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycle = null) {
1722
+ const parser = new PRArgumentParser(config);
1723
+ const prInfo = await parser.parsePRArguments(prArgs);
1724
+
1725
+ // Fail-fast council validation (mirrors performHeadlessReview).
1726
+ if (flags.council) {
1727
+ const council = await resolveCouncilHandle(db, flags.council);
1728
+ const configType = council.type || 'advanced';
1729
+ const councilConfig = normalizeCouncilConfig(council.config, configType);
1730
+ const validationError = validateCouncilConfig(councilConfig, configType);
1731
+ if (validationError) {
1732
+ throw new Error(`Invalid council "${council.name}": ${validationError}`);
1733
+ }
1734
+ }
1735
+
1736
+ const repositoryForBinding = prInfo.bindingRepository
1737
+ || normalizeRepository(prInfo.owner, prInfo.repo);
1738
+ const headlessBinding = resolveHostBinding(repositoryForBinding, config);
1739
+ if (!headlessBinding.token) {
1740
+ throw new Error(buildMissingTokenError({
1741
+ owner: prInfo.owner,
1742
+ repo: prInfo.repo,
1743
+ bindingRepository: repositoryForBinding,
1744
+ apiHost: headlessBinding.apiHost
1745
+ }));
1746
+ }
1747
+
1748
+ console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in headless mode`);
1749
+
1750
+ const githubClient = new GitHubClient(headlessBinding);
1751
+ const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
1752
+ if (!repoExists) {
1753
+ throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
1754
+ }
1755
+
1756
+ console.log('Fetching pull request data from GitHub...');
1757
+ const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
1758
+
1759
+ let worktreePath;
1760
+ let diff;
1761
+ let changedFiles;
1762
+ let poolWorktreeId = null;
1763
+ let poolLifecycle = null;
1764
+ const repository = normalizeRepository(prInfo.owner, prInfo.repo);
1765
+
1766
+ // Everything from here on can acquire a pool worktree and then do work that
1767
+ // may throw (diff generation, storePRData, metadata query, resolveReviewConfig).
1768
+ // The caller (handleHeadlessAnalysis) releases the pool slot in a finally, but
1769
+ // ONLY once it holds the returned prep object — a throw before that return would
1770
+ // leak the acquired worktree. So on the throwing path we release it here before
1771
+ // re-throwing. `poolWorktreeId` is null until acquire succeeds, so the guarded
1772
+ // release is a no-op when the throw predates acquisition (no double-release: the
1773
+ // caller's finally runs only on the success path that returns below).
1774
+ try {
1775
+ if (flags.useCheckout) {
1776
+ worktreePath = process.cwd();
1777
+ const isMatchingRepo = await parser.isMatchingRepository(worktreePath, prInfo.owner, prInfo.repo);
1778
+ if (!isMatchingRepo) {
1779
+ throw new Error(
1780
+ `--use-checkout requires running from a checkout of ${prInfo.owner}/${prInfo.repo}, ` +
1781
+ `but current directory does not match. Either cd to the correct repository or remove --use-checkout.`
1782
+ );
1783
+ }
1784
+ await registerRepositoryLocation(db, worktreePath, prInfo.owner, prInfo.repo);
1785
+ console.log(`Using current checkout at ${worktreePath}`);
1786
+
1787
+ console.log('Generating unified diff from checkout...');
1788
+ const git = simpleGit(worktreePath);
1789
+ try {
1790
+ await fetchNoTags(git, ['origin', prData.base_sha]);
1791
+ } catch (fetchError) {
1792
+ try {
1793
+ await git.raw(['cat-file', '-t', prData.base_sha]);
1794
+ } catch {
1795
+ throw new Error(`Base SHA ${prData.base_sha} is not available locally and fetch failed: ${fetchError.message}`);
1796
+ }
1797
+ }
1798
+ diff = await git.diff([
1799
+ `${prData.base_sha}...${prData.head_sha}`,
1800
+ '--unified=3',
1801
+ ...GIT_DIFF_FLAGS_ARRAY
1802
+ ]);
1803
+ const diffSummary = await git.diffSummary([
1804
+ `${prData.base_sha}...${prData.head_sha}`,
1805
+ ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
1806
+ ]);
1807
+ const gitattributes = await getGeneratedFilePatterns(worktreePath);
1808
+ changedFiles = diffSummary.files.map(file => {
1809
+ const resolvedFile = resolveRenamedFile(file.file);
1810
+ const isRenamed = resolvedFile !== file.file;
1811
+ const result = {
1812
+ file: resolvedFile,
1813
+ insertions: file.insertions,
1814
+ deletions: file.deletions,
1815
+ changes: file.changes,
1816
+ binary: file.binary || false,
1817
+ generated: gitattributes.isGenerated(resolvedFile)
1818
+ };
1819
+ if (isRenamed) {
1820
+ result.renamed = true;
1821
+ result.renamedFrom = resolveRenamedFileOld(file.file);
1822
+ }
1823
+ return result;
1824
+ });
1825
+ } else {
1826
+ const currentDir = parser.getCurrentDirectory();
1827
+ const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
1828
+
1829
+ let repositoryPath;
1830
+ let worktreeSourcePath;
1831
+ let checkoutScript;
1832
+ let checkoutTimeout;
1833
+ let worktreeConfig = null;
1834
+ let poolSize = 0;
1835
+ let resetScript = null;
1836
+ if (isMatchingRepo) {
1837
+ repositoryPath = currentDir;
1838
+ await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
1839
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1840
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1841
+ const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
1842
+ checkoutScript = resolved.checkoutScript;
1843
+ checkoutTimeout = resolved.checkoutTimeout;
1844
+ worktreeConfig = resolved.worktreeConfig;
1845
+ poolSize = resolved.poolSize || 0;
1846
+ resetScript = resolved.resetScript || null;
1847
+ } else {
1848
+ console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
1849
+ const result = await findRepositoryPath({
1850
+ db,
1851
+ owner: prInfo.owner,
1852
+ repo: prInfo.repo,
1853
+ repository,
1854
+ bindingRepository: repositoryForBinding,
1855
+ prNumber: prInfo.number,
1856
+ config,
1857
+ cloneUrl: prData?.repository?.clone_url,
1858
+ onProgress: (progress) => {
1859
+ if (progress.message) {
1860
+ console.log(progress.message);
1861
+ }
1862
+ }
1863
+ });
1864
+ repositoryPath = result.repositoryPath;
1865
+ worktreeSourcePath = result.worktreeSourcePath;
1866
+ checkoutScript = result.checkoutScript;
1867
+ checkoutTimeout = result.checkoutTimeout;
1868
+ worktreeConfig = result.worktreeConfig;
1869
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1870
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1871
+ const { poolSize: resolvedPoolSize } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
1872
+ poolSize = resolvedPoolSize || 0;
1873
+ resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
1874
+ }
1875
+
1876
+ const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
1877
+ if (poolSize > 0) {
1878
+ console.log('Acquiring pool worktree...');
1879
+ poolLifecycle = externalPoolLifecycle || new WorktreePoolLifecycle(db, config);
1880
+ const result = await poolLifecycle.acquireForPR(
1881
+ { owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number, repository },
1882
+ prData,
1883
+ repositoryPath,
1884
+ { worktreeSourcePath, checkoutScript, checkoutTimeout, resetScript, worktreeConfig, poolSize }
1885
+ );
1886
+ worktreePath = result.worktreePath;
1887
+ poolWorktreeId = result.worktreeId;
1888
+ console.log('Pool worktree acquired');
1889
+ } else {
1890
+ console.log('Setting up git worktree...');
1891
+ ({ path: worktreePath } = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
1892
+ worktreeSourcePath,
1893
+ checkoutScript,
1894
+ checkoutTimeout
1895
+ }));
1896
+ }
1897
+
1898
+ console.log('Generating unified diff...');
1899
+ diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
1900
+ changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
1901
+ }
1902
+
1903
+ console.log('Storing pull request data...');
1904
+ const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
1905
+ skipWorktreeRecord: !!flags.useCheckout
1906
+ });
1907
+
1908
+ if (poolWorktreeId && poolLifecycle) {
1909
+ await poolLifecycle.setReviewOwner(poolWorktreeId, storedReviewId);
1910
+ }
1911
+
1912
+ if (isNewReview) {
1913
+ fireReviewStartedHook({
1914
+ reviewId: storedReviewId, prNumber: prInfo.number,
1915
+ owner: prInfo.owner, repo: prInfo.repo, prData, config,
1916
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
1917
+ }
1918
+
1919
+ const prMetadata = await queryOne(db, `
1920
+ SELECT id, pr_data FROM pr_metadata
1921
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
1922
+ `, [prInfo.number, repository]);
1923
+
1924
+ if (!prMetadata) {
1925
+ throw new Error('Failed to retrieve stored PR metadata');
1926
+ }
1927
+
1928
+ const storedPRData = JSON.parse(prMetadata.pr_data);
1929
+
1930
+ const reviewRepo = new ReviewRepository(db);
1931
+ const { review } = await reviewRepo.getOrCreate({ prNumber: prInfo.number, repository });
1932
+
1933
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1934
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1935
+
1936
+ const reviewConfig = await resolveReviewConfig(
1937
+ db,
1938
+ repository,
1939
+ { council: flags.council, model: flags.model },
1940
+ config
1941
+ );
1942
+
1943
+ const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
1944
+ const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
1945
+ const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
1946
+ const providerOverrides = { load_skills: loadSkills };
1947
+
1948
+ return {
1949
+ githubClient,
1950
+ worktreePath,
1951
+ storedPRData,
1952
+ review,
1953
+ repository,
1954
+ repoSettings,
1955
+ poolWorktreeId,
1956
+ poolLifecycle,
1957
+ reviewConfig,
1958
+ providerOverrides
1959
+ };
1960
+ } catch (prepErr) {
1961
+ // Release the acquired pool worktree (if any) before propagating — the
1962
+ // caller never received a prep object, so its finally won't run. Guarded on
1963
+ // poolWorktreeId so a throw before acquisition is a no-op. Release errors are
1964
+ // logged, not allowed to mask the original failure.
1965
+ if (poolWorktreeId && poolLifecycle) {
1966
+ try {
1967
+ await poolLifecycle.releaseAfterHeadless(poolWorktreeId);
1968
+ logger.info(`Released pool worktree ${poolWorktreeId} after headless prep failure`);
1969
+ } catch (releaseErr) {
1970
+ logger.error(`Failed to release pool worktree ${poolWorktreeId} after prep failure: ${releaseErr.message}`);
1971
+ }
1972
+ }
1973
+ throw prepErr;
1974
+ }
1975
+ }
1976
+
1977
+ /**
1978
+ * Record a completed, zero-suggestion analysis run for an empty-scope headless
1979
+ * local review (the analyzer is never invoked). Mirrors the web routes'
1980
+ * empty-scope guard but, lacking an HTTP response, persists a normal-shaped run
1981
+ * so `buildHeadlessJson` emits the standard `{ run, suggestions: [], count: 0 }`
1982
+ * envelope and the command still exits 0 ("zero suggestions is still success").
1983
+ * The run is attributed to whatever `reviewConfig` resolved (single or council).
1984
+ *
1985
+ * @param {Object} db - Database instance
1986
+ * @param {Object} params
1987
+ * @param {number} params.reviewId - Review id the run belongs to
1988
+ * @param {Object} params.reviewConfig - resolveReviewConfig result (single|council)
1989
+ * @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
1990
+ * @param {string|null} params.headSha - Head SHA recorded on the run
1991
+ * @returns {Promise<string>} The created run id
1992
+ */
1993
+ async function recordEmptyScopeRun(db, { reviewId, reviewConfig, instructions, headSha }) {
1994
+ const runId = uuidv4();
1995
+ const runRepo = new AnalysisRunRepository(db);
1996
+ const isCouncil = reviewConfig.type === 'council';
1997
+ await runRepo.create({
1998
+ id: runId,
1999
+ reviewId,
2000
+ provider: isCouncil ? 'council' : reviewConfig.provider,
2001
+ model: isCouncil ? reviewConfig.council.id : reviewConfig.model,
2002
+ tier: null,
2003
+ globalInstructions: instructions?.globalInstructions || null,
2004
+ repoInstructions: instructions?.repoInstructions || null,
2005
+ requestInstructions: instructions?.requestInstructions || null,
2006
+ headSha: headSha || null,
2007
+ configType: isCouncil ? reviewConfig.configType : 'single',
2008
+ levelsConfig: null
2009
+ });
2010
+ await runRepo.update(runId, {
2011
+ status: 'completed',
2012
+ summary: 'No changes found in the selected scope — analysis skipped.',
2013
+ totalSuggestions: 0,
2014
+ filesAnalyzed: 0
2015
+ });
2016
+ return runId;
2017
+ }
2018
+
2019
+ /**
2020
+ * Headless analysis-only handler: run analysis (single or council), store it in
2021
+ * the DB, report the consolidated run to stdout/stderr, and exit. No server, no
2022
+ * browser, no GitHub post. Works with a PR arg or --local.
2023
+ *
2024
+ * @param {Array<string>} prArgs - PR identifier args (empty for --local)
2025
+ * @param {Object} config - Application configuration
2026
+ * @param {Object} db - Database instance
2027
+ * @param {Object} flags - Parsed CLI flags
2028
+ * @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} poolLifecycle
2029
+ * - The session's rehydrated pool lifecycle. Forwarded to `preparePrHeadless`
2030
+ * for PR-mode pool acquisition so the rehydrated in-memory tracker is used;
2031
+ * local mode has no worktree/pool and ignores it.
2032
+ */
2033
+ async function handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle) {
2034
+ // NOTE: In --json mode, redirectConsoleToStderr() is already called in main()
2035
+ // BEFORE the DB-init/migration/pool-rehydrate block, so stdout is clean by the
2036
+ // time we get here (those earlier steps log to stdout and would otherwise
2037
+ // precede and corrupt the JSON document). No redirect is needed in this
2038
+ // handler — it would be too late and is redundant.
2039
+
2040
+ // Resolve per-run custom instructions; a bad --instructions-file path fails
2041
+ // fast with a clear operational error (propagates to main()'s catch → exit 1).
2042
+ const requestInstructions = await resolveCliInstructions(flags);
2043
+
2044
+ const globalInstructions = config.globalInstructions || null;
2045
+
2046
+ let runId;
2047
+ let mode;
2048
+
2049
+ if (flags.local) {
2050
+ mode = 'local';
2051
+
2052
+ // Fail-fast council validation (mirror the PR path; local mode otherwise
2053
+ // lacks an explicit pre-flight check here).
2054
+ if (flags.council) {
2055
+ const council = await resolveCouncilHandle(db, flags.council);
2056
+ const configType = council.type || 'advanced';
2057
+ const councilConfig = normalizeCouncilConfig(council.config, configType);
2058
+ const validationError = validateCouncilConfig(councilConfig, configType);
2059
+ if (validationError) {
2060
+ throw new Error(`Invalid council "${council.name}": ${validationError}`);
2061
+ }
2062
+ }
2063
+
2064
+ const resolvedPath = require('path').resolve(flags.localPath || process.cwd());
2065
+ console.log(`Finding git repository root from ${resolvedPath}...`);
2066
+ const repoPath = await findGitRoot(resolvedPath);
2067
+ console.log(`Found git repository at: ${repoPath}`);
2068
+
2069
+ // Headless is a one-shot run: suppress the interactive summary/tour jobs so
2070
+ // they don't spend provider budget after the result is reported or keep the
2071
+ // event loop alive (CLI hang). See setupLocalReviewSession.
2072
+ const session = await setupLocalReviewSession({ db, config, repoPath, flags, startBackgroundJobs: false });
2073
+
2074
+ const repository = session.repository;
2075
+ const repoSettings = await new RepoSettingsRepository(db).getRepoSettings(repository);
2076
+ const repoInstructions = repoSettings?.default_instructions || null;
2077
+
2078
+ // Build local analysis metadata (mirror src/routes/local.js).
2079
+ const hasBranch = includesBranch(session.scopeStart);
2080
+ let analysisBaseSha = session.headSha;
2081
+ if (hasBranch && session.baseBranch) {
2082
+ try {
2083
+ analysisBaseSha = await findMergeBase(repoPath, session.baseBranch);
2084
+ } catch {
2085
+ // Fall back to HEAD
2086
+ }
2087
+ }
2088
+ const localMetadata = {
2089
+ id: session.sessionId,
2090
+ repository,
2091
+ title: hasBranch
2092
+ ? `Branch changes: ${session.baseBranch}..HEAD`
2093
+ : `Local changes in ${repository}`,
2094
+ description: hasBranch
2095
+ ? `Reviewing committed changes on branch against ${session.baseBranch}`
2096
+ : `Reviewing uncommitted changes in ${repoPath}`,
2097
+ base_sha: analysisBaseSha,
2098
+ head_sha: session.headSha,
2099
+ // Scope context the analyzer's executable-provider path consults FIRST when
2100
+ // rebuilding the local diff (src/ai/analyzer.js → generateDiffForExecutable /
2101
+ // getChangedFiles check scopeStart/scopeEnd before base_sha/head_sha). Without
2102
+ // these, unstaged-/untracked-only and mixed scopes (where base_sha === head_sha
2103
+ // === HEAD) fall through to the SHA branch and produce an empty/partial diff.
2104
+ // Matches the interactive local council metadata (src/routes/local.js).
2105
+ base_branch: session.baseBranch || null,
2106
+ head_branch: session.branch || null,
2107
+ scopeStart: session.scopeStart,
2108
+ scopeEnd: session.scopeEnd,
2109
+ reviewType: 'local'
2110
+ };
2111
+
2112
+ const reviewConfig = await resolveReviewConfig(
2113
+ db,
2114
+ repository,
2115
+ { council: flags.council, model: flags.model },
2116
+ config
2117
+ );
2118
+ const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
2119
+ const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
2120
+ const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
2121
+ const providerOverrides = { load_skills: loadSkills };
2122
+
2123
+ const instructions = { globalInstructions, repoInstructions, requestInstructions };
2124
+
2125
+ // Empty-scope detection is driven by the SESSION DIFF, not a second
2126
+ // changed-file enumeration. setupLocalReviewSession already generated the
2127
+ // scoped diff via generateScopedDiff, which THROWS on a hard git failure and
2128
+ // returns '' only when the scope is genuinely empty — so an empty diff is
2129
+ // authoritative, while an operational git error has already aborted the run.
2130
+ // (getChangedFiles, by contrast, swallows git errors and returns [], which
2131
+ // would let a failure masquerade as "no changes" and exit 0.)
2132
+ const isEmptyScope = !session.diff || session.diff.trim().length === 0;
2133
+
2134
+ if (isEmptyScope) {
2135
+ // Empty-scope guard, mirroring the web routes' rejectIfEmptyScope (which
2136
+ // returns 409). Headless has no HTTP response, so instead emit a normal-
2137
+ // shaped, completed run with zero suggestions: emitHeadlessResult then
2138
+ // produces the standard { mode, run, suggestions: [], count: 0 } envelope
2139
+ // and exits 0 (zero suggestions is still success). Skipping the analyzer
2140
+ // avoids spending provider tokens on a guaranteed-empty result.
2141
+ runId = await recordEmptyScopeRun(db, {
2142
+ reviewId: session.sessionId,
2143
+ reviewConfig,
2144
+ instructions,
2145
+ headSha: session.headSha
2146
+ });
2147
+ } else {
2148
+ // Scope is non-empty (diff present), so the analyzer needs the scope-aware
2149
+ // changed-file list for suggestion validation. Use the throwing variant:
2150
+ // since we KNOW the scope is non-empty, a [] from a swallowed git error
2151
+ // would be a lie — surface it as a non-zero exit instead.
2152
+ const changedFiles = await getChangedFiles(repoPath, {
2153
+ scopeStart: session.scopeStart,
2154
+ scopeEnd: session.scopeEnd,
2155
+ baseBranch: session.baseBranch || null,
2156
+ }, { throwOnError: true });
2157
+
2158
+ ({ runId } = await runHeadlessAnalysis(db, config, {
2159
+ reviewId: session.sessionId,
2160
+ worktreePath: repoPath,
2161
+ prMetadata: localMetadata,
2162
+ changedFiles,
2163
+ repository,
2164
+ repoSettings,
2165
+ instructions,
2166
+ reviewConfig,
2167
+ providerOverrides,
2168
+ githubClient: undefined
2169
+ }));
2170
+ }
2171
+ } else {
2172
+ mode = 'pr';
2173
+ // Forward the session's rehydrated pool lifecycle so pool acquisition goes
2174
+ // through the instance whose in-memory tracker resetAndRehydrate() populated
2175
+ // (mirrors performHeadlessReview's externalPoolLifecycle convention).
2176
+ const prep = await preparePrHeadless(db, config, flags, prArgs, poolLifecycle);
2177
+ try {
2178
+ const repoInstructions = prep.repoSettings?.default_instructions || null;
2179
+ const instructions = { globalInstructions, repoInstructions, requestInstructions };
2180
+ ({ runId } = await runHeadlessAnalysis(db, config, {
2181
+ reviewId: prep.review.id,
2182
+ worktreePath: prep.worktreePath,
2183
+ prMetadata: prep.storedPRData,
2184
+ changedFiles: null,
2185
+ repository: prep.repository,
2186
+ repoSettings: prep.repoSettings,
2187
+ instructions,
2188
+ reviewConfig: prep.reviewConfig,
2189
+ providerOverrides: prep.providerOverrides,
2190
+ githubClient: prep.githubClient
2191
+ }));
2192
+ } finally {
2193
+ // Release the pool worktree (mirror performHeadlessReview's finally) so a
2194
+ // pool slot isn't leaked per headless run. Local mode has no pool.
2195
+ if (prep.poolWorktreeId && prep.poolLifecycle) {
2196
+ try {
2197
+ await prep.poolLifecycle.releaseAfterHeadless(prep.poolWorktreeId);
2198
+ logger.info(`Released pool worktree ${prep.poolWorktreeId} after headless analysis`);
2199
+ } catch (releaseErr) {
2200
+ logger.error(`Failed to release pool worktree ${prep.poolWorktreeId}: ${releaseErr.message}`);
2201
+ }
2202
+ }
2203
+ }
2204
+ }
2205
+
2206
+ await emitHeadlessResult(db, { runId, mode, flags });
2207
+ }
2208
+
2209
+ /**
2210
+ * Build the machine-readable JSON document for a completed headless run.
2211
+ *
2212
+ * Returns the run metadata (with `level_outcomes` / `levels_config` parsed) plus
2213
+ * the run's consolidated final suggestions (orchestrated layer; per-suggestion
2214
+ * `reasoning` parsed) and a count. Exported for unit testing.
2215
+ *
2216
+ * @param {Object} db - Database instance
2217
+ * @param {string} runId - Analysis run id
2218
+ * @param {'pr'|'local'} mode - Review mode (passthrough)
2219
+ * @returns {Promise<Object>} { ok, mode, run, suggestions, count }
2220
+ */
2221
+ async function buildHeadlessJson(db, runId, mode) {
2222
+ const run = await new AnalysisRunRepository(db).getById(runId, { includeDiff: false });
2223
+ if (run) {
2224
+ run.level_outcomes = safeParseJson(run.level_outcomes);
2225
+ run.levels_config = safeParseJson(run.levels_config);
2226
+ }
2227
+
2228
+ const rawSuggestions = await new CommentRepository(db).getFinalSuggestionsByRunId(runId);
2229
+ const suggestions = rawSuggestions.map(s => ({
2230
+ ...s,
2231
+ reasoning: safeParseJson(s.reasoning)
2232
+ }));
2233
+
2234
+ return {
2235
+ // `ok` lets a machine consumer branch on success/failure with a single field;
2236
+ // see buildHeadlessErrorJson for the failure shape.
2237
+ ok: true,
2238
+ mode,
2239
+ run,
2240
+ suggestions,
2241
+ count: suggestions.length
2242
+ };
2243
+ }
2244
+
2245
+ /**
2246
+ * Build the machine-readable JSON document for a FAILED headless run.
2247
+ *
2248
+ * Mirrors buildHeadlessJson's envelope so a --headless --json consumer parses one
2249
+ * stream and branches on `ok`: success → { ok: true, ... }, failure → this. The
2250
+ * process still exits non-zero, so exit-code-based consumers are unaffected.
2251
+ * Exported for unit testing.
2252
+ *
2253
+ * @param {Object} params
2254
+ * @param {'pr'|'local'} params.mode - Review mode (best-effort, derived from args)
2255
+ * @param {Error} params.error - The thrown error
2256
+ * @returns {Object} { ok: false, mode, error: { message } }
2257
+ */
2258
+ function buildHeadlessErrorJson({ mode, error }) {
2259
+ return {
2260
+ ok: false,
2261
+ mode,
2262
+ error: {
2263
+ message: error && error.message ? error.message : String(error)
2264
+ }
2265
+ };
2266
+ }
2267
+
2268
+ /**
2269
+ * Emit the result of a headless run.
2270
+ *
2271
+ * In --json mode, writes the buildHeadlessJson document to stdout (via
2272
+ * process.stdout.write, since console.log is remapped to stderr in JSON mode).
2273
+ * Otherwise prints a human-readable summary to stdout. A successful run with zero
2274
+ * suggestions is still success — operational errors propagate to main()'s catch.
2275
+ *
2276
+ * @param {Object} db - Database instance
2277
+ * @param {Object} params
2278
+ * @param {string} params.runId - Analysis run id
2279
+ * @param {'pr'|'local'} params.mode - Review mode
2280
+ * @param {Object} params.flags - Parsed CLI flags
2281
+ */
2282
+ async function emitHeadlessResult(db, { runId, mode, flags }) {
2283
+ const doc = await buildHeadlessJson(db, runId, mode);
2284
+
2285
+ if (flags.json) {
2286
+ process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
2287
+ return;
2288
+ }
2289
+
2290
+ const run = doc.run || {};
2291
+ const lines = [];
2292
+ lines.push('');
2293
+ lines.push('Headless analysis complete');
2294
+ lines.push(` Run ID: ${runId}`);
2295
+ lines.push(` Mode: ${mode}`);
2296
+ if (run.config_type === 'council' || run.provider === 'council') {
2297
+ lines.push(` Council: ${run.model || '(unknown)'}`);
2298
+ } else {
2299
+ lines.push(` Provider: ${run.provider || '(unknown)'}`);
2300
+ lines.push(` Model: ${run.model || '(unknown)'}`);
2301
+ }
2302
+ lines.push(` Config type: ${run.config_type || 'standard'}`);
2303
+ lines.push(` Status: ${run.status || '(unknown)'}`);
2304
+ if (run.summary) {
2305
+ lines.push('');
2306
+ lines.push('Summary:');
2307
+ lines.push(run.summary);
2308
+ }
2309
+ lines.push('');
2310
+ lines.push(`Suggestions: ${doc.count}`);
2311
+ for (const s of doc.suggestions) {
2312
+ const line = s.line_end && s.line_end !== s.line_start
2313
+ ? `${s.line_start}-${s.line_end}`
2314
+ : `${s.line_start}`;
2315
+ const sev = s.severity ? `/${s.severity}` : '';
2316
+ lines.push(` ${s.file}:${line} [${s.type || 'comment'}${sev}] ${s.title || ''}`.trimEnd());
2317
+ }
2318
+ lines.push('');
2319
+
2320
+ process.stdout.write(lines.join('\n') + '\n');
2321
+ }
2322
+
1230
2323
  /**
1231
2324
  * Handle draft mode review workflow
1232
2325
  * Runs AI analysis and submits draft review to GitHub without opening browser
@@ -1386,7 +2479,14 @@ function gracefulShutdown(signal) {
1386
2479
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1387
2480
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1388
2481
 
1389
- // Handle uncaught exceptions
2482
+ // Handle uncaught exceptions.
2483
+ //
2484
+ // Note: in --headless --json mode, errors that ESCAPE main()'s try (floating
2485
+ // promises, timer/child-process callbacks) reach here and are reported as prose
2486
+ // on stderr, not the JSON failure envelope. The headless flow awaits its full
2487
+ // analysis, so this is a narrow gap; a JSON envelope here would need an
2488
+ // emit-once guard to avoid a second document on stdout during post-success
2489
+ // teardown.
1390
2490
  process.on('uncaughtException', (error) => {
1391
2491
  console.error('Uncaught exception:', error);
1392
2492
  process.exit(1);
@@ -1402,4 +2502,4 @@ if (require.main === module) {
1402
2502
  main();
1403
2503
  }
1404
2504
 
1405
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
2505
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };