@in-the-loop-labs/pair-review 4.0.0 → 4.1.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.
Files changed (42) hide show
  1. package/README.md +82 -7
  2. package/package.json +2 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/index.js +87 -10
  8. package/public/js/local.js +19 -9
  9. package/public/js/pr.js +64 -36
  10. package/public/js/repo-links.js +11 -3
  11. package/public/js/utils/analyze-params.js +69 -0
  12. package/public/js/utils/provider-model.js +66 -1
  13. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  14. package/public/js/vendor/pierre-diffs.js +1880 -0
  15. package/public/local.html +1 -0
  16. package/public/pr.html +1 -0
  17. package/public/setup.html +35 -16
  18. package/src/config.js +150 -28
  19. package/src/database.js +127 -3
  20. package/src/external/github-adapter.js +18 -3
  21. package/src/github/client.js +37 -0
  22. package/src/github/parser.js +41 -7
  23. package/src/interactive-analysis-config.js +2 -2
  24. package/src/links/repo-links.js +66 -28
  25. package/src/local-review.js +134 -5
  26. package/src/local-scope.js +38 -0
  27. package/src/main.js +199 -33
  28. package/src/routes/config.js +47 -12
  29. package/src/routes/external-comments.js +13 -1
  30. package/src/routes/github-collections.js +175 -13
  31. package/src/routes/local.js +11 -20
  32. package/src/routes/pr.js +63 -36
  33. package/src/routes/setup.js +63 -8
  34. package/src/routes/shared.js +85 -0
  35. package/src/routes/stack-analysis.js +39 -3
  36. package/src/server.js +74 -3
  37. package/src/setup/local-setup.js +23 -7
  38. package/src/setup/pr-setup.js +237 -39
  39. package/src/setup/stack-setup.js +7 -2
  40. package/src/single-port.js +73 -18
  41. package/src/utils/host-resolution.js +157 -0
  42. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
package/src/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
3
  const { v4: uuidv4 } = require('uuid');
4
- const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
4
+ const { loadConfig, getConfigDir, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
5
5
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository, CommentRepository, AnalysisRunRepository } = require('./database');
6
6
  const { PRArgumentParser } = require('./github/parser');
7
7
  const { GitHubClient } = require('./github/client');
@@ -20,8 +20,9 @@ const { resolveReviewConfig } = require('./review-config');
20
20
  const { redirectConsoleToStderr } = require('./mcp-stdio');
21
21
  const { getChangedFiles } = require('./routes/executable-analysis');
22
22
  const { safeParseJson } = require('./utils/safe-parse-json');
23
- const { includesBranch } = require('./local-scope');
24
- const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
23
+ const { includesBranch, parseScopeArg, VALID_SCOPE_RANGES } = require('./local-scope');
24
+ const { storePRData, resolvePrHostBinding, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
25
+ const { isDualHostRepo, resolvePreflightBinding, hostSetupParamValue } = require('./utils/host-resolution');
25
26
  const { fireReviewStartedHook } = require('./hooks/payloads');
26
27
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
27
28
  const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
@@ -190,6 +191,21 @@ OPTIONS:
190
191
  -h, --help Show this help message and exit
191
192
  -l, --local [path] Review local uncommitted changes
192
193
  Optional path defaults to current directory
194
+ --scope <start>..<end> (Local mode only) Set the diff scope for the review.
195
+ Stops, in order: branch, staged, unstaged, untracked.
196
+ The range must include 'unstaged'. Valid ranges:
197
+ branch..unstaged committed+staged+unstaged vs merge-base
198
+ branch..untracked everything vs merge-base (incl. untracked)
199
+ staged..unstaged staged + unstaged vs HEAD
200
+ staged..untracked staged + unstaged + untracked vs HEAD
201
+ unstaged..unstaged only unstaged working-tree changes
202
+ unstaged..untracked unstaged + untracked (the default)
203
+ Requires --local; cannot be used with a PR. The chosen
204
+ scope is persisted so the web UI opens with it too.
205
+ --base <branch> (Local mode only) Override base-branch detection for a
206
+ branch-relative scope. Requires --scope starting at
207
+ 'branch' (e.g. --scope branch..untracked). Defaults to
208
+ the persisted, then auto-detected, base branch.
193
209
  --mcp Start as an MCP stdio server for AI coding agents.
194
210
  The web UI also starts for the human reviewer.
195
211
  --model <name> Override the AI model. Claude Code is the default provider.
@@ -200,6 +216,13 @@ OPTIONS:
200
216
  sonnet-5-high, sonnet-4.6
201
217
  (opus is Opus 4.8 XHigh, the default)
202
218
  or use provider-specific models with Antigravity/Codex
219
+ --provider <name> Override the AI provider for headless modes
220
+ (--ai-draft / --ai-review). Defaults to the
221
+ repo/app default provider (claude). Pair with
222
+ --model when the model belongs to a non-default
223
+ provider (e.g. --provider codex --model gpt-5.5).
224
+ Available: claude, antigravity, codex, copilot,
225
+ opencode, cursor-agent, pi
203
226
  --council <handle> Run analysis with a saved council (multi-voice). Handle is a
204
227
  council name, name-slug, or id (prefix). See --list-councils.
205
228
  --list-councils List saved councils (handles to use with --council) and exit
@@ -216,6 +239,9 @@ EXAMPLES:
216
239
  pair-review 123 # Review PR #123 in current repo
217
240
  pair-review https://github.com/owner/repo/pull/456
218
241
  pair-review --local # Review uncommitted local changes
242
+ pair-review --local --scope branch..untracked # Review whole branch vs merge-base
243
+ pair-review --local --scope staged..unstaged # Review staged + unstaged changes
244
+ pair-review --local --scope branch..untracked --base develop # Branch scope vs develop
219
245
  pair-review 123 --ai # Auto-run AI analysis
220
246
  pair-review --ai-review # CI mode: auto-detect PR, submit review
221
247
  pair-review 123 --ai-draft --council security-review # Headless council draft
@@ -232,6 +258,7 @@ ENVIRONMENT VARIABLES:
232
258
  PAIR_REVIEW_ANTIGRAVITY_CMD Custom command to invoke Antigravity CLI (default: agy)
233
259
  PAIR_REVIEW_CODEX_CMD Custom command to invoke Codex CLI (default: codex)
234
260
  PAIR_REVIEW_MODEL Override the AI model (same as --model flag, default: opus)
261
+ PAIR_REVIEW_PROVIDER Override the AI provider (same as --provider flag, default: claude)
235
262
 
236
263
  CONFIGURATION:
237
264
  Config file: ~/.pair-review/config.json
@@ -395,6 +422,7 @@ const KNOWN_FLAGS = new Set([
395
422
  '-l', '--local',
396
423
  '--mcp',
397
424
  '--model',
425
+ '--provider',
398
426
  '--council',
399
427
  '--list-councils',
400
428
  '--register',
@@ -469,6 +497,14 @@ function parseArgs(args) {
469
497
  } else {
470
498
  throw new Error('--model flag requires a model name (e.g., --model sonnet)');
471
499
  }
500
+ } else if (arg === '--provider') {
501
+ // Next argument is the provider name
502
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
503
+ flags.provider = args[i + 1];
504
+ i++; // Skip next argument since we consumed it
505
+ } else {
506
+ throw new Error('--provider flag requires a provider name (e.g., --provider codex)');
507
+ }
472
508
  } else if (arg === '--council') {
473
509
  // Next argument is the council handle (name, name-slug, or id prefix)
474
510
  if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
@@ -477,6 +513,25 @@ function parseArgs(args) {
477
513
  } else {
478
514
  throw new Error('--council flag requires a council handle (e.g., --council my-council)');
479
515
  }
516
+ } else if (arg === '--scope') {
517
+ // Next argument is a scope range like `unstaged..untracked`. A range
518
+ // won't legitimately start with '-', so apply the same missing-value
519
+ // guard as --model. Range validity is checked later in main().
520
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
521
+ flags.scope = args[i + 1];
522
+ i++; // Skip next argument since we consumed it
523
+ } else {
524
+ throw new Error('--scope flag requires a range value (e.g., --scope unstaged..untracked)');
525
+ }
526
+ } else if (arg === '--base') {
527
+ // Next argument is a base branch name. A branch name won't legitimately
528
+ // start with '-', so apply the same guard as --model.
529
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
530
+ flags.base = args[i + 1];
531
+ i++; // Skip next argument since we consumed it
532
+ } else {
533
+ throw new Error('--base flag requires a branch name (e.g., --base main)');
534
+ }
480
535
  } else if (arg === '--list-councils') {
481
536
  flags.listCouncils = true;
482
537
  } else if (arg === '-l' || arg === '--local') {
@@ -515,6 +570,32 @@ function parseArgs(args) {
515
570
  return { prArgs, flags };
516
571
  }
517
572
 
573
+ /**
574
+ * Fold the PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL env vars into parsed flags.
575
+ *
576
+ * `--provider`/`--model` and their env-var twins are two channels for the same
577
+ * override, but only the flags are threaded through single-port delegation
578
+ * (`buildDelegationUrl` forwards `flags.provider`/`flags.model`). Without this
579
+ * fold, an env-only invocation (`PAIR_REVIEW_PROVIDER=codex pair-review 123
580
+ * --ai`) delegates with an empty `flags.provider` and the override is silently
581
+ * dropped at the process boundary. Normalizing env→flags once here gives every
582
+ * downstream reader — delegation, the flags→env mirror, headless resolvers — a
583
+ * single effective value. Explicit flags always win over env.
584
+ *
585
+ * @param {object} flags - Parsed CLI flags (mutated in place)
586
+ * @param {object} [env] - Environment source (defaults to process.env; injectable for tests)
587
+ * @returns {object} the same `flags`, for chaining
588
+ */
589
+ function normalizeProviderModelFlags(flags, env = process.env) {
590
+ if (!flags.provider && env.PAIR_REVIEW_PROVIDER) {
591
+ flags.provider = env.PAIR_REVIEW_PROVIDER;
592
+ }
593
+ if (!flags.model && env.PAIR_REVIEW_MODEL) {
594
+ flags.model = env.PAIR_REVIEW_MODEL;
595
+ }
596
+ return flags;
597
+ }
598
+
518
599
  /**
519
600
  * Main application entry point
520
601
  */
@@ -586,6 +667,7 @@ ENVIRONMENT VARIABLES:
586
667
  PAIR_REVIEW_ANTIGRAVITY_CMD Custom Antigravity CLI command (default: agy)
587
668
  PAIR_REVIEW_CODEX_CMD Custom Codex CLI command (default: codex)
588
669
  PAIR_REVIEW_MODEL Default AI model (e.g., opus, sonnet, haiku)
670
+ PAIR_REVIEW_PROVIDER Default AI provider (e.g., claude, antigravity, codex)
589
671
  PAIR_REVIEW_DB_NAME Custom database filename (overrides config)
590
672
 
591
673
  LOCAL CONFIG:
@@ -656,6 +738,10 @@ AI PROVIDERS:
656
738
  // single-port delegation can skip DB entirely)
657
739
  const { prArgs, flags } = parseArgs(args);
658
740
 
741
+ // Normalize env→flags before delegation so an env-only provider/model
742
+ // override rides the single-port delegation URL to the running server.
743
+ normalizeProviderModelFlags(flags);
744
+
659
745
  // Headless-mode flag validation (fail fast, before any DB/server work).
660
746
  // --instructions and --instructions-file are mutually exclusive.
661
747
  if (flags.instructions && flags.instructionsFile) {
@@ -669,6 +755,39 @@ AI PROVIDERS:
669
755
  if (flags.headless && prArgs.length === 0 && !flags.local) {
670
756
  throw new Error('--headless flag requires a pull request number/URL or --local to run analysis.');
671
757
  }
758
+ // --scope and --base are local-review diff controls. They require --local
759
+ // and cannot be combined with a PR positional. They do NOT imply --local
760
+ // (explicit flags only — no magic mode switching).
761
+ if (flags.scope || flags.base) {
762
+ if (prArgs.length > 0) {
763
+ throw new Error('--scope/--base apply to local reviews only; they cannot be combined with a pull request number/URL.');
764
+ }
765
+ if (!flags.local) {
766
+ throw new Error('--scope/--base require --local (they set the diff scope for a local review).');
767
+ }
768
+ }
769
+ // Validate the --scope range against the six accepted local-review scopes.
770
+ let parsedScopeFlag = null;
771
+ if (flags.scope) {
772
+ parsedScopeFlag = parseScopeArg(flags.scope);
773
+ if (!parsedScopeFlag) {
774
+ throw new Error(
775
+ `Invalid --scope value "${flags.scope}". Valid ranges are: ${VALID_SCOPE_RANGES.join(', ')}. ` +
776
+ "The range must be two stops joined by '..' and must include 'unstaged'."
777
+ );
778
+ }
779
+ }
780
+ // --base overrides base-branch detection and only applies to a
781
+ // branch-relative scope (one that starts at 'branch').
782
+ if (flags.base) {
783
+ if (!parsedScopeFlag || parsedScopeFlag.start !== 'branch') {
784
+ throw new Error("--base requires --scope with a branch-relative range (starting at 'branch', e.g. --scope branch..untracked).");
785
+ }
786
+ // Same validation the set-scope route uses to prevent shell injection.
787
+ if (!/^[\w.\-/]+$/.test(flags.base)) {
788
+ throw new Error(`Invalid --base branch name "${flags.base}".`);
789
+ }
790
+ }
672
791
  // --instructions[-file] only takes effect in a mode that actually runs
673
792
  // analysis. Plain interactive mode never auto-analyzes, so reject the
674
793
  // orphaned case with a clear message rather than silently dropping the text
@@ -767,6 +886,11 @@ AI PROVIDERS:
767
886
  // Skipped for: headless modes (no browser), single_port: false (dev mode).
768
887
  // --headless never delegates/opens a browser — it runs analysis locally and
769
888
  // reports to stdout/stderr.
889
+ //
890
+ // --scope/--base ARE delegated: attemptDelegation carries them on the local
891
+ // URL (buildDelegationUrl), the setup page relays them, and the setup route
892
+ // re-validates and applies them — so a scoped --local while a server is
893
+ // running lands at the requested scope instead of crashing on the busy port.
770
894
  if (config.single_port !== false && !flags.aiReview && !flags.aiDraft && !flags.headless) {
771
895
  // Carry --instructions across delegation. When an analyzing mode
772
896
  // (--ai/--council) ALSO supplies per-run instructions and a server is
@@ -954,7 +1078,12 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
954
1078
  // captured `${owner}/${repo}` for monorepo-style patterns.
955
1079
  const repositoryForBinding = prInfo.bindingRepository
956
1080
  || normalizeRepository(prInfo.owner, prInfo.repo);
957
- const binding = resolveHostBinding(repositoryForBinding, config);
1081
+ // A pasted URL tells us the host up front (alt-host `url_pattern` match →
1082
+ // api_host string; github/graphite URL → null), so preflight the token
1083
+ // against that host. A bare number leaves host undefined → ambiguity rule,
1084
+ // but a dual repo with only an alt token is still usable (the setup probe
1085
+ // tries the alt host first) — resolvePreflightBinding tolerates that.
1086
+ const binding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
958
1087
  if (!binding.token) {
959
1088
  throw new Error(buildMissingTokenError({
960
1089
  owner: prInfo.owner,
@@ -977,6 +1106,13 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
977
1106
  process.env.PAIR_REVIEW_MODEL = flags.model;
978
1107
  }
979
1108
 
1109
+ // Set provider override if provided via CLI flag. Mirrored into the env
1110
+ // var so the web/UI analysis paths (which read PAIR_REVIEW_PROVIDER via
1111
+ // getProvider()) honor it too, matching how --model works.
1112
+ if (flags.provider) {
1113
+ process.env.PAIR_REVIEW_PROVIDER = flags.provider;
1114
+ }
1115
+
980
1116
  // Resolve the council handle FAIL-FAST before starting the server, so a bad
981
1117
  // handle surfaces as a clean error (caught below) instead of after the
982
1118
  // browser has opened.
@@ -1011,6 +1147,15 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
1011
1147
  url += `&council=${councilSelection.id}`;
1012
1148
  }
1013
1149
 
1150
+ // Thread the pasted URL's host to setup so it binds directly instead of
1151
+ // probing (shared mapping — see hostSetupParamValue): an alt-host string
1152
+ // forwards the api_host; a github URL on a DUAL repo forwards the 'github'
1153
+ // sentinel; bare numbers / plain / exclusive repos omit it.
1154
+ const hostParam = hostSetupParamValue(prInfo.host, isDualHostRepo(config, repositoryForBinding));
1155
+ if (hostParam) {
1156
+ url += (url.includes('?') ? '&' : '?') + `host=${encodeURIComponent(hostParam)}`;
1157
+ }
1158
+
1014
1159
  console.log(`Opening browser to: ${url}`);
1015
1160
  await open(url);
1016
1161
 
@@ -1107,29 +1252,34 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
1107
1252
  // serves many monorepo-shaped URLs.
1108
1253
  const repositoryForBinding = prInfo.bindingRepository
1109
1254
  || normalizeRepository(prInfo.owner, prInfo.repo);
1110
- const headlessBinding = resolveHostBinding(repositoryForBinding, config);
1111
- if (!headlessBinding.token) {
1255
+ // Preflight the token so a missing credential fails fast before any network
1256
+ // work. Tolerates a dual repo with only an alt token when the host is
1257
+ // unknown (the setup probe tries the alt host first).
1258
+ const preflightBinding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
1259
+ if (!preflightBinding.token) {
1112
1260
  throw new Error(buildMissingTokenError({
1113
1261
  owner: prInfo.owner,
1114
1262
  repo: prInfo.repo,
1115
1263
  bindingRepository: repositoryForBinding,
1116
- apiHost: headlessBinding.apiHost
1264
+ apiHost: preflightBinding.apiHost
1117
1265
  }));
1118
1266
  }
1119
- const githubToken = headlessBinding.token;
1120
1267
 
1121
1268
  console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in ${options.modeLabel}`);
1122
1269
 
1123
- // Create GitHub client and verify repository access
1124
- const githubClient = new GitHubClient(headlessBinding);
1125
- const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
1126
- if (!repoExists) {
1127
- throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
1128
- }
1129
-
1130
- // Fetch PR data from GitHub
1270
+ // Resolve the per-PR host binding and fetch the PR. Applies host precedence
1271
+ // (URL-paste host stored host → ambiguity/probe for dual repos) and
1272
+ // returns the chosen binding so its host is stamped by storePRData below.
1131
1273
  console.log('Fetching pull request data from GitHub...');
1132
- const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
1274
+ const { binding: headlessBinding, prData, githubClient } = await resolvePrHostBinding({
1275
+ db, config, bindingRepository: repositoryForBinding,
1276
+ owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number,
1277
+ // Only forward a github.com token as the github fallback; if the preflight
1278
+ // resolved an ALT binding (dual repo, alt-only token), there is no github
1279
+ // fallback token, so pass '' rather than sending the alt token to github.com.
1280
+ host: prInfo.host, githubToken: preflightBinding.apiHost === null ? preflightBinding.token : ''
1281
+ });
1282
+ const githubToken = headlessBinding.token || preflightBinding.token;
1133
1283
 
1134
1284
  let worktreePath;
1135
1285
  let diff;
@@ -1290,7 +1440,8 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
1290
1440
  // Store PR data in database
1291
1441
  console.log('Storing pull request data...');
1292
1442
  const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
1293
- skipWorktreeRecord: !!flags.useCheckout
1443
+ skipWorktreeRecord: !!flags.useCheckout,
1444
+ host: headlessBinding.host
1294
1445
  });
1295
1446
 
1296
1447
  // Persist review→worktree mapping in DB for pool usage tracking
@@ -1340,10 +1491,13 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
1340
1491
  // The early councilSelection above already fail-fast validated an explicit
1341
1492
  // --council; resolveReviewConfig re-resolves the same handle (cheap in-memory
1342
1493
  // match) and unifies single/council selection with the headless path.
1494
+ // --provider (flags.provider) rides in as an explicit single-model pick; the
1495
+ // resolver's ladder (explicit › PAIR_REVIEW_PROVIDER › repo › config › 'claude')
1496
+ // delivers the CLI override that commit 662e introduced.
1343
1497
  const reviewConfig = await resolveReviewConfig(
1344
1498
  db,
1345
1499
  repository,
1346
- { council: flags.council, model: flags.model },
1500
+ { council: flags.council, provider: flags.provider, model: flags.model },
1347
1501
  config
1348
1502
  );
1349
1503
 
@@ -1736,26 +1890,30 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
1736
1890
 
1737
1891
  const repositoryForBinding = prInfo.bindingRepository
1738
1892
  || normalizeRepository(prInfo.owner, prInfo.repo);
1739
- const headlessBinding = resolveHostBinding(repositoryForBinding, config);
1740
- if (!headlessBinding.token) {
1893
+ // Preflight the token so a missing credential fails fast before any network
1894
+ // work. Tolerates a dual repo with only an alt token when the host is unknown
1895
+ // (the setup probe tries the alt host first).
1896
+ const preflightBinding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
1897
+ if (!preflightBinding.token) {
1741
1898
  throw new Error(buildMissingTokenError({
1742
1899
  owner: prInfo.owner,
1743
1900
  repo: prInfo.repo,
1744
1901
  bindingRepository: repositoryForBinding,
1745
- apiHost: headlessBinding.apiHost
1902
+ apiHost: preflightBinding.apiHost
1746
1903
  }));
1747
1904
  }
1748
1905
 
1749
1906
  console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in headless mode`);
1750
1907
 
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
-
1908
+ // Resolve the per-PR host binding and fetch (host precedence: URL-paste host
1909
+ // stored host ambiguity/probe for dual repos).
1757
1910
  console.log('Fetching pull request data from GitHub...');
1758
- const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
1911
+ const { binding: headlessBinding, prData, githubClient } = await resolvePrHostBinding({
1912
+ db, config, bindingRepository: repositoryForBinding,
1913
+ owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number,
1914
+ // Only forward a github.com token as the github fallback (see performHeadlessReview).
1915
+ host: prInfo.host, githubToken: preflightBinding.apiHost === null ? preflightBinding.token : ''
1916
+ });
1759
1917
 
1760
1918
  let worktreePath;
1761
1919
  let diff;
@@ -1903,7 +2061,8 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
1903
2061
 
1904
2062
  console.log('Storing pull request data...');
1905
2063
  const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
1906
- skipWorktreeRecord: !!flags.useCheckout
2064
+ skipWorktreeRecord: !!flags.useCheckout,
2065
+ host: headlessBinding.host
1907
2066
  });
1908
2067
 
1909
2068
  if (poolWorktreeId && poolLifecycle) {
@@ -1934,10 +2093,17 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
1934
2093
  const repoSettingsRepo = new RepoSettingsRepository(db);
1935
2094
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1936
2095
 
2096
+ // Thread --provider (flags.provider) through as an explicit single-model pick,
2097
+ // exactly as the PR-submit path at the top of the file and the local headless
2098
+ // path do. handleHeadlessAnalysis dispatches BEFORE the local/PR split (and thus
2099
+ // before handlePullRequest's PAIR_REVIEW_PROVIDER env mirror), so omitting it
2100
+ // here would let `pair-review <pr> --headless --provider <p>` silently fall
2101
+ // through to the default provider (or, with a repo default council, switch to
2102
+ // council mode) — while --model in the same invocation was honored.
1937
2103
  const reviewConfig = await resolveReviewConfig(
1938
2104
  db,
1939
2105
  repository,
1940
- { council: flags.council, model: flags.model },
2106
+ { council: flags.council, provider: flags.provider, model: flags.model },
1941
2107
  config
1942
2108
  );
1943
2109
 
@@ -2113,7 +2279,7 @@ async function handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle)
2113
2279
  const reviewConfig = await resolveReviewConfig(
2114
2280
  db,
2115
2281
  repository,
2116
- { council: flags.council, model: flags.model },
2282
+ { council: flags.council, provider: flags.provider, model: flags.model },
2117
2283
  config
2118
2284
  );
2119
2285
  const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
@@ -2503,4 +2669,4 @@ if (require.main === module) {
2503
2669
  main();
2504
2670
  }
2505
2671
 
2506
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
2672
+ module.exports = { main, parseArgs, normalizeProviderModelFlags, detectPRFromGitHubEnvironment, printCouncilList, handleHeadlessAnalysis, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  const express = require('express');
13
- const { RepoSettingsRepository, ReviewRepository, queryOne } = require('../database');
13
+ const { RepoSettingsRepository, ReviewRepository, PRMetadataRepository, queryOne } = require('../database');
14
14
  const {
15
15
  getAllProvidersInfo,
16
16
  testProviderAvailability,
@@ -21,12 +21,12 @@ const {
21
21
  modelMatches
22
22
  } = require('../ai');
23
23
  const { normalizeRepository } = require('../utils/paths');
24
+ const { resolvePreflightBinding } = require('../utils/host-resolution');
24
25
  const {
25
26
  isRunningViaNpx,
26
27
  getGitHubToken,
27
28
  getDefaultProvider,
28
29
  getDefaultModel,
29
- resolveHostBinding,
30
30
  resolveBindingRepositoryFromPR,
31
31
  getSummaryEnabled,
32
32
  getSummaryAutoGenerate,
@@ -82,10 +82,11 @@ router.get('/runtime-config.js', (req, res) => {
82
82
  * - has_global_github_token: always present. True iff `getGitHubToken(config)`
83
83
  * resolves a token from the top-level config / env (no repo context).
84
84
  * - has_github_token: present ONLY when both ?owner and ?repo query
85
- * parameters are supplied. True iff a token can be resolved for that
86
- * specific repository via `resolveHostBinding(repo, config)` — this
87
- * covers repo-scoped `token`, `token_command`, alt-host bindings, AND
88
- * falls through to the global lookup. Callers that know which repo
85
+ * parameters are supplied. True iff ANY usable binding for that specific
86
+ * repository resolves a token (`resolvePreflightBinding`) — this covers
87
+ * repo-scoped `token`, `token_command`, alt-host bindings, a DUAL repo whose
88
+ * only credential is the alt-host token, AND falls through to the global
89
+ * lookup. Callers that know which repo
89
90
  * they're rendering should pass these params so that repo-scoped
90
91
  * authentication is reflected accurately (e.g. for deciding whether
91
92
  * to enable GitHub-comment dedup). When the params are absent, the
@@ -146,10 +147,14 @@ router.get('/api/config', (req, res) => {
146
147
  let hasRepoGithubToken = null;
147
148
  if (hasRepoContext) {
148
149
  const repository = `${owner}/${repo}`;
149
- // resolveHostBinding already falls through to top-level config when
150
- // no repo-scoped token is configured, so this is a true union of
151
- // "repo-scoped binding works" OR "global token works".
152
- hasRepoGithubToken = Boolean(resolveHostBinding(repository, config).token);
150
+ // "Has any usable binding" semantics. resolveHostBinding already falls
151
+ // through to top-level config when no repo-scoped token is configured, so
152
+ // the no-host lookup unions "repo-scoped binding works" OR "global token
153
+ // works". But for a DUAL repo the no-host lookup uses the github ambiguity
154
+ // binding, which misses a repo whose only credential is the alt-host token —
155
+ // resolvePreflightBinding additionally tries the alt candidate, so an
156
+ // alt-only dual repo reports true (matching what setup/probe will do).
157
+ hasRepoGithubToken = Boolean(resolvePreflightBinding(repository, config, undefined).token);
153
158
  }
154
159
 
155
160
  // Only return safe configuration values (not secrets like github_token)
@@ -163,6 +168,16 @@ router.get('/api/config', (req, res) => {
163
168
  comment_format: config.comment_format || 'legacy',
164
169
  default_provider: defaultPair.provider,
165
170
  default_model: defaultPair.model,
171
+ // CLI/env override (PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL, set by
172
+ // `--provider` / `--model`). Surfaced as a DEDICATED signal — not folded
173
+ // into default_provider/default_model above — because every frontend seed
174
+ // site resolves `resolveProviderModelPair([repoSettings, appConfig])` with
175
+ // repo settings FIRST. Folding the override into appConfig would still lose
176
+ // to a repo's saved default, violating the documented `CLI/env > repo
177
+ // settings` contract. The frontend prepends this override ahead of
178
+ // repoSettings via buildProviderModelScopes(). null when unset.
179
+ provider_override: process.env.PAIR_REVIEW_PROVIDER || null,
180
+ model_override: process.env.PAIR_REVIEW_MODEL || null,
166
181
  // Include npx detection for frontend command examples
167
182
  is_running_via_npx: isRunningViaNpx(),
168
183
  enable_chat: config.enable_chat !== false,
@@ -301,8 +316,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
301
316
  * `javascript:` URLs stripped). The url_template is NOT substituted here —
302
317
  * the frontend has the live PR/branch context, so it performs the
303
318
  * whitelisted substitution at render time.
319
+ *
320
+ * Optional `?number=<n>` query: the PR number the header is being rendered
321
+ * for. For a dual-host repo (`api_host` + `exclusive: false`) the link set
322
+ * depends on which system the PR lives on, so we look up the PR's stored host
323
+ * and resolve links against it. Omitted (Local mode, plain/exclusive repos)
324
+ * or an unknown PR → repo-level defaults, byte-identical to before.
304
325
  */
305
- router.get('/api/repos/:owner/:repo/links', (req, res) => {
326
+ router.get('/api/repos/:owner/:repo/links', async (req, res) => {
306
327
  try {
307
328
  const { owner, repo } = req.params;
308
329
  const repository = normalizeRepository(owner, repo);
@@ -311,7 +332,21 @@ router.get('/api/repos/:owner/:repo/links', (req, res) => {
311
332
  // `repos[...]` entry serving many captured owner/repo via
312
333
  // url_pattern) surface the right link config.
313
334
  const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
314
- const links = resolveRepoLinks(config, bindingRepository);
335
+
336
+ // Per-PR host awareness: when the caller names a PR, look up its stored
337
+ // host so a dual-host repo shows the correct link set (github links for a
338
+ // github-hosted PR, the external link for an alt-hosted one). A missing
339
+ // row leaves `host` undefined, which resolveRepoLinks treats as the
340
+ // repo-level default — so non-dual repos are unaffected regardless.
341
+ let host;
342
+ const prNumber = parseInt(req.query.number, 10);
343
+ if (Number.isInteger(prNumber) && prNumber > 0) {
344
+ const prMetadataRepo = new PRMetadataRepository(req.app.get('db'));
345
+ const storedHost = await prMetadataRepo.getPRHost(repository, prNumber);
346
+ if (storedHost !== undefined) host = storedHost;
347
+ }
348
+
349
+ const links = resolveRepoLinks(config, bindingRepository, host);
315
350
  res.json({ repository, links });
316
351
  } catch (error) {
317
352
  logger.error('Error resolving repo links:', error);
@@ -20,6 +20,7 @@ const express = require('express');
20
20
  const {
21
21
  ExternalCommentRepository,
22
22
  ReviewRepository,
23
+ PRMetadataRepository,
23
24
  withTransaction
24
25
  } = require('../database');
25
26
  const { getAdapter } = require('../external');
@@ -135,6 +136,17 @@ async function executeSync({ db, config, review, source, _deps }) {
135
136
  );
136
137
  }
137
138
 
139
+ // Look up the PR's stored host so a DUAL repo's alt-hosted PR binds to the
140
+ // alt host (and its line-based anchoring path) rather than api.github.com.
141
+ // Pass the raw stored value through to the adapter, which applies the
142
+ // legacy-NULL convention against its resolved binding key. `undefined`
143
+ // (no row / unknown) preserves the two-arg ambiguity behaviour.
144
+ let storedHost;
145
+ if (Number.isInteger(review.pr_number)) {
146
+ const prMetadataRepo = new PRMetadataRepository(db);
147
+ storedHost = await prMetadataRepo.getPRHost(review.repository, review.pr_number);
148
+ }
149
+
138
150
  // Delegate credential resolution to the adapter so the route stays
139
151
  // source-agnostic and each adapter can name its own env var in errors.
140
152
  // Thread `review.repository` through so the adapter resolves the
@@ -145,7 +157,7 @@ async function executeSync({ db, config, review, source, _deps }) {
145
157
  // `isAltHost` reflects whether the resolved binding targets an alternate
146
158
  // Git host. Alt-hosts don't implement GitHub's deprecated `position`
147
159
  // field, so it drives line-based anchoring in `mapComment` below.
148
- const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps);
160
+ const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps, { storedHost });
149
161
 
150
162
  const apiRows = await adapter.fetchComments({
151
163
  client,