@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
@@ -68,6 +68,89 @@ function getModel(req) {
68
68
  return 'opus';
69
69
  }
70
70
 
71
+ /**
72
+ * Get the provider to use for AI analysis
73
+ * Priority: CLI flag (PAIR_REVIEW_PROVIDER env var) > config.default_provider
74
+ * > config.provider (legacy) > 'claude' default
75
+ * Mirrors getModel() so the --provider CLI flag / PAIR_REVIEW_PROVIDER env var
76
+ * is honored by the web/UI analysis paths the same way --model is.
77
+ * @param {Object} req - Express request object
78
+ * @returns {string} Provider id to use
79
+ */
80
+ function getProvider(req) {
81
+ // CLI flag takes priority (passed via environment variable)
82
+ if (process.env.PAIR_REVIEW_PROVIDER) {
83
+ return process.env.PAIR_REVIEW_PROVIDER;
84
+ }
85
+
86
+ // Config file setting (default_provider preferred, provider for backwards compatibility)
87
+ const config = req.app.get('config');
88
+ if (config) {
89
+ if (config.default_provider) {
90
+ return config.default_provider;
91
+ }
92
+ // Backwards compatibility with old config key
93
+ if (config.provider) {
94
+ return config.provider;
95
+ }
96
+ }
97
+
98
+ // Default fallback
99
+ return 'claude';
100
+ }
101
+
102
+ /**
103
+ * Resolve the provider/model pair for a web/UI-triggered analysis.
104
+ *
105
+ * Precedence (highest first):
106
+ * 1. request body (requestProvider / requestModel)
107
+ * 2. env/CLI override (PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL, via getProvider/getModel)
108
+ * 3. saved repo settings (default_provider / default_model)
109
+ * 4. config/legacy defaults (via getProvider/getModel, ending at 'claude'/'opus')
110
+ *
111
+ * The env/CLI override intentionally outranks saved repo settings to match the
112
+ * documented `CLI/env > repo settings` contract already honored by the
113
+ * headless/stack/MCP paths. Because getProvider()/getModel() read the env var
114
+ * first, calling them in the env branch returns the override; the final branch
115
+ * (reached only when the env var is unset) returns config/legacy/default.
116
+ *
117
+ * Both the PR route (src/routes/pr.js) and the local route (src/routes/local.js)
118
+ * MUST use this helper so the two paths resolve the same pair — keeping the
119
+ * resolution logic in one place prevents the paths from silently diverging.
120
+ *
121
+ * @param {Object} req - Express request (source of config + env-backed helpers)
122
+ * @param {Object} [opts]
123
+ * @param {string} [opts.requestProvider] - provider from the request body
124
+ * @param {string} [opts.requestModel] - model from the request body
125
+ * @param {Object} [opts.repoSettings] - saved repo settings row (may be null)
126
+ * @returns {{ provider: string, model: string }}
127
+ */
128
+ function resolveProviderModel(req, { requestProvider, requestModel, repoSettings } = {}) {
129
+ let provider;
130
+ if (requestProvider) {
131
+ provider = requestProvider;
132
+ } else if (process.env.PAIR_REVIEW_PROVIDER) {
133
+ provider = getProvider(req); // env/CLI override
134
+ } else if (repoSettings && repoSettings.default_provider) {
135
+ provider = repoSettings.default_provider;
136
+ } else {
137
+ provider = getProvider(req); // config/legacy/'claude'
138
+ }
139
+
140
+ let model;
141
+ if (requestModel) {
142
+ model = requestModel;
143
+ } else if (process.env.PAIR_REVIEW_MODEL) {
144
+ model = getModel(req); // env/CLI override
145
+ } else if (repoSettings && repoSettings.default_model) {
146
+ model = repoSettings.default_model;
147
+ } else {
148
+ model = getModel(req); // config/legacy/'opus'
149
+ }
150
+
151
+ return { provider, model };
152
+ }
153
+
71
154
  /**
72
155
  * Determine completion level and suggestion counts from analysis result
73
156
  * @param {Object} result - Analysis result object
@@ -474,6 +557,8 @@ module.exports = {
474
557
  activeProcesses,
475
558
  activeSetups,
476
559
  getModel,
560
+ getProvider,
561
+ resolveProviderModel,
477
562
  determineCompletionInfo,
478
563
  broadcastProgress,
479
564
  broadcastIndexAnalysisEvent,
@@ -20,6 +20,7 @@ const { mergeInstructions } = require('../utils/instructions');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { GitHubClient } = require('../github/client');
22
22
  const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveRepoOptions, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
23
+ const { storedHostToOption } = require('../utils/host-resolution');
23
24
  const { setupStackPR } = require('../setup/stack-setup');
24
25
  const Analyzer = require('../ai/analyzer');
25
26
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -253,7 +254,14 @@ async function executeStackAnalysis(params) {
253
254
  // monorepo-style `url_pattern` configs (one `repos[...]` entry serves many
254
255
  // captured owner/repo pairs). The PR identity is still used for DB rows and
255
256
  // worktree identity.
256
- const stackBinding = deps.resolveHostBinding(bindingRepository, config);
257
+ // Resolve the stack binding from the MAIN (trigger) PR's stored host so every
258
+ // sibling is fetched from — and later stamped with — the same host. Stacks
259
+ // don't span systems, so a dual repo's alt-hosted stack must not fall back to
260
+ // github via the two-arg ambiguity rule. `storedHostToOption` applies the
261
+ // legacy-NULL convention; `undefined` (no row) preserves the ambiguity rule.
262
+ const mainStoredHost = await new PRMetadataRepository(db).getPRHost(repository, triggerPRNumber);
263
+ const stackHostOption = storedHostToOption(config, bindingRepository, mainStoredHost);
264
+ const stackBinding = deps.resolveHostBinding(bindingRepository, config, stackHostOption || {});
257
265
  const githubToken = stackBinding.token;
258
266
  const prDataMap = new Map();
259
267
  if (githubToken) {
@@ -365,6 +373,32 @@ async function executeStackAnalysis(params) {
365
373
  }
366
374
  }
367
375
 
376
+ /**
377
+ * Resolve the provider/model pair for a single/executable stack analysis.
378
+ *
379
+ * Precedence (highest first): request body > env/CLI override
380
+ * (PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL) > saved repo settings >
381
+ * config default > legacy config key > hard default ('claude' / 'opus').
382
+ *
383
+ * Both env vars are read together so a CLI invocation like
384
+ * `pair-review 123 --provider codex --model gpt-5.5` (which mirrors BOTH flags
385
+ * into the environment) resolves the requested pair, rather than pairing a
386
+ * non-default provider with a default Claude model.
387
+ *
388
+ * @param {Object} [args]
389
+ * @param {string} [args.reqProvider] - provider from the analysis request body
390
+ * @param {string} [args.reqModel] - model from the analysis request body
391
+ * @param {Object} [args.repoSettings] - saved repo settings row (may be null)
392
+ * @param {Object} [args.config] - loaded app config
393
+ * @returns {{ provider: string, model: string }}
394
+ */
395
+ function _resolveStackProviderModel({ reqProvider, reqModel, repoSettings, config } = {}) {
396
+ const cfg = config || {};
397
+ const provider = reqProvider || process.env.PAIR_REVIEW_PROVIDER || repoSettings?.default_provider || cfg.default_provider || cfg.provider || 'claude';
398
+ const model = reqModel || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || cfg.default_model || cfg.model || 'opus';
399
+ return { provider, model };
400
+ }
401
+
368
402
  /**
369
403
  * Run setup + analysis for a single PR in the stack.
370
404
  * Called in parallel for all PRs.
@@ -437,8 +471,9 @@ async function analyzeStackPR(deps, db, config, {
437
471
  providerOverridesMap: councilProviderOverridesMap
438
472
  });
439
473
  } else {
440
- let selectedProvider = reqProvider || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
441
- let selectedModel = reqModel || repoSettings?.default_model || config.default_model || config.model || 'opus';
474
+ const { provider: selectedProvider, model: selectedModel } = _resolveStackProviderModel({
475
+ reqProvider, reqModel, repoSettings, config
476
+ });
442
477
 
443
478
  // Resolve load_skills across all config tiers
444
479
  const providerLoadSkills = config.providers?.[selectedProvider]?.load_skills;
@@ -959,3 +994,4 @@ module.exports.activeStackAnalyses = activeStackAnalyses;
959
994
  module.exports.executeStackAnalysis = executeStackAnalysis;
960
995
  module.exports.waitForAnalysisCompletion = waitForAnalysisCompletion;
961
996
  module.exports.estimateCouncilTimeout = estimateCouncilTimeout;
997
+ module.exports._resolveStackProviderModel = _resolveStackProviderModel;
package/src/server.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const express = require('express');
3
3
  const path = require('path');
4
- const { loadConfig, getGitHubToken, resolveDbName, warnIfDevModeWithoutDbName } = require('./config');
5
- const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./database');
4
+ const { loadConfig, getGitHubToken, resolveDbName, warnIfDevModeWithoutDbName, getRepoConfig, resolveBindingRepositoryFromPR, isExclusiveAltHost } = require('./config');
5
+ const { initializeDatabase, getDatabaseStatus, queryOne, run, PRMetadataRepository } = require('./database');
6
6
  const { normalizeRepository } = require('./utils/paths');
7
7
  const { applyConfigOverrides, checkAllProviders } = require('./ai');
8
8
  const { checkAllChatProviders } = require('./chat/chat-providers');
@@ -27,6 +27,69 @@ function applyEnvOverrides(config) {
27
27
  return config;
28
28
  }
29
29
 
30
+ /**
31
+ * Apply an explicit `?host` correction from the /pr fast path.
32
+ *
33
+ * Dashboard clicks and pasted URLs can carry `?host` to say which system a PR
34
+ * lives on. When the PR already has metadata + a worktree, the route serves
35
+ * pr.html directly (no setup), so this is the only place that consumes the
36
+ * hint on the fast path. It is a LABEL correction only — the no-collision
37
+ * assumption means a same-numbered PR is the same logical PR, so no re-fetch.
38
+ *
39
+ * Sentinel mapping mirrors setup.html: `'github'` → null (github.com), any
40
+ * other non-empty string → an alt `api_host` URL, absent/empty → no-op. The
41
+ * target is validated against config so a stale link can't stamp a host the
42
+ * repo isn't configured for (which `resolveHostBinding` would later reject);
43
+ * on any mismatch we warn and ignore rather than break the page load.
44
+ *
45
+ * @param {Object} db - Database handle
46
+ * @param {Object} config - Loaded config
47
+ * @param {string} owner - URL owner segment
48
+ * @param {string} repo - URL repo segment
49
+ * @param {string} repository - Normalized `owner/repo` used as the pr_metadata key
50
+ * @param {number} prNumber - PR number
51
+ * @param {*} rawHost - `req.query.host` (already URL-decoded by Express)
52
+ * @returns {Promise<void>}
53
+ */
54
+ async function applyHostQueryCorrection(db, config, owner, repo, repository, prNumber, rawHost) {
55
+ if (rawHost === undefined || rawHost === null || rawHost === '') return;
56
+ if (typeof rawHost !== 'string') return;
57
+
58
+ const targetHost = rawHost === 'github' ? null : rawHost;
59
+
60
+ // Resolve the repo's config entry (handles monorepo-style keys matched via
61
+ // url_pattern) to learn its configured api_host.
62
+ const bindingRepo = resolveBindingRepositoryFromPR(owner, repo, config);
63
+ const repoConfig = getRepoConfig(config, bindingRepo);
64
+ const configuredApiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
65
+ ? repoConfig.api_host
66
+ : null;
67
+
68
+ if (targetHost !== null) {
69
+ // A string host must match this repo's configured api_host exactly.
70
+ if (targetHost !== configuredApiHost) {
71
+ logger.warn(`Ignoring ?host correction for ${repository} #${prNumber}: "${targetHost}" does not match configured api_host (${configuredApiHost || 'none'})`);
72
+ return;
73
+ }
74
+ } else if (isExclusiveAltHost(repoConfig)) {
75
+ // The github sentinel is meaningless for an exclusive alt-host repo (it has
76
+ // no github.com presence); stamping null would break binding resolution.
77
+ logger.warn(`Ignoring ?host=github correction for ${repository} #${prNumber}: repo is an exclusive alt-host repo with no github.com presence`);
78
+ return;
79
+ }
80
+
81
+ const prMetadataRepo = new PRMetadataRepository(db);
82
+ const stored = await prMetadataRepo.getPRHost(repository, prNumber);
83
+ // Fast path only runs when a row exists, so `stored` is null or a string.
84
+ // Skip the write when it already matches (avoids a needless updated_at bump).
85
+ if (stored === targetHost) return;
86
+
87
+ const changed = await prMetadataRepo.updatePRHost(repository, prNumber, targetHost);
88
+ if (changed) {
89
+ logger.info(`Corrected stored host for ${repository} #${prNumber} to ${targetHost === null ? 'github.com' : targetHost} via ?host`);
90
+ }
91
+ }
92
+
30
93
  /**
31
94
  * Request logging middleware (disabled for cleaner output)
32
95
  */
@@ -276,6 +339,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
276
339
  if (worktree) {
277
340
  // Update last_accessed_at so the recent reviews list reflects actual access
278
341
  run(db, 'UPDATE pr_metadata SET last_accessed_at = ? WHERE id = ?', [new Date().toISOString(), existing.id]).catch(err => logger.warn(`Failed to update last_accessed_at: ${err.message}`));
342
+ // Persist an explicit ?host correction BEFORE serving so pr.html's
343
+ // first data fetch resolves the binding against the corrected host.
344
+ // Never let a bad hint break the page — the helper swallows and warns.
345
+ try {
346
+ await applyHostQueryCorrection(db, config, owner, repo, repository, prNumber, req.query.host);
347
+ } catch (err) {
348
+ logger.warn(`Failed to apply ?host correction for ${repository} #${prNumber}: ${err.message}`);
349
+ }
279
350
  res.sendFile(path.join(__dirname, '..', 'public', 'pr.html'));
280
351
  } else {
281
352
  logger.info(`PR metadata exists but no worktree for ${repository} #${prNumber}, serving setup page`);
@@ -521,4 +592,4 @@ if (require.main === module) {
521
592
  startServer();
522
593
  }
523
594
 
524
- module.exports = { startServer, applyEnvOverrides };
595
+ module.exports = { startServer, applyEnvOverrides, applyHostQueryCorrection };
@@ -1,10 +1,10 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
- const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateScopedDiff, generateLocalReviewId, computeScopedDigest, findMainGitRoot } = require('../local-review');
2
+ const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateScopedDiff, generateLocalReviewId, computeScopedDigest, findMainGitRoot, resolveScopeAndBase, persistScopeSelection } = require('../local-review');
3
3
  const { ReviewRepository, RepoSettingsRepository } = require('../database');
4
4
  const { localReviewDiffs } = require('../routes/shared');
5
5
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
6
6
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
7
- const { STOPS, DEFAULT_SCOPE, reviewScope } = require('../local-scope');
7
+ const { STOPS } = require('../local-scope');
8
8
  const logger = require('../utils/logger');
9
9
  const { LOCAL_REVIEW_PATH_URL_ERROR, rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
10
10
  const path = require('path');
@@ -21,9 +21,12 @@ const fs = require('fs').promises;
21
21
  * @param {string} options.targetPath - Path to review (file or directory)
22
22
  * @param {Function} [options.onProgress] - Progress callback ({ step, status, message })
23
23
  * @param {Object} [options.config] - App config (for hooks)
24
+ * @param {Object} [options.flags] - Scope override { scope?, base? } forwarded from
25
+ * a delegated `--scope`/`--base` launch. The route MUST have validated these
26
+ * first. Absent flags leave scope resolution at persisted/default (unchanged).
24
27
  * @returns {Promise<Object>} Review session info
25
28
  */
26
- async function setupLocalReview({ db, targetPath, onProgress, config }) {
29
+ async function setupLocalReview({ db, targetPath, onProgress, config, flags = {} }) {
27
30
  const progress = typeof onProgress === 'function'
28
31
  ? onProgress
29
32
  : () => {};
@@ -104,10 +107,14 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
104
107
  }
105
108
 
106
109
  // ── Step: diff ──────────────────────────────────────────────────────
107
- let diff, stats, digest;
110
+ // Resolve scope + base via the shared helper so a delegated --scope/--base
111
+ // launch is honored identically to the cold-start CLI path. With no override
112
+ // (flags absent) this yields the persisted/default scope — unchanged behavior.
113
+ let diff, stats, digest, scopeStart, scopeEnd, baseBranch, scopeOverridden;
108
114
  try {
109
- const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
110
- const baseBranch = existingReview?.local_base_branch || null;
115
+ ({ scopeStart, scopeEnd, baseBranch, scopeOverridden } = await resolveScopeAndBase({
116
+ existingReview, flags, repoPath, branch, repository, config
117
+ }));
111
118
 
112
119
  progress({ step: 'diff', status: 'running', message: 'Generating diff for local changes...' });
113
120
 
@@ -146,6 +153,15 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
146
153
  });
147
154
  }
148
155
 
156
+ // Persist an explicitly-overridden scope (and its resolved base) so the web
157
+ // UI opens this delegated session at the requested scope — only after the
158
+ // diff above succeeded. No-op when no override was supplied.
159
+ if (scopeOverridden) {
160
+ await persistScopeSelection({
161
+ reviewRepo: storeRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath
162
+ });
163
+ }
164
+
149
165
  localReviewDiffs.set(sessionId, { diff, stats, digest });
150
166
 
151
167
  progress({ step: 'store', status: 'completed', message: `Review session ${sessionId} stored` });
@@ -159,7 +175,7 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
159
175
  if (config && hasHooks(hookEvent, config)) {
160
176
  getCachedUser(config).then(user => {
161
177
  const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
162
- const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
178
+ // Reflect the resolved (possibly overridden) scope, not just the persisted one.
163
179
  const si = STOPS.indexOf(scopeStart);
164
180
  const ei = STOPS.indexOf(scopeEnd);
165
181
  const scope = STOPS.slice(si, ei + 1);