@in-the-loop-labs/pair-review 3.8.0 → 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 { v4: uuidv4 } = require('uuid');
3
4
  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
+ 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,10 +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');
14
16
  const { resolveCouncilHandle, getCouncilLastUsedRepos, shortId } = require('./councils/resolve-council');
15
17
  const { runHeadlessCouncilAnalysis } = require('./councils/headless-council');
16
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');
17
24
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
18
25
  const { fireReviewStartedHook } = require('./hooks/payloads');
19
26
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
@@ -152,6 +159,31 @@ OPTIONS:
152
159
  review on GitHub (headless mode)
153
160
  --ai-review Run AI analysis and submit review to GitHub (CI mode)
154
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.
155
187
  --configure Show setup instructions and configuration options
156
188
  -d, --debug Enable verbose debug logging
157
189
  --debug-stream Log AI provider streaming events (tool calls, text chunks)
@@ -165,7 +197,7 @@ OPTIONS:
165
197
  also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
166
198
  opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
167
199
  opus-4.6-high, opus-4.6-1m, sonnet-4.6
168
- (opus is Opus 4.7 XHigh, the default)
200
+ (opus is Opus 4.8 XHigh, the default)
169
201
  or use provider-specific models with Gemini/Codex
170
202
  --council <handle> Run analysis with a saved council (multi-voice). Handle is a
171
203
  council name, name-slug, or id (prefix). See --list-councils.
@@ -186,6 +218,9 @@ EXAMPLES:
186
218
  pair-review 123 --ai # Auto-run AI analysis
187
219
  pair-review --ai-review # CI mode: auto-detect PR, submit review
188
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
189
224
  pair-review --list-councils # List saved councils and their handles
190
225
  pair-review --register # Register URL scheme handler
191
226
  pair-review --register --command "node bin/pair-review.js" # Custom command
@@ -348,6 +383,10 @@ const KNOWN_FLAGS = new Set([
348
383
  '--ai',
349
384
  '--ai-draft',
350
385
  '--ai-review',
386
+ '--headless',
387
+ '--json',
388
+ '--instructions',
389
+ '--instructions-file',
351
390
  '--configure',
352
391
  '-d', '--debug',
353
392
  '--debug-stream',
@@ -394,6 +433,29 @@ function parseArgs(args) {
394
433
  flags.aiDraft = true;
395
434
  } else if (arg === '--ai-review') {
396
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
+ }
397
459
  } else if (arg === '--use-checkout') {
398
460
  flags.useCheckout = true;
399
461
  } else if (arg === '--yolo') {
@@ -559,11 +621,33 @@ AI PROVIDERS:
559
621
  process.exit(0);
560
622
  }
561
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
+
562
644
  // Load configuration
563
645
  const { config, isFirstRun } = await loadConfig();
564
646
 
565
- // Show welcome message on first run (after early-exit flags are handled above)
566
- 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) {
567
651
  showWelcomeMessage();
568
652
  }
569
653
 
@@ -571,6 +655,40 @@ AI PROVIDERS:
571
655
  // single-port delegation can skip DB entirely)
572
656
  const { prArgs, flags } = parseArgs(args);
573
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
+
574
692
  // Apply debug_stream from config if not already enabled by CLI flag
575
693
  if (!flags.debugStream && config.debug_stream) {
576
694
  flags.debugStream = true;
@@ -646,9 +764,33 @@ AI PROVIDERS:
646
764
  // Single-port delegation: if a pair-review server is already running on the
647
765
  // configured port, delegate to it (open browser URL) and exit immediately.
648
766
  // Skipped for: headless modes (no browser), single_port: false (dev mode).
649
- if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
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
+
650
790
  const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
651
- councilId: cliCouncil?.id || null
791
+ councilId: cliCouncil?.id || null,
792
+ db,
793
+ localRepository
652
794
  });
653
795
  if (delegated) {
654
796
  if (db) { try { db.close(); } catch { /* ignore */ } }
@@ -679,6 +821,14 @@ AI PROVIDERS:
679
821
  const poolLifecycle = new WorktreePoolLifecycle(db, config);
680
822
  await poolLifecycle.resetAndRehydrate();
681
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
+
682
832
  // Check for local mode (review uncommitted local changes)
683
833
  if (flags.local) {
684
834
  // Resolve localPath, defaulting to cwd if not provided
@@ -744,8 +894,33 @@ AI PROVIDERS:
744
894
  console.log('No pull request specified. Starting server...');
745
895
  await startServerOnly(config, poolLifecycle);
746
896
  }
747
-
897
+
748
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
+ }
749
924
  console.error(`\n❌ Error: ${error.message}\n`);
750
925
  process.exit(1);
751
926
  }
@@ -820,7 +995,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
820
995
  let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
821
996
  const shouldAnalyze = flags.ai || flags.council;
822
997
  if (shouldAnalyze) url += '?analyze=true';
823
- if (councilSelection) url += `&council=${councilSelection.id}`;
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}`;
1011
+ }
824
1012
 
825
1013
  console.log(`Opening browser to: ${url}`);
826
1014
  await open(url);
@@ -898,8 +1086,9 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
898
1086
  prInfo = await parser.parsePRArguments(args);
899
1087
 
900
1088
  // 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;
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.
903
1092
  if (flags.council) {
904
1093
  const council = await resolveCouncilHandle(db, flags.council);
905
1094
  const configType = council.type || 'advanced';
@@ -909,7 +1098,6 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
909
1098
  if (validationError) {
910
1099
  throw new Error(`Invalid council "${council.name}": ${validationError}`);
911
1100
  }
912
- councilSelection = { council, configType, councilConfig };
913
1101
  }
914
1102
 
915
1103
  // Resolve host binding for the target repo (handles alt-host).
@@ -1141,10 +1329,26 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
1141
1329
  const repoInstructions = repoSettings?.default_instructions || null;
1142
1330
  const globalInstructions = config.globalInstructions || null;
1143
1331
 
1144
- // Run AI analysis
1145
- const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
1146
- const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
1147
- 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;
1148
1352
  const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
1149
1353
  const providerOverrides = { load_skills: loadSkills };
1150
1354
 
@@ -1154,33 +1358,18 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
1154
1358
  // githubClient is forwarded so the analyzer can pre-fetch existing PR review
1155
1359
  // comments when callers opt in via excludePrevious.github.
1156
1360
  logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
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
- }
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
+ });
1184
1373
  analysisSummary = analysisResult.summary;
1185
1374
  console.log('AI analysis completed successfully');
1186
1375
  } catch (analysisError) {
@@ -1421,6 +1610,716 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
1421
1610
  }
1422
1611
  }
1423
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
+
1424
2323
  /**
1425
2324
  * Handle draft mode review workflow
1426
2325
  * Runs AI analysis and submits draft review to GitHub without opening browser
@@ -1580,7 +2479,14 @@ function gracefulShutdown(signal) {
1580
2479
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1581
2480
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1582
2481
 
1583
- // 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.
1584
2490
  process.on('uncaughtException', (error) => {
1585
2491
  console.error('Uncaught exception:', error);
1586
2492
  process.exit(1);
@@ -1596,4 +2502,4 @@ if (require.main === module) {
1596
2502
  main();
1597
2503
  }
1598
2504
 
1599
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList };
2505
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };