@in-the-loop-labs/pair-review 3.9.1 → 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 (53) hide show
  1. package/README.md +92 -17
  2. package/package.json +6 -9
  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/components/AdvancedConfigTab.js +1 -1
  8. package/public/js/components/AnalysisConfigModal.js +2 -2
  9. package/public/js/index.js +87 -10
  10. package/public/js/local.js +20 -10
  11. package/public/js/pr.js +67 -39
  12. package/public/js/repo-links.js +11 -3
  13. package/public/js/utils/analyze-params.js +69 -0
  14. package/public/js/utils/provider-model.js +67 -2
  15. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  16. package/public/js/vendor/pierre-diffs.js +1880 -0
  17. package/public/local.html +1 -0
  18. package/public/pr.html +1 -0
  19. package/public/setup.html +35 -16
  20. package/src/ai/analyzer.js +4 -4
  21. package/src/ai/antigravity-provider.js +594 -0
  22. package/src/ai/index.js +1 -1
  23. package/src/ai/opencode-provider.js +1 -1
  24. package/src/ai/provider.js +5 -5
  25. package/src/ai/stream-parser.js +1 -52
  26. package/src/chat/acp-bridge.js +1 -1
  27. package/src/chat/chat-providers.js +1 -9
  28. package/src/config.js +153 -31
  29. package/src/database.js +128 -4
  30. package/src/external/github-adapter.js +18 -3
  31. package/src/github/client.js +37 -0
  32. package/src/github/parser.js +41 -7
  33. package/src/interactive-analysis-config.js +2 -2
  34. package/src/links/repo-links.js +66 -28
  35. package/src/local-review.js +134 -5
  36. package/src/local-scope.js +38 -0
  37. package/src/main.js +203 -37
  38. package/src/routes/config.js +49 -14
  39. package/src/routes/external-comments.js +13 -1
  40. package/src/routes/github-collections.js +175 -13
  41. package/src/routes/local.js +11 -20
  42. package/src/routes/pr.js +63 -36
  43. package/src/routes/setup.js +63 -8
  44. package/src/routes/shared.js +85 -0
  45. package/src/routes/stack-analysis.js +39 -3
  46. package/src/server.js +74 -3
  47. package/src/setup/local-setup.js +23 -7
  48. package/src/setup/pr-setup.js +237 -39
  49. package/src/setup/stack-setup.js +7 -2
  50. package/src/single-port.js +73 -18
  51. package/src/utils/host-resolution.js +157 -0
  52. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
  53. package/src/ai/gemini-provider.js +0 -752
@@ -212,56 +212,6 @@ function parseCodexLine(line, options = {}) {
212
212
  }
213
213
  }
214
214
 
215
- /**
216
- * Parse a single Gemini stream-json JSONL line into a normalized event.
217
- * Returns null if the line should not be emitted.
218
- *
219
- * Gemini stream-json event types:
220
- * - message (role: "assistant") → assistant_text
221
- * - tool_use (tool_name, parameters) → tool_use
222
- * - init, message (role: "user"), tool_result, result → filtered out
223
- *
224
- * @param {string} line - A single JSONL line from Gemini stdout
225
- * @param {Object} [options] - Parse options
226
- * @param {string} [options.cwd] - Working directory to strip from file paths
227
- * @returns {{ type: string, text: string, timestamp: number } | null}
228
- */
229
- function parseGeminiLine(line, options = {}) {
230
- if (!line || !line.trim()) return null;
231
-
232
- const { cwd } = options;
233
-
234
- try {
235
- const event = JSON.parse(line);
236
- const eventType = event.type;
237
-
238
- if (eventType === 'message' && event.role === 'assistant' && event.content && event.content.trim()) {
239
- return {
240
- type: 'assistant_text',
241
- text: truncateSnippet(event.content),
242
- timestamp: Date.now()
243
- };
244
- }
245
-
246
- if (eventType === 'tool_use') {
247
- const toolName = event.tool_name || 'unknown';
248
- const detail = extractToolDetail(event.parameters, cwd);
249
- const text = detail ? `${toolName}: ${detail}` : toolName;
250
- return {
251
- type: 'tool_use',
252
- text: truncateSnippet(text),
253
- timestamp: Date.now()
254
- };
255
- }
256
-
257
- // init, user messages, tool_result, result — never emit
258
- return null;
259
- } catch {
260
- // Best-effort side channel — silently ignore non-JSON or malformed lines.
261
- return null;
262
- }
263
- }
264
-
265
215
  /**
266
216
  * Parse a single OpenCode JSONL line into a normalized event.
267
217
  * Returns null if the line should not be emitted.
@@ -323,7 +273,7 @@ function parseOpenCodeLine(line, options = {}) {
323
273
  */
324
274
  class StreamParser {
325
275
  /**
326
- * @param {Function} parseLine - Provider-specific line parser (e.g. parseClaudeLine, parseGeminiLine)
276
+ * @param {Function} parseLine - Provider-specific line parser (e.g. parseClaudeLine, parseCodexLine)
327
277
  * @param {Function} onEvent - Callback receiving normalized events { type, text, timestamp }
328
278
  * @param {Object} [options] - Options passed through to parseLine (e.g. { cwd })
329
279
  */
@@ -636,7 +586,6 @@ module.exports = {
636
586
  extractToolDetail,
637
587
  parseClaudeLine,
638
588
  parseCodexLine,
639
- parseGeminiLine,
640
589
  parseOpenCodeLine,
641
590
  parseCursorAgentLine,
642
591
  parsePiLine,
@@ -82,7 +82,7 @@ class AcpBridge extends EventEmitter {
82
82
  const args = [...this.acpArgs];
83
83
  const useShell = this.useShell;
84
84
 
85
- // For multi-word commands (e.g. "devx gemini"), use shell mode
85
+ // For multi-word commands (e.g. "devx claude"), use shell mode
86
86
  const spawnCmd = useShell ? `${command} ${args.join(' ')}` : command;
87
87
  const spawnArgs = useShell ? [] : args;
88
88
 
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Chat Provider Registry
4
4
  *
5
- * Defines named chat providers (Pi, Copilot, Gemini, OpenCode, Claude, Codex, Cursor) with their
5
+ * Defines named chat providers (Pi, Copilot, OpenCode, Claude, Codex, Cursor) with their
6
6
  * default commands/args, config overrides, and availability checks.
7
7
  */
8
8
 
@@ -32,14 +32,6 @@ const CHAT_PROVIDERS = {
32
32
  args: ['--acp', '--stdio'],
33
33
  env: {},
34
34
  },
35
- 'gemini-acp': {
36
- id: 'gemini-acp',
37
- name: 'Gemini (ACP)',
38
- type: 'acp',
39
- command: 'gemini',
40
- args: ['--experimental-acp'],
41
- env: {},
42
- },
43
35
  'opencode-acp': {
44
36
  id: 'opencode-acp',
45
37
  name: 'OpenCode (ACP)',
package/src/config.js CHANGED
@@ -66,8 +66,8 @@ const DEFAULT_CONFIG = {
66
66
  port: 7247,
67
67
  single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
68
68
  theme: "light",
69
- default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
70
- default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
69
+ default_provider: "claude", // AI provider: 'claude', 'antigravity', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
70
+ default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-3.1-pro-low' for Antigravity)
71
71
  tours: {
72
72
  enabled: false, // When true, the guided-tour feature is available (toolbar button visible, etc.)
73
73
  auto_generate: true, // When true, a tour generation job is kicked off automatically on review load
@@ -89,7 +89,7 @@ const DEFAULT_CONFIG = {
89
89
  db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
90
90
  yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
91
91
  enable_chat: true, // When true, enables the chat panel feature (uses chat_provider)
92
- chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', 'gemini-acp', 'opencode-acp', 'cursor-acp', 'codex'
92
+ chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', 'opencode-acp', 'cursor-acp', 'codex'
93
93
  comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
94
94
  chat: { enable_shortcuts: true, enter_to_send: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons, enter_to_send: Enter sends message instead of newline)
95
95
  providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
@@ -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,
package/src/database.js CHANGED
@@ -21,7 +21,7 @@ function getDbPath() {
21
21
  /**
22
22
  * Current schema version - increment this when adding new migrations
23
23
  */
24
- const CURRENT_SCHEMA_VERSION = 48;
24
+ const CURRENT_SCHEMA_VERSION = 51;
25
25
 
26
26
  /**
27
27
  * Database schema SQL statements
@@ -123,6 +123,7 @@ const SCHEMA_SQL = {
123
123
  pr_data TEXT,
124
124
  last_ai_run_id TEXT,
125
125
  last_accessed_at TEXT,
126
+ host TEXT,
126
127
  UNIQUE(pr_number, repository)
127
128
  )
128
129
  `,
@@ -312,6 +313,7 @@ const SCHEMA_SQL = {
312
313
  html_url TEXT,
313
314
  state TEXT DEFAULT 'open',
314
315
  collection TEXT NOT NULL,
316
+ host TEXT,
315
317
  fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
316
318
  )
317
319
  `,
@@ -349,6 +351,7 @@ const SCHEMA_SQL = {
349
351
  diff_position INTEGER,
350
352
  commit_sha TEXT,
351
353
  is_outdated INTEGER NOT NULL DEFAULT 0,
354
+ is_file_level INTEGER NOT NULL DEFAULT 0,
352
355
  original_line_start INTEGER,
353
356
  original_line_end INTEGER,
354
357
  original_commit_sha TEXT,
@@ -397,8 +400,14 @@ const INDEX_SQL = [
397
400
  'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
398
401
  // Hunk summaries indexes
399
402
  'CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)',
400
- // GitHub PR cache indexes
401
- 'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
403
+ // GitHub PR cache indexes. `host` is part of the unique key so a dual-host
404
+ // repo can cache the same (collection, owner, repo, number) from github.com
405
+ // (host NULL) AND its alt host (host = api_host URL) without colliding —
406
+ // independently-numbered PRs across systems overlap on small numbers. SQLite
407
+ // treats NULLs as distinct in unique indexes, so two NULL-host rows would not
408
+ // collide, but the collections refresh DELETEs the collection before
409
+ // re-inserting, so duplicate NULL-host rows can't accumulate. See migration 51.
410
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number, host)',
402
411
  // Worktree pool indexes
403
412
  'CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)',
404
413
  'CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)',
@@ -1305,6 +1314,9 @@ const MIGRATIONS = {
1305
1314
  fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
1306
1315
  )
1307
1316
  `);
1317
+ // Historical shape: no `host` column exists at v26 (added in migration
1318
+ // 50). Migration 51 widens this index to include `host`. Do NOT add
1319
+ // `host` here — it would reference a column that doesn't exist yet.
1308
1320
  db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)');
1309
1321
  console.log(' Created github_pr_cache table');
1310
1322
  } else {
@@ -2159,6 +2171,72 @@ const MIGRATIONS = {
2159
2171
  console.log(' Table tours already exists');
2160
2172
  }
2161
2173
  console.log('Migration to schema version 48 complete');
2174
+ },
2175
+
2176
+ // Migration to version 49: Add is_file_level flag to external_comments so
2177
+ // file-level review comments (GitHub subject_type='file') can be rendered in
2178
+ // the per-file comments zone instead of as a bogus line-1 annotation.
2179
+ // Idempotent single-column ALTER guarded by columnExists — safe to re-run.
2180
+ // A single DDL statement needs no transaction wrapper.
2181
+ 49: (db) => {
2182
+ console.log('Running migration to schema version 49: Add is_file_level to external_comments...');
2183
+ if (!tableExists(db, 'external_comments')) {
2184
+ // Table not created yet (older instance below version 45). Migration 45
2185
+ // creates it with the current base schema; nothing to alter here.
2186
+ console.log(' external_comments table not present; nothing to alter');
2187
+ } else if (!columnExists(db, 'external_comments', 'is_file_level')) {
2188
+ db.exec('ALTER TABLE external_comments ADD COLUMN is_file_level INTEGER NOT NULL DEFAULT 0');
2189
+ console.log(' Added is_file_level column to external_comments');
2190
+ } else {
2191
+ console.log(' Column is_file_level already exists');
2192
+ }
2193
+ console.log('Migration to schema version 49 complete');
2194
+ },
2195
+
2196
+ // Migration to version 50: Add host column to pr_metadata and github_pr_cache
2197
+ // for per-PR host resolution (dual GitHub + alt-host repos). NULL means
2198
+ // github.com; otherwise the configured api_host URL string. No backfill — the
2199
+ // NULL-means-github fallback makes existing rows resolve identically, and rows
2200
+ // are re-stamped on next fetch. Each ALTER is guarded by columnExists so the
2201
+ // migration is idempotent; single DDL statements need no transaction wrapper.
2202
+ 50: (db) => {
2203
+ console.log('Running migration to schema version 50: Add host to pr_metadata and github_pr_cache...');
2204
+ if (tableExists(db, 'pr_metadata') && !columnExists(db, 'pr_metadata', 'host')) {
2205
+ db.exec('ALTER TABLE pr_metadata ADD COLUMN host TEXT');
2206
+ console.log(' Added host column to pr_metadata');
2207
+ } else {
2208
+ console.log(' Column host already exists on pr_metadata (or table absent)');
2209
+ }
2210
+ if (tableExists(db, 'github_pr_cache') && !columnExists(db, 'github_pr_cache', 'host')) {
2211
+ db.exec('ALTER TABLE github_pr_cache ADD COLUMN host TEXT');
2212
+ console.log(' Added host column to github_pr_cache');
2213
+ } else {
2214
+ console.log(' Column host already exists on github_pr_cache (or table absent)');
2215
+ }
2216
+ console.log('Migration to schema version 50 complete');
2217
+ },
2218
+
2219
+ // Migration to version 51: widen idx_github_pr_cache_unique to include host.
2220
+ // The collections refresh inserts github rows (host NULL) and alt-host rows
2221
+ // (host = api_host URL) under the same collection in one transaction. For a
2222
+ // dual-host repo whose PRs are numbered independently per system, a shared
2223
+ // (collection, owner, repo, number) would violate the old 4-column unique
2224
+ // index — and the single throw rolls back the DELETE plus BOTH insert loops,
2225
+ // so the user sees zero PRs for that collection. Adding host to the key lets
2226
+ // the two systems' same-numbered PRs coexist. The new index is strictly wider
2227
+ // than the old one, so existing data can never violate it. Idempotent:
2228
+ // DROP IF EXISTS then CREATE IF NOT EXISTS yields the same shape on re-run and
2229
+ // is safe on both a v50 DB (old 4-column index) and a fresh DB.
2230
+ 51: (db) => {
2231
+ console.log('Running migration to schema version 51: widen idx_github_pr_cache_unique to include host...');
2232
+ if (tableExists(db, 'github_pr_cache')) {
2233
+ db.exec('DROP INDEX IF EXISTS idx_github_pr_cache_unique');
2234
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number, host)');
2235
+ console.log(' Rebuilt idx_github_pr_cache_unique with host in the key');
2236
+ } else {
2237
+ console.log(' Table github_pr_cache absent; nothing to reindex');
2238
+ }
2239
+ console.log('Migration to schema version 51 complete');
2162
2240
  }
2163
2241
  };
2164
2242
 
@@ -4974,6 +5052,52 @@ class PRMetadataRepository {
4974
5052
  };
4975
5053
  }
4976
5054
 
5055
+ /**
5056
+ * Get the stored host binding for a PR.
5057
+ *
5058
+ * Distinguishes "no row" from "row says github". Used by per-PR host
5059
+ * resolution (dual GitHub + alt-host repos) to decide which system a PR
5060
+ * lives on before building a binding.
5061
+ *
5062
+ * @param {string} repository - Repository in owner/repo format
5063
+ * @param {number} prNumber - Pull request number
5064
+ * @returns {Promise<string|null|undefined>} The stored api_host URL string,
5065
+ * `null` when the row exists but host is unset (github.com), or `undefined`
5066
+ * when no pr_metadata row exists for the PR.
5067
+ */
5068
+ async getPRHost(repository, prNumber) {
5069
+ const row = await queryOne(this.db, `
5070
+ SELECT host FROM pr_metadata
5071
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
5072
+ `, [prNumber, repository]);
5073
+
5074
+ if (!row) return undefined;
5075
+ return row.host;
5076
+ }
5077
+
5078
+ /**
5079
+ * Update ONLY the host binding for a PR's metadata row.
5080
+ *
5081
+ * Used to persist an explicit host correction (e.g. a `?host` query param on
5082
+ * the /pr fast path) without re-running full setup. Leaves every other column
5083
+ * untouched.
5084
+ *
5085
+ * @param {string} repository - Repository in owner/repo format
5086
+ * @param {number} prNumber - Pull request number
5087
+ * @param {string|null} host - api_host URL string, or null for github.com
5088
+ * @returns {Promise<boolean>} True if a matching row was updated, false when
5089
+ * no pr_metadata row exists for the PR.
5090
+ */
5091
+ async updatePRHost(repository, prNumber, host) {
5092
+ const result = await run(this.db, `
5093
+ UPDATE pr_metadata
5094
+ SET host = ?, updated_at = CURRENT_TIMESTAMP
5095
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
5096
+ `, [host, prNumber, repository]);
5097
+
5098
+ return result.changes > 0;
5099
+ }
5100
+
4977
5101
  /**
4978
5102
  * Update the last_ai_run_id for a PR metadata record
4979
5103
  * @param {number} id - PR metadata record ID
@@ -5007,7 +5131,7 @@ class AnalysisRunRepository {
5007
5131
  * @param {Object} runInfo - Run information
5008
5132
  * @param {string} runInfo.id - Unique run ID (UUID)
5009
5133
  * @param {number} runInfo.reviewId - Review ID (references reviews.id, works for both PR and local modes)
5010
- * @param {string} [runInfo.provider] - AI provider (claude, gemini, etc.)
5134
+ * @param {string} [runInfo.provider] - AI provider (claude, antigravity, etc.)
5011
5135
  * @param {string} [runInfo.model] - AI model name
5012
5136
  * @param {string} [runInfo.customInstructions] - Merged custom instructions (kept for backward compatibility)
5013
5137
  * @param {string} [runInfo.globalInstructions] - Global instructions from ~/.pair-review/global-instructions.md