@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
@@ -70,9 +70,17 @@ class LocalManager {
70
70
  await this.loadLocalReview();
71
71
 
72
72
  // Auto-trigger analysis if ?analyze=true is present
73
- const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
73
+ const searchParams = new URLSearchParams(window.location.search);
74
+ const autoAnalyze = searchParams.get('analyze');
74
75
  if (autoAnalyze === 'true' && !window.prManager.isAnalyzing) {
75
76
  const manager = window.prManager;
77
+ // Provider/model override carried on the URL by single-port delegation
78
+ // (the delegated-to server is a different process whose env never saw the
79
+ // CLI flag). Prepended ahead of repo settings in _buildDefaultAnalysisConfig.
80
+ const urlOverride = {
81
+ provider: searchParams.get('provider'),
82
+ model: searchParams.get('model')
83
+ };
76
84
  try {
77
85
  // Prefer a stashed bulk-analysis config (threaded via analysisConfigId).
78
86
  // The CLI uses it to carry --instructions plus the resolved
@@ -97,15 +105,15 @@ class LocalManager {
97
105
  manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
98
106
  manager._getAppConfig()
99
107
  ]);
100
- config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
108
+ config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig, null, urlOverride);
101
109
  }
102
110
 
103
111
  await this.startLocalAnalysis(null, config);
104
112
  } finally {
105
113
  const cleanUrl = new URL(window.location);
106
- cleanUrl.searchParams.delete('analyze');
107
- cleanUrl.searchParams.delete('analysisConfigId');
108
- cleanUrl.searchParams.delete('council');
114
+ // Strip the whole auto-analyze intent bundle (analyze/analysisConfigId/
115
+ // council/provider/model) so a manual refresh does not replay the intent.
116
+ window.stripAnalyzeParams(cleanUrl);
109
117
  history.replaceState(null, '', cleanUrl);
110
118
  }
111
119
  }
@@ -348,11 +356,13 @@ class LocalManager {
348
356
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
349
357
  // are never seeded with a cross-provider model (e.g. antigravity + opus), which
350
358
  // would blank the model <select> and be rejected by the backend.
359
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
360
+ // settings so `--provider` seeds the modal as the default selection.
351
361
  const providersInfo = await manager._getProvidersInfo();
352
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
353
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
354
- { provider: appConfig.default_provider, model: appConfig.default_model }
355
- ], providersInfo);
362
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
363
+ window.buildProviderModelScopes(repoSettings, appConfig),
364
+ providersInfo
365
+ );
356
366
 
357
367
  // Determine default tab (priority: localStorage > repo settings > 'single')
358
368
  const tabStorageKey = `pair-review-tab:local-${reviewId}`;
package/public/js/pr.js CHANGED
@@ -634,7 +634,7 @@ class PRManager {
634
634
  * @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
635
635
  * @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
636
636
  */
637
- async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
637
+ async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null, urlOverride = null) {
638
638
  const defaultTab = repoSettings?.default_tab || 'single';
639
639
  const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
640
640
 
@@ -642,6 +642,8 @@ class PRManager {
642
642
  // highest priority for council selection. When present we force the council
643
643
  // branch regardless of default_tab/settings, and derive configType from the
644
644
  // council's own type ('council' or 'advanced') rather than the repo default.
645
+ // An explicit council outranks a provider/model override here, matching the
646
+ // backend precedence in resolveReviewConfig where --council beats --provider.
645
647
  const urlSearch = (typeof window !== 'undefined' && window.location && window.location.search) || '';
646
648
  const urlCouncilId = new URLSearchParams(urlSearch).get('council');
647
649
  if (urlCouncilId) {
@@ -676,7 +678,14 @@ class PRManager {
676
678
  }
677
679
  }
678
680
 
679
- if ((defaultTab === 'council' || defaultTab === 'advanced') && councilId) {
681
+ // A CLI/env or delegation-URL provider/model override names a single
682
+ // provider, which is incompatible with a multi-voice council. When an
683
+ // override is active we bypass the repo's *default* council and force the
684
+ // single-provider path so `--provider` is always honored. (An explicit
685
+ // `?council=` above still wins, matching the backend precedence.)
686
+ const overrideActive = window.hasProviderModelOverride(appConfig, urlOverride);
687
+
688
+ if (!overrideActive && (defaultTab === 'council' || defaultTab === 'advanced') && councilId) {
680
689
  // Fetch the full council config so the progress modal can render correctly
681
690
  let councilConfig = null;
682
691
  let councilName = null;
@@ -705,11 +714,13 @@ class PRManager {
705
714
  // independently (repo || app || hardcoded) can mix a provider from one
706
715
  // scope with a model from another, yielding an invalid pair (e.g.
707
716
  // antigravity/opus) that startAnalysis would forward to the backend as-is.
717
+ // buildProviderModelScopes prepends any CLI/env or delegation-URL override
718
+ // ahead of repo settings so `--provider` outranks a repo's saved default.
708
719
  const providers = providersInfo || await this._getProvidersInfo();
709
- const { provider, model } = window.resolveProviderModelPair([
710
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
711
- { provider: appConfig.default_provider, model: appConfig.default_model }
712
- ], providers);
720
+ const { provider, model } = window.resolveProviderModelPair(
721
+ window.buildProviderModelScopes(repoSettings, appConfig, urlOverride),
722
+ providers
723
+ );
713
724
 
714
725
  return {
715
726
  provider,
@@ -750,10 +761,18 @@ class PRManager {
750
761
  * @param {number} prNumber - PR number
751
762
  */
752
763
  async _maybeAutoAnalyze(owner, repo, prNumber) {
753
- const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
764
+ const searchParams = new URLSearchParams(window.location.search);
765
+ const autoAnalyze = searchParams.get('analyze');
754
766
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
755
767
  this._autoAnalyzeRequested = true;
756
768
  let shouldCleanUrl = true;
769
+ // Provider/model override carried on the URL by single-port delegation
770
+ // (the delegated-to server is a different process whose env never saw the
771
+ // CLI flag). Prepended ahead of repo settings in _buildDefaultAnalysisConfig.
772
+ const urlOverride = {
773
+ provider: searchParams.get('provider'),
774
+ model: searchParams.get('model')
775
+ };
757
776
  try {
758
777
  // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
759
778
  // Otherwise, refresh to ensure we have the latest PR data in case the worktree
@@ -791,7 +810,7 @@ class PRManager {
791
810
  this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
792
811
  this._getAppConfig()
793
812
  ]);
794
- config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
813
+ config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig, null, urlOverride);
795
814
  }
796
815
 
797
816
  await this.startAnalysis(owner, repo, prNumber, null, config);
@@ -799,9 +818,9 @@ class PRManager {
799
818
  this._autoAnalyzeRequested = false;
800
819
  if (shouldCleanUrl) {
801
820
  const cleanUrl = new URL(window.location);
802
- cleanUrl.searchParams.delete('analyze');
803
- cleanUrl.searchParams.delete('analysisConfigId');
804
- cleanUrl.searchParams.delete('council');
821
+ // Strip the whole auto-analyze intent bundle (analyze/analysisConfigId/
822
+ // council/provider/model) so a manual refresh does not replay the intent.
823
+ window.stripAnalyzeParams(cleanUrl);
805
824
  history.replaceState(null, '', cleanUrl);
806
825
  }
807
826
  }
@@ -3055,11 +3074,13 @@ class PRManager {
3055
3074
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
3056
3075
  // are never seeded with a cross-provider model (e.g. antigravity + opus), which
3057
3076
  // would blank the model <select> and be rejected by the backend.
3077
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
3078
+ // settings so `--provider` seeds the modal as the default selection.
3058
3079
  const providersInfo = await this._getProvidersInfo();
3059
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
3060
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
3061
- { provider: appConfig.default_provider, model: appConfig.default_model }
3062
- ], providersInfo);
3080
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
3081
+ window.buildProviderModelScopes(repoSettings, appConfig),
3082
+ providersInfo
3083
+ );
3063
3084
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
3064
3085
  const rememberedTab = localStorage.getItem(tabStorageKey);
3065
3086
  const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
@@ -6848,11 +6869,13 @@ class PRManager {
6848
6869
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
6849
6870
  // are never seeded with a cross-provider model (e.g. antigravity + opus), which
6850
6871
  // would blank the model <select> and be rejected by the backend.
6872
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
6873
+ // settings so `--provider` seeds the modal as the default selection.
6851
6874
  const providersInfo = await this._getProvidersInfo();
6852
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
6853
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
6854
- { provider: appConfig.default_provider, model: appConfig.default_model }
6855
- ], providersInfo);
6875
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
6876
+ window.buildProviderModelScopes(repoSettings, appConfig),
6877
+ providersInfo
6878
+ );
6856
6879
 
6857
6880
  // Determine default tab (priority: localStorage > repo settings > 'single')
6858
6881
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
@@ -7021,29 +7044,34 @@ class PRManager {
7021
7044
 
7022
7045
  /**
7023
7046
  * Build the worktree-not-found recovery URL. When the user arrived via
7024
- * auto-analyze (?analyze=true), the reload link preserves the auto-analyze
7025
- * state so analysis re-triggers after worktree setup. Both the
7026
- * `analysisConfigId` and `council` params are carried through, since
7027
- * `_buildDefaultAnalysisConfig()` treats `?council=<id>` as the
7028
- * highest-priority analysis source dropping it would silently fall back to
7029
- * the repo/default analysis configuration on retry.
7047
+ * auto-analyze (?analyze=true), the reload link preserves the whole
7048
+ * auto-analyze intent bundle (analyze/analysisConfigId/council/provider/model)
7049
+ * so analysis re-triggers with the same selection after worktree setup.
7050
+ * Carrying is delegated to the shared `carryAnalyzeParams` relay so this
7051
+ * hop stays in lockstep with the other three (see analyze-params.js);
7052
+ * dropping `council` would silently fall back to the repo/default analysis
7053
+ * config, and dropping `provider`/`model` would lose a `--provider` override.
7030
7054
  * @param {string} owner - Repository owner
7031
7055
  * @param {string} repo - Repository name
7032
7056
  * @param {number} number - PR number
7033
7057
  * @returns {string} The recovery URL (unescaped)
7034
7058
  */
7035
7059
  _buildWorktreeRecoveryUrl(owner, repo, number) {
7036
- let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
7060
+ // A fixed base is fine — only pathname + search are used for the link, so
7061
+ // the origin is irrelevant (and this avoids depending on window.location).
7062
+ const setupUrl = new URL(
7063
+ `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`,
7064
+ 'http://localhost'
7065
+ );
7037
7066
  if (this._autoAnalyzeRequested) {
7038
- const currentParams = new URLSearchParams(window.location.search);
7039
- const params = new URLSearchParams({ analyze: 'true' });
7040
- const analysisConfigId = currentParams.get('analysisConfigId');
7041
- const councilId = currentParams.get('council');
7042
- if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
7043
- if (councilId) params.set('council', councilId);
7044
- setupUrl += `?${params.toString()}`;
7067
+ // The current search still carries the bundle here — it isn't stripped
7068
+ // until _maybeAutoAnalyze's finally block, which runs after this handler.
7069
+ // Force analyze=true so the retry still auto-triggers even if the source
7070
+ // somehow lost it, then carry the rest of the bundle via the shared relay.
7071
+ setupUrl.searchParams.set('analyze', 'true');
7072
+ window.carryAnalyzeParams(window.location.search, setupUrl);
7045
7073
  }
7046
- return setupUrl;
7074
+ return setupUrl.pathname + setupUrl.search;
7047
7075
  }
7048
7076
 
7049
7077
  /**
@@ -7055,14 +7083,14 @@ class PRManager {
7055
7083
  * @param {number} number - PR number
7056
7084
  */
7057
7085
  showWorktreeNotFoundError(owner, repo, number) {
7058
- const setupUrl = this._buildWorktreeRecoveryUrl(owner, repo, number);
7086
+ const href = this._buildWorktreeRecoveryUrl(owner, repo, number);
7059
7087
  const container = document.getElementById('pr-container');
7060
7088
  if (container) {
7061
7089
  container.innerHTML = `
7062
7090
  <div class="error-container">
7063
7091
  <div class="error-icon">Warning</div>
7064
7092
  <div class="error-message">Worktree not found. Please reload the PR to set up the worktree before running analysis.</div>
7065
- <a class="btn btn-primary" href="${this.escapeHtml(setupUrl)}">Reload PR</a>
7093
+ <a class="btn btn-primary" href="${this.escapeHtml(href)}">Reload PR</a>
7066
7094
  </div>
7067
7095
  `;
7068
7096
  container.style.display = 'block';
@@ -246,9 +246,17 @@
246
246
  _currentLinks = null;
247
247
  _currentContext = context || null;
248
248
  try {
249
- const response = await fetch(
250
- '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links'
251
- );
249
+ // Pass the PR number (when known) so the server can resolve the link set
250
+ // against this PR's host for a dual-host repo (github links for a
251
+ // github-hosted PR, the external link for an alt-hosted one). Local mode
252
+ // has no PR number, so the query is omitted and the server returns the
253
+ // repo-level default.
254
+ let url = '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links';
255
+ const number = context && context.number;
256
+ if (number !== undefined && number !== null && number !== '') {
257
+ url += '?number=' + encodeURIComponent(String(number));
258
+ }
259
+ const response = await fetch(url);
252
260
  if (!response.ok) {
253
261
  console.warn('[repo-links] Failed to fetch repo links:', response.status);
254
262
  return;
@@ -0,0 +1,69 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared "auto-analyze intent" URL-param relay.
4
+ *
5
+ * The params `analyze`, `analysisConfigId`, `council`, `provider`, and `model`
6
+ * form a single bundle that must travel TOGETHER through every browser hop
7
+ * between the single-port delegation URL and the review page that consumes them:
8
+ *
9
+ * delegation URL → setup.html redirect(s) → review page auto-analyze
10
+ * review page → "Reload PR" retry → setup.html → review page auto-analyze
11
+ *
12
+ * Each hop used to cherry-pick fields by name, so any hop that forgot one
13
+ * silently dropped it — which is exactly how a delegated `--provider` override
14
+ * got lost on its way to the running server (and how a `--council` selection
15
+ * would drop on the "Reload PR" retry). Centralizing the bundle here means
16
+ * adding a new intent param later is a one-line edit in ONE place instead of a
17
+ * fresh bug at every call site.
18
+ *
19
+ * Note: setup-internal params (e.g. `path`, consumed by the local-mode setup
20
+ * POST) are deliberately NOT in the bundle, so they never leak onto the review
21
+ * page URL.
22
+ */
23
+ const ANALYZE_PARAM_KEYS = ['analyze', 'analysisConfigId', 'council', 'provider', 'model'];
24
+
25
+ /**
26
+ * Copy the auto-analyze intent bundle from a source query onto a target URL.
27
+ * Only non-empty values present in the source are copied; other params already
28
+ * on `toUrl` are left untouched.
29
+ *
30
+ * @param {URLSearchParams|string} fromSearch - source query (e.g. window.location.search)
31
+ * @param {URL} toUrl - destination URL object, mutated in place
32
+ * @returns {URL} the same `toUrl`, for chaining
33
+ */
34
+ function carryAnalyzeParams(fromSearch, toUrl) {
35
+ if (!toUrl) return toUrl;
36
+ const src = typeof fromSearch === 'string' ? new URLSearchParams(fromSearch) : fromSearch;
37
+ if (!src || typeof src.get !== 'function') return toUrl;
38
+ for (const key of ANALYZE_PARAM_KEYS) {
39
+ const value = src.get(key);
40
+ if (value !== null && value !== '') {
41
+ toUrl.searchParams.set(key, value);
42
+ }
43
+ }
44
+ return toUrl;
45
+ }
46
+
47
+ /**
48
+ * Delete the auto-analyze intent bundle from a URL. Used by the consumer after
49
+ * it acts on the intent, so a manual page refresh does not replay it.
50
+ *
51
+ * @param {URL} url - URL object, mutated in place
52
+ * @returns {URL} the same `url`, for chaining
53
+ */
54
+ function stripAnalyzeParams(url) {
55
+ if (!url) return url;
56
+ for (const key of ANALYZE_PARAM_KEYS) {
57
+ url.searchParams.delete(key);
58
+ }
59
+ return url;
60
+ }
61
+
62
+ if (typeof window !== 'undefined') {
63
+ window.carryAnalyzeParams = carryAnalyzeParams;
64
+ window.stripAnalyzeParams = stripAnalyzeParams;
65
+ }
66
+
67
+ if (typeof module !== 'undefined' && module.exports) {
68
+ module.exports = { carryAnalyzeParams, stripAnalyzeParams, ANALYZE_PARAM_KEYS };
69
+ }
@@ -86,10 +86,75 @@ function resolveProviderModelPair(scopes, providersInfo) {
86
86
  return { provider: 'claude', model: providerDefaultModel(claude) };
87
87
  }
88
88
 
89
+ /**
90
+ * Build the ordered scope list for resolveProviderModelPair, injecting the
91
+ * CLI/env override AHEAD of saved repo settings.
92
+ *
93
+ * The override must outrank repo settings to honor the documented
94
+ * `CLI/env > repo settings` contract. Folding the override into appConfig's
95
+ * default_provider/default_model alone is NOT enough: every seed site passes
96
+ * repoSettings first, so an env-aware appConfig would still lose to a repo's
97
+ * saved default. Prepending the override scope is the only correct fix.
98
+ *
99
+ * Two override channels, highest precedence first:
100
+ * 1. `extraOverride` — a per-invocation override carried on the auto-analyze
101
+ * URL (single-port delegation: the flag can only reach the already-running
102
+ * server through the URL).
103
+ * 2. `appConfig.provider_override` / `appConfig.model_override` — the
104
+ * PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL env override surfaced by
105
+ * /api/config, for the process that actually received the CLI flag.
106
+ *
107
+ * A provider-only override (`{ provider: 'codex', model: null }`) resolves via
108
+ * resolveProviderModelPair to codex + codex's own default model, matching CLI
109
+ * semantics where `--provider codex` alone pins the provider.
110
+ *
111
+ * @param {Object} repoSettings - saved repo settings row (may be null)
112
+ * @param {Object} [appConfig] - /api/config response (or __pairReview-shaped equivalent)
113
+ * @param {{provider?: string, model?: string}} [extraOverride] - per-invocation override (URL params)
114
+ * @returns {Array<{provider?: string, model?: string}>} ordered scopes for resolveProviderModelPair
115
+ */
116
+ function buildProviderModelScopes(repoSettings, appConfig = {}, extraOverride = null) {
117
+ const cfg = appConfig || {};
118
+ const scopes = [];
119
+ // 1. Per-invocation override (delegation URL params) — highest precedence.
120
+ if (extraOverride && (extraOverride.provider || extraOverride.model)) {
121
+ scopes.push({ provider: extraOverride.provider || null, model: extraOverride.model || null });
122
+ }
123
+ // 2. CLI/env override surfaced by /api/config (non-delegated invocations).
124
+ if (cfg.provider_override || cfg.model_override) {
125
+ scopes.push({ provider: cfg.provider_override || null, model: cfg.model_override || null });
126
+ }
127
+ // 3. Saved repo settings.
128
+ scopes.push({ provider: repoSettings?.default_provider, model: repoSettings?.default_model });
129
+ // 4. App/config defaults.
130
+ scopes.push({ provider: cfg.default_provider, model: cfg.default_model });
131
+ return scopes;
132
+ }
133
+
134
+ /**
135
+ * Whether a CLI/env provider-or-model override is active (from either channel).
136
+ * Used to decide whether auto-analyze should bypass a council default and force
137
+ * the single-provider path (a single-provider override is incompatible with a
138
+ * multi-voice council).
139
+ *
140
+ * @param {Object} [appConfig] - /api/config response (or __pairReview-shaped equivalent)
141
+ * @param {{provider?: string, model?: string}} [extraOverride] - per-invocation override (URL params)
142
+ * @returns {boolean}
143
+ */
144
+ function hasProviderModelOverride(appConfig = {}, extraOverride = null) {
145
+ const cfg = appConfig || {};
146
+ return !!(
147
+ (extraOverride && (extraOverride.provider || extraOverride.model)) ||
148
+ cfg.provider_override || cfg.model_override
149
+ );
150
+ }
151
+
89
152
  if (typeof window !== 'undefined') {
90
153
  window.resolveProviderModelPair = resolveProviderModelPair;
154
+ window.buildProviderModelScopes = buildProviderModelScopes;
155
+ window.hasProviderModelOverride = hasProviderModelOverride;
91
156
  }
92
157
 
93
158
  if (typeof module !== 'undefined' && module.exports) {
94
- module.exports = { resolveProviderModelPair };
159
+ module.exports = { resolveProviderModelPair, buildProviderModelScopes, hasProviderModelOverride };
95
160
  }