@in-the-loop-labs/pair-review 3.8.0 → 3.9.1
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 +175 -3
- 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/css/pr.css +12 -60
- package/public/js/components/CouncilProgressModal.js +0 -10
- package/public/js/local.js +27 -57
- package/public/js/pr.js +0 -114
- package/public/js/utils/provider-model.js +9 -2
- package/public/local.html +0 -7
- package/public/pr.html +0 -7
- package/src/ai/claude-provider.js +56 -34
- package/src/ai/index.js +8 -0
- package/src/ai/provider.js +225 -27
- package/src/councils/headless-council.js +13 -3
- 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 +43 -21
- package/src/main.js +953 -46
- 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 +116 -6
- 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
|
|
@@ -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.
|
|
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/
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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` +
|