@in-the-loop-labs/pair-review 4.0.0 → 4.1.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.
Files changed (43) 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/ai/codex-provider.js +59 -11
  19. package/src/config.js +150 -28
  20. package/src/database.js +127 -3
  21. package/src/external/github-adapter.js +18 -3
  22. package/src/github/client.js +37 -0
  23. package/src/github/parser.js +41 -7
  24. package/src/interactive-analysis-config.js +2 -2
  25. package/src/links/repo-links.js +66 -28
  26. package/src/local-review.js +134 -5
  27. package/src/local-scope.js +38 -0
  28. package/src/main.js +199 -33
  29. package/src/routes/config.js +47 -12
  30. package/src/routes/external-comments.js +13 -1
  31. package/src/routes/github-collections.js +175 -13
  32. package/src/routes/local.js +11 -20
  33. package/src/routes/pr.js +63 -36
  34. package/src/routes/setup.js +63 -8
  35. package/src/routes/shared.js +85 -0
  36. package/src/routes/stack-analysis.js +39 -3
  37. package/src/server.js +74 -3
  38. package/src/setup/local-setup.js +23 -7
  39. package/src/setup/pr-setup.js +237 -39
  40. package/src/setup/stack-setup.js +7 -2
  41. package/src/single-port.js +73 -18
  42. package/src/utils/host-resolution.js +157 -0
  43. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
package/public/local.html CHANGED
@@ -591,6 +591,7 @@
591
591
  <!-- Shared storage-key and provider/model helpers -->
592
592
  <script src="/js/utils/storage-keys.js"></script>
593
593
  <script src="/js/utils/provider-model.js"></script>
594
+ <script src="/js/utils/analyze-params.js"></script>
594
595
 
595
596
  <!-- Category emoji mapping -->
596
597
  <script src="/js/utils/category-emoji.js"></script>
package/public/pr.html CHANGED
@@ -394,6 +394,7 @@
394
394
  <!-- Shared storage-key and provider/model helpers -->
395
395
  <script src="/js/utils/storage-keys.js"></script>
396
396
  <script src="/js/utils/provider-model.js"></script>
397
+ <script src="/js/utils/analyze-params.js"></script>
397
398
 
398
399
  <!-- Category emoji mapping -->
399
400
  <script src="/js/utils/category-emoji.js"></script>
package/public/setup.html CHANGED
@@ -530,6 +530,7 @@
530
530
  <script src="/js/ws-client.js"></script>
531
531
  <script src="/js/components/UpdateBanner.js"></script>
532
532
  <script src="/js/utils/notification-sounds.js"></script>
533
+ <script src="/js/utils/analyze-params.js"></script>
533
534
 
534
535
  <script>
535
536
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
@@ -611,6 +612,14 @@
611
612
  context.path = params.get('path');
612
613
  }
613
614
  }
615
+ // Forward a delegated --scope/--base selection (the CLI carries them
616
+ // on the /local?path=... URL when delegating to a running server).
617
+ // The server re-validates; this only relays the request.
618
+ if (mode === 'local') {
619
+ const scopeParams = new URLSearchParams(window.location.search);
620
+ if (scopeParams.get('scope')) context.scope = scopeParams.get('scope');
621
+ if (scopeParams.get('base')) context.base = scopeParams.get('base');
622
+ }
614
623
 
615
624
  const steps = mode === 'local' ? LOCAL_STEPS : PR_STEPS;
616
625
  const stepStates = {};
@@ -751,7 +760,23 @@
751
760
  var fetchOptions = { method: 'POST' };
752
761
  if (mode === 'local') {
753
762
  fetchOptions.headers = { 'Content-Type': 'application/json' };
754
- fetchOptions.body = JSON.stringify({ path: context.path });
763
+ var localBody = { path: context.path };
764
+ if (context.scope) localBody.scope = context.scope;
765
+ if (context.base) localBody.base = context.base;
766
+ fetchOptions.body = JSON.stringify(localBody);
767
+ } else if (mode === 'pr') {
768
+ // A `host` query param (set when opening a PR from a dashboard
769
+ // collection row or a pasted URL) tells setup which system the
770
+ // PR lives on, so the fetch binds there directly instead of
771
+ // probing. Sentinel: the literal "github" means github.com and
772
+ // maps to body host null; any other value is an api_host URL
773
+ // string (alt host) — an api_host is always a URL, so "github"
774
+ // is unambiguous. Absent = unknown; the server derives the host.
775
+ var setupHost = new URLSearchParams(window.location.search).get('host');
776
+ if (setupHost) {
777
+ fetchOptions.headers = { 'Content-Type': 'application/json' };
778
+ fetchOptions.body = JSON.stringify({ host: setupHost === 'github' ? null : setupHost });
779
+ }
755
780
  }
756
781
 
757
782
  var response = await fetch(postUrl, fetchOptions);
@@ -764,16 +789,13 @@
764
789
 
765
790
  var data = await response.json();
766
791
 
767
- // If the review already exists, redirect immediately
792
+ // If the review already exists, redirect immediately.
793
+ // Relay the full auto-analyze intent bundle (analyze /
794
+ // analysisConfigId / council / provider / model) so a delegated
795
+ // override or CLI council survives this hop to the review page.
768
796
  if (data.existing && data.reviewUrl) {
769
797
  var targetUrl = new URL(data.reviewUrl, window.location.origin);
770
- var qs = new URLSearchParams(window.location.search);
771
- if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
772
- if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
773
- // Forward the CLI-supplied council selection (PR & local cold-start
774
- // and delegated paths route through here); the review page keys
775
- // council auto-analysis on this param.
776
- if (qs.get('council')) targetUrl.searchParams.set('council', qs.get('council'));
798
+ window.carryAnalyzeParams(window.location.search, targetUrl);
777
799
  window.location.href = targetUrl.toString();
778
800
  return;
779
801
  }
@@ -832,13 +854,10 @@
832
854
  setTimeout(function() {
833
855
  if (msg.reviewUrl) {
834
856
  var targetUrl = new URL(msg.reviewUrl, window.location.origin);
835
- var qs = new URLSearchParams(window.location.search);
836
- if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
837
- if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
838
- // Forward the CLI-supplied council selection (PR & local cold-start
839
- // and delegated paths route through here); the review page keys
840
- // council auto-analysis on this param.
841
- if (qs.get('council')) targetUrl.searchParams.set('council', qs.get('council'));
857
+ // Relay the full auto-analyze intent bundle (analyze /
858
+ // analysisConfigId / council / provider / model) so a
859
+ // delegated override or CLI council survives the hop.
860
+ window.carryAnalyzeParams(window.location.search, targetUrl);
842
861
  window.location.href = targetUrl.toString();
843
862
  }
844
863
  }, 400);
@@ -21,14 +21,18 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  /**
22
22
  * Codex model definitions with tier mappings
23
23
  *
24
- * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
24
+ * Based on OpenAI's GPT-5.6 launch and Models guide
25
+ * (openai.com/index/gpt-5-6 and developers.openai.com/api/docs/models)
26
+ * - gpt-5.6-sol: Flagship frontier model for complex professional work
27
+ * - gpt-5.6-terra: Balances intelligence and cost for everyday work
28
+ * - gpt-5.6-luna: Fast, affordable model for cost-sensitive, high-volume work
25
29
  * - gpt-5.4-nano: Cheapest model ($0.20/$1.25 per MTok), good for surface scans
26
30
  * - gpt-5.4-mini: Fast with 400k context ($0.75/$4.50 per MTok)
27
31
  * - gpt-5.3-codex: Industry-leading coding model for complex engineering tasks
28
- * - gpt-5.4 / gpt-5.5: Exposed only via -high / -xhigh reasoning variants so the
29
- * selected effort level is always explicit.
32
+ * - GPT-5.6, gpt-5.4, and gpt-5.5 models are exposed only through the requested
33
+ * explicit reasoning-effort variants.
30
34
  *
31
- * Reasoning-effort variants (-high / -xhigh) use `cli_model` to pass the base
35
+ * Reasoning-effort variants (-high / -xhigh / -max) use `cli_model` to pass the base
32
36
  * model ID to `codex exec -m` and add `-c model_reasoning_effort="..."` via
33
37
  * extra_args so Codex picks up the effort level through its config override.
34
38
  *
@@ -36,17 +40,61 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
36
40
  */
37
41
  const CODEX_MODELS = [
38
42
  {
39
- id: 'gpt-5.5-high',
40
- cli_model: 'gpt-5.5',
43
+ id: 'gpt-5.6-sol-high',
44
+ cli_model: 'gpt-5.6-sol',
41
45
  extra_args: ['-c', 'model_reasoning_effort="high"'],
42
- name: 'GPT-5.5 High',
46
+ name: 'GPT-5.6 Sol High',
43
47
  tier: 'thorough',
44
- tagline: 'Latest Deep',
45
- description: 'Latest-generation GPT model with high reasoning effort for demanding PR reviews, strong code understanding, and careful cross-file analysis.',
48
+ tagline: 'Frontier Review',
49
+ description: 'OpenAI flagship and best coding model yet, with high reasoning effort for demanding PR reviews, complex professional work, and cross-file analysis.',
46
50
  badge: 'Recommended',
47
51
  badgeClass: 'badge-recommended',
48
52
  default: true
49
53
  },
54
+ {
55
+ id: 'gpt-5.6-sol-xhigh',
56
+ cli_model: 'gpt-5.6-sol',
57
+ extra_args: ['-c', 'model_reasoning_effort="xhigh"'],
58
+ name: 'GPT-5.6 Sol XHigh',
59
+ tier: 'thorough',
60
+ tagline: 'Frontier Depth',
61
+ description: 'GPT-5.6 Sol with extra-high reasoning effort for difficult architectural reviews, subtle regressions, and security-sensitive changes.',
62
+ badge: 'Extra High',
63
+ badgeClass: 'badge-power'
64
+ },
65
+ {
66
+ id: 'gpt-5.6-terra-xhigh',
67
+ cli_model: 'gpt-5.6-terra',
68
+ extra_args: ['-c', 'model_reasoning_effort="xhigh"'],
69
+ name: 'GPT-5.6 Terra XHigh',
70
+ tier: 'balanced',
71
+ tagline: 'Intelligence & Value',
72
+ description: 'GPT-5.6 balanced model with extra-high reasoning effort, combining strong intelligence and lower cost for careful everyday PR reviews.',
73
+ badge: 'Best Balance',
74
+ badgeClass: 'badge-balanced'
75
+ },
76
+ {
77
+ id: 'gpt-5.6-luna-max',
78
+ cli_model: 'gpt-5.6-luna',
79
+ extra_args: ['-c', 'model_reasoning_effort="max"'],
80
+ name: 'GPT-5.6 Luna Max',
81
+ tier: 'balanced',
82
+ tagline: 'High-Volume Value',
83
+ description: 'GPT-5.6 fastest, most cost-efficient model with max reasoning effort for high-volume reviews and broad codebase scans.',
84
+ badge: 'Lowest Cost',
85
+ badgeClass: 'badge-speed'
86
+ },
87
+ {
88
+ id: 'gpt-5.5-high',
89
+ cli_model: 'gpt-5.5',
90
+ extra_args: ['-c', 'model_reasoning_effort="high"'],
91
+ name: 'GPT-5.5 High',
92
+ tier: 'thorough',
93
+ tagline: 'Previous Flagship',
94
+ description: 'Previous-generation GPT model with high reasoning effort for demanding PR reviews, strong code understanding, and careful cross-file analysis.',
95
+ badge: 'Previous Gen',
96
+ badgeClass: 'badge-power'
97
+ },
50
98
  {
51
99
  id: 'gpt-5.5-xhigh',
52
100
  cli_model: 'gpt-5.5',
@@ -122,7 +170,7 @@ class CodexProvider extends AIProvider {
122
170
  * @param {Object} configOverrides.env - Additional environment variables
123
171
  * @param {Object[]} configOverrides.models - Custom model definitions
124
172
  */
125
- constructor(model = 'gpt-5.5-high', configOverrides = {}) {
173
+ constructor(model = 'gpt-5.6-sol-high', configOverrides = {}) {
126
174
  super(model);
127
175
 
128
176
  // Command precedence: ENV > config > default
@@ -845,7 +893,7 @@ class CodexProvider extends AIProvider {
845
893
  }
846
894
 
847
895
  static getDefaultModel() {
848
- return 'gpt-5.5-high';
896
+ return 'gpt-5.6-sol-high';
849
897
  }
850
898
 
851
899
  static getInstallInstructions() {
package/src/config.js CHANGED
@@ -534,26 +534,61 @@ function _resolveFeatures(apiHost, explicit) {
534
534
  return out;
535
535
  }
536
536
 
537
+ /**
538
+ * Whether a repo config describes an *exclusive* alt-host repo — one whose
539
+ * every PR lives on the configured `api_host` and which has no github.com
540
+ * presence. True iff `api_host` is a non-empty string AND `exclusive` is not
541
+ * explicitly `false`. Omitting `exclusive` therefore preserves today's
542
+ * behaviour (an `api_host` repo is alt-host-only). Setting `exclusive: false`
543
+ * marks a *dual* repo whose PRs may live on github.com OR the alt host.
544
+ *
545
+ * @param {Object|null|undefined} repoConfig - A single `repos[...]` entry
546
+ * @returns {boolean}
547
+ */
548
+ function isExclusiveAltHost(repoConfig) {
549
+ if (!repoConfig || typeof repoConfig !== 'object') return false;
550
+ const apiHost = typeof repoConfig.api_host === 'string' && repoConfig.api_host;
551
+ if (!apiHost) return false;
552
+ return repoConfig.exclusive !== false;
553
+ }
554
+
537
555
  /**
538
556
  * Resolves the host binding for a given repository. The binding describes
539
557
  * which API host pair-review should talk to, the token to authenticate
540
558
  * with, and per-area dispatch flags.
541
559
  *
542
- * Token resolution priority for a repo with no `api_host` (github.com):
560
+ * The optional `options.host` selects the binding *flavor* for repos that can
561
+ * live on more than one host (dual repos, `exclusive: false`):
562
+ * - `undefined` (or no options) — legacy + ambiguity rule: an EXCLUSIVE
563
+ * alt-host repo binds to its alt host (today's behaviour); a DUAL repo or
564
+ * a plain github.com repo binds to github.com.
565
+ * - `null` — force a github.com binding. For an EXCLUSIVE alt-host repo this
566
+ * is a caller bug (that repo has no github.com presence) and throws.
567
+ * - `'<url>'` — force an alt-host binding; the string must equal the repo's
568
+ * configured `api_host`, otherwise the stored host no longer matches
569
+ * config and this throws.
570
+ *
571
+ * Token resolution priority for a github.com (github-flavored) binding of a
572
+ * plain repo:
543
573
  * 1. GITHUB_TOKEN environment variable
544
574
  * 2. repo-level `token`
545
575
  * 3. repo-level `token_command` (cached per (repo, command))
546
576
  * 4. top-level `github_token`
547
577
  * 5. top-level `github_token_command` (cached per (repo, command))
548
578
  *
549
- * For a repo with an `api_host` (alt-host), the github.com top-level
550
- * credentials are NOT used — `GITHUB_TOKEN`, `config.github_token`, and
551
- * `config.github_token_command` are all github.com-only and would be
552
- * the wrong token for an alt-host endpoint. Only the repo-scoped
553
- * `token` / `token_command` keys are consulted; missing those, the
554
- * lookup returns an empty token so the caller can surface a clear
579
+ * For an alt-host binding, the github.com top-level credentials are NOT used —
580
+ * `GITHUB_TOKEN`, `config.github_token`, and `config.github_token_command` are
581
+ * all github.com-only and would be the wrong token for an alt-host endpoint.
582
+ * Only the repo-scoped `token` / `token_command` keys are consulted; missing
583
+ * those, the lookup returns an empty token so the caller can surface a clear
555
584
  * "missing credential" error.
556
585
  *
586
+ * For the github.com binding of a DUAL repo, the reverse holds: the repo-scoped
587
+ * `token` / `token_command` are alt-host credentials and are NOT used — only
588
+ * the top-level github.com chain (env → `github_token` → `github_token_command`)
589
+ * is consulted. The repo's explicit `features` block (written for the alt host)
590
+ * also does not apply to its github.com binding.
591
+ *
557
592
  * Refreshable sources (`repo:token_command`, `config:github_token_command`)
558
593
  * additionally carry a `refresh` closure on the returned binding. Calling
559
594
  * `refresh()` busts the cached token for that exact source, re-runs the
@@ -564,58 +599,101 @@ function _resolveFeatures(apiHost, explicit) {
564
599
  *
565
600
  * @param {string|null|undefined} repository - "owner/repo" identifier, or null/undefined for no-repo fallback
566
601
  * @param {Object} config - Configuration object from loadConfig()
567
- * @returns {{ apiHost: string|null, token: string, features: Object, source: string, refresh: (function(): string)|null }}
602
+ * @param {{ host?: string|null }} [options] - Per-PR host override (see above)
603
+ * @returns {{ apiHost: string|null, host: string|null, token: string, features: Object, source: string, refresh: (function(): string)|null }}
568
604
  */
569
- function resolveHostBinding(repository, config) {
605
+ function resolveHostBinding(repository, config, options = {}) {
570
606
  const safeConfig = config || {};
571
607
  const repoConfig = repository ? getRepoConfig(safeConfig, repository) : null;
572
- const apiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
608
+ const configuredApiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
573
609
  ? repoConfig.api_host
574
610
  : null;
575
- const features = _resolveFeatures(apiHost, repoConfig?.features);
611
+ const requestedHost = options ? options.host : undefined;
612
+ const exclusive = isExclusiveAltHost(repoConfig);
613
+
614
+ // Decide the binding flavor from the requested host + repo config.
615
+ // `apiHost` null → github.com binding; non-null → alt-host binding.
616
+ let apiHost;
617
+ if (requestedHost === undefined) {
618
+ // Ambiguity rule: exclusive alt-host repo → alt binding (legacy);
619
+ // dual repo and plain github repo → github binding.
620
+ apiHost = exclusive ? configuredApiHost : null;
621
+ } else if (requestedHost === null) {
622
+ if (exclusive) {
623
+ throw new Error(
624
+ `resolveHostBinding: repository "${repository}" is an exclusive alt-host repo (api_host "${configuredApiHost}") and has no github.com presence, but a github.com binding was requested (host=null). This is a caller bug; pass the api_host string, or set "exclusive": false on the repo config.`
625
+ );
626
+ }
627
+ apiHost = null;
628
+ } else {
629
+ // A specific alt host was requested; it must match this repo's config.
630
+ if (requestedHost !== configuredApiHost) {
631
+ throw new Error(
632
+ `resolveHostBinding: requested host "${requestedHost}" for repository "${repository}" does not match its configured api_host (${configuredApiHost === null ? 'none' : `"${configuredApiHost}"`}). The stored host no longer matches config — re-open the PR from a URL to re-resolve its host.`
633
+ );
634
+ }
635
+ apiHost = configuredApiHost;
636
+ }
637
+
638
+ // Binding flavor booleans:
639
+ // - alt binding uses ONLY repo-scoped credentials + the repo's features.
640
+ // - github binding uses ONLY the top-level github.com chain.
641
+ // - the github binding of a DUAL repo must skip the repo-scoped
642
+ // credentials/features (they belong to the alt host).
643
+ const isAltBinding = apiHost !== null;
644
+ const isDualGithubBinding = !isAltBinding && configuredApiHost !== null;
645
+ const useRepoScopedToken = !isDualGithubBinding;
646
+ const useGithubChain = !isAltBinding;
647
+
648
+ // A dual repo's explicit `features` block was authored for its alt host, so
649
+ // it does not apply to the github.com binding; use plain github defaults.
650
+ const explicitFeatures = isDualGithubBinding ? undefined : repoConfig?.features;
651
+ const features = _resolveFeatures(apiHost, explicitFeatures);
576
652
 
577
653
  // Token resolution
578
654
  let token = '';
579
655
  let source = 'none';
580
656
 
581
- // 1. GITHUB_TOKEN env var, only for github.com (no api_host)
582
- if (apiHost === null && process.env.GITHUB_TOKEN) {
657
+ // 1. GITHUB_TOKEN env var, only for a github.com binding
658
+ if (useGithubChain && process.env.GITHUB_TOKEN) {
583
659
  token = process.env.GITHUB_TOKEN;
584
660
  source = 'env:GITHUB_TOKEN';
585
661
  logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
586
- return { apiHost, token, features, source, refresh: null };
662
+ return { apiHost, host: apiHost, token, features, source, refresh: null };
587
663
  }
588
664
 
589
- // 2. Repo-level literal token
590
- if (repoConfig && typeof repoConfig.token === 'string' && repoConfig.token) {
665
+ // 2. Repo-level literal token (alt-host credential; not used for a dual
666
+ // repo's github.com binding)
667
+ if (useRepoScopedToken && repoConfig && typeof repoConfig.token === 'string' && repoConfig.token) {
591
668
  token = repoConfig.token;
592
669
  source = 'repo:token';
593
670
  logger.debug(`Using token from repos[${repository}].token`);
594
- return { apiHost, token, features, source, refresh: null };
671
+ return { apiHost, host: apiHost, token, features, source, refresh: null };
595
672
  }
596
673
 
597
674
  // 3. Repo-level token_command
598
- if (repoConfig && typeof repoConfig.token_command === 'string' && repoConfig.token_command) {
675
+ if (useRepoScopedToken && repoConfig && typeof repoConfig.token_command === 'string' && repoConfig.token_command) {
599
676
  const result = _runTokenCommand(repoConfig.token_command, repository, 'repo:token_command');
600
677
  if (result) {
601
678
  return {
602
679
  apiHost,
680
+ host: apiHost,
603
681
  token: result,
604
682
  features,
605
683
  source: 'repo:token_command',
606
- refresh: _makeRefresh(repository, safeConfig, 'repo:token_command')
684
+ refresh: _makeRefresh(repository, safeConfig, 'repo:token_command', options)
607
685
  };
608
686
  }
609
687
  }
610
688
 
611
- // 4. Top-level github_token. Only consulted for github.com bindings
689
+ // 4. Top-level github_token. Only consulted for a github.com binding
612
690
  // the top-level token is a github.com credential and would fail
613
691
  // authentication when sent to an alt-host.
614
- if (apiHost === null && typeof safeConfig.github_token === 'string' && safeConfig.github_token) {
692
+ if (useGithubChain && typeof safeConfig.github_token === 'string' && safeConfig.github_token) {
615
693
  token = safeConfig.github_token;
616
694
  source = 'config:github_token';
617
695
  logger.debug('Using GitHub token from config.github_token');
618
- return { apiHost, token, features, source, refresh: null };
696
+ return { apiHost, host: apiHost, token, features, source, refresh: null };
619
697
  }
620
698
 
621
699
  // 5. Top-level github_token_command. Like step 4, github.com-only.
@@ -623,25 +701,26 @@ function resolveHostBinding(repository, config) {
623
701
  // command only — keying on repository would re-invoke the (often
624
702
  // slow) command per repo per session. Repo-level `token_command`
625
703
  // above keeps its per-(repo, command) cache key.
626
- if (apiHost === null && typeof safeConfig.github_token_command === 'string' && safeConfig.github_token_command) {
704
+ if (useGithubChain && typeof safeConfig.github_token_command === 'string' && safeConfig.github_token_command) {
627
705
  const result = _runTokenCommand(safeConfig.github_token_command, null, 'config:github_token_command');
628
706
  if (result) {
629
707
  return {
630
708
  apiHost,
709
+ host: apiHost,
631
710
  token: result,
632
711
  features,
633
712
  source: 'config:github_token_command',
634
- refresh: _makeRefresh(repository, safeConfig, 'config:github_token_command')
713
+ refresh: _makeRefresh(repository, safeConfig, 'config:github_token_command', options)
635
714
  };
636
715
  }
637
716
  }
638
717
 
639
- if (apiHost !== null && repository) {
718
+ if (isAltBinding && repository) {
640
719
  logger.debug(`No repo-scoped token resolved for alt-host repo ${repository} (${apiHost}); github.com top-level credentials are not used for alt-hosts`);
641
720
  } else {
642
721
  logger.debug('No token resolved for host binding');
643
722
  }
644
- return { apiHost, token: '', features, source: 'none', refresh: null };
723
+ return { apiHost, host: apiHost, token: '', features, source: 'none', refresh: null };
645
724
  }
646
725
 
647
726
  /**
@@ -656,14 +735,15 @@ function resolveHostBinding(repository, config) {
656
735
  * @param {string|null|undefined} repository - "owner/repo" identifier as supplied to resolveHostBinding
657
736
  * @param {Object} config - Configuration object from loadConfig()
658
737
  * @param {('repo:token_command'|'config:github_token_command')} source - The refreshable source backing the binding
738
+ * @param {{ host?: string|null }} [options] - The same host override the binding was resolved with, so the refresh re-resolves the SAME flavor (a two-arg re-resolve would apply the ambiguity rule and could pick the wrong host for a dual repo)
659
739
  * @returns {function(): string} - Closure resolving to the fresh token (empty string on failure)
660
740
  */
661
- function _makeRefresh(repository, config, source) {
741
+ function _makeRefresh(repository, config, source, options = {}) {
662
742
  return function refresh() {
663
743
  invalidateTokenCache(repository, config, source);
664
744
  // Re-resolve after invalidation so _runTokenCommand re-executes the
665
745
  // command. Returns '' if the command now fails or yields nothing.
666
- return resolveHostBinding(repository, config).token;
746
+ return resolveHostBinding(repository, config, options).token;
667
747
  };
668
748
  }
669
749
 
@@ -795,6 +875,22 @@ function validateRepoConfig(config) {
795
875
  const apiHost = (typeof repoEntry.api_host === 'string' && repoEntry.api_host) ? repoEntry.api_host : null;
796
876
  const features = (repoEntry.features && typeof repoEntry.features === 'object') ? repoEntry.features : {};
797
877
 
878
+ // `exclusive` marks whether an alt-host repo's PRs live ONLY on its
879
+ // `api_host` (default) or may also live on github.com (`exclusive: false`,
880
+ // a dual repo). It is meaningless without `api_host`.
881
+ if (repoEntry.exclusive !== undefined && repoEntry.exclusive !== null) {
882
+ if (typeof repoEntry.exclusive !== 'boolean') {
883
+ throw new Error(
884
+ `Invalid pair-review config: repos["${repoKey}"].exclusive must be a boolean.`
885
+ );
886
+ }
887
+ if (!apiHost) {
888
+ throw new Error(
889
+ `Invalid pair-review config: repos["${repoKey}"].exclusive is only valid when api_host is set.`
890
+ );
891
+ }
892
+ }
893
+
798
894
  for (const [area, value] of Object.entries(features)) {
799
895
  // Endpoint-override sub-keys (e.g. `pending_review_comments_endpoint`)
800
896
  // are validated separately below. Reject anything that ends in
@@ -910,6 +1006,31 @@ function validateRepoConfig(config) {
910
1006
  `Invalid pair-review config: repos["${repoKey}"].url_pattern is not a valid regular expression: ${err.message}`
911
1007
  );
912
1008
  }
1009
+
1010
+ // An `api_host`-bearing pattern must never match a canonical github.com /
1011
+ // Graphite URL — doing so pre-pins a github PR to the alt host and bypasses
1012
+ // the setup probe (a silent, durable wrong binding). Warn (do NOT throw —
1013
+ // we must not break existing configs; parsePRUrl also guards this at
1014
+ // runtime by discarding such matches) when the pattern is over-broad.
1015
+ if (typeof repoEntry.api_host === 'string' && repoEntry.api_host) {
1016
+ try {
1017
+ const rx = new RegExp(repoEntry.url_pattern);
1018
+ const canaries = [
1019
+ 'https://github.com/o/r/pull/1',
1020
+ 'https://app.graphite.dev/github/pr/o/r/1',
1021
+ 'https://app.graphite.com/github/o/r/pull/1'
1022
+ ];
1023
+ if (canaries.some((u) => rx.test(u))) {
1024
+ logger.warn(
1025
+ `repos["${repoKey}"].url_pattern also matches canonical github.com / Graphite URLs; ` +
1026
+ `anchor it (e.g. start with "^https://<your-alt-host>/") so it never pre-pins a github.com ` +
1027
+ `PR to the alt host. pair-review will still route such URLs to github.com.`
1028
+ );
1029
+ }
1030
+ } catch {
1031
+ // Invalid regex already reported above; nothing more to check.
1032
+ }
1033
+ }
913
1034
  }
914
1035
 
915
1036
  // Optional escape-hatch regex used by parseRepositoryFromURL to match
@@ -1512,6 +1633,7 @@ module.exports = {
1512
1633
  validatePort,
1513
1634
  getGitHubToken,
1514
1635
  resolveHostBinding,
1636
+ isExclusiveAltHost,
1515
1637
  invalidateTokenCache,
1516
1638
  validateRepoConfig,
1517
1639
  matchRepoByUrl,