@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/routes/mcp.js CHANGED
@@ -4,7 +4,7 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
4
4
  const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
5
5
  const { z } = require('zod');
6
6
  const { v4: uuidv4 } = require('uuid');
7
- const { ReviewRepository, CommentRepository, AnalysisRunRepository, RepoSettingsRepository, PRMetadataRepository, query } = require('../database');
7
+ const { ReviewRepository, CommentRepository, AnalysisRunRepository, RepoSettingsRepository, PRMetadataRepository } = require('../database');
8
8
  const { renderPromptForSkill } = require('../ai/prompts/render-for-skill');
9
9
  const Analyzer = require('../ai/analyzer');
10
10
  const { getTierForModel } = require('../ai/provider');
@@ -393,33 +393,15 @@ function createMCPServer(db, options = {}) {
393
393
  };
394
394
  }
395
395
 
396
- // Build parameterized WHERE conditions
397
- const params = [runId];
398
- const conditions = ["ai_run_id = ?", "source = 'ai'", 'ai_level IS NULL', '(is_raw = 0 OR is_raw IS NULL)'];
399
-
400
- if (args.status) {
401
- conditions.push('status = ?');
402
- params.push(args.status);
403
- } else {
404
- // Exclude dismissed by default — dismissed suggestions were explicitly
405
- // rejected by the reviewer and would be noise for an iterating agent.
406
- conditions.push("status IN ('active', 'adopted')");
407
- }
408
-
409
- if (args.file) {
410
- conditions.push('file = ?');
411
- params.push(args.file);
412
- }
413
-
414
- const filtered = await query(db, `
415
- SELECT
416
- id, ai_run_id, ai_level, ai_confidence,
417
- file, line_start, line_end, type, title, body,
418
- reasoning, status, is_file_level, severity, created_at
419
- FROM comments
420
- WHERE ${conditions.join('\n AND ')}
421
- ORDER BY file, line_start
422
- `, params);
396
+ // Resolve the run's consolidated final suggestions via the shared
397
+ // CommentRepository query. When the caller passes an explicit `status`,
398
+ // filter by just that one; otherwise default to active + adopted
399
+ // (excluding dismissed — those were explicitly rejected by the reviewer
400
+ // and would be noise for an iterating agent).
401
+ const filtered = await new CommentRepository(db).getFinalSuggestionsByRunId(runId, {
402
+ statuses: args.status ? [args.status] : ['active', 'adopted'],
403
+ file: args.file || null,
404
+ });
423
405
 
424
406
  return {
425
407
  content: [{
@@ -453,7 +435,10 @@ function createMCPServer(db, options = {}) {
453
435
  'Start an AI analysis within pair-review for local or PR changes. ' +
454
436
  'Returns immediately with tracking IDs so the caller can poll for completion. ' +
455
437
  'For local mode, provide (path + headSha). For PR mode, provide (repo + prNumber). ' +
456
- 'Use get_ai_analysis_runs with limit=1 to poll for completion, then get_ai_suggestions to fetch results.',
438
+ 'Use get_ai_analysis_runs with limit=1 to poll for completion, then get_ai_suggestions to fetch results. ' +
439
+ 'Note: this tool runs a single provider/model only — it does NOT dispatch a repo\'s ' +
440
+ 'configured default council (repo_settings.default_council_id), unlike the CLI ' +
441
+ '(--headless) and the web "Analyze" action. To run a council, use the web UI or the CLI.',
457
442
  {
458
443
  ...reviewLookupSchema,
459
444
  customInstructions: z.string().max(5000).optional()
@@ -586,7 +571,16 @@ function createMCPServer(db, options = {}) {
586
571
  logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
587
572
  }
588
573
 
589
- // Resolve provider and model
574
+ // Resolve provider and model.
575
+ //
576
+ // INTENTIONAL DIVERGENCE (documented in the tool description): MCP
577
+ // resolves a single provider/model here and does NOT consult
578
+ // resolveReviewConfig()/repo_settings.default_council_id, so a repo's
579
+ // default council is ignored on this path (the CLI --headless and the
580
+ // web "Analyze" action DO honor it). This ladder is also env-first
581
+ // (PAIR_REVIEW_* over repo defaults), matching review-config.js's
582
+ // _buildSingleSelection. Routing this through resolveReviewConfig (and
583
+ // adding council dispatch) is a deliberate follow-up, not an oversight.
590
584
  const repoSettings = repository ? await repoSettingsRepo.getRepoSettings(repository) : null;
591
585
  const provider = process.env.PAIR_REVIEW_PROVIDER || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
592
586
  const model = process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
@@ -741,7 +735,10 @@ function createMCPServer(db, options = {}) {
741
735
  // Get or create review record
742
736
  const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
743
737
 
744
- // Resolve provider and model
738
+ // Resolve provider and model. INTENTIONAL DIVERGENCE: single
739
+ // provider/model only — no resolveReviewConfig / default-council
740
+ // dispatch (see the local branch above and the tool description). The
741
+ // env-first ladder matches review-config.js's _buildSingleSelection.
745
742
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
746
743
  const provider = process.env.PAIR_REVIEW_PROVIDER || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
747
744
  const model = process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
package/src/routes/pr.js CHANGED
@@ -55,6 +55,7 @@ const { parseHunks } = require('../utils/diff-hunks');
55
55
  const { hashHunk } = require('../ai/hunk-hashing');
56
56
  const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
57
57
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
58
+ const { resolveReviewConfig } = require('../review-config');
58
59
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
59
60
  const { getProviderClass, createProvider } = require('../ai/provider');
60
61
  const { CommentRepository } = require('../database');
@@ -2038,6 +2039,84 @@ async function handleExecutablePRAnalysis(req, res, {
2038
2039
  });
2039
2040
  }
2040
2041
 
2042
+ /**
2043
+ * Launch a PR-mode council analysis.
2044
+ *
2045
+ * Shared by the explicit council endpoint (`POST .../analyses/council`) and the
2046
+ * plain-analyze default path (`POST .../analyses`) when a repo's
2047
+ * `default_council_id` resolves to a council and the request made no explicit
2048
+ * single-model pick. Both entry points build the same modeContext and call
2049
+ * `analysesRouter.launchCouncilAnalysis`, so council dispatch is not duplicated.
2050
+ *
2051
+ * Caller is responsible for resolving + validating `councilConfig`/`configType`
2052
+ * and persisting any request instructions before calling this.
2053
+ *
2054
+ * @returns {{ analysisId: string, runId: string }}
2055
+ */
2056
+ async function launchPrCouncilAnalysis(req, {
2057
+ repository, owner, repo, prNumber, prMetadata, worktreePath,
2058
+ review, reviewRepo, repoSettings, councilConfig, councilId, configType,
2059
+ repoInstructions, requestInstructions, excludePrevious
2060
+ }) {
2061
+ const db = req.app.get('db');
2062
+ const prCouncilConfig = req.app.get('config') || {};
2063
+
2064
+ const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2065
+ buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
2066
+
2067
+ // Build a GitHubClient for analyzer-side dedup pre-fetch (PR mode only).
2068
+ let councilGithubClient;
2069
+ try {
2070
+ const resolved = resolveBindingForRequest(req, repository);
2071
+ councilGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2072
+ } catch (configErr) {
2073
+ logger.warn(`Skipping GitHub dedup pre-fetch (council): ${configErr.message}`);
2074
+ councilGithubClient = undefined;
2075
+ }
2076
+ if (councilGithubClient) {
2077
+ logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (council)`);
2078
+ }
2079
+
2080
+ return analysesRouter.launchCouncilAnalysis(
2081
+ db,
2082
+ {
2083
+ reviewId: review.id,
2084
+ worktreePath,
2085
+ prMetadata,
2086
+ changedFiles: null,
2087
+ repository,
2088
+ headSha: prMetadata.head_sha,
2089
+ logLabel: `PR #${prNumber}`,
2090
+ initialStatusExtra: { prNumber, reviewType: 'pr' },
2091
+ config: prCouncilConfig,
2092
+ excludePrevious,
2093
+ serverPort: req.socket.localPort,
2094
+ githubClient: councilGithubClient,
2095
+ poolLifecycle: req.app.get('poolLifecycle'),
2096
+ providerOverrides: councilProviderOverrides,
2097
+ providerOverridesMap: councilProviderOverridesMap,
2098
+ hookContext: {
2099
+ mode: 'pr',
2100
+ prContext: {
2101
+ number: prNumber, owner, repo,
2102
+ author: prMetadata.author, baseBranch: prMetadata.base_branch,
2103
+ headBranch: prMetadata.head_branch,
2104
+ baseSha: prMetadata.base_sha || null, headSha: prMetadata.head_sha || null,
2105
+ },
2106
+ },
2107
+ onSuccess: async (result) => {
2108
+ if (result.summary) {
2109
+ await reviewRepo.upsertSummary(prNumber, repository, result.summary);
2110
+ }
2111
+ }
2112
+ },
2113
+ councilConfig,
2114
+ councilId,
2115
+ { globalInstructions: prCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
2116
+ configType
2117
+ );
2118
+ }
2119
+
2041
2120
  /**
2042
2121
  * Trigger AI analysis for a PR
2043
2122
  */
@@ -2165,6 +2244,40 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
2165
2244
 
2166
2245
  const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
2167
2246
 
2247
+ // Repo default-council parity: when the request makes NO explicit single-model
2248
+ // pick (no provider/model in the body), honor the repo's saved
2249
+ // default_council_id by dispatching to the same council path the explicit
2250
+ // council endpoint uses. An explicit provider/model in the request always
2251
+ // wins and falls through to the single-provider path unchanged below.
2252
+ // For repos with no default_council_id the resolver returns type:'single' and
2253
+ // we fall through, so single-provider behavior is byte-identical to before.
2254
+ if (!requestProvider && !requestModel) {
2255
+ const reviewConfig = await resolveReviewConfig(
2256
+ db,
2257
+ repository,
2258
+ { provider: requestProvider, model: requestModel },
2259
+ appConfig
2260
+ );
2261
+ if (reviewConfig.type === 'council') {
2262
+ logger.log('API', `Honoring repo default council for ${repository}: ${reviewConfig.council.name}`, 'cyan');
2263
+ const { analysisId: councilAnalysisId, runId: councilRunId } = await launchPrCouncilAnalysis(req, {
2264
+ repository, owner, repo, prNumber, prMetadata, worktreePath,
2265
+ review, reviewRepo, repoSettings: fetchedRepoSettings,
2266
+ councilConfig: reviewConfig.councilConfig,
2267
+ councilId: reviewConfig.council.id,
2268
+ configType: reviewConfig.configType,
2269
+ repoInstructions, requestInstructions, excludePrevious
2270
+ });
2271
+ return res.json({
2272
+ analysisId: councilAnalysisId,
2273
+ runId: councilRunId,
2274
+ status: 'started',
2275
+ message: 'Council analysis started in background',
2276
+ isCouncil: true
2277
+ });
2278
+ }
2279
+ }
2280
+
2168
2281
  // Resolve load_skills across all config tiers
2169
2282
  const providerLoadSkills = appConfig.providers?.[provider]?.load_skills;
2170
2283
  const loadSkills = resolveLoadSkills(appConfig, repository, fetchedRepoSettings, providerLoadSkills);
@@ -2518,62 +2631,11 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2518
2631
  await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
2519
2632
  }
2520
2633
 
2521
- const prCouncilConfig = req.app.get('config') || {};
2522
-
2523
- const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2524
- buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
2525
-
2526
- // Build a GitHubClient for analyzer-side dedup pre-fetch (PR mode only).
2527
- let councilGithubClient;
2528
- try {
2529
- const resolved = resolveBindingForRequest(req, repository);
2530
- councilGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2531
- } catch (configErr) {
2532
- logger.warn(`Skipping GitHub dedup pre-fetch (council): ${configErr.message}`);
2533
- councilGithubClient = undefined;
2534
- }
2535
- if (councilGithubClient) {
2536
- logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (council)`);
2537
- }
2538
-
2539
- const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2540
- db,
2541
- {
2542
- reviewId: review.id,
2543
- worktreePath,
2544
- prMetadata,
2545
- changedFiles: null,
2546
- repository,
2547
- headSha: prMetadata.head_sha,
2548
- logLabel: `PR #${prNumber}`,
2549
- initialStatusExtra: { prNumber, reviewType: 'pr' },
2550
- config: prCouncilConfig,
2551
- excludePrevious,
2552
- serverPort: req.socket.localPort,
2553
- githubClient: councilGithubClient,
2554
- poolLifecycle: req.app.get('poolLifecycle'),
2555
- providerOverrides: councilProviderOverrides,
2556
- providerOverridesMap: councilProviderOverridesMap,
2557
- hookContext: {
2558
- mode: 'pr',
2559
- prContext: {
2560
- number: prNumber, owner, repo,
2561
- author: prMetadata.author, baseBranch: prMetadata.base_branch,
2562
- headBranch: prMetadata.head_branch,
2563
- baseSha: prMetadata.base_sha || null, headSha: prMetadata.head_sha || null,
2564
- },
2565
- },
2566
- onSuccess: async (result) => {
2567
- if (result.summary) {
2568
- await reviewRepo.upsertSummary(prNumber, repository, result.summary);
2569
- }
2570
- }
2571
- },
2572
- councilConfig,
2573
- councilId,
2574
- { globalInstructions: prCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
2575
- configType
2576
- );
2634
+ const { analysisId, runId } = await launchPrCouncilAnalysis(req, {
2635
+ repository, owner, repo, prNumber, prMetadata, worktreePath,
2636
+ review, reviewRepo, repoSettings, councilConfig, councilId, configType,
2637
+ repoInstructions, requestInstructions, excludePrevious
2638
+ });
2577
2639
 
2578
2640
  res.json({
2579
2641
  analysisId,
@@ -5,6 +5,8 @@ const semver = require('semver');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const logger = require('./utils/logger');
7
7
  const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
8
+ const { normalizeRepository } = require('./utils/paths');
9
+ const { buildInteractiveAnalysisConfig } = require('./interactive-analysis-config');
8
10
  const { version: packageVersion } = require('../package.json');
9
11
 
10
12
  const HEALTH_TIMEOUT_MS = 2000;
@@ -17,7 +19,9 @@ const defaults = {
17
19
  open: (...args) => process.env.PAIR_REVIEW_NO_OPEN
18
20
  ? Promise.resolve()
19
21
  : import('open').then(({ default: open }) => open(...args)),
20
- PRArgumentParser
22
+ PRArgumentParser,
23
+ // Injected so the delegation handoff can be stubbed in tests without a DB.
24
+ buildInteractiveAnalysisConfig
21
25
  };
22
26
 
23
27
  /**
@@ -85,6 +89,71 @@ function notifyVersion(port, currentVersion, _deps) {
85
89
  req.end();
86
90
  }
87
91
 
92
+ /**
93
+ * Hand a resolved analysis config off to a DIFFERENT (already-running) pair-review
94
+ * process by POSTing it to its `/api/bulk-analysis-configs` endpoint, returning the
95
+ * short id the server assigns. This is the cross-process counterpart to the
96
+ * in-process `createBulkAnalysisConfig` store: the CLI invocation and the running
97
+ * server do not share memory, so a `--instructions` payload can only reach the
98
+ * server's store over HTTP.
99
+ *
100
+ * Rejects (does NOT swallow) on transport error, timeout, non-2xx status, or an
101
+ * unparseable/idless body — the caller converts that into a loud failure rather
102
+ * than silently dropping the instructions.
103
+ *
104
+ * @param {number} port
105
+ * @param {Object} analysisConfig - Resolved config (single or council snapshot)
106
+ * @param {object} [_deps] - Dependency overrides for testing
107
+ * @returns {Promise<string>} The stored analysis-config id
108
+ */
109
+ function storeAnalysisConfigRemote(port, analysisConfig, _deps) {
110
+ const deps = { ...defaults, ..._deps };
111
+ const payload = JSON.stringify({ analysisConfig });
112
+ return new Promise((resolve, reject) => {
113
+ const req = deps.httpRequest({
114
+ hostname: 'localhost',
115
+ port,
116
+ path: '/api/bulk-analysis-configs',
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ 'Content-Length': Buffer.byteLength(payload)
121
+ },
122
+ timeout: HEALTH_TIMEOUT_MS
123
+ }, (res) => {
124
+ let data = '';
125
+ res.on('data', (chunk) => { data += chunk; });
126
+ res.on('end', () => {
127
+ let body = null;
128
+ try {
129
+ body = data ? JSON.parse(data) : null;
130
+ } catch {
131
+ return reject(new Error(
132
+ `Invalid response from pair-review server on port ${port} while storing analysis config.`
133
+ ));
134
+ }
135
+ const status = res.statusCode || 0;
136
+ if (status >= 200 && status < 300 && body && body.id) {
137
+ resolve(body.id);
138
+ } else {
139
+ const detail = (body && body.error) || `unexpected status ${status}`;
140
+ reject(new Error(`Failed to store analysis config on the running server: ${detail}`));
141
+ }
142
+ });
143
+ });
144
+
145
+ req.on('error', (err) => {
146
+ reject(new Error(`Failed to reach pair-review server on port ${port}: ${err.message}`));
147
+ });
148
+ req.on('timeout', () => {
149
+ req.destroy();
150
+ reject(new Error(`Timed out storing analysis config on pair-review server (port ${port}).`));
151
+ });
152
+ req.write(payload);
153
+ req.end();
154
+ });
155
+ }
156
+
88
157
  /**
89
158
  * Build the URL to delegate to an existing server.
90
159
  * @param {number} port
@@ -96,6 +165,10 @@ function notifyVersion(port, currentVersion, _deps) {
96
165
  * @param {string} [context.localPath]
97
166
  * @param {boolean} [context.analyze] - Whether to trigger auto-analysis
98
167
  * @param {string} [context.councilId] - Resolved council id for council auto-analysis
168
+ * @param {string} [context.analysisConfigId] - Stored analysis-config id (encodes
169
+ * the resolved provider/model or council snapshot PLUS custom instructions). When
170
+ * present it supersedes `councilId` — the id already carries the council selection,
171
+ * mirroring the cold-start precedence in handlePullRequest/handleLocalReview.
99
172
  * @returns {string} Full URL
100
173
  */
101
174
  function buildDelegationUrl(port, mode, context = {}) {
@@ -104,15 +177,23 @@ function buildDelegationUrl(port, mode, context = {}) {
104
177
  let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
105
178
  const query = [];
106
179
  if (context.analyze) query.push('analyze=true');
107
- if (context.councilId) query.push(`council=${encodeURIComponent(context.councilId)}`);
180
+ if (context.analysisConfigId) {
181
+ query.push(`analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`);
182
+ } else if (context.councilId) {
183
+ query.push(`council=${encodeURIComponent(context.councilId)}`);
184
+ }
108
185
  if (query.length) url += `?${query.join('&')}`;
109
186
  return url;
110
187
  }
111
188
  if (mode === 'local') {
112
- // The `?path=` segment is always present, so analyze/council append with `&`.
189
+ // The `?path=` segment is always present, so analyze/config append with `&`.
113
190
  let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
114
191
  if (context.analyze) url += '&analyze=true';
115
- if (context.councilId) url += `&council=${encodeURIComponent(context.councilId)}`;
192
+ if (context.analysisConfigId) {
193
+ url += `&analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`;
194
+ } else if (context.councilId) {
195
+ url += `&council=${encodeURIComponent(context.councilId)}`;
196
+ }
116
197
  return url;
117
198
  }
118
199
  return `${base}/`;
@@ -148,6 +229,14 @@ async function parsePRArgsForDelegation(prArgs, config = null, _deps) {
148
229
  * resolves `flags.council` against the DB before delegation, since this
149
230
  * module has no DB access). When set, the delegated URL carries
150
231
  * `&council=<id>` and auto-analysis is forced on.
232
+ * @param {object|null} [options.db] - Open DB handle. Required to carry
233
+ * `--instructions` across delegation: the analysis config (provider/model or
234
+ * council snapshot + instructions) is resolved here and POSTed to the running
235
+ * server, whose returned id is threaded as `analysisConfigId`. Without it, the
236
+ * handoff is skipped and only `councilId`/`analyze` flow through (prior shape).
237
+ * @param {string|null} [options.localRepository] - owner/repo for local-mode
238
+ * repo-default resolution (the caller resolves it via getRepositoryName, the
239
+ * same value the cold-start session uses; PR mode derives it from prArgs here).
151
240
  * @returns {Promise<boolean>} true if delegated, false if should start fresh
152
241
  */
153
242
  async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
@@ -175,15 +264,35 @@ async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
175
264
  // Server is running — delegate to it
176
265
  deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
177
266
 
267
+ // Resolve + hand off the analysis config (provider/model or council snapshot +
268
+ // custom instructions) to the RUNNING server so a delegated `--ai`/`--council`
269
+ // run with `--instructions` honors them instead of silently dropping them. The
270
+ // build is gated on an analyzing mode + an open DB; it returns null when no
271
+ // instructions were supplied, in which case we fall back to the bare
272
+ // councilId/analyze params (unchanged prior behavior). A failed POST throws —
273
+ // the contract is loud-fail, never silent drop.
274
+ const handoffAnalysisConfigId = async (repository) => {
275
+ if (!analyze || !options.db) return null;
276
+ const analysisConfig = await deps.buildInteractiveAnalysisConfig({
277
+ db: options.db, config, flags, repository
278
+ });
279
+ if (!analysisConfig) return null;
280
+ deps.logger.info('Handing off analysis configuration (with instructions) to the running server');
281
+ return storeAnalysisConfigRemote(port, analysisConfig, _deps);
282
+ };
283
+
178
284
  // Determine mode and build URL
179
285
  let url;
180
286
  if (flags.local) {
181
287
  rejectUrlLikeLocalReviewPath(flags.localPath);
182
288
  const targetPath = path.resolve(flags.localPath || process.cwd());
183
- url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId });
289
+ const analysisConfigId = await handoffAnalysisConfigId(options.localRepository || null);
290
+ url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId, analysisConfigId });
184
291
  } else if (prArgs.length > 0) {
185
292
  const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
186
- url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId });
293
+ const repository = normalizeRepository(prInfo.owner, prInfo.repo);
294
+ const analysisConfigId = await handoffAnalysisConfigId(repository);
295
+ url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId, analysisConfigId });
187
296
  } else {
188
297
  url = buildDelegationUrl(port, 'server');
189
298
  }
@@ -205,6 +314,7 @@ async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
205
314
  module.exports = {
206
315
  detectRunningServer,
207
316
  notifyVersion,
317
+ storeAnalysisConfigRemote,
208
318
  buildDelegationUrl,
209
319
  parsePRArgsForDelegation,
210
320
  attemptDelegation,
@@ -30,6 +30,7 @@ class AILogger {
30
30
  this.enabled = true;
31
31
  this.debugEnabled = false;
32
32
  this.streamDebugEnabled = false;
33
+ this.quietEnabled = false;
33
34
  this._stdout = process.stdout;
34
35
  }
35
36
 
@@ -74,6 +75,26 @@ class AILogger {
74
75
  return this.streamDebugEnabled;
75
76
  }
76
77
 
78
+ /**
79
+ * Enable or disable quiet mode.
80
+ * Quiet suppresses chatty progress output (info/success/log/section) while
81
+ * still allowing warn and error through. Used in headless --json mode (the
82
+ * machine-readable sink for coding agents) so stderr carries only genuine
83
+ * warnings and errors, not progress narration.
84
+ * @param {boolean} enabled - Whether quiet mode should be enabled
85
+ */
86
+ setQuietEnabled(enabled) {
87
+ this.quietEnabled = enabled;
88
+ }
89
+
90
+ /**
91
+ * Check if quiet mode is enabled
92
+ * @returns {boolean} Whether quiet mode is enabled
93
+ */
94
+ isQuietEnabled() {
95
+ return this.quietEnabled;
96
+ }
97
+
77
98
  /**
78
99
  * Log debug message (only shown when debug is enabled)
79
100
  */
@@ -105,7 +126,7 @@ class AILogger {
105
126
  * Log AI analysis info
106
127
  */
107
128
  info(message) {
108
- if (!this.enabled) return;
129
+ if (!this.enabled || this.quietEnabled) return;
109
130
  const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
110
131
  this._stdout.write(
111
132
  `${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
@@ -118,7 +139,7 @@ class AILogger {
118
139
  * Log AI analysis success
119
140
  */
120
141
  success(message) {
121
- if (!this.enabled) return;
142
+ if (!this.enabled || this.quietEnabled) return;
122
143
  const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
123
144
  this._stdout.write(
124
145
  `${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
@@ -167,7 +188,7 @@ class AILogger {
167
188
  * Log with custom prefix
168
189
  */
169
190
  log(prefix, message, color = 'blue') {
170
- if (!this.enabled) return;
191
+ if (!this.enabled || this.quietEnabled) return;
171
192
  const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
172
193
  const prefixColor = COLORS[color] || COLORS.blue;
173
194
  this._stdout.write(
@@ -181,7 +202,7 @@ class AILogger {
181
202
  * Start a progress section
182
203
  */
183
204
  section(title) {
184
- if (!this.enabled) return;
205
+ if (!this.enabled || this.quietEnabled) return;
185
206
  this._stdout.write(
186
207
  `\n${COLORS.bright}${COLORS.cyan}${'─'.repeat(60)}${COLORS.reset}\n` +
187
208
  `${COLORS.bright}${COLORS.cyan}▶ ${title}${COLORS.reset}\n` +