openairev 0.2.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.
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import { listSessions } from '../session/session-manager.js';
3
+ import { getActiveChain } from '../session/chain-manager.js';
4
+ import { loadConfig } from '../config/config-loader.js';
5
+ import { requireConfig, statusColor, stageLabel, timeAgo } from './format-helpers.js';
6
+
7
+ export async function statusCommand() {
8
+ const cwd = process.cwd();
9
+ requireConfig(cwd);
10
+
11
+ const config = loadConfig(cwd);
12
+
13
+ console.log(chalk.bold('\nOpenAIRev Status\n'));
14
+
15
+ const agents = Object.entries(config.agents || {})
16
+ .filter(([, v]) => v.available)
17
+ .map(([k]) => k);
18
+ console.log(` Agents: ${agents.map(a => chalk.cyan(a)).join(', ') || chalk.dim('none')}`);
19
+ console.log(` Trigger: ${chalk.cyan(config.review_trigger)}`);
20
+
21
+ const toolNames = config.tools ? Object.keys(config.tools) : [];
22
+ console.log(` Tools: ${toolNames.map(t => chalk.dim(t)).join(', ') || chalk.dim('none')}`);
23
+
24
+ for (const [executor, policy] of Object.entries(config.review_policy || {})) {
25
+ const reviewer = typeof policy === 'string' ? policy : policy.reviewer;
26
+ const iterations = typeof policy === 'object' ? policy.max_iterations : null;
27
+ const iterStr = iterations ? ` (max ${iterations} iterations)` : '';
28
+ console.log(` Policy: ${chalk.cyan(executor)} → reviewed by ${chalk.cyan(reviewer)}${chalk.dim(iterStr)}`);
29
+ }
30
+
31
+ const activeChain = getActiveChain(cwd);
32
+ if (activeChain) {
33
+ const stColor = activeChain.status === 'blocked' ? chalk.yellow : chalk.blue;
34
+
35
+ console.log(chalk.bold('\n Active Workflow'));
36
+ console.log(` Chain: ${chalk.dim(activeChain.chain_id)}`);
37
+ console.log(` Stage: ${stColor(stageLabel(activeChain.stage))}`);
38
+ console.log(` Status: ${statusColor(activeChain.status)(activeChain.status)}`);
39
+ console.log(` Agents: ${chalk.cyan(activeChain.participants.executor)} ↔ ${chalk.cyan(activeChain.participants.reviewer)}`);
40
+ console.log(` Rounds: ${activeChain.rounds.length}/${activeChain.max_rounds}`);
41
+
42
+ if (activeChain.task?.user_request) console.log(` Task: ${chalk.dim(activeChain.task.user_request)}`);
43
+ if (activeChain.task?.spec_ref) console.log(` Spec: ${chalk.dim(activeChain.task.spec_ref)}`);
44
+
45
+ const phase = activeChain.phases?.[activeChain.phase_index];
46
+ if (phase) console.log(` Phase: ${phase.name} (${phase.status})`);
47
+
48
+ const pending = activeChain.questions?.filter(q => q.status === 'pending') || [];
49
+ if (pending.length > 0) {
50
+ console.log(chalk.yellow.bold('\n Pending Questions:'));
51
+ pending.forEach(q => console.log(` ${chalk.yellow('?')} [${q.id}] ${q.question}`));
52
+ }
53
+
54
+ const lastRound = activeChain.rounds?.[activeChain.rounds.length - 1];
55
+ if (lastRound?.review?.verdict) {
56
+ const v = lastRound.review.verdict;
57
+ console.log(`\n Last ${lastRound.kind}: ${statusColor(v.status)(v.status)} (${((v.confidence || 0) * 100).toFixed(0)}%)`);
58
+ }
59
+
60
+ console.log(chalk.dim(`\n Resume with: openairev resume`));
61
+ } else {
62
+ console.log(chalk.dim('\n No active workflows.'));
63
+ }
64
+
65
+ const sessions = listSessions(cwd, 1);
66
+ if (sessions.length > 0) {
67
+ const last = sessions[0];
68
+ const ago = timeAgo(new Date(last.created));
69
+ console.log(`\n Last review: ${statusColor(last.status)(last.status)} — ${ago}`);
70
+ }
71
+
72
+ console.log('');
73
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import YAML from 'yaml';
4
+ import { DEFAULTS } from './defaults.js';
5
+
6
+ const CONFIG_DIR = '.openairev';
7
+ const CONFIG_FILE = 'config.yaml';
8
+
9
+ export function getConfigDir(cwd = process.cwd()) {
10
+ return join(cwd, CONFIG_DIR);
11
+ }
12
+
13
+ export function getConfigPath(cwd = process.cwd()) {
14
+ return join(getConfigDir(cwd), CONFIG_FILE);
15
+ }
16
+
17
+ export function configExists(cwd = process.cwd()) {
18
+ return existsSync(getConfigPath(cwd));
19
+ }
20
+
21
+ export function loadConfig(cwd = process.cwd()) {
22
+ const configPath = getConfigPath(cwd);
23
+ if (!existsSync(configPath)) {
24
+ return deepMerge({}, DEFAULTS);
25
+ }
26
+ const raw = readFileSync(configPath, 'utf-8');
27
+ const parsed = YAML.parse(raw);
28
+ return deepMerge(DEFAULTS, parsed);
29
+ }
30
+
31
+ /**
32
+ * Get the reviewer agent name for a given executor.
33
+ * Supports both formats:
34
+ * review_policy.claude_code: "codex" (simple)
35
+ * review_policy.claude_code: { reviewer: "codex" } (full)
36
+ */
37
+ export function getReviewer(config, executor) {
38
+ const policy = config.review_policy?.[executor];
39
+ if (!policy) return null;
40
+ if (typeof policy === 'string') return policy;
41
+ return policy.reviewer || null;
42
+ }
43
+
44
+ /**
45
+ * Get max iterations for a given executor↔reviewer direction.
46
+ */
47
+ export function getMaxIterations(config, executor) {
48
+ const policy = config.review_policy?.[executor];
49
+ if (typeof policy === 'object' && policy.max_iterations != null) {
50
+ return policy.max_iterations;
51
+ }
52
+ return 3; // default
53
+ }
54
+
55
+ /**
56
+ * Deep merge two objects. User values override defaults.
57
+ * Arrays are replaced, not merged.
58
+ */
59
+ function deepMerge(defaults, overrides) {
60
+ if (!overrides) return { ...defaults };
61
+ if (!defaults) return { ...overrides };
62
+
63
+ const result = { ...defaults };
64
+ for (const key of Object.keys(overrides)) {
65
+ const val = overrides[key];
66
+ if (val && typeof val === 'object' && !Array.isArray(val) &&
67
+ result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
68
+ result[key] = deepMerge(result[key], val);
69
+ } else {
70
+ result[key] = val;
71
+ }
72
+ }
73
+ return result;
74
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, mkdirSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { loadConfig, configExists, getReviewer, getMaxIterations } from './config-loader.js';
6
+
7
+ const TMP = join(process.cwd(), '.test-tmp-config');
8
+
9
+ describe('config-loader', () => {
10
+ beforeEach(() => {
11
+ mkdirSync(join(TMP, '.openairev'), { recursive: true });
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(TMP, { recursive: true, force: true });
16
+ });
17
+
18
+ it('returns defaults when no config file exists', () => {
19
+ const config = loadConfig(TMP);
20
+ assert.equal(config.review_trigger, 'explicit');
21
+ assert.ok(config.agents.claude_code);
22
+ assert.ok(config.agents.codex);
23
+ });
24
+
25
+ it('detects config existence', () => {
26
+ assert.equal(configExists(TMP), false);
27
+ writeFileSync(join(TMP, '.openairev', 'config.yaml'), 'review_trigger: auto\n');
28
+ assert.equal(configExists(TMP), true);
29
+ });
30
+
31
+ it('loads and merges config with defaults', () => {
32
+ writeFileSync(join(TMP, '.openairev', 'config.yaml'), [
33
+ 'review_trigger: auto',
34
+ 'review_policy:',
35
+ ' claude_code:',
36
+ ' reviewer: codex',
37
+ ' max_iterations: 5',
38
+ ].join('\n'));
39
+
40
+ const config = loadConfig(TMP);
41
+ assert.equal(config.review_trigger, 'auto');
42
+ assert.equal(getReviewer(config, 'claude_code'), 'codex');
43
+ assert.ok(config.agents);
44
+ });
45
+
46
+ it('getReviewer works with simple string format', () => {
47
+ const config = { review_policy: { claude_code: 'codex', codex: 'claude_code' } };
48
+ assert.equal(getReviewer(config, 'claude_code'), 'codex');
49
+ assert.equal(getReviewer(config, 'codex'), 'claude_code');
50
+ assert.equal(getReviewer(config, 'unknown'), null);
51
+ });
52
+
53
+ it('getReviewer works with object format', () => {
54
+ const config = {
55
+ review_policy: {
56
+ claude_code: { reviewer: 'codex', max_iterations: 5 },
57
+ codex: { reviewer: 'claude_code', max_iterations: 1 },
58
+ },
59
+ };
60
+ assert.equal(getReviewer(config, 'claude_code'), 'codex');
61
+ assert.equal(getReviewer(config, 'codex'), 'claude_code');
62
+ });
63
+
64
+ it('getMaxIterations returns per-direction iterations', () => {
65
+ const config = {
66
+ review_policy: {
67
+ claude_code: { reviewer: 'codex', max_iterations: 5 },
68
+ codex: { reviewer: 'claude_code', max_iterations: 1 },
69
+ },
70
+ };
71
+ assert.equal(getMaxIterations(config, 'claude_code'), 5);
72
+ assert.equal(getMaxIterations(config, 'codex'), 1);
73
+ });
74
+
75
+ it('getMaxIterations returns default for simple string policy', () => {
76
+ const config = { review_policy: { claude_code: 'codex' } };
77
+ assert.equal(getMaxIterations(config, 'claude_code'), 3);
78
+ });
79
+
80
+ it('getMaxIterations returns default for unknown executor', () => {
81
+ assert.equal(getMaxIterations({}, 'unknown'), 3);
82
+ });
83
+
84
+ it('deep merges partial config without dropping nested defaults', () => {
85
+ // Only override claude_code agent, codex should still be present from defaults
86
+ writeFileSync(join(TMP, '.openairev', 'config.yaml'), [
87
+ 'agents:',
88
+ ' claude_code:',
89
+ ' available: true',
90
+ ].join('\n'));
91
+
92
+ const config = loadConfig(TMP);
93
+ assert.equal(config.agents.claude_code.available, true);
94
+ assert.equal(config.agents.claude_code.cmd, 'claude'); // from defaults
95
+ assert.ok(config.agents.codex); // not dropped
96
+ assert.equal(config.agents.codex.cmd, 'codex'); // from defaults
97
+ });
98
+
99
+ it('deep merges review_policy without dropping other directions', () => {
100
+ writeFileSync(join(TMP, '.openairev', 'config.yaml'), [
101
+ 'review_policy:',
102
+ ' claude_code:',
103
+ ' reviewer: codex',
104
+ ' max_iterations: 10',
105
+ ].join('\n'));
106
+
107
+ const config = loadConfig(TMP);
108
+ // Overridden value
109
+ assert.equal(getMaxIterations(config, 'claude_code'), 10);
110
+ // Default for codex direction still present
111
+ assert.equal(getReviewer(config, 'codex'), 'claude_code');
112
+ });
113
+ });
@@ -0,0 +1,38 @@
1
+ export const DEFAULTS = {
2
+ agents: {
3
+ claude_code: {
4
+ cmd: 'claude',
5
+ available: false,
6
+ },
7
+ codex: {
8
+ cmd: 'codex',
9
+ available: false,
10
+ },
11
+ },
12
+ // max_iterations is per direction — it controls how many rounds the
13
+ // EXECUTOR gets to fix its code after receiving review feedback.
14
+ // Claude Code is less stable at aligning to reviewer feedback across
15
+ // rounds, so it gets more iterations to converge. Codex applies
16
+ // feedback more consistently, so fewer iterations are needed.
17
+ // These are defaults — users override during `openairev init`.
18
+ review_policy: {
19
+ claude_code: {
20
+ reviewer: 'codex',
21
+ max_iterations: 5,
22
+ },
23
+ codex: {
24
+ reviewer: 'claude_code',
25
+ max_iterations: 1,
26
+ },
27
+ },
28
+ review_trigger: 'explicit',
29
+ tools: {
30
+ run_tests: 'npm test',
31
+ run_lint: 'npm run lint',
32
+ run_typecheck: 'npx tsc --noEmit',
33
+ },
34
+ session: {
35
+ store_history: true,
36
+ archive_after: '7d',
37
+ },
38
+ };
@@ -0,0 +1,44 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "status": {
5
+ "type": "string",
6
+ "enum": ["approved", "needs_changes", "reject"]
7
+ },
8
+ "critical_issues": {
9
+ "type": "array",
10
+ "items": { "type": "string" }
11
+ },
12
+ "missing_requirements": {
13
+ "type": "array",
14
+ "items": { "type": "string" }
15
+ },
16
+ "sequencing_issues": {
17
+ "type": "array",
18
+ "items": { "type": "string" }
19
+ },
20
+ "risks": {
21
+ "type": "array",
22
+ "items": { "type": "string" }
23
+ },
24
+ "risk_level": {
25
+ "type": "string",
26
+ "enum": ["low", "medium", "high"]
27
+ },
28
+ "confidence": {
29
+ "type": "number",
30
+ "minimum": 0,
31
+ "maximum": 1
32
+ },
33
+ "repair_instructions": {
34
+ "type": "array",
35
+ "items": { "type": "string" }
36
+ },
37
+ "false_positives_reconsidered": {
38
+ "type": "array",
39
+ "items": { "type": "string" }
40
+ }
41
+ },
42
+ "required": ["status", "critical_issues", "risk_level", "confidence"],
43
+ "additionalProperties": false
44
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "status": {
5
+ "type": "string",
6
+ "enum": ["approved", "needs_changes", "reject"]
7
+ },
8
+ "critical_issues": {
9
+ "type": "array",
10
+ "items": { "type": "string" }
11
+ },
12
+ "test_gaps": {
13
+ "type": "array",
14
+ "items": { "type": "string" }
15
+ },
16
+ "requirement_mismatches": {
17
+ "type": "array",
18
+ "items": { "type": "string" }
19
+ },
20
+ "rule_violations": {
21
+ "type": "array",
22
+ "items": { "type": "string" }
23
+ },
24
+ "risk_level": {
25
+ "type": "string",
26
+ "enum": ["low", "medium", "high"]
27
+ },
28
+ "confidence": {
29
+ "type": "number",
30
+ "minimum": 0,
31
+ "maximum": 1
32
+ },
33
+ "repair_instructions": {
34
+ "type": "array",
35
+ "items": { "type": "string" }
36
+ },
37
+ "false_positives_reconsidered": {
38
+ "type": "array",
39
+ "items": { "type": "string" }
40
+ }
41
+ },
42
+ "required": ["status", "critical_issues", "risk_level", "confidence"],
43
+ "additionalProperties": false
44
+ }
@@ -0,0 +1,261 @@
1
+ import { loadConfig, getReviewer } from '../config/config-loader.js';
2
+ import { getDiff } from '../tools/git-tools.js';
3
+ import { runToolGates } from '../tools/tool-runner.js';
4
+ import { runReview } from '../review/review-runner.js';
5
+ import { createSession, saveSession } from '../session/session-manager.js';
6
+
7
+ /**
8
+ * MCP Server using stdio with Content-Length framing per MCP spec.
9
+ * Both Claude Code and Codex can call this as an MCP server.
10
+ */
11
+ export function startMcpServer() {
12
+ const cwd = process.cwd();
13
+ const config = loadConfig(cwd);
14
+
15
+ let buffer = Buffer.alloc(0);
16
+
17
+ process.stdin.on('data', (chunk) => {
18
+ buffer = Buffer.concat([buffer, chunk]);
19
+ buffer = processBuffer(buffer, config, cwd);
20
+ });
21
+
22
+ process.stdin.on('end', () => process.exit(0));
23
+ }
24
+
25
+ /**
26
+ * Parse Content-Length framed messages from buffer.
27
+ * Format: "Content-Length: <N>\r\n\r\n<JSON body of N bytes>"
28
+ */
29
+ function processBuffer(buf, config, cwd) {
30
+ while (true) {
31
+ const headerEnd = buf.indexOf('\r\n\r\n');
32
+ if (headerEnd === -1) break;
33
+
34
+ const header = buf.slice(0, headerEnd).toString('utf-8');
35
+ const match = header.match(/Content-Length:\s*(\d+)/i);
36
+ if (!match) {
37
+ // Fallback: try newline-delimited JSON for compatibility
38
+ const nlIndex = buf.indexOf('\n');
39
+ if (nlIndex === -1) break;
40
+ const line = buf.slice(0, nlIndex).toString('utf-8').trim();
41
+ buf = buf.slice(nlIndex + 1);
42
+ if (line) {
43
+ try {
44
+ const request = JSON.parse(line);
45
+ handleRequest(request, config, cwd).then(response => {
46
+ if (response !== null) sendResponse(response);
47
+ }).catch(() => {});
48
+ } catch {
49
+ // skip malformed
50
+ }
51
+ }
52
+ continue;
53
+ }
54
+
55
+ const contentLength = parseInt(match[1], 10);
56
+ const bodyStart = headerEnd + 4;
57
+ if (buf.length < bodyStart + contentLength) break; // incomplete body
58
+
59
+ const body = buf.slice(bodyStart, bodyStart + contentLength).toString('utf-8');
60
+ buf = buf.slice(bodyStart + contentLength);
61
+
62
+ try {
63
+ const request = JSON.parse(body);
64
+ handleRequest(request, config, cwd).then(response => {
65
+ if (response !== null) sendResponse(response);
66
+ });
67
+ } catch {
68
+ sendResponse({
69
+ jsonrpc: '2.0',
70
+ error: { code: -32700, message: 'Parse error' },
71
+ id: null,
72
+ });
73
+ }
74
+ }
75
+ return buf; // Return unconsumed remainder
76
+ }
77
+
78
+ /**
79
+ * Send a JSON-RPC response with Content-Length framing.
80
+ */
81
+ function sendResponse(response) {
82
+ const body = JSON.stringify(response);
83
+ const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`;
84
+ process.stdout.write(header + body);
85
+ }
86
+
87
+ async function handleRequest(request, config, cwd) {
88
+ const { method, params, id } = request;
89
+
90
+ switch (method) {
91
+ case 'initialize':
92
+ return {
93
+ jsonrpc: '2.0',
94
+ result: {
95
+ protocolVersion: '2024-11-05',
96
+ capabilities: { tools: {} },
97
+ serverInfo: { name: 'openairev', version: '0.2.0' },
98
+ },
99
+ id,
100
+ };
101
+
102
+ case 'notifications/initialized':
103
+ return null; // Notifications get no response
104
+
105
+ case 'tools/list':
106
+ return {
107
+ jsonrpc: '2.0',
108
+ result: {
109
+ tools: getToolDefinitions(),
110
+ },
111
+ id,
112
+ };
113
+
114
+ case 'tools/call':
115
+ return {
116
+ jsonrpc: '2.0',
117
+ result: await callTool(params.name, params.arguments || {}, config, cwd),
118
+ id,
119
+ };
120
+
121
+ default:
122
+ return {
123
+ jsonrpc: '2.0',
124
+ error: { code: -32601, message: `Method not found: ${method}` },
125
+ id,
126
+ };
127
+ }
128
+ }
129
+
130
+ function getToolDefinitions() {
131
+ return [
132
+ {
133
+ name: 'openairev_review',
134
+ description: 'TRIGGER: Use this tool when the user says "review", "review my code", "get a review", "check my changes", "openairev", or asks for independent/cross-model code review. Sends current code changes to a DIFFERENT AI model for independent review. Returns a structured verdict with critical issues, test gaps, risk level, confidence score, and repair instructions. The reviewer is never the same model as the executor — this ensures unbiased, independent judgment.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ executor: {
139
+ type: 'string',
140
+ description: 'Which agent wrote the code (claude_code or codex). If you are Claude Code, set this to "claude_code". If you are Codex, set this to "codex". This determines which other model will review.',
141
+ },
142
+ diff: {
143
+ type: 'string',
144
+ description: 'The diff or code to review. If omitted, auto-detects from git (staged → unstaged → last commit).',
145
+ },
146
+ task_description: {
147
+ type: 'string',
148
+ description: 'What the code is supposed to do. Used for requirement checking. Include acceptance criteria if available.',
149
+ },
150
+ },
151
+ },
152
+ },
153
+ {
154
+ name: 'openairev_status',
155
+ description: 'Get the status and verdict of the most recent OpenAIRev review session. Use when the user asks "what did the review say", "review status", or "last review results".',
156
+ inputSchema: { type: 'object', properties: {} },
157
+ },
158
+ {
159
+ name: 'openairev_run_tests',
160
+ description: 'Run the project test suite and return pass/fail results. Use when user asks to "run tests" or you need to verify code before review.',
161
+ inputSchema: { type: 'object', properties: {} },
162
+ },
163
+ {
164
+ name: 'openairev_run_lint',
165
+ description: 'Run the project linter and return results.',
166
+ inputSchema: { type: 'object', properties: {} },
167
+ },
168
+ {
169
+ name: 'openairev_get_diff',
170
+ description: 'Get the current git diff (staged, unstaged, or last commit).',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ ref: { type: 'string', description: 'Git ref to diff against' },
175
+ },
176
+ },
177
+ },
178
+ ];
179
+ }
180
+
181
+ async function callTool(name, args, config, cwd) {
182
+ try {
183
+ switch (name) {
184
+ case 'openairev_review': {
185
+ const executor = args.executor || Object.keys(config.agents).find(a => config.agents[a].available);
186
+ const reviewerName = getReviewer(config, executor);
187
+ if (!reviewerName) {
188
+ return formatResult(`No reviewer configured for executor "${executor}"`);
189
+ }
190
+ const diff = args.diff || getDiff();
191
+
192
+ if (!diff.trim()) {
193
+ return formatResult('No changes found to review.');
194
+ }
195
+
196
+ const review = await runReview(diff, {
197
+ config,
198
+ reviewerName,
199
+ taskDescription: args.task_description,
200
+ cwd,
201
+ });
202
+
203
+ // Save session
204
+ const session = createSession({ executor, reviewer: reviewerName });
205
+ session.iterations.push({ round: 1, review, timestamp: new Date().toISOString() });
206
+ session.final_verdict = review.verdict;
207
+ session.status = 'completed';
208
+ saveSession(session, cwd);
209
+
210
+ // Return executor-facing feedback (framed as peer review, not user command)
211
+ return formatResult(review.executor_feedback || JSON.stringify(review.verdict || review, null, 2));
212
+ }
213
+
214
+ case 'openairev_status': {
215
+ const { listSessions } = await import('../session/session-manager.js');
216
+ const sessions = listSessions(cwd, 1);
217
+ if (sessions.length === 0) {
218
+ return formatResult('No review sessions found.');
219
+ }
220
+ const last = sessions[0];
221
+ return formatResult(JSON.stringify({
222
+ id: last.id,
223
+ status: last.status,
224
+ verdict: last.final_verdict,
225
+ created: last.created,
226
+ }, null, 2));
227
+ }
228
+
229
+ case 'openairev_run_tests': {
230
+ const testCmd = config.tools?.run_tests || 'npm test';
231
+ const results = runToolGates(['run_tests'], cwd, { run_tests: testCmd });
232
+ return formatResult(JSON.stringify(results.tests, null, 2));
233
+ }
234
+
235
+ case 'openairev_run_lint': {
236
+ const lintCmd = config.tools?.run_lint || 'npm run lint';
237
+ const results = runToolGates(['run_lint'], cwd, { run_lint: lintCmd });
238
+ return formatResult(JSON.stringify(results.lint, null, 2));
239
+ }
240
+
241
+ case 'openairev_get_diff': {
242
+ const diff = getDiff(args.ref);
243
+ return formatResult(diff || 'No changes found.');
244
+ }
245
+
246
+ default:
247
+ return formatResult(`Unknown tool: ${name}`);
248
+ }
249
+ } catch (e) {
250
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
251
+ }
252
+ }
253
+
254
+ function formatResult(text) {
255
+ return { content: [{ type: 'text', text }] };
256
+ }
257
+
258
+ // If run directly, start the server
259
+ if (process.argv[1] && process.argv[1].includes('mcp-server')) {
260
+ startMcpServer();
261
+ }