@in-the-loop-labs/pair-review 3.7.2 → 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/README.md +188 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/local.js +28 -8
- package/public/js/pr.js +92 -7
- package/public/js/utils/provider-model.js +9 -2
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +31 -30
- package/src/ai/codex-provider.js +4 -3
- package/src/ai/copilot-provider.js +15 -1
- package/src/ai/cursor-agent-provider.js +4 -3
- package/src/ai/executable-provider.js +12 -4
- package/src/ai/gemini-provider.js +15 -1
- package/src/ai/index.js +14 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +294 -38
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +128 -0
- package/src/councils/resolve-council.js +167 -0
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +54 -23
- package/src/main.js +1125 -25
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +131 -5
- package/src/utils/logger.js +25 -4
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
|
|
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
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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,
|
package/src/single-port.js
CHANGED
|
@@ -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
|
|
@@ -95,18 +164,36 @@ function notifyVersion(port, currentVersion, _deps) {
|
|
|
95
164
|
* @param {number} [context.number]
|
|
96
165
|
* @param {string} [context.localPath]
|
|
97
166
|
* @param {boolean} [context.analyze] - Whether to trigger auto-analysis
|
|
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.
|
|
98
172
|
* @returns {string} Full URL
|
|
99
173
|
*/
|
|
100
174
|
function buildDelegationUrl(port, mode, context = {}) {
|
|
101
175
|
const base = `http://localhost:${port}`;
|
|
102
176
|
if (mode === 'pr') {
|
|
103
177
|
let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
|
|
104
|
-
|
|
178
|
+
const query = [];
|
|
179
|
+
if (context.analyze) query.push('analyze=true');
|
|
180
|
+
if (context.analysisConfigId) {
|
|
181
|
+
query.push(`analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`);
|
|
182
|
+
} else if (context.councilId) {
|
|
183
|
+
query.push(`council=${encodeURIComponent(context.councilId)}`);
|
|
184
|
+
}
|
|
185
|
+
if (query.length) url += `?${query.join('&')}`;
|
|
105
186
|
return url;
|
|
106
187
|
}
|
|
107
188
|
if (mode === 'local') {
|
|
189
|
+
// The `?path=` segment is always present, so analyze/config append with `&`.
|
|
108
190
|
let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
|
|
109
191
|
if (context.analyze) url += '&analyze=true';
|
|
192
|
+
if (context.analysisConfigId) {
|
|
193
|
+
url += `&analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`;
|
|
194
|
+
} else if (context.councilId) {
|
|
195
|
+
url += `&council=${encodeURIComponent(context.councilId)}`;
|
|
196
|
+
}
|
|
110
197
|
return url;
|
|
111
198
|
}
|
|
112
199
|
return `${base}/`;
|
|
@@ -137,11 +224,29 @@ async function parsePRArgsForDelegation(prArgs, config = null, _deps) {
|
|
|
137
224
|
* @param {object} flags - Parsed CLI flags
|
|
138
225
|
* @param {string[]} prArgs - PR arguments from CLI
|
|
139
226
|
* @param {object} [_deps] - Dependency overrides for testing
|
|
227
|
+
* @param {object} [options] - Pre-resolved values from the caller
|
|
228
|
+
* @param {string|null} [options.councilId] - Resolved council id (the caller
|
|
229
|
+
* resolves `flags.council` against the DB before delegation, since this
|
|
230
|
+
* module has no DB access). When set, the delegated URL carries
|
|
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).
|
|
140
240
|
* @returns {Promise<boolean>} true if delegated, false if should start fresh
|
|
141
241
|
*/
|
|
142
|
-
async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
242
|
+
async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
|
|
143
243
|
const deps = { ...defaults, ..._deps };
|
|
144
244
|
const port = config.port;
|
|
245
|
+
const councilId = options.councilId || null;
|
|
246
|
+
// A council selection implies analysis: the browser-side council
|
|
247
|
+
// auto-analysis is gated on the `council` param + `analyze=true`, mirroring
|
|
248
|
+
// the cold-start URL built in handlePullRequest/handleLocalReview.
|
|
249
|
+
const analyze = !!(flags.ai || flags.council);
|
|
145
250
|
|
|
146
251
|
const result = await detectRunningServer(port, _deps);
|
|
147
252
|
|
|
@@ -159,15 +264,35 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
|
159
264
|
// Server is running — delegate to it
|
|
160
265
|
deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
|
|
161
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
|
+
|
|
162
284
|
// Determine mode and build URL
|
|
163
285
|
let url;
|
|
164
286
|
if (flags.local) {
|
|
165
287
|
rejectUrlLikeLocalReviewPath(flags.localPath);
|
|
166
288
|
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
167
|
-
|
|
289
|
+
const analysisConfigId = await handoffAnalysisConfigId(options.localRepository || null);
|
|
290
|
+
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId, analysisConfigId });
|
|
168
291
|
} else if (prArgs.length > 0) {
|
|
169
292
|
const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
|
|
170
|
-
|
|
293
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
294
|
+
const analysisConfigId = await handoffAnalysisConfigId(repository);
|
|
295
|
+
url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId, analysisConfigId });
|
|
171
296
|
} else {
|
|
172
297
|
url = buildDelegationUrl(port, 'server');
|
|
173
298
|
}
|
|
@@ -189,6 +314,7 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
|
189
314
|
module.exports = {
|
|
190
315
|
detectRunningServer,
|
|
191
316
|
notifyVersion,
|
|
317
|
+
storeAnalysisConfigRemote,
|
|
192
318
|
buildDelegationUrl,
|
|
193
319
|
parsePRArgsForDelegation,
|
|
194
320
|
attemptDelegation,
|
package/src/utils/logger.js
CHANGED
|
@@ -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` +
|