@contextrail/code-review-agent 0.1.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +26 -0
  2. package/MODEL_RECOMMENDATIONS.md +178 -0
  3. package/README.md +177 -0
  4. package/dist/config/defaults.d.ts +72 -0
  5. package/dist/config/defaults.js +113 -0
  6. package/dist/config/index.d.ts +34 -0
  7. package/dist/config/index.js +89 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +603 -0
  10. package/dist/llm/factory.d.ts +21 -0
  11. package/dist/llm/factory.js +50 -0
  12. package/dist/llm/index.d.ts +3 -0
  13. package/dist/llm/index.js +2 -0
  14. package/dist/llm/service.d.ts +38 -0
  15. package/dist/llm/service.js +191 -0
  16. package/dist/llm/types.d.ts +119 -0
  17. package/dist/llm/types.js +1 -0
  18. package/dist/logging/logger.d.ts +9 -0
  19. package/dist/logging/logger.js +52 -0
  20. package/dist/mcp/client.d.ts +429 -0
  21. package/dist/mcp/client.js +173 -0
  22. package/dist/mcp/mcp-tools.d.ts +292 -0
  23. package/dist/mcp/mcp-tools.js +40 -0
  24. package/dist/mcp/token-validation.d.ts +31 -0
  25. package/dist/mcp/token-validation.js +57 -0
  26. package/dist/mcp/tools-provider.d.ts +18 -0
  27. package/dist/mcp/tools-provider.js +24 -0
  28. package/dist/observability/index.d.ts +2 -0
  29. package/dist/observability/index.js +1 -0
  30. package/dist/observability/metrics.d.ts +48 -0
  31. package/dist/observability/metrics.js +86 -0
  32. package/dist/orchestrator/agentic-orchestrator.d.ts +29 -0
  33. package/dist/orchestrator/agentic-orchestrator.js +136 -0
  34. package/dist/orchestrator/prompts.d.ts +25 -0
  35. package/dist/orchestrator/prompts.js +98 -0
  36. package/dist/orchestrator/validation.d.ts +2 -0
  37. package/dist/orchestrator/validation.js +7 -0
  38. package/dist/orchestrator/writer.d.ts +4 -0
  39. package/dist/orchestrator/writer.js +17 -0
  40. package/dist/output/aggregator.d.ts +30 -0
  41. package/dist/output/aggregator.js +132 -0
  42. package/dist/output/prompts.d.ts +32 -0
  43. package/dist/output/prompts.js +153 -0
  44. package/dist/output/schema.d.ts +1515 -0
  45. package/dist/output/schema.js +224 -0
  46. package/dist/output/writer.d.ts +31 -0
  47. package/dist/output/writer.js +120 -0
  48. package/dist/review-inputs/chunking.d.ts +29 -0
  49. package/dist/review-inputs/chunking.js +113 -0
  50. package/dist/review-inputs/diff-summary.d.ts +52 -0
  51. package/dist/review-inputs/diff-summary.js +83 -0
  52. package/dist/review-inputs/file-patterns.d.ts +40 -0
  53. package/dist/review-inputs/file-patterns.js +182 -0
  54. package/dist/review-inputs/filtering.d.ts +31 -0
  55. package/dist/review-inputs/filtering.js +53 -0
  56. package/dist/review-inputs/git-diff-provider.d.ts +2 -0
  57. package/dist/review-inputs/git-diff-provider.js +42 -0
  58. package/dist/review-inputs/index.d.ts +46 -0
  59. package/dist/review-inputs/index.js +122 -0
  60. package/dist/review-inputs/path-validation.d.ts +10 -0
  61. package/dist/review-inputs/path-validation.js +37 -0
  62. package/dist/review-inputs/surrounding-context.d.ts +35 -0
  63. package/dist/review-inputs/surrounding-context.js +180 -0
  64. package/dist/review-inputs/triage.d.ts +57 -0
  65. package/dist/review-inputs/triage.js +81 -0
  66. package/dist/reviewers/executor.d.ts +41 -0
  67. package/dist/reviewers/executor.js +357 -0
  68. package/dist/reviewers/findings-merge.d.ts +9 -0
  69. package/dist/reviewers/findings-merge.js +131 -0
  70. package/dist/reviewers/iteration.d.ts +17 -0
  71. package/dist/reviewers/iteration.js +95 -0
  72. package/dist/reviewers/persistence.d.ts +17 -0
  73. package/dist/reviewers/persistence.js +55 -0
  74. package/dist/reviewers/progress-tracker.d.ts +115 -0
  75. package/dist/reviewers/progress-tracker.js +194 -0
  76. package/dist/reviewers/prompt.d.ts +42 -0
  77. package/dist/reviewers/prompt.js +246 -0
  78. package/dist/reviewers/tool-call-tracker.d.ts +18 -0
  79. package/dist/reviewers/tool-call-tracker.js +40 -0
  80. package/dist/reviewers/types.d.ts +12 -0
  81. package/dist/reviewers/types.js +1 -0
  82. package/dist/reviewers/validation-rules.d.ts +27 -0
  83. package/dist/reviewers/validation-rules.js +189 -0
  84. package/package.json +79 -0
@@ -0,0 +1,136 @@
1
+ import { z } from 'zod';
2
+ import { DEFAULT_MAX_STEPS } from '../config/defaults.js';
3
+ import { generateUserMessagePrompt } from './prompts.js';
4
+ import { writeOrchestratorOutputs } from './writer.js';
5
+ import { filterValidReviewers, formatReviewerSelectionError } from './validation.js';
6
+ import { extractFilePatterns } from '../reviewers/executor.js';
7
+ import { fileMatchesPatterns } from '../review-inputs/file-patterns.js';
8
+ import { createLlmService } from '../llm/factory.js';
9
+ const extractSystemPrompt = (text) => {
10
+ const start = text.indexOf('<system_prompt>');
11
+ const end = text.indexOf('</system_prompt>');
12
+ if (start === -1 || end === -1 || end <= start) {
13
+ return null;
14
+ }
15
+ const inner = text.slice(start + '<system_prompt>'.length, end).trim();
16
+ return inner.length > 0 ? inner : null;
17
+ };
18
+ const orchestratorOutputSchema = z.object({
19
+ understanding: z.string().describe('Understanding of the changes based on the summary'),
20
+ reviewers: z.array(z.string()).min(1).describe('Selected reviewer names from available list'),
21
+ });
22
+ export const runOrchestrator = async (inputs, outputDir, deps) => {
23
+ const { mcpClient, config, logger } = deps;
24
+ // Fetch review-orchestration prompt from MCP
25
+ const promptResult = await mcpClient.getPrompt({ name: 'review-orchestration' });
26
+ const promptMessages = [];
27
+ for (const message of promptResult.messages) {
28
+ const raw = message.content.type === 'text' ? (message.content.text ?? '') : '';
29
+ const systemPrompt = extractSystemPrompt(raw);
30
+ if (systemPrompt) {
31
+ promptMessages.push({ role: 'system', content: systemPrompt });
32
+ continue;
33
+ }
34
+ const role = message.role === 'user' ? 'user' : message.role === 'assistant' ? 'assistant' : 'system';
35
+ promptMessages.push({ role, content: raw });
36
+ }
37
+ // Extract reviewer catalog from prompt
38
+ const promptText = promptMessages.map((m) => m.content).join('\n');
39
+ const reviewerMatches = Array.from(promptText.matchAll(/reviewer-[a-z-]+/g)).map((match) => match[0]);
40
+ const availableReviewers = Array.from(new Set(reviewerMatches));
41
+ // Pre-fetch reviewer file patterns before orchestrator runs
42
+ logger?.debug(`Pre-fetching file patterns for ${availableReviewers.length} reviewers...`);
43
+ const reviewerFilePatterns = new Map();
44
+ const patternFetchPromises = availableReviewers.map(async (reviewer) => {
45
+ try {
46
+ const reviewerPromptResult = await mcpClient.getPrompt({ name: reviewer });
47
+ const patterns = extractFilePatterns(reviewerPromptResult.metadata);
48
+ reviewerFilePatterns.set(reviewer, patterns);
49
+ return { reviewer, patterns };
50
+ }
51
+ catch (error) {
52
+ logger?.warn(`Failed to fetch patterns for ${reviewer}:`, error);
53
+ reviewerFilePatterns.set(reviewer, undefined);
54
+ return { reviewer, patterns: undefined };
55
+ }
56
+ });
57
+ await Promise.all(patternFetchPromises);
58
+ logger?.debug(`Pre-fetched file patterns for ${reviewerFilePatterns.size} reviewers`);
59
+ // Build user message with change summary and reviewer file patterns
60
+ const userMessage = generateUserMessagePrompt(inputs, availableReviewers, config.prDescription, reviewerFilePatterns, config.reviewDomains);
61
+ // Create LLM service with MCP tools
62
+ const { service: llmService } = createLlmService({
63
+ openRouterApiKey: config.openRouterApiKey,
64
+ mcpClient,
65
+ logger,
66
+ });
67
+ // Prepare messages
68
+ const messages = [
69
+ ...promptMessages,
70
+ {
71
+ role: 'user',
72
+ content: userMessage,
73
+ },
74
+ ];
75
+ logger?.debug('Orchestrator prompt messages:', messages);
76
+ // Call LLM service with structured output
77
+ const maxSteps = config.maxSteps ?? DEFAULT_MAX_STEPS;
78
+ const result = await llmService.generateStructuredOutput(messages, orchestratorOutputSchema, {
79
+ model: config.orchestratorModel,
80
+ maxSteps,
81
+ metadata: {
82
+ operation: 'orchestrator',
83
+ model: config.orchestratorModel,
84
+ },
85
+ });
86
+ // Extract tool calls and context IDs for activity log
87
+ const toolCalls = [];
88
+ const contextIds = [];
89
+ if (result.toolCalls) {
90
+ for (const call of result.toolCalls) {
91
+ toolCalls.push({ tool: call.toolName, input: call.input });
92
+ if (call.toolName === 'get_context' && call.input && typeof call.input === 'object') {
93
+ const maybeId = call.input.contextId;
94
+ if (maybeId && !contextIds.includes(maybeId)) {
95
+ contextIds.push(maybeId);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ const output = result.output;
101
+ // Validate reviewers are in available list
102
+ let validReviewers = filterValidReviewers(output.reviewers, availableReviewers);
103
+ if (validReviewers.length === 0) {
104
+ throw new Error(formatReviewerSelectionError(output.reviewers, availableReviewers));
105
+ }
106
+ // Post-selection validation: check that each reviewer's file patterns match at least one PR file
107
+ const validatedReviewers = [];
108
+ const skippedReviewers = [];
109
+ for (const reviewer of validReviewers) {
110
+ const patterns = reviewerFilePatterns.get(reviewer);
111
+ // If no patterns defined, include the reviewer (backward compatible)
112
+ if (!patterns) {
113
+ validatedReviewers.push(reviewer);
114
+ continue;
115
+ }
116
+ // Check if at least one file matches the reviewer's patterns
117
+ const hasMatch = inputs.files.some((file) => fileMatchesPatterns(file, patterns));
118
+ if (hasMatch) {
119
+ validatedReviewers.push(reviewer);
120
+ }
121
+ else {
122
+ skippedReviewers.push(reviewer);
123
+ const includePatterns = patterns.include?.join(', ') || 'none';
124
+ logger?.info(`Skipped ${reviewer}: no matching files in PR (patterns: ${includePatterns})`);
125
+ }
126
+ }
127
+ if (validatedReviewers.length === 0) {
128
+ throw new Error(`[ORCH] All selected reviewers were skipped due to file pattern mismatches. Selected: ${validReviewers.join(', ')}, Skipped: ${skippedReviewers.join(', ')}`);
129
+ }
130
+ // Write outputs to orchestrator/ subfolder
131
+ await writeOrchestratorOutputs(outputDir, output.understanding, validatedReviewers, toolCalls, contextIds);
132
+ return {
133
+ understanding: output.understanding,
134
+ reviewers: validatedReviewers,
135
+ };
136
+ };
@@ -0,0 +1,25 @@
1
+ import { ReviewInputs } from '../review-inputs/index.js';
2
+ import type { FilePatterns } from '../review-inputs/file-patterns.js';
3
+ /**
4
+ * Generate the activity content for the orchestrator.
5
+ * @param understanding - The understanding of the changes.
6
+ * @param reviewers - The selected reviewers.
7
+ * @param toolCalls - The tool calls.
8
+ * @param contextIds - The context IDs.
9
+ * @returns The activity content.
10
+ */
11
+ declare const generateActivityContentPrompt: (understanding: string, reviewers: string[], toolCalls: Array<{
12
+ tool: string;
13
+ input?: unknown;
14
+ }>, contextIds: string[]) => string;
15
+ /**
16
+ * Generate the user message for the orchestrator.
17
+ * @param inputs - The review inputs.
18
+ * @param availableReviewers - The available reviewers.
19
+ * @param prDescription - Optional PR description to include as context.
20
+ * @param reviewerFilePatterns - Map of reviewer names to their file patterns.
21
+ * @param reviewDomains - Optional review focus domains to prioritize.
22
+ * @returns The user message.
23
+ */
24
+ declare const generateUserMessagePrompt: (inputs: ReviewInputs, availableReviewers: string[], prDescription?: string, reviewerFilePatterns?: Map<string, FilePatterns | undefined>, reviewDomains?: string[]) => string;
25
+ export { generateActivityContentPrompt, generateUserMessagePrompt };
@@ -0,0 +1,98 @@
1
+ import { isDiffInputs } from '../review-inputs/index.js';
2
+ import { generateDiffSummary } from '../review-inputs/diff-summary.js';
3
+ import dedent from 'dedent';
4
+ /**
5
+ * Generate the activity content for the orchestrator.
6
+ * @param understanding - The understanding of the changes.
7
+ * @param reviewers - The selected reviewers.
8
+ * @param toolCalls - The tool calls.
9
+ * @param contextIds - The context IDs.
10
+ * @returns The activity content.
11
+ */
12
+ const generateActivityContentPrompt = (understanding, reviewers, toolCalls, contextIds) => dedent(`# Orchestration Activity Log
13
+
14
+ **Phase**: Orchestration
15
+ **Completed**: ${new Date().toISOString()}
16
+
17
+ ## Understanding
18
+
19
+ ${understanding}
20
+
21
+ ## Selected Reviewers
22
+
23
+ ${reviewers.map((r) => `- ${r}`).join('\n')}
24
+
25
+ ## Tool Calls
26
+
27
+ ${toolCalls.length > 0 ? toolCalls.map((tc) => `- **${tc.tool}**: ${JSON.stringify(tc.input, null, 2)}`).join('\n\n') : 'None'}
28
+
29
+ ## ContextRail Contexts Used
30
+
31
+ ${contextIds.length > 0 ? contextIds.map((id) => `- ${id}`).join('\n') : 'None'}
32
+ `);
33
+ /**
34
+ * Generate the user message for the orchestrator.
35
+ * @param inputs - The review inputs.
36
+ * @param availableReviewers - The available reviewers.
37
+ * @param prDescription - Optional PR description to include as context.
38
+ * @param reviewerFilePatterns - Map of reviewer names to their file patterns.
39
+ * @param reviewDomains - Optional review focus domains to prioritize.
40
+ * @returns The user message.
41
+ */
42
+ const generateUserMessagePrompt = (inputs, availableReviewers, prDescription, reviewerFilePatterns, reviewDomains) => {
43
+ const diffSummary = generateDiffSummary(inputs);
44
+ const prDescriptionBlock = prDescription ? `\n\nPR Description:\n${prDescription}\n` : '';
45
+ // Build reviewer scope information block
46
+ const reviewerScopeBlock = reviewerFilePatterns
47
+ ? `\n\nReviewer file patterns (use this to select reviewers that match the changed files):
48
+ ${availableReviewers
49
+ .map((reviewer) => {
50
+ const patterns = reviewerFilePatterns.get(reviewer);
51
+ if (!patterns || !patterns.include || patterns.include.length === 0) {
52
+ return `- ${reviewer}: matches all files (no patterns defined)`;
53
+ }
54
+ const includePatterns = patterns.include.join(', ');
55
+ const excludePatterns = patterns.exclude && patterns.exclude.length > 0 ? ` (excludes: ${patterns.exclude.join(', ')})` : '';
56
+ return `- ${reviewer}: ${includePatterns}${excludePatterns}`;
57
+ })
58
+ .join('\n')}
59
+ `
60
+ : '';
61
+ const reviewDomainsBlock = reviewDomains && reviewDomains.length > 0
62
+ ? `\n\nReview focus domains:\n${reviewDomains.map((domain) => `- ${domain}`).join('\n')}\n`
63
+ : '';
64
+ return dedent(`
65
+ You are selecting reviewers for this change set.
66
+ ${prDescriptionBlock}${reviewerScopeBlock}${reviewDomainsBlock}
67
+ Change summary (read-only):
68
+ ${JSON.stringify({
69
+ mode: inputs.mode,
70
+ files: inputs.files,
71
+ fileCount: inputs.files.length,
72
+ hasDiffs: isDiffInputs(inputs) && Object.keys(inputs.diffs).length > 0,
73
+ diffSummary: isDiffInputs(inputs) && diffSummary.files.length > 0
74
+ ? {
75
+ totalFiles: diffSummary.totalFiles,
76
+ totalAdded: diffSummary.totalAdded,
77
+ totalRemoved: diffSummary.totalRemoved,
78
+ perFile: diffSummary.files.map((f) => ({
79
+ file: f.file,
80
+ fileType: f.fileType,
81
+ added: f.added,
82
+ removed: f.removed,
83
+ })),
84
+ }
85
+ : undefined,
86
+ }, null, 2)}
87
+
88
+ Available reviewers (choose from this list only):
89
+ ${availableReviewers.map((r) => `- ${r}`).join('\n')}
90
+
91
+ Output requirements:
92
+ - Provide a short, plain-language understanding of the changes.
93
+ - Select the minimal set of reviewers needed to cover the risks.
94
+ - Consider reviewer file patterns when selecting (reviewers will be automatically skipped if their patterns don't match any PR files).
95
+ - If review focus domains are provided, prioritize reviewers and rationale aligned to those domains.
96
+ `);
97
+ };
98
+ export { generateActivityContentPrompt, generateUserMessagePrompt };
@@ -0,0 +1,2 @@
1
+ export declare const filterValidReviewers: (selected: string[], available: string[]) => string[];
2
+ export declare const formatReviewerSelectionError: (selected: string[], available: string[]) => string;
@@ -0,0 +1,7 @@
1
+ export const filterValidReviewers = (selected, available) => {
2
+ if (available.length === 0) {
3
+ return [];
4
+ }
5
+ return selected.filter((reviewer) => available.includes(reviewer));
6
+ };
7
+ export const formatReviewerSelectionError = (selected, available) => `[ORCH] No valid reviewers selected. Selected: ${selected.join(', ')}, Available: ${available.join(', ')}`;
@@ -0,0 +1,4 @@
1
+ export declare const writeOrchestratorOutputs: (outputDir: string, understanding: string, reviewers: string[], toolCalls: Array<{
2
+ tool: string;
3
+ input?: unknown;
4
+ }>, contextIds: string[]) => Promise<void>;
@@ -0,0 +1,17 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { generateActivityContentPrompt } from './prompts.js';
4
+ export const writeOrchestratorOutputs = async (outputDir, understanding, reviewers, toolCalls, contextIds) => {
5
+ const orchestratorDir = path.join(outputDir, 'orchestrator');
6
+ await mkdir(orchestratorDir, { recursive: true });
7
+ await writeFile(path.join(orchestratorDir, 'understanding.json'), JSON.stringify({ understanding }, null, 2));
8
+ await writeFile(path.join(orchestratorDir, 'selection.json'), JSON.stringify({ reviewers }, null, 2));
9
+ await writeFile(path.join(orchestratorDir, 'progress.json'), JSON.stringify({
10
+ phase: 'orchestration',
11
+ status: 'completed',
12
+ completedAt: new Date().toISOString(),
13
+ reviewers,
14
+ }, null, 2));
15
+ const activityContent = generateActivityContentPrompt(understanding, reviewers, toolCalls, contextIds);
16
+ await writeFile(path.join(orchestratorDir, 'activity.md'), activityContent);
17
+ };
@@ -0,0 +1,30 @@
1
+ import type { ReviewerResult, ReviewDecision, AggregationTokenUsage, SynthesisResult } from './schema.js';
2
+ import type { Logger } from '../logging/logger.js';
3
+ export type AggregationConfig = {
4
+ openRouterApiKey: string;
5
+ model: string;
6
+ maxSteps?: number;
7
+ logger?: Logger;
8
+ };
9
+ export declare const validateAggregationMaxSteps: (value: number | undefined) => number;
10
+ /**
11
+ * Synthesize findings from multiple reviewers.
12
+ * Deduplicates findings, flags contradictions, and surfaces compound risks.
13
+ *
14
+ * @param reviewerResults - Array of reviewer results with findings
15
+ * @param config - Aggregation configuration
16
+ * @returns Synthesis result with deduplicated findings, contradictions, and compound risks
17
+ */
18
+ export declare const synthesizeFindings: (reviewerResults: ReviewerResult[], config: AggregationConfig) => Promise<{
19
+ synthesis: SynthesisResult;
20
+ usage?: AggregationTokenUsage;
21
+ }>;
22
+ export declare const generateReviewDecision: (understanding: string, synthesisResult: SynthesisResult, config: AggregationConfig) => Promise<{
23
+ decision: ReviewDecision;
24
+ usage?: AggregationTokenUsage;
25
+ }>;
26
+ /**
27
+ * Enforce deterministic decision consistency with synthesized evidence.
28
+ * If there are no critical/major findings, request-changes is not allowed.
29
+ */
30
+ export declare const normalizeDecisionWithSynthesis: (decision: ReviewDecision, synthesisResult: SynthesisResult) => ReviewDecision;
@@ -0,0 +1,132 @@
1
+ import { reviewDecisionSchema, synthesisResultSchema, cleanFinding } from './schema.js';
2
+ import { buildReviewDecisionPrompt, buildSynthesisPrompt } from './prompts.js';
3
+ import { createLlmService } from '../llm/factory.js';
4
+ const DEFAULT_AGGREGATION_STEPS = 5;
5
+ const MIN_AGGREGATION_STEPS = 1;
6
+ const MAX_AGGREGATION_STEPS = 20;
7
+ export const validateAggregationMaxSteps = (value) => {
8
+ const maxSteps = value ?? DEFAULT_AGGREGATION_STEPS;
9
+ if (!Number.isInteger(maxSteps) || maxSteps < MIN_AGGREGATION_STEPS || maxSteps > MAX_AGGREGATION_STEPS) {
10
+ throw new Error(`Invalid aggregation maxSteps: ${String(value)}. Expected an integer between ${MIN_AGGREGATION_STEPS} and ${MAX_AGGREGATION_STEPS}.`);
11
+ }
12
+ return maxSteps;
13
+ };
14
+ /**
15
+ * Synthesize findings from multiple reviewers.
16
+ * Deduplicates findings, flags contradictions, and surfaces compound risks.
17
+ *
18
+ * @param reviewerResults - Array of reviewer results with findings
19
+ * @param config - Aggregation configuration
20
+ * @returns Synthesis result with deduplicated findings, contradictions, and compound risks
21
+ */
22
+ export const synthesizeFindings = async (reviewerResults, config) => {
23
+ const { service: llmService } = createLlmService({
24
+ openRouterApiKey: config.openRouterApiKey,
25
+ logger: config.logger,
26
+ enableMetrics: true,
27
+ });
28
+ const maxSteps = validateAggregationMaxSteps(config.maxSteps);
29
+ const totalInputFindings = reviewerResults.reduce((sum, rr) => sum + rr.findings.length, 0);
30
+ config.logger?.debug(`Synthesis evidence input: reviewers=${reviewerResults.length}, totalFindings=${totalInputFindings}, byReviewer=${JSON.stringify(reviewerResults.map((rr) => ({
31
+ reviewer: rr.reviewer,
32
+ findings: rr.findings.length,
33
+ validated: rr.validated,
34
+ })))}`);
35
+ const result = await llmService.generateStructuredOutput([
36
+ {
37
+ role: 'system',
38
+ content: 'You are a synthesis agent that deduplicates findings across multiple reviewers, flags contradictions, and identifies compound risks. Produce structured output matching the provided schema.',
39
+ },
40
+ {
41
+ role: 'user',
42
+ content: buildSynthesisPrompt(reviewerResults),
43
+ },
44
+ ], synthesisResultSchema, {
45
+ model: config.model,
46
+ maxSteps,
47
+ metadata: {
48
+ operation: 'synthesis',
49
+ model: config.model,
50
+ },
51
+ });
52
+ // Normalize optional finding fields for stable downstream processing.
53
+ const cleanedSynthesis = {
54
+ ...result.output,
55
+ findings: result.output.findings.map((f) => {
56
+ const cleaned = cleanFinding(f);
57
+ // Preserve sourceReviewers from synthesized finding
58
+ return {
59
+ ...cleaned,
60
+ sourceReviewers: f.sourceReviewers,
61
+ };
62
+ }),
63
+ };
64
+ const synthesisSeverityCounts = cleanedSynthesis.findings.reduce((acc, finding) => {
65
+ acc[finding.severity] += 1;
66
+ return acc;
67
+ }, { critical: 0, major: 0, minor: 0, info: 0, pass: 0 });
68
+ config.logger?.debug(`Synthesis evidence output: dedupedFindings=${cleanedSynthesis.findings.length}, contradictions=${cleanedSynthesis.contradictions.length}, compoundRisks=${cleanedSynthesis.compoundRisks.length}, severity=${JSON.stringify(synthesisSeverityCounts)}`);
69
+ const usage = result.usage
70
+ ? {
71
+ operation: 'synthesis',
72
+ usage: result.usage,
73
+ }
74
+ : undefined;
75
+ return { synthesis: cleanedSynthesis, usage };
76
+ };
77
+ export const generateReviewDecision = async (understanding, synthesisResult, config) => {
78
+ const { service: llmService } = createLlmService({
79
+ openRouterApiKey: config.openRouterApiKey,
80
+ logger: config.logger,
81
+ enableMetrics: true,
82
+ });
83
+ const maxSteps = validateAggregationMaxSteps(config.maxSteps);
84
+ const blockingFindings = synthesisResult.findings.filter((finding) => finding.severity === 'critical' || finding.severity === 'major');
85
+ config.logger?.debug(`Decision evidence input: findings=${synthesisResult.findings.length}, blockingFindings=${blockingFindings.length}, contradictions=${synthesisResult.contradictions.length}, compoundRisks=${synthesisResult.compoundRisks.length}`);
86
+ const result = await llmService.generateStructuredOutput([
87
+ {
88
+ role: 'system',
89
+ content: 'You are a senior code reviewer. Produce a concise, structured decision that matches the provided schema.',
90
+ },
91
+ {
92
+ role: 'user',
93
+ content: buildReviewDecisionPrompt(understanding, synthesisResult),
94
+ },
95
+ ], reviewDecisionSchema, {
96
+ model: config.model,
97
+ maxSteps,
98
+ metadata: {
99
+ operation: 'review-decision',
100
+ model: config.model,
101
+ },
102
+ });
103
+ const usage = result.usage
104
+ ? {
105
+ operation: 'review-decision',
106
+ usage: result.usage,
107
+ }
108
+ : undefined;
109
+ return { decision: result.output, usage };
110
+ };
111
+ /**
112
+ * Enforce deterministic decision consistency with synthesized evidence.
113
+ * If there are no critical/major findings, request-changes is not allowed.
114
+ */
115
+ export const normalizeDecisionWithSynthesis = (decision, synthesisResult) => {
116
+ const hasBlockingFindings = synthesisResult.findings.some((finding) => finding.severity === 'critical' || finding.severity === 'major');
117
+ if (!hasBlockingFindings && decision.decision === 'request-changes') {
118
+ return {
119
+ decision: 'approve',
120
+ summary: 'No critical or major synthesized findings were identified, so this review is approved.',
121
+ rationale: 'Decision normalized to approve because synthesized findings contain no critical or major issues.',
122
+ };
123
+ }
124
+ if (hasBlockingFindings && decision.decision === 'approve') {
125
+ return {
126
+ decision: 'request-changes',
127
+ summary: 'Critical or major synthesized findings are present and must be addressed before approval.',
128
+ rationale: 'Decision normalized to request-changes because synthesized findings include critical/major issues.',
129
+ };
130
+ }
131
+ return decision;
132
+ };
@@ -0,0 +1,32 @@
1
+ import type { ReviewerResult } from './schema.js';
2
+ /**
3
+ * Generate a human-readable pre-summary from reviewer results.
4
+ * Includes severity counts and top findings per reviewer.
5
+ *
6
+ * @param reviewerResults - Array of reviewer results to summarize
7
+ * @returns Formatted pre-summary string
8
+ */
9
+ export declare const generatePreSummary: (reviewerResults: ReviewerResult[]) => string;
10
+ /**
11
+ * Build synthesis prompt for cross-reviewer synthesis.
12
+ * Takes all reviewer findings and produces deduplicated findings, contradictions, and compound risks.
13
+ *
14
+ * @param reviewerResults - Array of reviewer results with findings
15
+ * @returns Synthesis prompt string
16
+ */
17
+ export declare const buildSynthesisPrompt: (reviewerResults: ReviewerResult[]) => string;
18
+ export declare const buildReviewDecisionPrompt: (understanding: string, synthesisResult: {
19
+ findings: Array<{
20
+ severity: string;
21
+ title: string;
22
+ description: string;
23
+ }>;
24
+ contradictions: Array<{
25
+ context: string;
26
+ }>;
27
+ compoundRisks: Array<{
28
+ description: string;
29
+ affectedFindings: string[];
30
+ severity: string;
31
+ }>;
32
+ }) => string;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Generate a human-readable pre-summary from reviewer results.
3
+ * Includes severity counts and top findings per reviewer.
4
+ *
5
+ * @param reviewerResults - Array of reviewer results to summarize
6
+ * @returns Formatted pre-summary string
7
+ */
8
+ export const generatePreSummary = (reviewerResults) => {
9
+ const summaries = [];
10
+ for (const result of reviewerResults) {
11
+ const { reviewer, findings, validated, notes } = result;
12
+ // Count findings by severity
13
+ const severityCounts = {
14
+ critical: findings.filter((f) => f.severity === 'critical').length,
15
+ major: findings.filter((f) => f.severity === 'major').length,
16
+ minor: findings.filter((f) => f.severity === 'minor').length,
17
+ info: findings.filter((f) => f.severity === 'info').length,
18
+ pass: findings.filter((f) => f.severity === 'pass').length,
19
+ };
20
+ const totalIssueFindings = findings.filter((f) => f.severity !== 'pass').length;
21
+ // Build reviewer summary
22
+ const parts = [`## ${reviewer}`];
23
+ parts.push(`Status: ${validated ? '✓ Validated' : '✗ Not validated'}`);
24
+ parts.push(`Issue findings: ${totalIssueFindings}`);
25
+ parts.push(`Pass signals: ${severityCounts.pass}`);
26
+ if (totalIssueFindings > 0) {
27
+ const severityParts = [];
28
+ if (severityCounts.critical > 0)
29
+ severityParts.push(`${severityCounts.critical} critical`);
30
+ if (severityCounts.major > 0)
31
+ severityParts.push(`${severityCounts.major} major`);
32
+ if (severityCounts.minor > 0)
33
+ severityParts.push(`${severityCounts.minor} minor`);
34
+ if (severityCounts.info > 0)
35
+ severityParts.push(`${severityCounts.info} info`);
36
+ parts.push(`Severity breakdown: ${severityParts.join(', ')}`);
37
+ // Include top findings (up to 3, prioritizing critical/major)
38
+ const sortedFindings = [...findings]
39
+ .filter((f) => f.severity !== 'pass')
40
+ .sort((a, b) => {
41
+ const severityOrder = { critical: 0, major: 1, minor: 2, info: 3, pass: 4 };
42
+ return severityOrder[a.severity] - severityOrder[b.severity];
43
+ });
44
+ const topFindings = sortedFindings.slice(0, 3);
45
+ if (topFindings.length > 0) {
46
+ parts.push('\nTop findings:');
47
+ for (const finding of topFindings) {
48
+ const location = finding.file
49
+ ? `${finding.file}${finding.line ? `:${finding.line}` : ''}`
50
+ : 'general';
51
+ parts.push(`- [${finding.severity.toUpperCase()}] ${finding.title} (${location})`);
52
+ }
53
+ }
54
+ }
55
+ else if (severityCounts.pass > 0) {
56
+ parts.push('No issues reported. Pass signal(s) recorded.');
57
+ }
58
+ else {
59
+ parts.push('No findings reported.');
60
+ }
61
+ if (notes) {
62
+ parts.push(`Notes: ${notes}`);
63
+ }
64
+ summaries.push(parts.join('\n'));
65
+ }
66
+ return summaries.join('\n\n');
67
+ };
68
+ /**
69
+ * Build synthesis prompt for cross-reviewer synthesis.
70
+ * Takes all reviewer findings and produces deduplicated findings, contradictions, and compound risks.
71
+ *
72
+ * @param reviewerResults - Array of reviewer results with findings
73
+ * @returns Synthesis prompt string
74
+ */
75
+ export const buildSynthesisPrompt = (reviewerResults) => {
76
+ const reviewerSections = [];
77
+ for (const result of reviewerResults) {
78
+ const { reviewer, findings } = result;
79
+ const findingsJson = JSON.stringify(findings.map((f) => ({
80
+ severity: f.severity,
81
+ title: f.title,
82
+ description: f.description,
83
+ rationale: f.rationale,
84
+ suggestedFix: f.suggestedFix,
85
+ file: f.file,
86
+ line: f.line,
87
+ endLine: f.endLine,
88
+ contextIdsUsed: f.contextIdsUsed,
89
+ contextIdsViolated: f.contextIdsViolated,
90
+ contextTitles: f.contextTitles,
91
+ })), null, 2);
92
+ reviewerSections.push(`## ${reviewer}\n\nFindings:\n\`\`\`json\n${findingsJson}\n\`\`\``);
93
+ }
94
+ return `You are synthesizing findings from multiple code reviewers to produce a coherent, deduplicated review.
95
+
96
+ Your task:
97
+ 1. **Deduplicate findings**: When multiple reviewers identify the same issue (same file, overlapping line range, similar title/description), merge them into a single finding. Keep the highest severity, merge descriptions, and track which reviewers identified it in \`sourceReviewers\`.
98
+
99
+ 2. **Flag contradictions**: Identify cases where reviewers disagree about the same code location (e.g., "Reviewer A says X is a security issue, Reviewer B says X is fine"). List these in the \`contradictions\` array.
100
+
101
+ 3. **Surface compound risks**: Identify cases where multiple findings together create a higher risk than individually (e.g., "Multiple SQL injection points + missing input validation = critical compound risk"). List these in \`compoundRisks\`.
102
+
103
+ Matching criteria for deduplication:
104
+ - Same file path
105
+ - Overlapping line ranges (or same line if both have line numbers)
106
+ - Similar title/description (>80% semantic similarity)
107
+
108
+ When merging duplicates:
109
+ - Keep the highest severity level
110
+ - Combine descriptions (avoid repetition)
111
+ - Include all source reviewers in \`sourceReviewers\`
112
+ - Merge context IDs and context titles if present
113
+
114
+ Reviewer findings:
115
+ ${reviewerSections.join('\n\n')}
116
+
117
+ Output requirements:
118
+ - \`findings\`: Array of deduplicated findings with \`sourceReviewers\` field
119
+ - \`contradictions\`: Array of contradictions (can be empty)
120
+ - \`compoundRisks\`: Array of compound risks (can be empty)
121
+ - \`notes\`: Always include this field; use a string when useful, otherwise \`null\`
122
+ `;
123
+ };
124
+ export const buildReviewDecisionPrompt = (understanding, synthesisResult) => {
125
+ const findingsSummary = synthesisResult.findings.length > 0
126
+ ? synthesisResult.findings
127
+ .map((f) => `- [${f.severity.toUpperCase()}] ${f.title}: ${f.description}`)
128
+ .join('\n')
129
+ : '- None';
130
+ const contradictionsText = synthesisResult.contradictions.length > 0
131
+ ? `\n\nContradictions:\n${synthesisResult.contradictions.map((c) => `- ${c.context}`).join('\n')}`
132
+ : '';
133
+ const compoundRisksText = synthesisResult.compoundRisks.length > 0
134
+ ? `\n\nCompound Risks:\n${synthesisResult.compoundRisks.map((r) => `- [${r.severity.toUpperCase()}] ${r.description}`).join('\n')}`
135
+ : '';
136
+ return `You are making a final review decision based on synthesized findings from multiple reviewers.
137
+
138
+ Return "approve" only when there are no critical or major findings.
139
+ Return "request-changes" when there are any critical/major findings.
140
+ Hard rule: if synthesized findings contain zero critical/major items, decision MUST be "approve".
141
+
142
+ Provide:
143
+ - decision: approve | request-changes
144
+ - summary: 2-4 sentences, plain language
145
+ - rationale: explain why the decision was made, referencing key findings or lack thereof
146
+
147
+ Review context:
148
+ ${understanding}
149
+
150
+ Synthesized findings:
151
+ ${findingsSummary}${contradictionsText}${compoundRisksText}
152
+ `;
153
+ };