@in-the-loop-labs/pair-review 3.8.0 → 3.9.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.
package/src/mcp-stdio.js CHANGED
@@ -15,12 +15,47 @@ const logger = require('./utils/logger');
15
15
  /**
16
16
  * Redirect all console output and logger writes to stderr.
17
17
  * Must be called before any other module logs to stdout.
18
+ *
19
+ * Also sets `PAIR_REVIEW_QUIET_STDOUT=1` so that code which writes directly to
20
+ * `process.stdout` (bypassing console/logger) can detect that stdout is reserved
21
+ * and route its output to stderr instead. The only such path today is the child
22
+ * stdout of a configured checkout script (`executeCheckoutScript`,
23
+ * src/git/worktree.js). In both modes that call this function — MCP stdio (stdout
24
+ * is JSON-RPC) and headless `--json` (stdout is the JSON document) — keeping that
25
+ * child output off stdout is the correct, desired behavior.
26
+ *
27
+ * @param {Object} [opts]
28
+ * @param {boolean} [opts.quiet=false] - When true (headless `--json` without
29
+ * `--debug`), *drop* progress narration entirely rather than relocating it to
30
+ * stderr. A coding agent's shell tool captures stderr into its context window,
31
+ * so relocated narration still costs tokens; quiet mode no-ops the ungated
32
+ * `console.log/info` narration and puts the logger into quiet mode
33
+ * (suppressing info/success/log/section). `console.warn`, `console.error`,
34
+ * `logger.warn`, and `logger.error` still emit to stderr — quiet drops only
35
+ * progress narration and never swallows warnings/errors.
18
36
  */
19
- function redirectConsoleToStderr() {
20
- console.log = console.error;
21
- console.info = console.error;
22
- console.warn = console.error;
23
- logger.setOutputStream(process.stderr);
37
+ function redirectConsoleToStderr({ quiet = false } = {}) {
38
+ if (quiet) {
39
+ // Drop ungated narration; keep console.error real (→ stderr).
40
+ const noop = () => {};
41
+ console.log = noop;
42
+ console.info = noop;
43
+ // console.warn carries genuine diagnostics agents need (e.g. the
44
+ // --council/--model advisory, worktree-migration warnings) — route it to
45
+ // stderr rather than swallowing it. Quiet drops progress narration only.
46
+ console.warn = console.error;
47
+ // logger.warn writes to _stdout — point it at stderr so a warning during a
48
+ // run never corrupts the JSON document on real stdout.
49
+ logger.setOutputStream(process.stderr);
50
+ logger.setQuietEnabled(true);
51
+ } else {
52
+ console.log = console.error;
53
+ console.info = console.error;
54
+ console.warn = console.error;
55
+ logger.setOutputStream(process.stderr);
56
+ }
57
+ // Process-level signal for raw process.stdout.write paths (see JSDoc above).
58
+ process.env.PAIR_REVIEW_QUIET_STDOUT = '1';
24
59
  }
25
60
 
26
61
  /**
@@ -0,0 +1,164 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Repo-default review-configuration resolver.
4
+ *
5
+ * A single helper that decides whether a review run should use a council or a
6
+ * single provider/model, honoring (in order of precedence): explicit CLI/request
7
+ * picks, the repo's saved defaults (`repo_settings.default_council_id` /
8
+ * `default_provider` / `default_model`), and finally the global config defaults.
9
+ *
10
+ * Both the headless CLI path and the interactive web analyze routes use this so
11
+ * that a repo's `default_council_id` — previously stored but never consulted —
12
+ * is honored consistently everywhere.
13
+ *
14
+ * The council-selection object returned here is intentionally drop-in compatible
15
+ * with what `runHeadlessCouncilAnalysis` (src/councils/headless-council.js) and
16
+ * the existing `performHeadlessReview` council path (src/main.js) consume:
17
+ * `{ council, configType, councilConfig }`.
18
+ * The derivation of `configType`/`councilConfig` mirrors that code exactly
19
+ * (`council.type || 'advanced'` + `normalizeCouncilConfig`).
20
+ */
21
+
22
+ const { RepoSettingsRepository, CouncilRepository } = require('./database');
23
+ const { resolveCouncilHandle } = require('./councils/resolve-council');
24
+ const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
25
+ const logger = require('./utils/logger');
26
+
27
+ /**
28
+ * Derive the `{ council, configType, councilConfig }` selection object from a
29
+ * resolved council row, applying the same normalization + validation as the
30
+ * existing headless council path (`performHeadlessReview`, src/main.js).
31
+ *
32
+ * @param {Object} council - Full council row (parsed config), as returned by
33
+ * `resolveCouncilHandle` or `CouncilRepository.getById`.
34
+ * @returns {{ council: Object, configType: string, councilConfig: Object }}
35
+ * @throws {Error} If the council's config is invalid for its type.
36
+ * @private
37
+ */
38
+ function _buildCouncilSelection(council) {
39
+ const configType = council.type || 'advanced';
40
+ const councilConfig = normalizeCouncilConfig(council.config, configType);
41
+ // validateCouncilConfig returns an error string, or null when valid.
42
+ const validationError = validateCouncilConfig(councilConfig, configType);
43
+ if (validationError) {
44
+ throw new Error(`Invalid council "${council.name}": ${validationError}`);
45
+ }
46
+ return { council, configType, councilConfig };
47
+ }
48
+
49
+ /**
50
+ * Resolve the single-provider/model pair from the per-field precedence ladders.
51
+ *
52
+ * Mirrors the canonical (pre-refactor) headless derivation in
53
+ * `performHeadlessReview` and the MCP ladder (`src/routes/mcp.js`), which both
54
+ * let a deliberate per-invocation environment override win over a sticky,
55
+ * persisted repo default:
56
+ * provider: explicit › PAIR_REVIEW_PROVIDER › repo default › config.default_provider › config.provider › 'claude'
57
+ * model: explicit › PAIR_REVIEW_MODEL › repo default › config.default_model › config.model › 'opus'
58
+ *
59
+ * The environment variables sit ABOVE the repo defaults on purpose: `--ai-draft`
60
+ * / `--ai-review` (which route through this resolver via `performHeadlessReview`)
61
+ * historically resolved env-first, and CI/agent callers set `PAIR_REVIEW_MODEL` /
62
+ * `PAIR_REVIEW_PROVIDER` as one-shot overrides that should beat a repo's saved
63
+ * `default_model` / `default_provider`. (The interactive web "Analyze" route keeps
64
+ * its own repo-before-env single ladder in `src/routes/{pr,local}.js`; this
65
+ * resolver is consulted there only to detect council mode, not to pick the model.)
66
+ *
67
+ * Each field falls through independently, so supplying only `explicit.model`
68
+ * still resolves the provider from env/repo/config defaults (and vice versa).
69
+ *
70
+ * @param {Object} explicit - { provider, model } (either may be undefined)
71
+ * @param {Object|null} repoSettings - Row from RepoSettingsRepository.getRepoSettings
72
+ * @param {Object} config - Global config object
73
+ * @returns {{ type: 'single', provider: string, model: string }}
74
+ * @private
75
+ */
76
+ function _buildSingleSelection(explicit, repoSettings, config) {
77
+ const cfg = config || {};
78
+ const provider = explicit.provider
79
+ || process.env.PAIR_REVIEW_PROVIDER
80
+ || repoSettings?.default_provider
81
+ || cfg.default_provider
82
+ || cfg.provider
83
+ || 'claude';
84
+ const model = explicit.model
85
+ || process.env.PAIR_REVIEW_MODEL
86
+ || repoSettings?.default_model
87
+ || cfg.default_model
88
+ || cfg.model
89
+ || 'opus';
90
+ return { type: 'single', provider, model };
91
+ }
92
+
93
+ /**
94
+ * Resolve the review configuration (council vs single provider/model) for a run.
95
+ *
96
+ * Precedence (highest first):
97
+ * 1. `explicit.council` — a `--council` handle (id / id-prefix / name). Resolved
98
+ * via `resolveCouncilHandle`. A bad handle throws (fail-fast for CLI/UI).
99
+ * 2. `explicit.provider` / `explicit.model` — an explicit single-model pick
100
+ * (e.g. `--model`). Returns a single selection; any missing field falls
101
+ * through to env/repo/config defaults.
102
+ * 3. `repo_settings.default_council_id` — looked up directly by id via
103
+ * `CouncilRepository.getById` (we already hold the UUID, so no handle
104
+ * matching is needed). If the id points to a council that no longer exists,
105
+ * a warning is logged and resolution falls through to the single default.
106
+ * 4. `PAIR_REVIEW_PROVIDER` / `PAIR_REVIEW_MODEL` — one-shot env overrides
107
+ * (deliberately above repo defaults — see `_buildSingleSelection`).
108
+ * 5. `repo_settings.default_provider` / `default_model` — single selection.
109
+ * 6. Global `config` defaults — single selection (final hardcoded fallbacks
110
+ * 'claude' / 'opus').
111
+ *
112
+ * @param {Object} db - Database instance.
113
+ * @param {string} repository - Repository in `owner/repo` form (may be null/undefined
114
+ * for repos with no saved settings; treated as "no repo defaults").
115
+ * @param {Object} [explicit] - Explicit picks: `{ council, provider, model }`.
116
+ * `council` is a CLI handle string (id-prefix/name) or a pre-resolved id.
117
+ * Any field may be undefined.
118
+ * @param {Object} [config] - Global config object (default provider/model, etc.).
119
+ * @returns {Promise<{ type: 'council', council: Object, configType: string, councilConfig: Object }
120
+ * | { type: 'single', provider: string, model: string }>}
121
+ * @throws {Error} If `explicit.council` cannot be resolved or its config is invalid.
122
+ */
123
+ async function resolveReviewConfig(db, repository, explicit = {}, config = {}) {
124
+ const { council: explicitCouncil, provider: explicitProvider, model: explicitModel } = explicit || {};
125
+
126
+ // 1. Explicit --council handle wins over everything.
127
+ if (explicitCouncil) {
128
+ const council = await resolveCouncilHandle(db, explicitCouncil);
129
+ return { type: 'council', ..._buildCouncilSelection(council) };
130
+ }
131
+
132
+ // 2. Explicit single-model pick (--provider / --model). Returns single; any
133
+ // missing field still resolves from repo/config defaults via the ladder.
134
+ if (explicitProvider || explicitModel) {
135
+ const repoSettings = repository
136
+ ? await new RepoSettingsRepository(db).getRepoSettings(repository)
137
+ : null;
138
+ return _buildSingleSelection({ provider: explicitProvider, model: explicitModel }, repoSettings, config);
139
+ }
140
+
141
+ // No explicit pick — consult the repo's saved defaults.
142
+ const repoSettings = repository
143
+ ? await new RepoSettingsRepository(db).getRepoSettings(repository)
144
+ : null;
145
+
146
+ // 3. Repo default council (resolve directly by id — we already hold the UUID).
147
+ if (repoSettings?.default_council_id) {
148
+ const council = await new CouncilRepository(db).getById(repoSettings.default_council_id);
149
+ if (council) {
150
+ return { type: 'council', ..._buildCouncilSelection(council) };
151
+ }
152
+ // The configured default council no longer exists. Don't fail the run —
153
+ // fall through to the single-provider default so analysis still proceeds.
154
+ logger.warn(
155
+ `Repo default council "${repoSettings.default_council_id}" for ${repository} ` +
156
+ `was not found; falling back to default provider/model.`
157
+ );
158
+ }
159
+
160
+ // 4 & 5. Single selection from repo defaults, then global config, then hardcoded.
161
+ return _buildSingleSelection({}, repoSettings, config);
162
+ }
163
+
164
+ module.exports = { resolveReviewConfig };
@@ -13,6 +13,7 @@ const express = require('express');
13
13
  const logger = require('../utils/logger');
14
14
  const { VALID_TIERS } = require('../ai/prompts/config');
15
15
  const { getAllProvidersInfo } = require('../ai');
16
+ const { modelMatches } = require('../ai/provider');
16
17
  const { normalizeCouncilConfig, validateCouncilConfig } = require('./councils');
17
18
 
18
19
  const router = express.Router();
@@ -150,9 +151,16 @@ function sanitizeSingleConfig(config) {
150
151
  // provider (a mismatched pair that slipped past the client resolver), fall back
151
152
  // to the provider's own default rather than forwarding an invalid pair. Unknown
152
153
  // providers (custom/unavailable, not in the registry) pass through unchanged.
154
+ // A model "belongs" to the provider if config.model matches a model id OR one of
155
+ // its aliases (e.g. 'opus' is an alias of the canonical 'opus-4.8-xhigh'). Matching
156
+ // id-only would wrongly treat a valid alias as a mismatched pair and silently coerce
157
+ // it to the provider default.
153
158
  let normalizedModel = config.model;
154
159
  const providerInfo = getAllProvidersInfo().find(p => p.id === config.provider);
155
- if (providerInfo && !providerInfo.models.some(m => m.id === config.model)) {
160
+ const modelBelongsToProvider = providerInfo && providerInfo.models.some(
161
+ m => modelMatches(m, config.model)
162
+ );
163
+ if (providerInfo && !modelBelongsToProvider) {
156
164
  normalizedModel = providerInfo.defaultModel || config.model;
157
165
  }
158
166
 
@@ -239,24 +247,45 @@ function sanitizeAnalysisConfig(config) {
239
247
  return sanitizeSingleConfig(config);
240
248
  }
241
249
 
242
- router.post('/api/bulk-analysis-configs', (req, res) => {
243
- try {
244
- const result = sanitizeAnalysisConfig(req.body?.analysisConfig);
245
- if (result.error) {
246
- return res.status(400).json({ error: result.error });
247
- }
250
+ /**
251
+ * Validate + store an analysis config, returning its short id. Single source of
252
+ * truth for the in-memory store, shared by the HTTP POST handler (index-page bulk
253
+ * launcher) and the CLI interactive `--instructions` flow, which stashes the
254
+ * resolved config so the browser-side auto-analyze can pick it up via the
255
+ * `analysisConfigId` URL param (same in-process Map the GET handler reads).
256
+ *
257
+ * @param {Object} analysisConfig - Raw analysis config (single or council shape)
258
+ * @returns {{ id: string, expiresInMs: number }}
259
+ * @throws {Error} with `.statusCode = 400` when the config fails validation
260
+ */
261
+ function createBulkAnalysisConfig(analysisConfig) {
262
+ const result = sanitizeAnalysisConfig(analysisConfig);
263
+ if (result.error) {
264
+ const err = new Error(result.error);
265
+ err.statusCode = 400;
266
+ throw err;
267
+ }
248
268
 
249
- pruneExpired();
269
+ pruneExpired();
270
+
271
+ const id = crypto.randomUUID();
272
+ configs.set(id, {
273
+ analysisConfig: result.config,
274
+ expiresAt: Date.now() + CONFIG_TTL_MS
275
+ });
276
+ enforceMaxConfigs();
250
277
 
251
- const id = crypto.randomUUID();
252
- configs.set(id, {
253
- analysisConfig: result.config,
254
- expiresAt: Date.now() + CONFIG_TTL_MS
255
- });
256
- enforceMaxConfigs();
278
+ return { id, expiresInMs: CONFIG_TTL_MS };
279
+ }
257
280
 
258
- res.json({ success: true, id, expiresInMs: CONFIG_TTL_MS });
281
+ router.post('/api/bulk-analysis-configs', (req, res) => {
282
+ try {
283
+ const { id, expiresInMs } = createBulkAnalysisConfig(req.body?.analysisConfig);
284
+ res.json({ success: true, id, expiresInMs });
259
285
  } catch (error) {
286
+ if (error.statusCode === 400) {
287
+ return res.status(400).json({ error: error.message });
288
+ }
260
289
  logger.error('Failed to store bulk analysis config:', error);
261
290
  res.status(500).json({ error: 'Failed to store bulk analysis config' });
262
291
  }
@@ -287,6 +316,7 @@ function _getBulkAnalysisConfig(id) {
287
316
  }
288
317
 
289
318
  module.exports = router;
319
+ module.exports.createBulkAnalysisConfig = createBulkAnalysisConfig;
290
320
  module.exports._resetBulkAnalysisConfigs = _resetBulkAnalysisConfigs;
291
321
  module.exports._getBulkAnalysisConfig = _getBulkAnalysisConfig;
292
322
  module.exports._pruneExpired = pruneExpired;
@@ -17,7 +17,8 @@ const {
17
17
  getCachedAvailability,
18
18
  getAllCachedAvailability,
19
19
  checkAllProviders,
20
- isCheckInProgress
20
+ isCheckInProgress,
21
+ modelMatches
21
22
  } = require('../ai');
22
23
  const { normalizeRepository } = require('../utils/paths');
23
24
  const {
@@ -113,9 +114,13 @@ function resolveDefaultProviderModel(config) {
113
114
  // provider-only override like `default_provider: 'gemini'` would otherwise inherit
114
115
  // a foreign Anthropic model and return a mismatched pair. When the model does not
115
116
  // belong to the provider, derive a coherent default from the provider itself.
116
- const modelBelongs = explicitModel && providerInfo?.models?.some(m => m.id === explicitModel);
117
- if (modelBelongs) {
118
- return { provider, model: explicitModel };
117
+ // Match by canonical id OR alias so a config naming an alias (e.g. 'opus') is
118
+ // recognized — but return the canonical id, because the frontend matches model
119
+ // cards by canonical id only; returning the raw alias would fail to match and
120
+ // silently fall back to the provider default.
121
+ const matchedModel = explicitModel && providerInfo?.models?.find(m => modelMatches(m, explicitModel));
122
+ if (matchedModel) {
123
+ return { provider, model: matchedModel.id };
119
124
  }
120
125
  return { provider, model: providerInfo?.defaultModel || getDefaultModel(config) };
121
126
  }
@@ -96,9 +96,15 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
96
96
  * (branch, staged, unstaged, untracked) that were included in the diff.
97
97
  * @param {string} cwd - Working directory
98
98
  * @param {Object} context - Executable context with baseSha/headSha or scope fields
99
+ * @param {Object} [options]
100
+ * @param {boolean} [options.throwOnError=false] - When true, a git failure rethrows
101
+ * instead of being logged-and-swallowed to `[]`. Callers that have already
102
+ * established the scope is non-empty (e.g. headless local, which checks the
103
+ * session diff first) use this so an operational git error becomes a non-zero
104
+ * exit rather than masquerading as an empty changed-file set / "no changes".
99
105
  * @returns {Promise<string[]>} Changed file paths
100
106
  */
101
- async function getChangedFiles(cwd, context) {
107
+ async function getChangedFiles(cwd, context, { throwOnError = false } = {}) {
102
108
  try {
103
109
  if (context.baseSha && context.headSha) {
104
110
  const { stdout } = await execPromise(
@@ -154,6 +160,9 @@ async function getChangedFiles(cwd, context) {
154
160
  .filter(f => f.length > 0);
155
161
  return [...new Set(all)];
156
162
  } catch (error) {
163
+ if (throwOnError) {
164
+ throw new Error(`Failed to enumerate changed files in ${cwd}: ${error.message}`);
165
+ }
157
166
  logger.warn(`Could not get changed files list: ${error.message}`);
158
167
  return [];
159
168
  }
@@ -32,6 +32,7 @@ const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAUL
32
32
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
33
33
  const { getShaAbbrevLength } = require('../git/sha-abbrev');
34
34
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
35
+ const { resolveReviewConfig } = require('../review-config');
35
36
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
36
37
  const { getProviderClass, createProvider } = require('../ai/provider');
37
38
  const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
@@ -1219,6 +1220,137 @@ async function handleExecutableAnalysis(req, res, {
1219
1220
  });
1220
1221
  }
1221
1222
 
1223
+ /**
1224
+ * Launch a local-mode council analysis.
1225
+ *
1226
+ * Shared by the explicit council endpoint (`POST .../analyses/council`) and the
1227
+ * plain-analyze default path (`POST .../analyses`) when a repo's
1228
+ * `default_council_id` resolves to a council and the request made no explicit
1229
+ * single-model pick. Both entry points build the same modeContext and call
1230
+ * `analysesRouter.launchCouncilAnalysis`, so council dispatch is not duplicated.
1231
+ *
1232
+ * The caller is responsible for resolving + validating `councilConfig`/`configType`
1233
+ * and for the empty-scope guard (`rejectIfEmptyScope`) before invoking this.
1234
+ *
1235
+ * @returns {{ analysisId: string, runId: string }}
1236
+ */
1237
+ async function launchLocalCouncilAnalysis(req, {
1238
+ reviewId, review, localPath, councilConfig, councilId, configType,
1239
+ requestInstructions, excludePrevious
1240
+ }) {
1241
+ const db = req.app.get('db');
1242
+
1243
+ const { start: councilScopeStart, end: councilScopeEnd } = reviewScope(review);
1244
+ const councilHasBranch = includesBranch(councilScopeStart);
1245
+
1246
+ // Compute merge-base when branch is in scope
1247
+ let analysisBaseSha = review.local_head_sha;
1248
+ if (councilHasBranch && review.local_base_branch) {
1249
+ try {
1250
+ analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
1251
+ } catch {
1252
+ // Fall back to HEAD
1253
+ }
1254
+ }
1255
+
1256
+ const prMetadata = {
1257
+ reviewType: 'local',
1258
+ repository: review.repository,
1259
+ title: null,
1260
+ description: null,
1261
+ base_sha: analysisBaseSha,
1262
+ head_sha: review.local_head_sha,
1263
+ base_branch: review.local_base_branch || null,
1264
+ head_branch: review.local_head_branch || null,
1265
+ scopeStart: councilScopeStart,
1266
+ scopeEnd: councilScopeEnd,
1267
+ };
1268
+
1269
+ // Use the scope-aware helper so the file list matches the generated diff
1270
+ // (covers branch, staged, unstaged, and untracked stops as appropriate).
1271
+ const changedFiles = await getChangedFiles(localPath, {
1272
+ scopeStart: councilScopeStart,
1273
+ scopeEnd: councilScopeEnd,
1274
+ baseBranch: review.local_base_branch || null,
1275
+ });
1276
+
1277
+ // Generate and cache diff. Hoist the result out of the try so we can also
1278
+ // persist it to `local_diffs` below (after reviewRepo is constructed) — the
1279
+ // council path previously cached the diff in-memory only, which left the
1280
+ // manual tour/summary buttons reporting a false "no-diff" after a restart.
1281
+ let councilDiff = null;
1282
+ let councilStats = null;
1283
+ let councilDigest = null;
1284
+ try {
1285
+ const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
1286
+ councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
1287
+ councilDiff = diffResult.diff;
1288
+ councilStats = diffResult.stats;
1289
+ setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
1290
+ } catch (diffError) {
1291
+ logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
1292
+ }
1293
+
1294
+ // Resolve instructions
1295
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1296
+ const reviewRepo = new ReviewRepository(db);
1297
+
1298
+ // Durably persist the diff so it survives a restart and the manual
1299
+ // tour/summary buttons can find it (parity with the analysis-push path).
1300
+ if (councilDiff) {
1301
+ try {
1302
+ await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
1303
+ } catch (saveError) {
1304
+ logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
1305
+ }
1306
+ }
1307
+ const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
1308
+ const repoInstructions = repoSettings?.default_instructions || null;
1309
+
1310
+ if (requestInstructions) {
1311
+ await reviewRepo.updateReview(reviewId, {
1312
+ customInstructions: requestInstructions
1313
+ });
1314
+ }
1315
+
1316
+ // Import launchCouncilAnalysis from analyses.js
1317
+ const analysesRouter = require('./analyses');
1318
+ const localCouncilConfig = req.app.get('config') || {};
1319
+
1320
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
1321
+ buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
1322
+
1323
+ // Local mode has no associated GitHub PR, so we do not pass a githubClient.
1324
+ // The analyzer drops the GitHub dedup section when no client is supplied.
1325
+ return analysesRouter.launchCouncilAnalysis(
1326
+ db,
1327
+ {
1328
+ reviewId,
1329
+ worktreePath: localPath,
1330
+ prMetadata,
1331
+ changedFiles,
1332
+ repository: review.repository,
1333
+ headSha: review.local_head_sha,
1334
+ logLabel: `local review #${reviewId}`,
1335
+ initialStatusExtra: { reviewId, reviewType: 'local' },
1336
+ config: localCouncilConfig,
1337
+ excludePrevious,
1338
+ serverPort: req.socket.localPort,
1339
+ providerOverrides: councilProviderOverrides,
1340
+ providerOverridesMap: councilProviderOverridesMap,
1341
+ hookContext: {
1342
+ mode: 'local',
1343
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1344
+ },
1345
+ runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
1346
+ },
1347
+ councilConfig,
1348
+ councilId,
1349
+ { globalInstructions: localCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
1350
+ configType
1351
+ );
1352
+ }
1353
+
1222
1354
  /**
1223
1355
  * Start Level 1 AI analysis for local review
1224
1356
  */
@@ -1273,6 +1405,40 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1273
1405
 
1274
1406
  const appConfig = req.app.get('config') || {};
1275
1407
 
1408
+ // Repo default-council parity: when the request makes NO explicit single-model
1409
+ // pick (no provider/model in the body), honor the repo's saved
1410
+ // default_council_id by dispatching to the same council path the explicit
1411
+ // council endpoint uses. An explicit provider/model in the request always
1412
+ // wins and falls through to the single-provider path unchanged below.
1413
+ // For repos with no default_council_id the resolver returns type:'single' and
1414
+ // we fall through, so single-provider behavior is byte-identical to before.
1415
+ if (!requestProvider && !requestModel) {
1416
+ const reviewConfig = await resolveReviewConfig(
1417
+ db,
1418
+ repository,
1419
+ { provider: requestProvider, model: requestModel },
1420
+ appConfig
1421
+ );
1422
+ if (reviewConfig.type === 'council') {
1423
+ logger.log('API', `Honoring repo default council for ${repository}: ${reviewConfig.council.name}`, 'cyan');
1424
+ const { analysisId: councilAnalysisId, runId: councilRunId } = await launchLocalCouncilAnalysis(req, {
1425
+ reviewId, review, localPath,
1426
+ councilConfig: reviewConfig.councilConfig,
1427
+ councilId: reviewConfig.council.id,
1428
+ configType: reviewConfig.configType,
1429
+ requestInstructions,
1430
+ excludePrevious
1431
+ });
1432
+ return res.json({
1433
+ analysisId: councilAnalysisId,
1434
+ runId: councilRunId,
1435
+ status: 'started',
1436
+ message: 'Council analysis started in background',
1437
+ isCouncil: true
1438
+ });
1439
+ }
1440
+ }
1441
+
1276
1442
  // Determine provider: request body > repo settings > config > default ('claude')
1277
1443
  let selectedProvider;
1278
1444
  if (requestProvider) {
@@ -2238,117 +2404,12 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2238
2404
  // Guard: reject if scope resolves to zero changed files
2239
2405
  if (await rejectIfEmptyScope(res, review, localPath)) return;
2240
2406
 
2241
- const { start: councilScopeStart, end: councilScopeEnd } = reviewScope(review);
2242
- const councilHasBranch = includesBranch(councilScopeStart);
2243
-
2244
- // Compute merge-base when branch is in scope
2245
- let analysisBaseSha = review.local_head_sha;
2246
- if (councilHasBranch && review.local_base_branch) {
2247
- try {
2248
- analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
2249
- } catch {
2250
- // Fall back to HEAD
2251
- }
2252
- }
2253
-
2254
- const prMetadata = {
2255
- reviewType: 'local',
2256
- repository: review.repository,
2257
- title: null,
2258
- description: null,
2259
- base_sha: analysisBaseSha,
2260
- head_sha: review.local_head_sha,
2261
- base_branch: review.local_base_branch || null,
2262
- head_branch: review.local_head_branch || null,
2263
- scopeStart: councilScopeStart,
2264
- scopeEnd: councilScopeEnd,
2265
- };
2266
-
2267
- // Use the scope-aware helper so the file list matches the generated diff
2268
- // (covers branch, staged, unstaged, and untracked stops as appropriate).
2269
- const changedFiles = await getChangedFiles(localPath, {
2270
- scopeStart: councilScopeStart,
2271
- scopeEnd: councilScopeEnd,
2272
- baseBranch: review.local_base_branch || null,
2407
+ const { analysisId, runId } = await launchLocalCouncilAnalysis(req, {
2408
+ reviewId, review, localPath, councilConfig, councilId, configType,
2409
+ requestInstructions: rawInstructions?.trim() || null,
2410
+ excludePrevious
2273
2411
  });
2274
2412
 
2275
- // Generate and cache diff. Hoist the result out of the try so we can also
2276
- // persist it to `local_diffs` below (after reviewRepo is constructed) — the
2277
- // council path previously cached the diff in-memory only, which left the
2278
- // manual tour/summary buttons reporting a false "no-diff" after a restart.
2279
- let councilDiff = null;
2280
- let councilStats = null;
2281
- let councilDigest = null;
2282
- try {
2283
- const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
2284
- councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
2285
- councilDiff = diffResult.diff;
2286
- councilStats = diffResult.stats;
2287
- setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
2288
- } catch (diffError) {
2289
- logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
2290
- }
2291
-
2292
- // Resolve instructions
2293
- const repoSettingsRepo = new RepoSettingsRepository(db);
2294
- const reviewRepo = new ReviewRepository(db);
2295
-
2296
- // Durably persist the diff so it survives a restart and the manual
2297
- // tour/summary buttons can find it (parity with the analysis-push path).
2298
- if (councilDiff) {
2299
- try {
2300
- await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
2301
- } catch (saveError) {
2302
- logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
2303
- }
2304
- }
2305
- const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
2306
- const repoInstructions = repoSettings?.default_instructions || null;
2307
- const requestInstructions = rawInstructions?.trim() || null;
2308
-
2309
- if (requestInstructions) {
2310
- await reviewRepo.updateReview(reviewId, {
2311
- customInstructions: requestInstructions
2312
- });
2313
- }
2314
-
2315
- // Import launchCouncilAnalysis from analyses.js
2316
- const analysesRouter = require('./analyses');
2317
- const localCouncilConfig = req.app.get('config') || {};
2318
-
2319
- const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2320
- buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
2321
-
2322
- // Local mode has no associated GitHub PR, so we do not pass a githubClient.
2323
- // The analyzer drops the GitHub dedup section when no client is supplied.
2324
- const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2325
- db,
2326
- {
2327
- reviewId,
2328
- worktreePath: localPath,
2329
- prMetadata,
2330
- changedFiles,
2331
- repository: review.repository,
2332
- headSha: review.local_head_sha,
2333
- logLabel: `local review #${reviewId}`,
2334
- initialStatusExtra: { reviewId, reviewType: 'local' },
2335
- config: localCouncilConfig,
2336
- excludePrevious,
2337
- serverPort: req.socket.localPort,
2338
- providerOverrides: councilProviderOverrides,
2339
- providerOverridesMap: councilProviderOverridesMap,
2340
- hookContext: {
2341
- mode: 'local',
2342
- localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
2343
- },
2344
- runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
2345
- },
2346
- councilConfig,
2347
- councilId,
2348
- { globalInstructions: localCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
2349
- configType
2350
- );
2351
-
2352
2413
  res.json({
2353
2414
  analysisId,
2354
2415
  runId,