@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
package/dist/index.js ADDED
@@ -0,0 +1,603 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { loadConfig, validateConfig } from './config/index.js';
4
+ import { DEFAULT_ORCHESTRATOR_MODEL, DEFAULT_REVIEWER_MODEL } from './config/defaults.js';
5
+ import { createLogger, parseLogLevel } from './logging/logger.js';
6
+ import { McpClient } from './mcp/client.js';
7
+ import { buildReviewInputs, triagePr } from './review-inputs/index.js';
8
+ import { runOrchestrator } from './orchestrator/agentic-orchestrator.js';
9
+ import { runReviewerLoop } from './reviewers/executor.js';
10
+ import { aggregateResults, writeResult, writeTokenBudgetMetrics } from './output/writer.js';
11
+ import { metadataSchema } from './output/schema.js';
12
+ import { generateReviewDecision, normalizeDecisionWithSynthesis, synthesizeFindings } from './output/aggregator.js';
13
+ const parseArgs = (args) => {
14
+ const parsed = {};
15
+ let i = 0;
16
+ while (i < args.length) {
17
+ const arg = args[i];
18
+ if (!arg) {
19
+ i += 1;
20
+ continue;
21
+ }
22
+ if (arg === '-h' || arg === '--help') {
23
+ parsed.help = true;
24
+ i += 1;
25
+ }
26
+ else if (arg === '--repo' && i + 1 < args.length) {
27
+ parsed.repo = args[i + 1];
28
+ i += 2;
29
+ }
30
+ else if (arg === '--from' && i + 1 < args.length) {
31
+ parsed.from = args[i + 1];
32
+ i += 2;
33
+ }
34
+ else if (arg === '--to' && i + 1 < args.length) {
35
+ parsed.to = args[i + 1];
36
+ i += 2;
37
+ }
38
+ else if (arg === '--output' && i + 1 < args.length) {
39
+ parsed.output = args[i + 1];
40
+ i += 2;
41
+ }
42
+ else if (arg === '--files' && i + 1 < args.length) {
43
+ const raw = args[i + 1] ?? '';
44
+ const files = raw
45
+ .split(',')
46
+ .map((file) => file.trim())
47
+ .filter(Boolean);
48
+ if (!parsed.files) {
49
+ parsed.files = [];
50
+ }
51
+ parsed.files.push(...files);
52
+ i += 2;
53
+ }
54
+ else if (arg === '--file' && i + 1 < args.length) {
55
+ const file = args[i + 1];
56
+ if (file) {
57
+ if (!parsed.files) {
58
+ parsed.files = [];
59
+ }
60
+ parsed.files.push(file);
61
+ }
62
+ i += 2;
63
+ }
64
+ else if (arg === '--log-level' && i + 1 < args.length) {
65
+ parsed.logLevel = args[i + 1];
66
+ i += 2;
67
+ }
68
+ else if (arg === '--pr-description' && i + 1 < args.length) {
69
+ parsed.prDescription = args[i + 1];
70
+ i += 2;
71
+ }
72
+ else if (arg === '--domains' && i + 1 < args.length) {
73
+ const raw = args[i + 1] ?? '';
74
+ const domains = raw
75
+ .split(',')
76
+ .map((domain) => domain.trim())
77
+ .filter(Boolean);
78
+ if (!parsed.domains) {
79
+ parsed.domains = [];
80
+ }
81
+ parsed.domains.push(...domains);
82
+ i += 2;
83
+ }
84
+ else if (arg === '--max-steps' && i + 1 < args.length) {
85
+ const value = parseInt(args[i + 1] ?? '10', 10);
86
+ parsed.maxSteps = Number.isNaN(value) ? undefined : value;
87
+ i += 2;
88
+ }
89
+ else if (arg === '--max-iterations' && i + 1 < args.length) {
90
+ const value = parseInt(args[i + 1] ?? '2', 10);
91
+ parsed.maxIterations = Number.isNaN(value) ? undefined : value;
92
+ i += 2;
93
+ }
94
+ else if (arg === '--aggregation-max-steps' && i + 1 < args.length) {
95
+ const value = parseInt(args[i + 1] ?? '5', 10);
96
+ parsed.aggregationMaxSteps = Number.isNaN(value) ? undefined : value;
97
+ i += 2;
98
+ }
99
+ else if (arg === '--max-tokens-per-file' && i + 1 < args.length) {
100
+ const value = parseInt(args[i + 1] ?? '20000', 10);
101
+ parsed.maxTokensPerFile = Number.isNaN(value) ? undefined : value;
102
+ i += 2;
103
+ }
104
+ else if (arg === '--context-lines' && i + 1 < args.length) {
105
+ const value = parseInt(args[i + 1] ?? '10', 10);
106
+ parsed.contextLines = Number.isNaN(value) ? undefined : value;
107
+ i += 2;
108
+ }
109
+ else if (!parsed.command && !arg.startsWith('-')) {
110
+ parsed.command = arg;
111
+ i += 1;
112
+ }
113
+ else {
114
+ i += 1;
115
+ }
116
+ }
117
+ return parsed;
118
+ };
119
+ const printHelp = (log) => {
120
+ log.info(`code-review-agent
121
+
122
+ Usage:
123
+ code-review-agent review --repo <path> --from <sha> --to <sha> [--output <dir>]
124
+ code-review-agent review --repo <path> --files <file1,file2> [--output <dir>]
125
+ code-review-agent review --repo <path> --file <file> [--file <file> ...] [--output <dir>]
126
+
127
+ Commands:
128
+ review Run code review on git diff
129
+
130
+ Options:
131
+ --repo <path> Repository path (default: current directory)
132
+ --from <sha> Base commit SHA (required)
133
+ --to <sha> Head commit SHA (required)
134
+ --files <list> Comma-separated list of files to review
135
+ --file <path> File to review (repeatable)
136
+ --output <dir> Output directory (default: ./review)
137
+ --log-level <lv> Log level: debug|info|warn|error|silent (overrides DEBUG)
138
+ --max-steps <n> Maximum LLM steps per call (default: 10)
139
+ --max-iterations <n> Maximum reviewer validation iterations (default: 2)
140
+ --aggregation-max-steps <n> Maximum steps for aggregation (default: 5)
141
+ --max-tokens-per-file <n> Token budget for surrounding context per file (default: 20000)
142
+ --context-lines <n> Lines of context around changes (default: 10)
143
+ --pr-description <text> PR description to include as context (optional)
144
+ --domains <list> Comma-separated review focus domains (optional)
145
+ -h, --help Show this help message
146
+
147
+ Environment Variables:
148
+ CONTEXTRAIL_MCP_SERVER_URL ContextRail MCP server URL (required)
149
+ CONTEXTRAIL_MCP_JWT_TOKEN ContextRail MCP authentication token (required)
150
+ OPENROUTER_API_KEY OpenRouter API key (required)
151
+ LLM_MODEL_ORCHESTRATOR Model for orchestrator (default: anthropic/claude-haiku-4.5)
152
+ LLM_MODEL_REVIEWER Model for reviewers (default: anthropic/claude-haiku-4.5)
153
+ MAX_STEPS Maximum LLM steps per call (default: 10)
154
+ MAX_ITERATIONS Maximum reviewer validation iterations (default: 2)
155
+ AGGREGATION_MAX_STEPS Maximum steps for aggregation (default: 5)
156
+ MAX_TOKENS_PER_FILE Token budget for surrounding context per file (default: 20000)
157
+ CONTEXT_LINES Lines of context around changes (default: 10)
158
+ REVIEW_DOMAINS Comma-separated review focus domains (optional)
159
+
160
+ Examples:
161
+ code-review-agent review --repo . --from HEAD^ --to HEAD
162
+ code-review-agent review --repo . --files src/a.ts,src/b.ts
163
+ code-review-agent review --repo . --file src/a.ts --file src/b.ts
164
+ code-review-agent review --repo /path/to/repo --from abc123 --to def456 --output ./review-results
165
+ `);
166
+ };
167
+ const validateArgs = (args) => {
168
+ if (args.help) {
169
+ return;
170
+ }
171
+ if (args.command !== 'review') {
172
+ throw new Error(`Unknown command: ${args.command ?? 'none'}. Use 'review' or see --help.`);
173
+ }
174
+ const hasFileList = Array.isArray(args.files) && args.files.length > 0;
175
+ if (!hasFileList) {
176
+ if (!args.from) {
177
+ throw new Error('Missing required argument: --from');
178
+ }
179
+ if (!args.to) {
180
+ throw new Error('Missing required argument: --to');
181
+ }
182
+ }
183
+ };
184
+ const main = async () => {
185
+ try {
186
+ const args = parseArgs(process.argv.slice(2));
187
+ if (args.help) {
188
+ // Keep --help available even when required env vars are not set.
189
+ const helpLog = createLogger(parseLogLevel(args.logLevel ?? process.env.DEBUG));
190
+ printHelp(helpLog);
191
+ process.exit(0);
192
+ }
193
+ const config = loadConfig();
194
+ const log = createLogger(parseLogLevel(args.logLevel ?? config.logLevel));
195
+ log.debug(`Log level set to ${args.logLevel ?? config.logLevel}`);
196
+ // Validate arguments
197
+ validateArgs(args);
198
+ // Override config with CLI args
199
+ if (args.maxSteps !== undefined) {
200
+ config.maxSteps = args.maxSteps;
201
+ }
202
+ if (args.maxIterations !== undefined) {
203
+ config.maxIterations = args.maxIterations;
204
+ }
205
+ if (args.aggregationMaxSteps !== undefined) {
206
+ config.aggregationMaxSteps = args.aggregationMaxSteps;
207
+ }
208
+ if (args.maxTokensPerFile !== undefined) {
209
+ config.maxTokensPerFile = args.maxTokensPerFile;
210
+ }
211
+ if (args.contextLines !== undefined) {
212
+ config.contextLines = args.contextLines;
213
+ }
214
+ if (args.prDescription !== undefined) {
215
+ config.prDescription = args.prDescription;
216
+ }
217
+ if (args.domains && args.domains.length > 0) {
218
+ config.reviewDomains = args.domains;
219
+ }
220
+ // Load and validate config
221
+ const validatedConfig = validateConfig(config);
222
+ // Set defaults
223
+ const repoPath = args.repo ?? process.cwd();
224
+ const outputDir = args.output ?? path.join(process.cwd(), 'review');
225
+ log.info('Starting code review...');
226
+ log.info(`Repository: ${repoPath}`);
227
+ log.info(`Output: ${outputDir}`);
228
+ if (args.files && args.files.length > 0) {
229
+ log.info(`Files: ${args.files.join(', ')}`);
230
+ }
231
+ else {
232
+ log.info(`From: ${args.from}`);
233
+ log.info(`To: ${args.to}`);
234
+ }
235
+ // Connect MCP client
236
+ const mcpClient = new McpClient({
237
+ serverUrl: validatedConfig.mcpServerUrl,
238
+ authToken: validatedConfig.mcpAuthToken,
239
+ clientName: 'code-review-agent',
240
+ clientVersion: '0.1.0',
241
+ logger: log,
242
+ });
243
+ mcpClientInstance = mcpClient; // Store for graceful shutdown
244
+ await mcpClient.connect();
245
+ log.info('Connected to MCP server');
246
+ try {
247
+ // Build review inputs
248
+ log.info('Building review inputs...');
249
+ const inputs = args.files && args.files.length > 0
250
+ ? await buildReviewInputs({
251
+ mode: 'file-list',
252
+ files: args.files,
253
+ basePath: repoPath,
254
+ surroundingContext: {
255
+ enabled: true,
256
+ maxTokensPerFile: validatedConfig.maxTokensPerFile,
257
+ contextLines: validatedConfig.contextLines,
258
+ },
259
+ })
260
+ : await buildReviewInputs({
261
+ mode: 'diff',
262
+ repoPath,
263
+ from: args.from,
264
+ to: args.to,
265
+ surroundingContext: {
266
+ enabled: true,
267
+ maxTokensPerFile: validatedConfig.maxTokensPerFile,
268
+ contextLines: validatedConfig.contextLines,
269
+ },
270
+ });
271
+ log.info(`Found ${inputs.files.length} files to review`);
272
+ if (inputs.files.length > 0) {
273
+ log.debug(` Files: ${inputs.files.slice(0, 10).join(', ')}${inputs.files.length > 10 ? ` ... and ${inputs.files.length - 10} more` : ''}`);
274
+ }
275
+ // Triage PR to determine if it's trivial
276
+ const triageResult = triagePr(inputs);
277
+ log.info(`PR triage: ${triageResult.reason}`);
278
+ if (triageResult.isTrivial) {
279
+ log.info(`Trivial PR detected (${triageResult.isDocsOnly ? 'docs-only' : 'small'}). Skipping full reviewer flow.`);
280
+ // For trivial PRs, we still run orchestrator but with awareness
281
+ // The orchestrator can use this information to select fewer reviewers or skip entirely
282
+ }
283
+ // Run orchestrator
284
+ log.info('Running orchestrator...');
285
+ const prDescription = config.prDescription;
286
+ if (prDescription) {
287
+ log.debug('PR description provided, will be included in prompts');
288
+ }
289
+ if (config.reviewDomains && config.reviewDomains.length > 0) {
290
+ log.debug(`Review domains provided: ${config.reviewDomains.join(', ')}`);
291
+ }
292
+ const orchestratorOutput = await runOrchestrator(inputs, outputDir, {
293
+ mcpClient,
294
+ config: {
295
+ openRouterApiKey: validatedConfig.openRouterApiKey,
296
+ orchestratorModel: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
297
+ maxSteps: validatedConfig.maxSteps,
298
+ prDescription,
299
+ reviewDomains: validatedConfig.reviewDomains,
300
+ },
301
+ logger: log,
302
+ });
303
+ log.info(`Selected reviewers: ${orchestratorOutput.reviewers.join(', ')}`);
304
+ log.debug(`Orchestrator understanding:\n${orchestratorOutput.understanding}`);
305
+ // Run reviewers in parallel with progress logging
306
+ log.info(`Running ${orchestratorOutput.reviewers.length} reviewer(s) in parallel...`);
307
+ const reviewerFailures = [];
308
+ const reviewerResults = await Promise.all(orchestratorOutput.reviewers.map(async (reviewer) => {
309
+ const startTime = Date.now();
310
+ log.info(`[${reviewer}] Starting review...`);
311
+ try {
312
+ const findings = await runReviewerLoop(reviewer, inputs, orchestratorOutput.understanding, outputDir, {
313
+ mcpClient,
314
+ config: {
315
+ openRouterApiKey: validatedConfig.openRouterApiKey,
316
+ reviewerModel: validatedConfig.reviewerModel ?? DEFAULT_REVIEWER_MODEL,
317
+ criticModel: validatedConfig.criticModel,
318
+ maxSteps: validatedConfig.maxSteps,
319
+ maxIterations: validatedConfig.maxIterations,
320
+ prDescription,
321
+ reviewDomains: validatedConfig.reviewDomains,
322
+ },
323
+ logger: log,
324
+ });
325
+ const result = {
326
+ reviewer,
327
+ findings: findings.findings,
328
+ validated: findings.validated,
329
+ notes: findings.notes ?? undefined,
330
+ };
331
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
332
+ log.info(`[${reviewer}] Completed in ${duration}s`);
333
+ // Log reviewer results
334
+ const issueCount = findings.findings.filter((f) => f.severity !== 'pass').length;
335
+ const passCount = findings.findings.filter((f) => f.severity === 'pass').length;
336
+ if (issueCount === 0) {
337
+ log.info(` ✓ ${reviewer}: Clean pass (pass signals: ${passCount}, validated: ${findings.validated})`);
338
+ }
339
+ else {
340
+ log.info(` ✓ ${reviewer}: ${issueCount} issue(s) (pass signals: ${passCount}, validated: ${findings.validated})`);
341
+ }
342
+ log.debug(` Findings: ${JSON.stringify(findings.findings, null, 2)}`);
343
+ if (findings.notes) {
344
+ log.debug(` Notes: ${findings.notes}`);
345
+ }
346
+ return result;
347
+ }
348
+ catch (error) {
349
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
350
+ const message = error instanceof Error ? error.message : 'Unknown error';
351
+ log.error(`[${reviewer}] Failed after ${duration}s: ${message}`);
352
+ // Provide actionable error guidance
353
+ if (message.includes('No object generated') || message.includes('No output generated')) {
354
+ log.error(`[${reviewer}] TROUBLESHOOTING: The model may not support structured output well.`);
355
+ log.error(`[${reviewer}] Try: 1) Use a different model (e.g., anthropic/claude-haiku-4.5)`);
356
+ log.error(`[${reviewer}] 2) Check model compatibility with structured output`);
357
+ log.error(`[${reviewer}] 3) Review the model's response format requirements`);
358
+ }
359
+ reviewerFailures.push({ reviewer, message });
360
+ return {
361
+ reviewer,
362
+ findings: [],
363
+ validated: false,
364
+ notes: `Error: ${message}`,
365
+ };
366
+ }
367
+ }));
368
+ // Aggregate results
369
+ log.info('Aggregating results...');
370
+ log.debug(` Total issue findings to aggregate: ${reviewerResults.reduce((sum, rr) => sum + rr.findings.filter((f) => f.severity !== 'pass').length, 0)}`);
371
+ log.debug(` Total pass signals to aggregate: ${reviewerResults.reduce((sum, rr) => sum + rr.findings.filter((f) => f.severity === 'pass').length, 0)}`);
372
+ log.debug(` Reviewers validated: ${reviewerResults.filter((rr) => rr.validated).length}/${reviewerResults.length}`);
373
+ const metadata = metadataSchema.parse({
374
+ timestamp: new Date().toISOString(),
375
+ mode: inputs.mode,
376
+ fileCount: inputs.files.length,
377
+ });
378
+ // Synthesize findings across reviewers (deduplication, contradictions, compound risks)
379
+ log.info(`Synthesis pass starting (model: ${validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL}, reviewers: ${reviewerResults.length})`);
380
+ const synthesisResult = await synthesizeFindings(reviewerResults, {
381
+ openRouterApiKey: validatedConfig.openRouterApiKey,
382
+ model: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
383
+ maxSteps: validatedConfig.aggregationMaxSteps,
384
+ logger: log,
385
+ });
386
+ log.info(`Synthesis pass complete: ${synthesisResult.synthesis.findings.length} deduped findings, ${synthesisResult.synthesis.contradictions.length} contradictions, ${synthesisResult.synthesis.compoundRisks.length} compound risks`);
387
+ log.debug(` Synthesis: ${synthesisResult.synthesis.findings.length} deduplicated findings`);
388
+ log.debug(` Contradictions: ${synthesisResult.synthesis.contradictions.length}`);
389
+ log.debug(` Compound risks: ${synthesisResult.synthesis.compoundRisks.length}`);
390
+ const synthesisSeverityCounts = synthesisResult.synthesis.findings.reduce((acc, finding) => {
391
+ acc[finding.severity] += 1;
392
+ return acc;
393
+ }, { critical: 0, major: 0, minor: 0, info: 0, pass: 0 });
394
+ log.debug(` Synthesis severity: ${JSON.stringify(synthesisSeverityCounts)}`);
395
+ // Generate review decision based on synthesized findings
396
+ log.info(`Decision pass starting (model: ${validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL}, synthesis findings: ${synthesisResult.synthesis.findings.length})`);
397
+ const decisionResult = await generateReviewDecision(orchestratorOutput.understanding, synthesisResult.synthesis, {
398
+ openRouterApiKey: validatedConfig.openRouterApiKey,
399
+ model: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
400
+ maxSteps: validatedConfig.aggregationMaxSteps,
401
+ logger: log,
402
+ });
403
+ const normalizedDecision = normalizeDecisionWithSynthesis(decisionResult.decision, synthesisResult.synthesis);
404
+ const decisionEvidence = {
405
+ findings: synthesisResult.synthesis.findings.length,
406
+ blockingFindings: synthesisResult.synthesis.findings.filter((f) => f.severity === 'critical' || f.severity === 'major').length,
407
+ contradictions: synthesisResult.synthesis.contradictions.length,
408
+ compoundRisks: synthesisResult.synthesis.compoundRisks.length,
409
+ modelDecision: decisionResult.decision.decision,
410
+ normalizedDecision: normalizedDecision.decision,
411
+ };
412
+ log.debug(` Decision evidence: ${JSON.stringify(decisionEvidence)}`);
413
+ if (normalizedDecision.decision !== decisionResult.decision.decision) {
414
+ log.warn(`Decision normalized from ${decisionResult.decision.decision} to ${normalizedDecision.decision} to match synthesized findings.`);
415
+ }
416
+ log.info(`Decision pass complete: ${normalizedDecision.decision}`);
417
+ log.debug(` Aggregation decision: ${normalizedDecision.decision}`);
418
+ log.debug(` Decision summary: ${normalizedDecision.summary}`);
419
+ log.debug(` Decision rationale: ${normalizedDecision.rationale}`);
420
+ const result = aggregateResults(metadata, orchestratorOutput.understanding, normalizedDecision, reviewerResults, reviewerFailures, synthesisResult.synthesis);
421
+ // Write result.json
422
+ await writeResult(outputDir, result);
423
+ const resultPath = path.join(outputDir, 'result.json');
424
+ log.info(`Results written to ${resultPath}`);
425
+ log.debug(` Full review results available at: ${resultPath}`);
426
+ // Write token budget metrics
427
+ const aggregationUsage = [];
428
+ if (synthesisResult.usage) {
429
+ aggregationUsage.push(synthesisResult.usage);
430
+ }
431
+ if (decisionResult.usage) {
432
+ aggregationUsage.push(decisionResult.usage);
433
+ }
434
+ await writeTokenBudgetMetrics(outputDir, orchestratorOutput.reviewers, aggregationUsage.length > 0 ? aggregationUsage : undefined);
435
+ log.info(`Token budget metrics written to ${path.join(outputDir, 'token-budget.json')}`);
436
+ // Print summary
437
+ log.info('\n═══════════════════════════════════════════════════════════');
438
+ log.info('Review Summary');
439
+ log.info('═══════════════════════════════════════════════════════════');
440
+ log.info(` Files reviewed: ${result.metadata.fileCount}`);
441
+ log.info(` Reviewers executed: ${reviewerResults.length}`);
442
+ log.info(` Total issue findings: ${result.summary.totalFindings}`);
443
+ log.info(` Critical: ${result.summary.bySeverity.critical}`);
444
+ log.info(` Major: ${result.summary.bySeverity.major}`);
445
+ log.info(` Minor: ${result.summary.bySeverity.minor}`);
446
+ log.info(` Info: ${result.summary.bySeverity.info}`);
447
+ log.info(` Pass signals: ${result.summary.bySeverity.pass}`);
448
+ // Show per-reviewer breakdown
449
+ if (result.summary.totalFindings > 0) {
450
+ log.info('\n Findings by reviewer:');
451
+ for (const [reviewer, count] of Object.entries(result.summary.byReviewer)) {
452
+ log.info(` ${reviewer}: ${count} finding(s)`);
453
+ }
454
+ }
455
+ // Show decision details
456
+ log.info(`\n Decision: ${result.decision.decision.toUpperCase()}`);
457
+ log.info(` Summary: ${result.decision.summary}`);
458
+ log.info(` Rationale: ${result.decision.rationale}`);
459
+ // Show reviewer pass summaries (positive signal)
460
+ const passReviewers = reviewerResults
461
+ .map((rr) => ({
462
+ reviewer: rr.reviewer,
463
+ passFinding: rr.findings.find((f) => f.severity === 'pass'),
464
+ notes: rr.notes,
465
+ validated: rr.validated,
466
+ }))
467
+ .filter((rr) => rr.validated && (rr.passFinding || rr.notes));
468
+ if (passReviewers.length > 0) {
469
+ log.info('\n Reviewer pass summaries:');
470
+ for (const rr of passReviewers) {
471
+ const passLine = rr.notes?.trim() ?? rr.passFinding?.title ?? 'PASS';
472
+ log.info(` ${rr.reviewer}: ${passLine.split('\n')[0] ?? passLine}`);
473
+ }
474
+ }
475
+ // Debug: Show synthesized (deduplicated) findings when available.
476
+ if (result.synthesis && result.synthesis.findings.length > 0) {
477
+ log.debug('\n Deduplicated findings (synthesis):');
478
+ for (const finding of result.synthesis.findings) {
479
+ log.debug(` [${finding.severity.toUpperCase()}] ${finding.title}`);
480
+ if (finding.file) {
481
+ log.debug(` File: ${finding.file}${finding.line ? `:${finding.line}` : ''}`);
482
+ }
483
+ if (finding.sourceReviewers && finding.sourceReviewers.length > 0) {
484
+ log.debug(` Source reviewers: ${finding.sourceReviewers.join(', ')}`);
485
+ }
486
+ if (finding.contextTitles && finding.contextTitles.length > 0) {
487
+ log.debug(` ContextRail Standards: ${finding.contextTitles.join(', ')}`);
488
+ }
489
+ log.debug(` ${finding.description}`);
490
+ }
491
+ }
492
+ else {
493
+ // Fallback: Show raw reviewer findings when synthesis is unavailable.
494
+ const hasAnyEntries = reviewerResults.some((rr) => rr.findings.length > 0);
495
+ if (hasAnyEntries) {
496
+ log.debug('\n All findings:');
497
+ for (const rr of reviewerResults) {
498
+ if (rr.findings.length > 0) {
499
+ log.debug(`\n ${rr.reviewer}:`);
500
+ for (const finding of rr.findings) {
501
+ log.debug(` [${finding.severity.toUpperCase()}] ${finding.title}`);
502
+ if (finding.file) {
503
+ log.debug(` File: ${finding.file}${finding.line ? `:${finding.line}` : ''}`);
504
+ }
505
+ if (finding.contextTitles && finding.contextTitles.length > 0) {
506
+ log.debug(` ContextRail Standards: ${finding.contextTitles.join(', ')}`);
507
+ }
508
+ log.debug(` ${finding.description}`);
509
+ }
510
+ }
511
+ }
512
+ }
513
+ }
514
+ // Show failures if any
515
+ if (result.failures && result.failures.length > 0) {
516
+ log.warn('\n Reviewer failures:');
517
+ for (const failure of result.failures) {
518
+ log.warn(` ${failure.reviewer}: ${failure.message}`);
519
+ }
520
+ }
521
+ log.info('═══════════════════════════════════════════════════════════\n');
522
+ // Set exit code based on reviewer statuses
523
+ const hasFailures = reviewerResults.some((rr) => !rr.validated);
524
+ if (hasFailures) {
525
+ log.error('❌ Review failed: Some reviewers did not validate');
526
+ process.exit(1);
527
+ }
528
+ else if (result.decision.decision === 'request-changes') {
529
+ log.error('❌ Review decision: REQUEST CHANGES');
530
+ log.error(` Reason: ${result.decision.rationale}`);
531
+ process.exit(1);
532
+ }
533
+ else {
534
+ if (result.summary.totalFindings === 0) {
535
+ log.info('✅ Review passed: No findings - code is clean!');
536
+ }
537
+ else {
538
+ log.info('✅ Review passed: Findings present but decision is APPROVE');
539
+ log.info(` ${result.summary.totalFindings} finding(s) were reviewed and deemed acceptable`);
540
+ }
541
+ log.info(` Decision rationale: ${result.decision.rationale}`);
542
+ process.exit(0);
543
+ }
544
+ }
545
+ finally {
546
+ await mcpClient.close();
547
+ }
548
+ }
549
+ catch (error) {
550
+ const log = createLogger(parseLogLevel(process.env.DEBUG));
551
+ const normalizedError = error instanceof Error ? error : new Error('Unknown error');
552
+ const message = normalizedError.message;
553
+ const stack = normalizedError.stack;
554
+ log.error({ msg: 'Fatal error', error: message, stack });
555
+ if (stack && process.env.NODE_ENV === 'development') {
556
+ log.debug({ msg: 'Error stack trace', stack });
557
+ }
558
+ await gracefulShutdown('SIGTERM', normalizedError);
559
+ return;
560
+ }
561
+ };
562
+ // Set up process-level error handlers with structured logging
563
+ let mcpClientInstance = null;
564
+ const gracefulShutdown = async (signal, error) => {
565
+ const log = createLogger(parseLogLevel(process.env.DEBUG));
566
+ log.warn({ msg: `Received ${signal}. Shutting down...`, signal });
567
+ try {
568
+ if (mcpClientInstance) {
569
+ await mcpClientInstance.close();
570
+ }
571
+ process.exit(error ? 1 : 0);
572
+ }
573
+ catch (shutdownError) {
574
+ const message = shutdownError instanceof Error ? shutdownError.message : 'Unknown error';
575
+ log.error({ msg: 'Error during shutdown', error: message });
576
+ process.exit(1);
577
+ }
578
+ };
579
+ process.on('uncaughtException', async (error) => {
580
+ const log = createLogger(parseLogLevel(process.env.DEBUG));
581
+ log.error({
582
+ msg: 'Uncaught exception',
583
+ error: error.message,
584
+ stack: error.stack,
585
+ name: error.name,
586
+ });
587
+ await gracefulShutdown('SIGTERM', error);
588
+ });
589
+ process.on('unhandledRejection', async (reason) => {
590
+ const log = createLogger(parseLogLevel(process.env.DEBUG));
591
+ const message = reason instanceof Error ? reason.message : String(reason);
592
+ const stack = reason instanceof Error ? reason.stack : undefined;
593
+ log.error({
594
+ msg: 'Unhandled rejection',
595
+ error: message,
596
+ stack,
597
+ });
598
+ await gracefulShutdown('SIGTERM', reason instanceof Error ? reason : new Error(message));
599
+ });
600
+ // Capture SIGTERM and SIGINT for graceful shutdown
601
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
602
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
603
+ main();
@@ -0,0 +1,21 @@
1
+ import { LlmService } from './service.js';
2
+ import type { ModelProvider } from './types.js';
3
+ import type { Logger } from '../logging/logger.js';
4
+ import { LlmMetricsCollector } from '../observability/metrics.js';
5
+ import type { McpClient } from '../mcp/client.js';
6
+ /**
7
+ * Create an OpenRouter model provider.
8
+ */
9
+ export declare const createOpenRouterModelProvider: (apiKey: string) => ModelProvider;
10
+ /**
11
+ * Create an LLM service with OpenRouter and optional MCP tools.
12
+ */
13
+ export declare const createLlmService: (config: {
14
+ openRouterApiKey: string;
15
+ mcpClient?: McpClient;
16
+ logger?: Logger;
17
+ enableMetrics?: boolean;
18
+ }) => {
19
+ service: LlmService;
20
+ metrics?: LlmMetricsCollector;
21
+ };
@@ -0,0 +1,50 @@
1
+ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2
+ import { LlmService } from './service.js';
3
+ import { LlmMetricsCollector } from '../observability/metrics.js';
4
+ import { createToolsProvider } from '../mcp/tools-provider.js';
5
+ /**
6
+ * Create an OpenRouter model provider.
7
+ */
8
+ export const createOpenRouterModelProvider = (apiKey) => {
9
+ const openrouter = createOpenRouter({ apiKey });
10
+ return (modelName) => openrouter(modelName);
11
+ };
12
+ /**
13
+ * Create an LLM service with OpenRouter and optional MCP tools.
14
+ */
15
+ export const createLlmService = (config) => {
16
+ const { openRouterApiKey, mcpClient, logger, enableMetrics = true } = config;
17
+ // Create model provider
18
+ const modelProvider = createOpenRouterModelProvider(openRouterApiKey);
19
+ // Create tools provider if MCP client is available
20
+ const toolsProvider = mcpClient ? createToolsProvider(mcpClient) : undefined;
21
+ // Create metrics collector if enabled
22
+ const metrics = enableMetrics ? new LlmMetricsCollector(logger) : undefined;
23
+ // Create observability hooks
24
+ const hooks = metrics
25
+ ? {
26
+ onCallStart: (metadata) => {
27
+ logger?.debug(`[LLM] Starting: ${metadata.operation} (model: ${metadata.model})`);
28
+ },
29
+ onCallComplete: (metadata, usage) => {
30
+ metrics.recordCall(metadata, usage);
31
+ logger?.debug(`[LLM] Completed: ${metadata.operation} (tokens: ${usage?.totalTokens ?? 'unknown'})`);
32
+ },
33
+ onCallError: (metadata, error) => {
34
+ metrics.recordError(metadata, error);
35
+ logger?.error(`[LLM] Error: ${metadata.operation}`, error);
36
+ },
37
+ onToolCall: (metadata, toolName) => {
38
+ metrics.recordToolCall(metadata, toolName);
39
+ logger?.debug(`[LLM] Tool call: ${toolName} (operation: ${metadata.operation})`);
40
+ },
41
+ }
42
+ : undefined;
43
+ const service = new LlmService({
44
+ modelProvider,
45
+ toolsProvider,
46
+ hooks,
47
+ logger,
48
+ });
49
+ return { service, metrics };
50
+ };
@@ -0,0 +1,3 @@
1
+ export { LlmService } from './service.js';
2
+ export { createLlmService, createOpenRouterModelProvider } from './factory.js';
3
+ export type { LlmCallResult, LlmCallConfig, LlmCallMetadata, TokenUsage, ModelProvider, ToolsProvider, LlmObservabilityHooks, ToolChoice, } from './types.js';