@in-the-loop-labs/pair-review 3.8.0 → 3.9.1
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/README.md +175 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +12 -60
- package/public/js/components/CouncilProgressModal.js +0 -10
- package/public/js/local.js +27 -57
- package/public/js/pr.js +0 -114
- package/public/js/utils/provider-model.js +9 -2
- package/public/local.html +0 -7
- package/public/pr.html +0 -7
- package/src/ai/claude-provider.js +56 -34
- package/src/ai/index.js +8 -0
- package/src/ai/provider.js +225 -27
- package/src/councils/headless-council.js +13 -3
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +43 -21
- package/src/main.js +953 -46
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +116 -6
- package/src/utils/logger.js +25 -4
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)
|
|
@@ -164,8 +196,9 @@ OPTIONS:
|
|
|
164
196
|
Available models: opus, sonnet, haiku (Claude Code);
|
|
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
|
-
opus-4.6-high, opus-4.6-1m, sonnet-
|
|
168
|
-
|
|
199
|
+
opus-4.6-high, opus-4.6-1m, sonnet-5-xhigh,
|
|
200
|
+
sonnet-5-high, sonnet-4.6
|
|
201
|
+
(opus is Opus 4.8 XHigh, the default)
|
|
169
202
|
or use provider-specific models with Gemini/Codex
|
|
170
203
|
--council <handle> Run analysis with a saved council (multi-voice). Handle is a
|
|
171
204
|
council name, name-slug, or id (prefix). See --list-councils.
|
|
@@ -186,6 +219,9 @@ EXAMPLES:
|
|
|
186
219
|
pair-review 123 --ai # Auto-run AI analysis
|
|
187
220
|
pair-review --ai-review # CI mode: auto-detect PR, submit review
|
|
188
221
|
pair-review 123 --ai-draft --council security-review # Headless council draft
|
|
222
|
+
pair-review --local --headless --json # Analyze local changes, emit JSON, exit
|
|
223
|
+
pair-review 123 --headless # Analyze a PR, print a summary, exit
|
|
224
|
+
pair-review --local --headless --council security # Headless council analysis
|
|
189
225
|
pair-review --list-councils # List saved councils and their handles
|
|
190
226
|
pair-review --register # Register URL scheme handler
|
|
191
227
|
pair-review --register --command "node bin/pair-review.js" # Custom command
|
|
@@ -348,6 +384,10 @@ const KNOWN_FLAGS = new Set([
|
|
|
348
384
|
'--ai',
|
|
349
385
|
'--ai-draft',
|
|
350
386
|
'--ai-review',
|
|
387
|
+
'--headless',
|
|
388
|
+
'--json',
|
|
389
|
+
'--instructions',
|
|
390
|
+
'--instructions-file',
|
|
351
391
|
'--configure',
|
|
352
392
|
'-d', '--debug',
|
|
353
393
|
'--debug-stream',
|
|
@@ -394,6 +434,29 @@ function parseArgs(args) {
|
|
|
394
434
|
flags.aiDraft = true;
|
|
395
435
|
} else if (arg === '--ai-review') {
|
|
396
436
|
flags.aiReview = true;
|
|
437
|
+
} else if (arg === '--headless') {
|
|
438
|
+
flags.headless = true;
|
|
439
|
+
} else if (arg === '--json') {
|
|
440
|
+
flags.json = true;
|
|
441
|
+
} else if (arg === '--instructions') {
|
|
442
|
+
// Next argument is free-text instructions. Unlike --model/--council, the
|
|
443
|
+
// value may legitimately begin with '-' (free text), so we only require
|
|
444
|
+
// that a token follows — we do NOT apply the !startsWith('-') guard.
|
|
445
|
+
if (i + 1 < args.length) {
|
|
446
|
+
flags.instructions = args[i + 1];
|
|
447
|
+
i++; // Skip next argument since we consumed it
|
|
448
|
+
} else {
|
|
449
|
+
throw new Error('--instructions flag requires a text value (e.g., --instructions "focus on error handling")');
|
|
450
|
+
}
|
|
451
|
+
} else if (arg === '--instructions-file') {
|
|
452
|
+
// Next argument is a file path. A path won't legitimately start with '-',
|
|
453
|
+
// so apply the same guard as --model to catch a missing value early.
|
|
454
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
455
|
+
flags.instructionsFile = args[i + 1];
|
|
456
|
+
i++; // Skip next argument since we consumed it
|
|
457
|
+
} else {
|
|
458
|
+
throw new Error('--instructions-file flag requires a file path (e.g., --instructions-file ./review-notes.txt)');
|
|
459
|
+
}
|
|
397
460
|
} else if (arg === '--use-checkout') {
|
|
398
461
|
flags.useCheckout = true;
|
|
399
462
|
} else if (arg === '--yolo') {
|
|
@@ -559,11 +622,33 @@ AI PROVIDERS:
|
|
|
559
622
|
process.exit(0);
|
|
560
623
|
}
|
|
561
624
|
|
|
625
|
+
// In headless --json mode, stdout is reserved for the single JSON document,
|
|
626
|
+
// so lock it down as EARLY as possible — before loadConfig() (which on a
|
|
627
|
+
// genuine first run creates the config dir and can log) and before the
|
|
628
|
+
// first-run welcome box below, both of which would otherwise corrupt the
|
|
629
|
+
// JSON. Peek the raw args here: neither flag takes a value, parseArgs() still
|
|
630
|
+
// runs afterwards for full validation, and redirectConsoleToStderr() is
|
|
631
|
+
// idempotent (it only reassigns console.* / the logger stream), so the later
|
|
632
|
+
// gated call is unnecessary once this fires.
|
|
633
|
+
//
|
|
634
|
+
// Quiet by default: --headless --json is consumed mostly by coding agents,
|
|
635
|
+
// whose shell tools capture stderr into context — so we DROP progress
|
|
636
|
+
// narration rather than relocating it to stderr (warnings/errors still emit).
|
|
637
|
+
// --debug/-d restores the full verbose stderr stream for humans diagnosing a
|
|
638
|
+
// run. parseArgs() hasn't run yet, so peek raw args for the debug flags too.
|
|
639
|
+
const isHeadlessJson = args.includes('--headless') && args.includes('--json');
|
|
640
|
+
if (isHeadlessJson) {
|
|
641
|
+
const wantsDebug = args.includes('-d') || args.includes('--debug');
|
|
642
|
+
redirectConsoleToStderr({ quiet: !wantsDebug });
|
|
643
|
+
}
|
|
644
|
+
|
|
562
645
|
// Load configuration
|
|
563
646
|
const { config, isFirstRun } = await loadConfig();
|
|
564
647
|
|
|
565
|
-
// Show welcome message on first run (after early-exit flags are handled
|
|
566
|
-
|
|
648
|
+
// Show welcome message on first run (after early-exit flags are handled
|
|
649
|
+
// above). Suppressed in headless --json mode so nothing reaches stdout but
|
|
650
|
+
// the JSON document.
|
|
651
|
+
if (isFirstRun && !isHeadlessJson) {
|
|
567
652
|
showWelcomeMessage();
|
|
568
653
|
}
|
|
569
654
|
|
|
@@ -571,6 +656,40 @@ AI PROVIDERS:
|
|
|
571
656
|
// single-port delegation can skip DB entirely)
|
|
572
657
|
const { prArgs, flags } = parseArgs(args);
|
|
573
658
|
|
|
659
|
+
// Headless-mode flag validation (fail fast, before any DB/server work).
|
|
660
|
+
// --instructions and --instructions-file are mutually exclusive.
|
|
661
|
+
if (flags.instructions && flags.instructionsFile) {
|
|
662
|
+
throw new Error('--instructions and --instructions-file are mutually exclusive; provide only one.');
|
|
663
|
+
}
|
|
664
|
+
// --json only makes sense as the machine-readable sink for --headless.
|
|
665
|
+
if (flags.json && !flags.headless) {
|
|
666
|
+
throw new Error('--json requires --headless (it emits the headless run as JSON).');
|
|
667
|
+
}
|
|
668
|
+
// --headless needs something to analyze: a PR identifier or --local.
|
|
669
|
+
if (flags.headless && prArgs.length === 0 && !flags.local) {
|
|
670
|
+
throw new Error('--headless flag requires a pull request number/URL or --local to run analysis.');
|
|
671
|
+
}
|
|
672
|
+
// --instructions[-file] only takes effect in a mode that actually runs
|
|
673
|
+
// analysis. Plain interactive mode never auto-analyzes, so reject the
|
|
674
|
+
// orphaned case with a clear message rather than silently dropping the text
|
|
675
|
+
// (the failure mode this flag's parity is meant to avoid). The consuming
|
|
676
|
+
// modes are: --headless / --ai-draft / --ai-review (consume directly) and
|
|
677
|
+
// --ai / --council (threaded to the browser via analysisConfigId).
|
|
678
|
+
if (
|
|
679
|
+
(flags.instructions || flags.instructionsFile) &&
|
|
680
|
+
!(flags.headless || flags.aiDraft || flags.aiReview || flags.ai || flags.council)
|
|
681
|
+
) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
'--instructions/--instructions-file require a mode that runs analysis: ' +
|
|
684
|
+
'--headless, --ai, --ai-draft, --ai-review, or --council.'
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// NOTE: in headless --json mode, stdout was already redirected to stderr
|
|
689
|
+
// above (before loadConfig + the first-run welcome) — see `isHeadlessJson`.
|
|
690
|
+
// No redirect is repeated here: it would be too late to matter and is
|
|
691
|
+
// redundant given the early call.
|
|
692
|
+
|
|
574
693
|
// Apply debug_stream from config if not already enabled by CLI flag
|
|
575
694
|
if (!flags.debugStream && config.debug_stream) {
|
|
576
695
|
flags.debugStream = true;
|
|
@@ -646,9 +765,33 @@ AI PROVIDERS:
|
|
|
646
765
|
// Single-port delegation: if a pair-review server is already running on the
|
|
647
766
|
// configured port, delegate to it (open browser URL) and exit immediately.
|
|
648
767
|
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
649
|
-
|
|
768
|
+
// --headless never delegates/opens a browser — it runs analysis locally and
|
|
769
|
+
// reports to stdout/stderr.
|
|
770
|
+
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft && !flags.headless) {
|
|
771
|
+
// Carry --instructions across delegation. When an analyzing mode
|
|
772
|
+
// (--ai/--council) ALSO supplies per-run instructions and a server is
|
|
773
|
+
// already running, attemptDelegation resolves the analysis config and POSTs
|
|
774
|
+
// it to that (separate) process, threading the returned id as
|
|
775
|
+
// `analysisConfigId`. That handoff needs an open DB (repo-default/council
|
|
776
|
+
// resolution) and, for local mode, the repository name — PR mode derives it
|
|
777
|
+
// from the args inside attemptDelegation. Without instructions this is a
|
|
778
|
+
// no-op and the prior councilId/analyze URL shape is preserved.
|
|
779
|
+
const wantsInstructionHandoff =
|
|
780
|
+
(flags.instructions || flags.instructionsFile) && (flags.ai || flags.council);
|
|
781
|
+
if (wantsInstructionHandoff && !db) {
|
|
782
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
783
|
+
}
|
|
784
|
+
let localRepository = null;
|
|
785
|
+
if (wantsInstructionHandoff && flags.local) {
|
|
786
|
+
const localTarget = require('path').resolve(flags.localPath || process.cwd());
|
|
787
|
+
const localRoot = await findGitRoot(localTarget);
|
|
788
|
+
localRepository = await getRepositoryName(localRoot);
|
|
789
|
+
}
|
|
790
|
+
|
|
650
791
|
const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
|
|
651
|
-
councilId: cliCouncil?.id || null
|
|
792
|
+
councilId: cliCouncil?.id || null,
|
|
793
|
+
db,
|
|
794
|
+
localRepository
|
|
652
795
|
});
|
|
653
796
|
if (delegated) {
|
|
654
797
|
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
@@ -679,6 +822,14 @@ AI PROVIDERS:
|
|
|
679
822
|
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
680
823
|
await poolLifecycle.resetAndRehydrate();
|
|
681
824
|
|
|
825
|
+
// Headless analysis mode: run analysis (single or council), store in the DB,
|
|
826
|
+
// report results to stdout/stderr, and exit. No server, no browser, no GitHub
|
|
827
|
+
// post. Works with a PR arg or --local. Handled before the local/PR split.
|
|
828
|
+
if (flags.headless) {
|
|
829
|
+
await handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
682
833
|
// Check for local mode (review uncommitted local changes)
|
|
683
834
|
if (flags.local) {
|
|
684
835
|
// Resolve localPath, defaulting to cwd if not provided
|
|
@@ -744,8 +895,33 @@ AI PROVIDERS:
|
|
|
744
895
|
console.log('No pull request specified. Starting server...');
|
|
745
896
|
await startServerOnly(config, poolLifecycle);
|
|
746
897
|
}
|
|
747
|
-
|
|
898
|
+
|
|
748
899
|
} catch (error) {
|
|
900
|
+
// In headless --json mode the consumer (typically a coding agent) parses
|
|
901
|
+
// stdout as JSON. A prose error on stderr + empty stdout forces it to scrape
|
|
902
|
+
// English to learn what broke, so emit a structured failure envelope on
|
|
903
|
+
// stdout instead — one stream, JSON for success and failure alike. Read
|
|
904
|
+
// process.argv directly: `args`/`flags` are block-scoped to the try and
|
|
905
|
+
// unavailable here, and the throw may even predate parseArgs(). Exit stays
|
|
906
|
+
// non-zero so exit-code consumers are unaffected.
|
|
907
|
+
const argv = process.argv.slice(2);
|
|
908
|
+
const jsonMode = argv.includes('--headless') && argv.includes('--json');
|
|
909
|
+
if (jsonMode) {
|
|
910
|
+
// Canonical source for local-mode detection is parseArgs' -l/--local
|
|
911
|
+
// handling; we re-check argv here only because `flags` is block-scoped to
|
|
912
|
+
// the try and may not exist yet if the throw predates parseArgs. Keep any
|
|
913
|
+
// future local-mode alias in sync with that handling. Mode is best-effort
|
|
914
|
+
// (agents branch on `ok`, not `mode`).
|
|
915
|
+
const mode = (argv.includes('--local') || argv.includes('-l')) ? 'local' : 'pr';
|
|
916
|
+
// stdout may be a pipe (e.g. `pair-review … | jq`); a synchronous
|
|
917
|
+
// process.exit() can truncate a buffered write, so exit only once the
|
|
918
|
+
// envelope has flushed. The non-JSON branch below keeps the immediate exit.
|
|
919
|
+
process.stdout.write(
|
|
920
|
+
JSON.stringify(buildHeadlessErrorJson({ mode, error }), null, 2) + '\n',
|
|
921
|
+
() => process.exit(1)
|
|
922
|
+
);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
749
925
|
console.error(`\n❌ Error: ${error.message}\n`);
|
|
750
926
|
process.exit(1);
|
|
751
927
|
}
|
|
@@ -820,7 +996,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
820
996
|
let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
|
|
821
997
|
const shouldAnalyze = flags.ai || flags.council;
|
|
822
998
|
if (shouldAnalyze) url += '?analyze=true';
|
|
823
|
-
|
|
999
|
+
// When the run carries --instructions, stash the resolved analysis config
|
|
1000
|
+
// (provider/model or council snapshot + instructions) and thread its id so
|
|
1001
|
+
// the browser-side auto-analyze honors the instructions instead of silently
|
|
1002
|
+
// dropping them. The id already encodes the council selection, so prefer it
|
|
1003
|
+
// over the bare &council= param; fall back to &council= otherwise.
|
|
1004
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1005
|
+
const analysisConfigId = shouldAnalyze
|
|
1006
|
+
? await prepareInteractiveAnalysisConfig({ db, config, flags, repository })
|
|
1007
|
+
: null;
|
|
1008
|
+
if (analysisConfigId) {
|
|
1009
|
+
url += `&analysisConfigId=${analysisConfigId}`;
|
|
1010
|
+
} else if (councilSelection) {
|
|
1011
|
+
url += `&council=${councilSelection.id}`;
|
|
1012
|
+
}
|
|
824
1013
|
|
|
825
1014
|
console.log(`Opening browser to: ${url}`);
|
|
826
1015
|
await open(url);
|
|
@@ -898,8 +1087,9 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
898
1087
|
prInfo = await parser.parsePRArguments(args);
|
|
899
1088
|
|
|
900
1089
|
// Resolve + validate the council FAIL-FAST, before any worktree/network
|
|
901
|
-
// work, so a bad handle or invalid config surfaces immediately.
|
|
902
|
-
|
|
1090
|
+
// work, so a bad handle or invalid config surfaces immediately. The actual
|
|
1091
|
+
// council selection used for analysis is produced below by
|
|
1092
|
+
// resolveReviewConfig; this is a pre-flight validation only.
|
|
903
1093
|
if (flags.council) {
|
|
904
1094
|
const council = await resolveCouncilHandle(db, flags.council);
|
|
905
1095
|
const configType = council.type || 'advanced';
|
|
@@ -909,7 +1099,6 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
909
1099
|
if (validationError) {
|
|
910
1100
|
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
911
1101
|
}
|
|
912
|
-
councilSelection = { council, configType, councilConfig };
|
|
913
1102
|
}
|
|
914
1103
|
|
|
915
1104
|
// Resolve host binding for the target repo (handles alt-host).
|
|
@@ -1141,10 +1330,26 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
1141
1330
|
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1142
1331
|
const globalInstructions = config.globalInstructions || null;
|
|
1143
1332
|
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
const
|
|
1333
|
+
// Resolve per-run custom instructions from --instructions/--instructions-file.
|
|
1334
|
+
// Default stays null, preserving prior --ai-draft/--ai-review behavior.
|
|
1335
|
+
const requestInstructions = await resolveCliInstructions(flags);
|
|
1336
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
1337
|
+
|
|
1338
|
+
// Resolve the review configuration (council vs single provider/model) via the
|
|
1339
|
+
// shared resolver, so --ai-draft/--ai-review honor repo default councils too.
|
|
1340
|
+
// The early councilSelection above already fail-fast validated an explicit
|
|
1341
|
+
// --council; resolveReviewConfig re-resolves the same handle (cheap in-memory
|
|
1342
|
+
// match) and unifies single/council selection with the headless path.
|
|
1343
|
+
const reviewConfig = await resolveReviewConfig(
|
|
1344
|
+
db,
|
|
1345
|
+
repository,
|
|
1346
|
+
{ council: flags.council, model: flags.model },
|
|
1347
|
+
config
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
// Single-provider runs honor the repo/global tier-3 load_skills resolution.
|
|
1351
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
1352
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
1148
1353
|
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
1149
1354
|
const providerOverrides = { load_skills: loadSkills };
|
|
1150
1355
|
|
|
@@ -1154,33 +1359,18 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
1154
1359
|
// githubClient is forwarded so the analyzer can pre-fetch existing PR review
|
|
1155
1360
|
// comments when callers opt in via excludePrevious.github.
|
|
1156
1361
|
logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
}
|
|
1362
|
+
const { result: analysisResult } = await runHeadlessAnalysis(db, config, {
|
|
1363
|
+
reviewId: review.id,
|
|
1364
|
+
worktreePath,
|
|
1365
|
+
prMetadata: storedPRData,
|
|
1366
|
+
changedFiles: null,
|
|
1367
|
+
repository,
|
|
1368
|
+
repoSettings,
|
|
1369
|
+
instructions,
|
|
1370
|
+
reviewConfig,
|
|
1371
|
+
providerOverrides,
|
|
1372
|
+
githubClient
|
|
1373
|
+
});
|
|
1184
1374
|
analysisSummary = analysisResult.summary;
|
|
1185
1375
|
console.log('AI analysis completed successfully');
|
|
1186
1376
|
} catch (analysisError) {
|
|
@@ -1421,6 +1611,716 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
|
|
|
1421
1611
|
}
|
|
1422
1612
|
}
|
|
1423
1613
|
|
|
1614
|
+
/**
|
|
1615
|
+
* Shared single-vs-council analysis dispatch for headless runs.
|
|
1616
|
+
*
|
|
1617
|
+
* Both `performHeadlessReview` (GitHub-submit modes) and `handleHeadlessAnalysis`
|
|
1618
|
+
* (analysis-only headless mode) call this so the single/council branching — and
|
|
1619
|
+
* the instruction object threaded into every analyzer path — lives in exactly one
|
|
1620
|
+
* place. It only RUNS analysis and returns the run id + analyzer result; it never
|
|
1621
|
+
* submits to GitHub or releases pool worktrees (callers own that).
|
|
1622
|
+
*
|
|
1623
|
+
* The SAME `instructions` object ({ globalInstructions, repoInstructions,
|
|
1624
|
+
* requestInstructions }) is passed to both branches, centralizing the
|
|
1625
|
+
* three-analyzer-paths instruction hazard (analyzeAllLevels vs the two council
|
|
1626
|
+
* paths inside runHeadlessCouncilAnalysis).
|
|
1627
|
+
*
|
|
1628
|
+
* @param {Object} db - Database instance
|
|
1629
|
+
* @param {Object} config - Application configuration
|
|
1630
|
+
* @param {Object} params
|
|
1631
|
+
* @param {number} params.reviewId - Review id comments are stored under
|
|
1632
|
+
* @param {string} params.worktreePath - Checked-out worktree / local repo path
|
|
1633
|
+
* @param {Object} params.prMetadata - PR/local metadata (head_sha, base_sha, etc.)
|
|
1634
|
+
* @param {Array|null} params.changedFiles - Changed-file list (local mode) or null (PR mode computes it)
|
|
1635
|
+
* @param {string} params.repository - owner/repo
|
|
1636
|
+
* @param {Object|null} params.repoSettings - Repo settings row
|
|
1637
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
1638
|
+
* @param {Object} params.reviewConfig - Result of resolveReviewConfig (single|council)
|
|
1639
|
+
* @param {Object} params.providerOverrides - Single-provider overrides (e.g. { load_skills })
|
|
1640
|
+
* @param {Object} [params.githubClient] - GitHub client (PR mode) or undefined (local)
|
|
1641
|
+
* @returns {Promise<{ runId: string, result: Object }>}
|
|
1642
|
+
*/
|
|
1643
|
+
async function runHeadlessAnalysis(db, config, {
|
|
1644
|
+
reviewId,
|
|
1645
|
+
worktreePath,
|
|
1646
|
+
prMetadata,
|
|
1647
|
+
changedFiles,
|
|
1648
|
+
repository,
|
|
1649
|
+
repoSettings,
|
|
1650
|
+
instructions,
|
|
1651
|
+
reviewConfig,
|
|
1652
|
+
providerOverrides,
|
|
1653
|
+
githubClient
|
|
1654
|
+
}) {
|
|
1655
|
+
if (reviewConfig.type === 'council') {
|
|
1656
|
+
console.log(`Running council analysis: ${reviewConfig.council.name} (${reviewConfig.configType})...`);
|
|
1657
|
+
// Council voices can span multiple providers, so resolve a per-provider
|
|
1658
|
+
// load_skills map (tier-3 resolution per provider) the same way every other
|
|
1659
|
+
// council invoker does (pr.js / local.js / stack-analysis.js). Passing only
|
|
1660
|
+
// the shared providerOverrides would collapse every voice onto the default
|
|
1661
|
+
// provider's load_skills — see buildVoiceContext.
|
|
1662
|
+
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
1663
|
+
buildCouncilProviderOverrides(config, repository, repoSettings);
|
|
1664
|
+
const analyzer = new Analyzer(db, 'council', 'council', councilProviderOverrides, councilProviderOverridesMap);
|
|
1665
|
+
const result = await runHeadlessCouncilAnalysis(db, {
|
|
1666
|
+
analyzer,
|
|
1667
|
+
reviewId,
|
|
1668
|
+
council: reviewConfig.council,
|
|
1669
|
+
configType: reviewConfig.configType,
|
|
1670
|
+
councilConfig: reviewConfig.councilConfig,
|
|
1671
|
+
worktreePath,
|
|
1672
|
+
prMetadata,
|
|
1673
|
+
changedFiles,
|
|
1674
|
+
instructions,
|
|
1675
|
+
githubClient
|
|
1676
|
+
});
|
|
1677
|
+
return { runId: result.runId, result };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
console.log('Running AI analysis (all 3 levels)...');
|
|
1681
|
+
const runId = uuidv4();
|
|
1682
|
+
const analyzer = new Analyzer(db, reviewConfig.model, reviewConfig.provider, providerOverrides);
|
|
1683
|
+
const result = await analyzer.analyzeAllLevels(
|
|
1684
|
+
reviewId,
|
|
1685
|
+
worktreePath,
|
|
1686
|
+
prMetadata,
|
|
1687
|
+
null,
|
|
1688
|
+
instructions,
|
|
1689
|
+
changedFiles,
|
|
1690
|
+
{ githubClient, runId }
|
|
1691
|
+
);
|
|
1692
|
+
return { runId, result };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Prepare a PR for headless analysis-only mode (no GitHub submit).
|
|
1697
|
+
*
|
|
1698
|
+
* This is the analysis-only twin of the prep block inside `performHeadlessReview`.
|
|
1699
|
+
* It is intentionally a STANDALONE function rather than an extraction from
|
|
1700
|
+
* `performHeadlessReview`: the latter's prep and submit blocks share many locals
|
|
1701
|
+
* and that function is CI-critical, so duplicating the minimal prep here (the
|
|
1702
|
+
* documented fallback in the plan) keeps the submit path byte-identical and
|
|
1703
|
+
* untouched. The prep mirrors `performHeadlessReview` (parse → token/binding →
|
|
1704
|
+
* client → fetch PR → worktree/pool acquire → diff → storePRData → metadata →
|
|
1705
|
+
* review → repoSettings → reviewConfig), minus anything only the submit path
|
|
1706
|
+
* needs.
|
|
1707
|
+
*
|
|
1708
|
+
* @param {Object} db - Database instance
|
|
1709
|
+
* @param {Object} config - Application configuration
|
|
1710
|
+
* @param {Object} flags - Parsed CLI flags
|
|
1711
|
+
* @param {Array<string>} prArgs - PR identifier args
|
|
1712
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [externalPoolLifecycle]
|
|
1713
|
+
* - The session's already-rehydrated pool lifecycle. Reused for pool
|
|
1714
|
+
* acquisition (mirrors `performHeadlessReview`'s `externalPoolLifecycle`
|
|
1715
|
+
* convention) so the in-memory usage tracker populated by
|
|
1716
|
+
* `resetAndRehydrate()` is the one acquisitions go through. Falls back to a
|
|
1717
|
+
* fresh instance only when not supplied.
|
|
1718
|
+
* @returns {Promise<Object>} { githubClient, worktreePath, storedPRData, review,
|
|
1719
|
+
* repository, repoSettings, poolWorktreeId, poolLifecycle, reviewConfig,
|
|
1720
|
+
* providerOverrides }
|
|
1721
|
+
*/
|
|
1722
|
+
async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycle = null) {
|
|
1723
|
+
const parser = new PRArgumentParser(config);
|
|
1724
|
+
const prInfo = await parser.parsePRArguments(prArgs);
|
|
1725
|
+
|
|
1726
|
+
// Fail-fast council validation (mirrors performHeadlessReview).
|
|
1727
|
+
if (flags.council) {
|
|
1728
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
1729
|
+
const configType = council.type || 'advanced';
|
|
1730
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
1731
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
1732
|
+
if (validationError) {
|
|
1733
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const repositoryForBinding = prInfo.bindingRepository
|
|
1738
|
+
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1739
|
+
const headlessBinding = resolveHostBinding(repositoryForBinding, config);
|
|
1740
|
+
if (!headlessBinding.token) {
|
|
1741
|
+
throw new Error(buildMissingTokenError({
|
|
1742
|
+
owner: prInfo.owner,
|
|
1743
|
+
repo: prInfo.repo,
|
|
1744
|
+
bindingRepository: repositoryForBinding,
|
|
1745
|
+
apiHost: headlessBinding.apiHost
|
|
1746
|
+
}));
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in headless mode`);
|
|
1750
|
+
|
|
1751
|
+
const githubClient = new GitHubClient(headlessBinding);
|
|
1752
|
+
const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
|
|
1753
|
+
if (!repoExists) {
|
|
1754
|
+
throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
console.log('Fetching pull request data from GitHub...');
|
|
1758
|
+
const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
|
|
1759
|
+
|
|
1760
|
+
let worktreePath;
|
|
1761
|
+
let diff;
|
|
1762
|
+
let changedFiles;
|
|
1763
|
+
let poolWorktreeId = null;
|
|
1764
|
+
let poolLifecycle = null;
|
|
1765
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1766
|
+
|
|
1767
|
+
// Everything from here on can acquire a pool worktree and then do work that
|
|
1768
|
+
// may throw (diff generation, storePRData, metadata query, resolveReviewConfig).
|
|
1769
|
+
// The caller (handleHeadlessAnalysis) releases the pool slot in a finally, but
|
|
1770
|
+
// ONLY once it holds the returned prep object — a throw before that return would
|
|
1771
|
+
// leak the acquired worktree. So on the throwing path we release it here before
|
|
1772
|
+
// re-throwing. `poolWorktreeId` is null until acquire succeeds, so the guarded
|
|
1773
|
+
// release is a no-op when the throw predates acquisition (no double-release: the
|
|
1774
|
+
// caller's finally runs only on the success path that returns below).
|
|
1775
|
+
try {
|
|
1776
|
+
if (flags.useCheckout) {
|
|
1777
|
+
worktreePath = process.cwd();
|
|
1778
|
+
const isMatchingRepo = await parser.isMatchingRepository(worktreePath, prInfo.owner, prInfo.repo);
|
|
1779
|
+
if (!isMatchingRepo) {
|
|
1780
|
+
throw new Error(
|
|
1781
|
+
`--use-checkout requires running from a checkout of ${prInfo.owner}/${prInfo.repo}, ` +
|
|
1782
|
+
`but current directory does not match. Either cd to the correct repository or remove --use-checkout.`
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
await registerRepositoryLocation(db, worktreePath, prInfo.owner, prInfo.repo);
|
|
1786
|
+
console.log(`Using current checkout at ${worktreePath}`);
|
|
1787
|
+
|
|
1788
|
+
console.log('Generating unified diff from checkout...');
|
|
1789
|
+
const git = simpleGit(worktreePath);
|
|
1790
|
+
try {
|
|
1791
|
+
await fetchNoTags(git, ['origin', prData.base_sha]);
|
|
1792
|
+
} catch (fetchError) {
|
|
1793
|
+
try {
|
|
1794
|
+
await git.raw(['cat-file', '-t', prData.base_sha]);
|
|
1795
|
+
} catch {
|
|
1796
|
+
throw new Error(`Base SHA ${prData.base_sha} is not available locally and fetch failed: ${fetchError.message}`);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
diff = await git.diff([
|
|
1800
|
+
`${prData.base_sha}...${prData.head_sha}`,
|
|
1801
|
+
'--unified=3',
|
|
1802
|
+
...GIT_DIFF_FLAGS_ARRAY
|
|
1803
|
+
]);
|
|
1804
|
+
const diffSummary = await git.diffSummary([
|
|
1805
|
+
`${prData.base_sha}...${prData.head_sha}`,
|
|
1806
|
+
...GIT_DIFF_SUMMARY_FLAGS_ARRAY
|
|
1807
|
+
]);
|
|
1808
|
+
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
1809
|
+
changedFiles = diffSummary.files.map(file => {
|
|
1810
|
+
const resolvedFile = resolveRenamedFile(file.file);
|
|
1811
|
+
const isRenamed = resolvedFile !== file.file;
|
|
1812
|
+
const result = {
|
|
1813
|
+
file: resolvedFile,
|
|
1814
|
+
insertions: file.insertions,
|
|
1815
|
+
deletions: file.deletions,
|
|
1816
|
+
changes: file.changes,
|
|
1817
|
+
binary: file.binary || false,
|
|
1818
|
+
generated: gitattributes.isGenerated(resolvedFile)
|
|
1819
|
+
};
|
|
1820
|
+
if (isRenamed) {
|
|
1821
|
+
result.renamed = true;
|
|
1822
|
+
result.renamedFrom = resolveRenamedFileOld(file.file);
|
|
1823
|
+
}
|
|
1824
|
+
return result;
|
|
1825
|
+
});
|
|
1826
|
+
} else {
|
|
1827
|
+
const currentDir = parser.getCurrentDirectory();
|
|
1828
|
+
const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
|
|
1829
|
+
|
|
1830
|
+
let repositoryPath;
|
|
1831
|
+
let worktreeSourcePath;
|
|
1832
|
+
let checkoutScript;
|
|
1833
|
+
let checkoutTimeout;
|
|
1834
|
+
let worktreeConfig = null;
|
|
1835
|
+
let poolSize = 0;
|
|
1836
|
+
let resetScript = null;
|
|
1837
|
+
if (isMatchingRepo) {
|
|
1838
|
+
repositoryPath = currentDir;
|
|
1839
|
+
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
1840
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1841
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1842
|
+
const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
|
|
1843
|
+
checkoutScript = resolved.checkoutScript;
|
|
1844
|
+
checkoutTimeout = resolved.checkoutTimeout;
|
|
1845
|
+
worktreeConfig = resolved.worktreeConfig;
|
|
1846
|
+
poolSize = resolved.poolSize || 0;
|
|
1847
|
+
resetScript = resolved.resetScript || null;
|
|
1848
|
+
} else {
|
|
1849
|
+
console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
|
|
1850
|
+
const result = await findRepositoryPath({
|
|
1851
|
+
db,
|
|
1852
|
+
owner: prInfo.owner,
|
|
1853
|
+
repo: prInfo.repo,
|
|
1854
|
+
repository,
|
|
1855
|
+
bindingRepository: repositoryForBinding,
|
|
1856
|
+
prNumber: prInfo.number,
|
|
1857
|
+
config,
|
|
1858
|
+
cloneUrl: prData?.repository?.clone_url,
|
|
1859
|
+
onProgress: (progress) => {
|
|
1860
|
+
if (progress.message) {
|
|
1861
|
+
console.log(progress.message);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
repositoryPath = result.repositoryPath;
|
|
1866
|
+
worktreeSourcePath = result.worktreeSourcePath;
|
|
1867
|
+
checkoutScript = result.checkoutScript;
|
|
1868
|
+
checkoutTimeout = result.checkoutTimeout;
|
|
1869
|
+
worktreeConfig = result.worktreeConfig;
|
|
1870
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1871
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1872
|
+
const { poolSize: resolvedPoolSize } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
|
|
1873
|
+
poolSize = resolvedPoolSize || 0;
|
|
1874
|
+
resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
|
|
1878
|
+
if (poolSize > 0) {
|
|
1879
|
+
console.log('Acquiring pool worktree...');
|
|
1880
|
+
poolLifecycle = externalPoolLifecycle || new WorktreePoolLifecycle(db, config);
|
|
1881
|
+
const result = await poolLifecycle.acquireForPR(
|
|
1882
|
+
{ owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number, repository },
|
|
1883
|
+
prData,
|
|
1884
|
+
repositoryPath,
|
|
1885
|
+
{ worktreeSourcePath, checkoutScript, checkoutTimeout, resetScript, worktreeConfig, poolSize }
|
|
1886
|
+
);
|
|
1887
|
+
worktreePath = result.worktreePath;
|
|
1888
|
+
poolWorktreeId = result.worktreeId;
|
|
1889
|
+
console.log('Pool worktree acquired');
|
|
1890
|
+
} else {
|
|
1891
|
+
console.log('Setting up git worktree...');
|
|
1892
|
+
({ path: worktreePath } = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
|
|
1893
|
+
worktreeSourcePath,
|
|
1894
|
+
checkoutScript,
|
|
1895
|
+
checkoutTimeout
|
|
1896
|
+
}));
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
console.log('Generating unified diff...');
|
|
1900
|
+
diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
1901
|
+
changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
console.log('Storing pull request data...');
|
|
1905
|
+
const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
1906
|
+
skipWorktreeRecord: !!flags.useCheckout
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
1910
|
+
await poolLifecycle.setReviewOwner(poolWorktreeId, storedReviewId);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (isNewReview) {
|
|
1914
|
+
fireReviewStartedHook({
|
|
1915
|
+
reviewId: storedReviewId, prNumber: prInfo.number,
|
|
1916
|
+
owner: prInfo.owner, repo: prInfo.repo, prData, config,
|
|
1917
|
+
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const prMetadata = await queryOne(db, `
|
|
1921
|
+
SELECT id, pr_data FROM pr_metadata
|
|
1922
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
1923
|
+
`, [prInfo.number, repository]);
|
|
1924
|
+
|
|
1925
|
+
if (!prMetadata) {
|
|
1926
|
+
throw new Error('Failed to retrieve stored PR metadata');
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const storedPRData = JSON.parse(prMetadata.pr_data);
|
|
1930
|
+
|
|
1931
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1932
|
+
const { review } = await reviewRepo.getOrCreate({ prNumber: prInfo.number, repository });
|
|
1933
|
+
|
|
1934
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1935
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1936
|
+
|
|
1937
|
+
const reviewConfig = await resolveReviewConfig(
|
|
1938
|
+
db,
|
|
1939
|
+
repository,
|
|
1940
|
+
{ council: flags.council, model: flags.model },
|
|
1941
|
+
config
|
|
1942
|
+
);
|
|
1943
|
+
|
|
1944
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
1945
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
1946
|
+
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
1947
|
+
const providerOverrides = { load_skills: loadSkills };
|
|
1948
|
+
|
|
1949
|
+
return {
|
|
1950
|
+
githubClient,
|
|
1951
|
+
worktreePath,
|
|
1952
|
+
storedPRData,
|
|
1953
|
+
review,
|
|
1954
|
+
repository,
|
|
1955
|
+
repoSettings,
|
|
1956
|
+
poolWorktreeId,
|
|
1957
|
+
poolLifecycle,
|
|
1958
|
+
reviewConfig,
|
|
1959
|
+
providerOverrides
|
|
1960
|
+
};
|
|
1961
|
+
} catch (prepErr) {
|
|
1962
|
+
// Release the acquired pool worktree (if any) before propagating — the
|
|
1963
|
+
// caller never received a prep object, so its finally won't run. Guarded on
|
|
1964
|
+
// poolWorktreeId so a throw before acquisition is a no-op. Release errors are
|
|
1965
|
+
// logged, not allowed to mask the original failure.
|
|
1966
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
1967
|
+
try {
|
|
1968
|
+
await poolLifecycle.releaseAfterHeadless(poolWorktreeId);
|
|
1969
|
+
logger.info(`Released pool worktree ${poolWorktreeId} after headless prep failure`);
|
|
1970
|
+
} catch (releaseErr) {
|
|
1971
|
+
logger.error(`Failed to release pool worktree ${poolWorktreeId} after prep failure: ${releaseErr.message}`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
throw prepErr;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* Record a completed, zero-suggestion analysis run for an empty-scope headless
|
|
1980
|
+
* local review (the analyzer is never invoked). Mirrors the web routes'
|
|
1981
|
+
* empty-scope guard but, lacking an HTTP response, persists a normal-shaped run
|
|
1982
|
+
* so `buildHeadlessJson` emits the standard `{ run, suggestions: [], count: 0 }`
|
|
1983
|
+
* envelope and the command still exits 0 ("zero suggestions is still success").
|
|
1984
|
+
* The run is attributed to whatever `reviewConfig` resolved (single or council).
|
|
1985
|
+
*
|
|
1986
|
+
* @param {Object} db - Database instance
|
|
1987
|
+
* @param {Object} params
|
|
1988
|
+
* @param {number} params.reviewId - Review id the run belongs to
|
|
1989
|
+
* @param {Object} params.reviewConfig - resolveReviewConfig result (single|council)
|
|
1990
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
1991
|
+
* @param {string|null} params.headSha - Head SHA recorded on the run
|
|
1992
|
+
* @returns {Promise<string>} The created run id
|
|
1993
|
+
*/
|
|
1994
|
+
async function recordEmptyScopeRun(db, { reviewId, reviewConfig, instructions, headSha }) {
|
|
1995
|
+
const runId = uuidv4();
|
|
1996
|
+
const runRepo = new AnalysisRunRepository(db);
|
|
1997
|
+
const isCouncil = reviewConfig.type === 'council';
|
|
1998
|
+
await runRepo.create({
|
|
1999
|
+
id: runId,
|
|
2000
|
+
reviewId,
|
|
2001
|
+
provider: isCouncil ? 'council' : reviewConfig.provider,
|
|
2002
|
+
model: isCouncil ? reviewConfig.council.id : reviewConfig.model,
|
|
2003
|
+
tier: null,
|
|
2004
|
+
globalInstructions: instructions?.globalInstructions || null,
|
|
2005
|
+
repoInstructions: instructions?.repoInstructions || null,
|
|
2006
|
+
requestInstructions: instructions?.requestInstructions || null,
|
|
2007
|
+
headSha: headSha || null,
|
|
2008
|
+
configType: isCouncil ? reviewConfig.configType : 'single',
|
|
2009
|
+
levelsConfig: null
|
|
2010
|
+
});
|
|
2011
|
+
await runRepo.update(runId, {
|
|
2012
|
+
status: 'completed',
|
|
2013
|
+
summary: 'No changes found in the selected scope — analysis skipped.',
|
|
2014
|
+
totalSuggestions: 0,
|
|
2015
|
+
filesAnalyzed: 0
|
|
2016
|
+
});
|
|
2017
|
+
return runId;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* Headless analysis-only handler: run analysis (single or council), store it in
|
|
2022
|
+
* the DB, report the consolidated run to stdout/stderr, and exit. No server, no
|
|
2023
|
+
* browser, no GitHub post. Works with a PR arg or --local.
|
|
2024
|
+
*
|
|
2025
|
+
* @param {Array<string>} prArgs - PR identifier args (empty for --local)
|
|
2026
|
+
* @param {Object} config - Application configuration
|
|
2027
|
+
* @param {Object} db - Database instance
|
|
2028
|
+
* @param {Object} flags - Parsed CLI flags
|
|
2029
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} poolLifecycle
|
|
2030
|
+
* - The session's rehydrated pool lifecycle. Forwarded to `preparePrHeadless`
|
|
2031
|
+
* for PR-mode pool acquisition so the rehydrated in-memory tracker is used;
|
|
2032
|
+
* local mode has no worktree/pool and ignores it.
|
|
2033
|
+
*/
|
|
2034
|
+
async function handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle) {
|
|
2035
|
+
// NOTE: In --json mode, redirectConsoleToStderr() is already called in main()
|
|
2036
|
+
// BEFORE the DB-init/migration/pool-rehydrate block, so stdout is clean by the
|
|
2037
|
+
// time we get here (those earlier steps log to stdout and would otherwise
|
|
2038
|
+
// precede and corrupt the JSON document). No redirect is needed in this
|
|
2039
|
+
// handler — it would be too late and is redundant.
|
|
2040
|
+
|
|
2041
|
+
// Resolve per-run custom instructions; a bad --instructions-file path fails
|
|
2042
|
+
// fast with a clear operational error (propagates to main()'s catch → exit 1).
|
|
2043
|
+
const requestInstructions = await resolveCliInstructions(flags);
|
|
2044
|
+
|
|
2045
|
+
const globalInstructions = config.globalInstructions || null;
|
|
2046
|
+
|
|
2047
|
+
let runId;
|
|
2048
|
+
let mode;
|
|
2049
|
+
|
|
2050
|
+
if (flags.local) {
|
|
2051
|
+
mode = 'local';
|
|
2052
|
+
|
|
2053
|
+
// Fail-fast council validation (mirror the PR path; local mode otherwise
|
|
2054
|
+
// lacks an explicit pre-flight check here).
|
|
2055
|
+
if (flags.council) {
|
|
2056
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
2057
|
+
const configType = council.type || 'advanced';
|
|
2058
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
2059
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
2060
|
+
if (validationError) {
|
|
2061
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const resolvedPath = require('path').resolve(flags.localPath || process.cwd());
|
|
2066
|
+
console.log(`Finding git repository root from ${resolvedPath}...`);
|
|
2067
|
+
const repoPath = await findGitRoot(resolvedPath);
|
|
2068
|
+
console.log(`Found git repository at: ${repoPath}`);
|
|
2069
|
+
|
|
2070
|
+
// Headless is a one-shot run: suppress the interactive summary/tour jobs so
|
|
2071
|
+
// they don't spend provider budget after the result is reported or keep the
|
|
2072
|
+
// event loop alive (CLI hang). See setupLocalReviewSession.
|
|
2073
|
+
const session = await setupLocalReviewSession({ db, config, repoPath, flags, startBackgroundJobs: false });
|
|
2074
|
+
|
|
2075
|
+
const repository = session.repository;
|
|
2076
|
+
const repoSettings = await new RepoSettingsRepository(db).getRepoSettings(repository);
|
|
2077
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
2078
|
+
|
|
2079
|
+
// Build local analysis metadata (mirror src/routes/local.js).
|
|
2080
|
+
const hasBranch = includesBranch(session.scopeStart);
|
|
2081
|
+
let analysisBaseSha = session.headSha;
|
|
2082
|
+
if (hasBranch && session.baseBranch) {
|
|
2083
|
+
try {
|
|
2084
|
+
analysisBaseSha = await findMergeBase(repoPath, session.baseBranch);
|
|
2085
|
+
} catch {
|
|
2086
|
+
// Fall back to HEAD
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const localMetadata = {
|
|
2090
|
+
id: session.sessionId,
|
|
2091
|
+
repository,
|
|
2092
|
+
title: hasBranch
|
|
2093
|
+
? `Branch changes: ${session.baseBranch}..HEAD`
|
|
2094
|
+
: `Local changes in ${repository}`,
|
|
2095
|
+
description: hasBranch
|
|
2096
|
+
? `Reviewing committed changes on branch against ${session.baseBranch}`
|
|
2097
|
+
: `Reviewing uncommitted changes in ${repoPath}`,
|
|
2098
|
+
base_sha: analysisBaseSha,
|
|
2099
|
+
head_sha: session.headSha,
|
|
2100
|
+
// Scope context the analyzer's executable-provider path consults FIRST when
|
|
2101
|
+
// rebuilding the local diff (src/ai/analyzer.js → generateDiffForExecutable /
|
|
2102
|
+
// getChangedFiles check scopeStart/scopeEnd before base_sha/head_sha). Without
|
|
2103
|
+
// these, unstaged-/untracked-only and mixed scopes (where base_sha === head_sha
|
|
2104
|
+
// === HEAD) fall through to the SHA branch and produce an empty/partial diff.
|
|
2105
|
+
// Matches the interactive local council metadata (src/routes/local.js).
|
|
2106
|
+
base_branch: session.baseBranch || null,
|
|
2107
|
+
head_branch: session.branch || null,
|
|
2108
|
+
scopeStart: session.scopeStart,
|
|
2109
|
+
scopeEnd: session.scopeEnd,
|
|
2110
|
+
reviewType: 'local'
|
|
2111
|
+
};
|
|
2112
|
+
|
|
2113
|
+
const reviewConfig = await resolveReviewConfig(
|
|
2114
|
+
db,
|
|
2115
|
+
repository,
|
|
2116
|
+
{ council: flags.council, model: flags.model },
|
|
2117
|
+
config
|
|
2118
|
+
);
|
|
2119
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
2120
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
2121
|
+
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
2122
|
+
const providerOverrides = { load_skills: loadSkills };
|
|
2123
|
+
|
|
2124
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
2125
|
+
|
|
2126
|
+
// Empty-scope detection is driven by the SESSION DIFF, not a second
|
|
2127
|
+
// changed-file enumeration. setupLocalReviewSession already generated the
|
|
2128
|
+
// scoped diff via generateScopedDiff, which THROWS on a hard git failure and
|
|
2129
|
+
// returns '' only when the scope is genuinely empty — so an empty diff is
|
|
2130
|
+
// authoritative, while an operational git error has already aborted the run.
|
|
2131
|
+
// (getChangedFiles, by contrast, swallows git errors and returns [], which
|
|
2132
|
+
// would let a failure masquerade as "no changes" and exit 0.)
|
|
2133
|
+
const isEmptyScope = !session.diff || session.diff.trim().length === 0;
|
|
2134
|
+
|
|
2135
|
+
if (isEmptyScope) {
|
|
2136
|
+
// Empty-scope guard, mirroring the web routes' rejectIfEmptyScope (which
|
|
2137
|
+
// returns 409). Headless has no HTTP response, so instead emit a normal-
|
|
2138
|
+
// shaped, completed run with zero suggestions: emitHeadlessResult then
|
|
2139
|
+
// produces the standard { mode, run, suggestions: [], count: 0 } envelope
|
|
2140
|
+
// and exits 0 (zero suggestions is still success). Skipping the analyzer
|
|
2141
|
+
// avoids spending provider tokens on a guaranteed-empty result.
|
|
2142
|
+
runId = await recordEmptyScopeRun(db, {
|
|
2143
|
+
reviewId: session.sessionId,
|
|
2144
|
+
reviewConfig,
|
|
2145
|
+
instructions,
|
|
2146
|
+
headSha: session.headSha
|
|
2147
|
+
});
|
|
2148
|
+
} else {
|
|
2149
|
+
// Scope is non-empty (diff present), so the analyzer needs the scope-aware
|
|
2150
|
+
// changed-file list for suggestion validation. Use the throwing variant:
|
|
2151
|
+
// since we KNOW the scope is non-empty, a [] from a swallowed git error
|
|
2152
|
+
// would be a lie — surface it as a non-zero exit instead.
|
|
2153
|
+
const changedFiles = await getChangedFiles(repoPath, {
|
|
2154
|
+
scopeStart: session.scopeStart,
|
|
2155
|
+
scopeEnd: session.scopeEnd,
|
|
2156
|
+
baseBranch: session.baseBranch || null,
|
|
2157
|
+
}, { throwOnError: true });
|
|
2158
|
+
|
|
2159
|
+
({ runId } = await runHeadlessAnalysis(db, config, {
|
|
2160
|
+
reviewId: session.sessionId,
|
|
2161
|
+
worktreePath: repoPath,
|
|
2162
|
+
prMetadata: localMetadata,
|
|
2163
|
+
changedFiles,
|
|
2164
|
+
repository,
|
|
2165
|
+
repoSettings,
|
|
2166
|
+
instructions,
|
|
2167
|
+
reviewConfig,
|
|
2168
|
+
providerOverrides,
|
|
2169
|
+
githubClient: undefined
|
|
2170
|
+
}));
|
|
2171
|
+
}
|
|
2172
|
+
} else {
|
|
2173
|
+
mode = 'pr';
|
|
2174
|
+
// Forward the session's rehydrated pool lifecycle so pool acquisition goes
|
|
2175
|
+
// through the instance whose in-memory tracker resetAndRehydrate() populated
|
|
2176
|
+
// (mirrors performHeadlessReview's externalPoolLifecycle convention).
|
|
2177
|
+
const prep = await preparePrHeadless(db, config, flags, prArgs, poolLifecycle);
|
|
2178
|
+
try {
|
|
2179
|
+
const repoInstructions = prep.repoSettings?.default_instructions || null;
|
|
2180
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
2181
|
+
({ runId } = await runHeadlessAnalysis(db, config, {
|
|
2182
|
+
reviewId: prep.review.id,
|
|
2183
|
+
worktreePath: prep.worktreePath,
|
|
2184
|
+
prMetadata: prep.storedPRData,
|
|
2185
|
+
changedFiles: null,
|
|
2186
|
+
repository: prep.repository,
|
|
2187
|
+
repoSettings: prep.repoSettings,
|
|
2188
|
+
instructions,
|
|
2189
|
+
reviewConfig: prep.reviewConfig,
|
|
2190
|
+
providerOverrides: prep.providerOverrides,
|
|
2191
|
+
githubClient: prep.githubClient
|
|
2192
|
+
}));
|
|
2193
|
+
} finally {
|
|
2194
|
+
// Release the pool worktree (mirror performHeadlessReview's finally) so a
|
|
2195
|
+
// pool slot isn't leaked per headless run. Local mode has no pool.
|
|
2196
|
+
if (prep.poolWorktreeId && prep.poolLifecycle) {
|
|
2197
|
+
try {
|
|
2198
|
+
await prep.poolLifecycle.releaseAfterHeadless(prep.poolWorktreeId);
|
|
2199
|
+
logger.info(`Released pool worktree ${prep.poolWorktreeId} after headless analysis`);
|
|
2200
|
+
} catch (releaseErr) {
|
|
2201
|
+
logger.error(`Failed to release pool worktree ${prep.poolWorktreeId}: ${releaseErr.message}`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
await emitHeadlessResult(db, { runId, mode, flags });
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Build the machine-readable JSON document for a completed headless run.
|
|
2212
|
+
*
|
|
2213
|
+
* Returns the run metadata (with `level_outcomes` / `levels_config` parsed) plus
|
|
2214
|
+
* the run's consolidated final suggestions (orchestrated layer; per-suggestion
|
|
2215
|
+
* `reasoning` parsed) and a count. Exported for unit testing.
|
|
2216
|
+
*
|
|
2217
|
+
* @param {Object} db - Database instance
|
|
2218
|
+
* @param {string} runId - Analysis run id
|
|
2219
|
+
* @param {'pr'|'local'} mode - Review mode (passthrough)
|
|
2220
|
+
* @returns {Promise<Object>} { ok, mode, run, suggestions, count }
|
|
2221
|
+
*/
|
|
2222
|
+
async function buildHeadlessJson(db, runId, mode) {
|
|
2223
|
+
const run = await new AnalysisRunRepository(db).getById(runId, { includeDiff: false });
|
|
2224
|
+
if (run) {
|
|
2225
|
+
run.level_outcomes = safeParseJson(run.level_outcomes);
|
|
2226
|
+
run.levels_config = safeParseJson(run.levels_config);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
const rawSuggestions = await new CommentRepository(db).getFinalSuggestionsByRunId(runId);
|
|
2230
|
+
const suggestions = rawSuggestions.map(s => ({
|
|
2231
|
+
...s,
|
|
2232
|
+
reasoning: safeParseJson(s.reasoning)
|
|
2233
|
+
}));
|
|
2234
|
+
|
|
2235
|
+
return {
|
|
2236
|
+
// `ok` lets a machine consumer branch on success/failure with a single field;
|
|
2237
|
+
// see buildHeadlessErrorJson for the failure shape.
|
|
2238
|
+
ok: true,
|
|
2239
|
+
mode,
|
|
2240
|
+
run,
|
|
2241
|
+
suggestions,
|
|
2242
|
+
count: suggestions.length
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Build the machine-readable JSON document for a FAILED headless run.
|
|
2248
|
+
*
|
|
2249
|
+
* Mirrors buildHeadlessJson's envelope so a --headless --json consumer parses one
|
|
2250
|
+
* stream and branches on `ok`: success → { ok: true, ... }, failure → this. The
|
|
2251
|
+
* process still exits non-zero, so exit-code-based consumers are unaffected.
|
|
2252
|
+
* Exported for unit testing.
|
|
2253
|
+
*
|
|
2254
|
+
* @param {Object} params
|
|
2255
|
+
* @param {'pr'|'local'} params.mode - Review mode (best-effort, derived from args)
|
|
2256
|
+
* @param {Error} params.error - The thrown error
|
|
2257
|
+
* @returns {Object} { ok: false, mode, error: { message } }
|
|
2258
|
+
*/
|
|
2259
|
+
function buildHeadlessErrorJson({ mode, error }) {
|
|
2260
|
+
return {
|
|
2261
|
+
ok: false,
|
|
2262
|
+
mode,
|
|
2263
|
+
error: {
|
|
2264
|
+
message: error && error.message ? error.message : String(error)
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Emit the result of a headless run.
|
|
2271
|
+
*
|
|
2272
|
+
* In --json mode, writes the buildHeadlessJson document to stdout (via
|
|
2273
|
+
* process.stdout.write, since console.log is remapped to stderr in JSON mode).
|
|
2274
|
+
* Otherwise prints a human-readable summary to stdout. A successful run with zero
|
|
2275
|
+
* suggestions is still success — operational errors propagate to main()'s catch.
|
|
2276
|
+
*
|
|
2277
|
+
* @param {Object} db - Database instance
|
|
2278
|
+
* @param {Object} params
|
|
2279
|
+
* @param {string} params.runId - Analysis run id
|
|
2280
|
+
* @param {'pr'|'local'} params.mode - Review mode
|
|
2281
|
+
* @param {Object} params.flags - Parsed CLI flags
|
|
2282
|
+
*/
|
|
2283
|
+
async function emitHeadlessResult(db, { runId, mode, flags }) {
|
|
2284
|
+
const doc = await buildHeadlessJson(db, runId, mode);
|
|
2285
|
+
|
|
2286
|
+
if (flags.json) {
|
|
2287
|
+
process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const run = doc.run || {};
|
|
2292
|
+
const lines = [];
|
|
2293
|
+
lines.push('');
|
|
2294
|
+
lines.push('Headless analysis complete');
|
|
2295
|
+
lines.push(` Run ID: ${runId}`);
|
|
2296
|
+
lines.push(` Mode: ${mode}`);
|
|
2297
|
+
if (run.config_type === 'council' || run.provider === 'council') {
|
|
2298
|
+
lines.push(` Council: ${run.model || '(unknown)'}`);
|
|
2299
|
+
} else {
|
|
2300
|
+
lines.push(` Provider: ${run.provider || '(unknown)'}`);
|
|
2301
|
+
lines.push(` Model: ${run.model || '(unknown)'}`);
|
|
2302
|
+
}
|
|
2303
|
+
lines.push(` Config type: ${run.config_type || 'standard'}`);
|
|
2304
|
+
lines.push(` Status: ${run.status || '(unknown)'}`);
|
|
2305
|
+
if (run.summary) {
|
|
2306
|
+
lines.push('');
|
|
2307
|
+
lines.push('Summary:');
|
|
2308
|
+
lines.push(run.summary);
|
|
2309
|
+
}
|
|
2310
|
+
lines.push('');
|
|
2311
|
+
lines.push(`Suggestions: ${doc.count}`);
|
|
2312
|
+
for (const s of doc.suggestions) {
|
|
2313
|
+
const line = s.line_end && s.line_end !== s.line_start
|
|
2314
|
+
? `${s.line_start}-${s.line_end}`
|
|
2315
|
+
: `${s.line_start}`;
|
|
2316
|
+
const sev = s.severity ? `/${s.severity}` : '';
|
|
2317
|
+
lines.push(` ${s.file}:${line} [${s.type || 'comment'}${sev}] ${s.title || ''}`.trimEnd());
|
|
2318
|
+
}
|
|
2319
|
+
lines.push('');
|
|
2320
|
+
|
|
2321
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
2322
|
+
}
|
|
2323
|
+
|
|
1424
2324
|
/**
|
|
1425
2325
|
* Handle draft mode review workflow
|
|
1426
2326
|
* Runs AI analysis and submits draft review to GitHub without opening browser
|
|
@@ -1580,7 +2480,14 @@ function gracefulShutdown(signal) {
|
|
|
1580
2480
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1581
2481
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1582
2482
|
|
|
1583
|
-
// Handle uncaught exceptions
|
|
2483
|
+
// Handle uncaught exceptions.
|
|
2484
|
+
//
|
|
2485
|
+
// Note: in --headless --json mode, errors that ESCAPE main()'s try (floating
|
|
2486
|
+
// promises, timer/child-process callbacks) reach here and are reported as prose
|
|
2487
|
+
// on stderr, not the JSON failure envelope. The headless flow awaits its full
|
|
2488
|
+
// analysis, so this is a narrow gap; a JSON envelope here would need an
|
|
2489
|
+
// emit-once guard to avoid a second document on stdout during post-success
|
|
2490
|
+
// teardown.
|
|
1584
2491
|
process.on('uncaughtException', (error) => {
|
|
1585
2492
|
console.error('Uncaught exception:', error);
|
|
1586
2493
|
process.exit(1);
|
|
@@ -1596,4 +2503,4 @@ if (require.main === module) {
|
|
|
1596
2503
|
main();
|
|
1597
2504
|
}
|
|
1598
2505
|
|
|
1599
|
-
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList };
|
|
2506
|
+
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
|