@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
@@ -1137,6 +1137,43 @@ class GitHubClient {
1137
1137
  });
1138
1138
  }
1139
1139
 
1140
+ /**
1141
+ * List open pull requests for a single repository via the REST API.
1142
+ *
1143
+ * Used by the dashboard collections sweep against alt-hosts, which speak a
1144
+ * REST subset and generally have no Search API. The returned rows carry the
1145
+ * classification fields (`author`, `requested_reviewers`, `requested_teams`)
1146
+ * so the caller can bucket each PR into a collection locally, plus the same
1147
+ * display fields `searchPullRequests` returns so both paths cache uniformly.
1148
+ *
1149
+ * @param {string} owner - Repository owner
1150
+ * @param {string} repo - Repository name
1151
+ * @returns {Promise<Array<{owner: string, repo: string, number: number, title: string, author: string|null, updated_at: string, html_url: string, state: string, requested_reviewers: string[], requested_teams: string[]}>>}
1152
+ */
1153
+ async listOpenPullRequests(owner, repo) {
1154
+ const pulls = await this.octokit.paginate(
1155
+ this.octokit.rest.pulls.list,
1156
+ { owner, repo, state: 'open', per_page: 100 }
1157
+ );
1158
+
1159
+ return pulls.map(pr => ({
1160
+ owner,
1161
+ repo,
1162
+ number: pr.number,
1163
+ title: pr.title,
1164
+ author: pr.user?.login || null,
1165
+ updated_at: pr.updated_at,
1166
+ html_url: pr.html_url,
1167
+ state: pr.state,
1168
+ requested_reviewers: Array.isArray(pr.requested_reviewers)
1169
+ ? pr.requested_reviewers.map(r => r && r.login).filter(Boolean)
1170
+ : [],
1171
+ requested_teams: Array.isArray(pr.requested_teams)
1172
+ ? pr.requested_teams.map(t => t && t.slug).filter(Boolean)
1173
+ : []
1174
+ }));
1175
+ }
1176
+
1140
1177
  /**
1141
1178
  * Get the authenticated user's information.
1142
1179
  * @returns {Promise<{login: string, name: string, avatar_url: string}>}
@@ -90,11 +90,20 @@ class PRArgumentParser {
90
90
  if (!owner || !repo || typeof number !== 'number' || isNaN(number) || number <= 0) {
91
91
  return null;
92
92
  }
93
+ // Carry the matched repo's `api_host` so downstream setup binds the PR to
94
+ // the alternate host without probing. A `url_pattern` match on a repo with
95
+ // no `api_host` (a plain github.com entry) resolves to `host: null`.
96
+ const matchedApiHost = (match.repoConfig
97
+ && typeof match.repoConfig.api_host === 'string'
98
+ && match.repoConfig.api_host)
99
+ ? match.repoConfig.api_host
100
+ : null;
93
101
  return {
94
102
  owner,
95
103
  repo,
96
104
  number,
97
- bindingRepository: match.bindingRepository
105
+ bindingRepository: match.bindingRepository,
106
+ host: matchedApiHost
98
107
  };
99
108
  }
100
109
 
@@ -114,16 +123,30 @@ class PRArgumentParser {
114
123
  return null;
115
124
  }
116
125
 
126
+ // Clean up the URL - trim whitespace
127
+ const trimmedUrl = url.trim();
128
+
117
129
  // Try config-driven URL pattern matching first. This handles
118
130
  // alternate-host URLs and lets host-specific repos override the
119
131
  // built-in github.com path if they choose.
120
- const configMatch = this._matchUrlPatternFromConfig(url.trim());
132
+ const configMatch = this._matchUrlPatternFromConfig(trimmedUrl);
121
133
  if (configMatch) {
122
- return configMatch;
134
+ // INVARIANT: an `api_host`-bearing `url_pattern` must NEVER bind a
135
+ // canonical github.com / Graphite URL. An over-broad or unanchored pattern
136
+ // can match one and pre-pin it to the alt host (an explicit host bypasses
137
+ // the setup probe → a silent, durable wrong binding). When the config
138
+ // match carries an alt host but the URL is a canonical github/graphite URL,
139
+ // discard the match and fall through to the built-in parsers (host: null).
140
+ const sansProtocol = trimmedUrl.replace(/^https?:\/\//i, '');
141
+ const isCanonicalHostUrl = sansProtocol.startsWith('github.com/')
142
+ || sansProtocol.startsWith('app.graphite.dev/')
143
+ || sansProtocol.startsWith('app.graphite.com/');
144
+ if (!(configMatch.host && isCanonicalHostUrl)) {
145
+ return configMatch;
146
+ }
123
147
  }
124
148
 
125
- // Clean up the URL - trim whitespace
126
- let normalizedUrl = url.trim();
149
+ let normalizedUrl = trimmedUrl;
127
150
 
128
151
  // Add https:// if no protocol is present
129
152
  if (normalizedUrl.startsWith('github.com')) {
@@ -222,7 +245,9 @@ class PRArgumentParser {
222
245
  * @param {string} repo - Repository name
223
246
  * @param {string} numberStr - PR number as string
224
247
  * @param {string} source - Source name for error messages ('GitHub' or 'Graphite')
225
- * @returns {Object} Validated PR info { owner, repo, number }
248
+ * @returns {Object} Validated PR info `{ owner, repo, number, host? }`. `host`
249
+ * is `null` for github.com/Graphite URLs (definitively github); it is OMITTED
250
+ * (undefined) for the pair-review:// scheme, which carries no host in its path.
226
251
  * @private
227
252
  */
228
253
  _createPRInfo(owner, repo, numberStr, source) {
@@ -237,7 +262,16 @@ class PRArgumentParser {
237
262
  throw new Error(`Invalid ${source} URL format. Expected: ${exampleUrl}`);
238
263
  }
239
264
 
240
- return { owner, repo, number };
265
+ // A github.com or Graphite URL unambiguously identifies a github.com PR, so
266
+ // the host is explicitly null (not "unknown"). The pair-review:// scheme
267
+ // carries NO host in its path and is not github-only, so leave `host`
268
+ // undefined — it flows into the probe/derive path like a bare number,
269
+ // instead of self-healing (dual repo) or throwing (exclusive alt) on github.
270
+ const result = { owner, repo, number };
271
+ if (source !== 'pair-review://') {
272
+ result.host = null;
273
+ }
274
+ return result;
241
275
  }
242
276
 
243
277
  /**
@@ -93,7 +93,7 @@ async function resolveCliInstructions(flags) {
93
93
  * @param {Object} params
94
94
  * @param {Object} params.db - Database instance
95
95
  * @param {Object} params.config - Loaded global config
96
- * @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
96
+ * @param {Object} params.flags - Parsed CLI flags (council/provider/model/instructions)
97
97
  * @param {string} params.repository - owner/repo (for repo-default resolution)
98
98
  * @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
99
99
  */
@@ -104,7 +104,7 @@ async function buildInteractiveAnalysisConfig({ db, config, flags, repository })
104
104
  const reviewConfig = await resolveReviewConfig(
105
105
  db,
106
106
  repository,
107
- { council: flags.council, model: flags.model },
107
+ { council: flags.council, provider: flags.provider, model: flags.model },
108
108
  config
109
109
  );
110
110
 
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  const { getRepoConfig } = require('../config');
34
+ const { isDualHostRepoConfig } = require('../utils/host-resolution');
34
35
  const logger = require('../utils/logger');
35
36
 
36
37
  // Whitelist of allowed `{placeholder}` names. Anything else in the template
@@ -158,13 +159,28 @@ function sanitizeSvgIcon(svg) {
158
159
  * malformed), a warning is logged and `icon` becomes `null` — the link
159
160
  * is still rendered, just without a custom icon.
160
161
  *
162
+ * **Per-PR host awareness (dual-host repos only).** `host` is the PR's
163
+ * resolved host: `null` = github.com, an `api_host` URL string = the alt
164
+ * host, `undefined` = unknown / not applicable. It only affects a
165
+ * *dual-host* repo (`api_host` + `exclusive: false`):
166
+ * - `host === null` (github-hosted PR) → keep the GitHub/Graphite links
167
+ * (subject to any explicit `links.github/graphite: false`) and hide the
168
+ * alt-host external link.
169
+ * - `host === '<url>'` (alt-hosted PR) → keep the external link and hide
170
+ * the GitHub/Graphite links.
171
+ * Exclusive alt-host repos and plain github repos ignore `host` entirely, so
172
+ * existing two-arg callers (and `host === undefined`) render byte-identically
173
+ * to before this parameter existed.
174
+ *
161
175
  * @param {Object} config
162
176
  * @param {string} repository Canonical `owner/repo` identifier
177
+ * @param {string|null} [host] PR's resolved host: null=github, url=alt,
178
+ * undefined=unknown/not applicable
163
179
  * @returns {{ external: { label: string, url_template: string, icon: string|null }|null,
164
180
  * github: boolean,
165
181
  * graphite: boolean }}
166
182
  */
167
- function resolveRepoLinks(config, repository) {
183
+ function resolveRepoLinks(config, repository, host = undefined) {
168
184
  const result = { external: null, github: true, graphite: true };
169
185
  if (!config || !repository) return result;
170
186
 
@@ -172,33 +188,47 @@ function resolveRepoLinks(config, repository) {
172
188
  if (!repoConfig || typeof repoConfig !== 'object') return result;
173
189
 
174
190
  const links = repoConfig.links;
175
- if (!links || typeof links !== 'object') return result;
176
-
177
- if (links.github === false) result.github = false;
178
- if (links.graphite === false) result.graphite = false;
179
-
180
- const ext = links.external;
181
- if (ext && typeof ext === 'object'
182
- && typeof ext.label === 'string' && ext.label
183
- && typeof ext.url_template === 'string'
184
- && ext.url_template.startsWith('https://')) {
185
- let icon = null;
186
- if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
187
- icon = sanitizeSvgIcon(ext.icon);
188
- if (icon === null) {
189
- logger.warn(
190
- `Dropping links.external.icon for "${repository}" — failed sanitisation.`
191
- );
191
+ if (links && typeof links === 'object') {
192
+ if (links.github === false) result.github = false;
193
+ if (links.graphite === false) result.graphite = false;
194
+
195
+ const ext = links.external;
196
+ if (ext && typeof ext === 'object'
197
+ && typeof ext.label === 'string' && ext.label
198
+ && typeof ext.url_template === 'string'
199
+ && ext.url_template.startsWith('https://')) {
200
+ let icon = null;
201
+ if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
202
+ icon = sanitizeSvgIcon(ext.icon);
203
+ if (icon === null) {
204
+ logger.warn(
205
+ `Dropping links.external.icon for "${repository}" — failed sanitisation.`
206
+ );
207
+ }
192
208
  }
209
+ result.external = {
210
+ // Optional host display name (e.g. "Meteorite"). When absent, the
211
+ // field is null and consumers fall back to "GitHub" via resolveHostName.
212
+ name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
213
+ label: ext.label,
214
+ url_template: ext.url_template,
215
+ icon
216
+ };
217
+ }
218
+ }
219
+
220
+ // Per-PR host awareness for dual-host repos. A `host` of `undefined`
221
+ // (omitted arg, unknown host) leaves the repo-level result untouched, so
222
+ // non-dual repos and legacy two-arg callers are unaffected.
223
+ if (host !== undefined && isDualHostRepoConfig(repoConfig)) {
224
+ if (host === null) {
225
+ // github-hosted PR: the alt-host external link does not apply.
226
+ result.external = null;
227
+ } else {
228
+ // alt-hosted PR: hide the GitHub/Graphite links, keep the external one.
229
+ result.github = false;
230
+ result.graphite = false;
193
231
  }
194
- result.external = {
195
- // Optional host display name (e.g. "Meteorite"). When absent, the
196
- // field is null and consumers fall back to "GitHub" via resolveHostName.
197
- name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
198
- label: ext.label,
199
- url_template: ext.url_template,
200
- icon
201
- };
202
232
  }
203
233
 
204
234
  return result;
@@ -212,12 +242,20 @@ function resolveRepoLinks(config, repository) {
212
242
  * `"GitHub"`. This is the server-side counterpart to the frontend
213
243
  * `window.RepoLinks.hostName()` accessor.
214
244
  *
245
+ * Host-aware for dual-host repos: a github-hosted PR (`host === null`)
246
+ * reports "GitHub" even when an external name is configured, because
247
+ * `resolveRepoLinks` clears the external link for that host. See
248
+ * `resolveRepoLinks` for the `host` semantics. Non-dual repos and two-arg
249
+ * callers behave exactly as before.
250
+ *
215
251
  * @param {Object} config
216
252
  * @param {string} repository Canonical `owner/repo` identifier
253
+ * @param {string|null} [host] PR's resolved host: null=github, url=alt,
254
+ * undefined=unknown/not applicable
217
255
  * @returns {string} The configured host name, or "GitHub" by default
218
256
  */
219
- function resolveHostName(config, repository) {
220
- const links = resolveRepoLinks(config, repository);
257
+ function resolveHostName(config, repository, host = undefined) {
258
+ const links = resolveRepoLinks(config, repository, host);
221
259
  return (links.external && links.external.name) ? links.external.name : 'GitHub';
222
260
  }
223
261
 
@@ -11,7 +11,7 @@ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
11
11
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
12
12
 
13
13
  const execAsync = promisify(exec);
14
- const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
14
+ const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope, parseScopeArg } = require('./local-scope');
15
15
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
16
16
  const { resolveCouncilHandle } = require('./councils/resolve-council');
17
17
  const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
@@ -21,6 +21,10 @@ const summaryGenerator = require('./ai/summary-generator');
21
21
  const tourGenerator = require('./ai/tour-generator');
22
22
  const { getShaAbbrevLength } = require('./git/sha-abbrev');
23
23
  const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
24
+ // Namespace import (not destructured) so detectBaseBranch is resolved off the
25
+ // module object at call time — this keeps it interceptable by vi.spyOn on the
26
+ // base-branch module in tests, unlike a destructured binding captured at load.
27
+ const baseBranchModule = require('./git/base-branch');
24
28
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
25
29
 
26
30
  // Design note: This module uses execSync for git commands despite async function signatures.
@@ -694,6 +698,112 @@ async function generateLocalDiff(repoPath, options = {}) {
694
698
  };
695
699
  }
696
700
 
701
+ /**
702
+ * Resolve the effective scope and base branch for a local review session,
703
+ * applying an explicit `--scope`/`--base` (or delegated query) override on top
704
+ * of the persisted/default scope.
705
+ *
706
+ * Shared by the CLI seam (setupLocalReviewSession) and the web setup seam
707
+ * (src/setup/local-setup.js) so a scoped launch behaves identically whether it
708
+ * cold-starts a server or is delegated to a running one. Callers MUST have
709
+ * validated flags.scope/flags.base first (parseScopeArg + branch-name regex +
710
+ * "base requires branch scope"); an invalid flags.scope is treated as "no
711
+ * override" here rather than throwing.
712
+ *
713
+ * Precedence — scope: explicit override > persisted review scope > DEFAULT_SCOPE.
714
+ * Precedence — base: explicit --base > persisted local_base_branch >
715
+ * detectBaseBranch. The base is nulled for any non-branch scope so a stale value
716
+ * from a previously branch-scoped session never leaks into the diff/persist/return.
717
+ *
718
+ * @param {Object} params
719
+ * @param {Object|null} params.existingReview - Persisted review row, or null for a fresh session
720
+ * @param {Object} [params.flags] - { scope?, base? }
721
+ * @param {string} params.repoPath
722
+ * @param {string} params.branch - Current branch name
723
+ * @param {string} params.repository - owner/repo (for host binding / PR base lookup)
724
+ * @param {Object} params.config
725
+ * @returns {Promise<{scopeStart: string, scopeEnd: string, baseBranch: string|null, scopeOverridden: boolean}>}
726
+ */
727
+ async function resolveScopeAndBase({ existingReview, flags = {}, repoPath, branch, repository, config }) {
728
+ const scopeOverride = flags.scope ? parseScopeArg(flags.scope) : null;
729
+ let scopeStart, scopeEnd;
730
+ if (scopeOverride) {
731
+ ({ start: scopeStart, end: scopeEnd } = scopeOverride);
732
+ } else if (existingReview) {
733
+ ({ start: scopeStart, end: scopeEnd } = reviewScope(existingReview));
734
+ } else {
735
+ ({ start: scopeStart, end: scopeEnd } = DEFAULT_SCOPE);
736
+ }
737
+
738
+ let baseBranch = existingReview?.local_base_branch || null;
739
+ if (scopeOverride && includesBranch(scopeStart)) {
740
+ if (flags.base) {
741
+ baseBranch = flags.base;
742
+ } else if (!baseBranch) {
743
+ const branchBinding = repository ? resolveHostBinding(repository, config) : null;
744
+ const token = branchBinding?.token || getGitHubToken(config);
745
+ const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
746
+ repository,
747
+ enableGraphite: config?.enable_graphite === true,
748
+ _deps: (token || branchBinding) ? {
749
+ getGitHubToken: () => token || '',
750
+ getHostBinding: () => branchBinding || null
751
+ } : undefined
752
+ });
753
+ if (!detection) {
754
+ throw new Error(
755
+ `Could not detect a base branch for scope '${scopeStart}..${scopeEnd}'. ` +
756
+ 'Pass --base <branch> to specify one explicitly.'
757
+ );
758
+ }
759
+ baseBranch = detection.baseBranch;
760
+ }
761
+ }
762
+
763
+ // A non-branch scope has no base branch. Drop any value seeded from a
764
+ // previously branch-scoped session so the stale base never reaches the diff,
765
+ // the persisted row, or the returned session — matching the web set-scope
766
+ // route, which persists null when leaving branch scope.
767
+ if (!includesBranch(scopeStart)) {
768
+ baseBranch = null;
769
+ }
770
+
771
+ return { scopeStart, scopeEnd, baseBranch, scopeOverridden: !!scopeOverride };
772
+ }
773
+
774
+ /**
775
+ * Persist an explicitly-overridden scope (and its resolved base) and auto-name
776
+ * the review when a branch scope is newly applied to an unnamed review. Call
777
+ * ONLY after generateScopedDiff succeeds, so a scope whose diff fails (e.g. a
778
+ * bad --base) never sticks. Shared by the CLI and web setup seams. Caller gates
779
+ * this on `scopeOverridden` — it always writes when invoked.
780
+ *
781
+ * @param {Object} params
782
+ * @param {Object} params.reviewRepo - ReviewRepository instance
783
+ * @param {number} params.sessionId
784
+ * @param {Object|null} params.existingReview
785
+ * @param {string} params.scopeStart
786
+ * @param {string} params.scopeEnd
787
+ * @param {string|null} params.baseBranch
788
+ * @param {string} params.branch - Current branch (stored as head branch for branch scopes)
789
+ * @param {string} params.repoPath
790
+ */
791
+ async function persistScopeSelection({ reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath }) {
792
+ await reviewRepo.updateLocalScope(sessionId, scopeStart, scopeEnd, baseBranch, branch);
793
+
794
+ // Keep CLI and web set-scope in sync: when a branch scope is newly applied to
795
+ // an as-yet-unnamed review, auto-name it from the first commit subject.
796
+ // Mirrors routes/local.js set-scope. A fresh session has no existing review,
797
+ // which counts as both unnamed and previously non-branch.
798
+ const oldScopeStart = existingReview ? reviewScope(existingReview).start : DEFAULT_SCOPE.start;
799
+ if (!existingReview?.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
800
+ const firstSubject = await module.exports.getFirstCommitSubject(repoPath, baseBranch);
801
+ if (firstSubject) {
802
+ await reviewRepo.updateReview(sessionId, { name: firstSubject.slice(0, 200) });
803
+ }
804
+ }
805
+ }
806
+
697
807
  /**
698
808
  * Set up a local review session: resolve git state, persist the diff,
699
809
  * and enqueue the background summary job. Caller is responsible for
@@ -777,7 +887,11 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
777
887
  console.log(`Created new review session (ID: ${sessionId})`);
778
888
  }
779
889
 
780
- const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
890
+ // Resolve scope + base (explicit --scope/--base override > persisted > default)
891
+ // via the shared helper so the CLI and delegated web-setup seam stay identical.
892
+ const { scopeStart, scopeEnd, baseBranch, scopeOverridden } = await resolveScopeAndBase({
893
+ existingReview, flags, repoPath, branch, repository, config
894
+ });
781
895
 
782
896
  const hookEvent = existingReview ? 'review.loaded' : 'review.started';
783
897
  if (hasHooks(hookEvent, config)) {
@@ -790,11 +904,20 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
790
904
  fireHooks(hookEvent, payload, config);
791
905
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
792
906
  }
793
- const baseBranch = existingReview?.local_base_branch || null;
794
907
 
795
908
  console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
796
909
  const { diff, stats } = await module.exports.generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
797
910
 
911
+ // Persist an explicitly-requested scope (and its resolved base) so the web UI
912
+ // later opens with the same scope. Only after generateScopedDiff succeeds, so a
913
+ // scope whose diff fails (e.g. a bad --base) never sticks. Never touch persisted
914
+ // scope when no override was supplied.
915
+ if (scopeOverridden) {
916
+ await persistScopeSelection({
917
+ reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath
918
+ });
919
+ }
920
+
798
921
  let branchInfo = null;
799
922
  if (!includesBranch(scopeStart)) {
800
923
  const untrackedFiles = await getUntrackedFiles(repoPath);
@@ -932,6 +1055,11 @@ async function handleLocalReview(targetPath, flags = {}) {
932
1055
  if (flags.model && !flags.council) {
933
1056
  process.env.PAIR_REVIEW_MODEL = flags.model;
934
1057
  }
1058
+ // Mirror --provider too so the local web/UI routes (which read
1059
+ // PAIR_REVIEW_PROVIDER via getProvider(req)) honor it, matching --model above.
1060
+ if (flags.provider) {
1061
+ process.env.PAIR_REVIEW_PROVIDER = flags.provider;
1062
+ }
935
1063
 
936
1064
  console.log('Starting server...');
937
1065
  const port = await startServer(db);
@@ -1085,14 +1213,13 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1085
1213
  if (untrackedFiles && untrackedFiles.length > 0) return null;
1086
1214
 
1087
1215
  try {
1088
- const { detectBaseBranch } = require('./git/base-branch');
1089
1216
  const depsOverride = githubToken || hostBinding
1090
1217
  ? {
1091
1218
  getGitHubToken: () => githubToken || '',
1092
1219
  getHostBinding: () => hostBinding || null
1093
1220
  }
1094
1221
  : undefined;
1095
- const detection = await detectBaseBranch(repoPath, branch, {
1222
+ const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
1096
1223
  repository,
1097
1224
  enableGraphite,
1098
1225
  _deps: depsOverride
@@ -1117,6 +1244,8 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1117
1244
  module.exports = {
1118
1245
  handleLocalReview,
1119
1246
  setupLocalReviewSession,
1247
+ resolveScopeAndBase,
1248
+ persistScopeSelection,
1120
1249
  findGitRoot,
1121
1250
  findMainGitRoot,
1122
1251
  getHeadSha,
@@ -15,6 +15,42 @@ function isValidScope(start, end) {
15
15
  return si !== -1 && ei !== -1 && si <= ei && si <= UNSTAGED_INDEX && ei >= UNSTAGED_INDEX;
16
16
  }
17
17
 
18
+ /**
19
+ * The full set of valid scope ranges, formatted as `start..end` strings.
20
+ * Computed from STOPS + isValidScope so it stays the single source of truth.
21
+ * Ordered by STOPS position (branch-first).
22
+ * @type {string[]}
23
+ */
24
+ const VALID_SCOPE_RANGES = (() => {
25
+ const ranges = [];
26
+ for (const start of STOPS) {
27
+ for (const end of STOPS) {
28
+ if (isValidScope(start, end)) ranges.push(`${start}..${end}`);
29
+ }
30
+ }
31
+ return ranges;
32
+ })();
33
+
34
+ /**
35
+ * Parse a `--scope` CLI argument of the form `<start>..<end>` into a scope
36
+ * object. Splits on `..`, trims each side, and delegates validation to
37
+ * isValidScope (the single source of truth). Single tokens, missing `..`,
38
+ * unknown stops, non-contiguous ranges, ranges excluding 'unstaged', and
39
+ * reversed ranges all return null.
40
+ *
41
+ * @param {string} value - Raw CLI value (e.g. 'branch..untracked')
42
+ * @returns {{ start: string, end: string }|null} Parsed scope, or null if invalid
43
+ */
44
+ function parseScopeArg(value) {
45
+ if (typeof value !== 'string') return null;
46
+ const parts = value.split('..');
47
+ if (parts.length !== 2) return null;
48
+ const start = parts[0].trim();
49
+ const end = parts[1].trim();
50
+ if (!isValidScope(start, end)) return null;
51
+ return { start, end };
52
+ }
53
+
18
54
  function normalizeScope(start, end) {
19
55
  if (isValidScope(start, end)) return { start, end };
20
56
  const si = STOPS.indexOf(start);
@@ -130,7 +166,9 @@ function scopeGitHints(start, end, baseBranch) {
130
166
  const LocalScope = {
131
167
  STOPS,
132
168
  DEFAULT_SCOPE,
169
+ VALID_SCOPE_RANGES,
133
170
  isValidScope,
171
+ parseScopeArg,
134
172
  normalizeScope,
135
173
  reviewScope,
136
174
  scopeIncludes,