openairev 0.2.4 → 0.3.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/bin/openairev.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
+ import { VERSION } from '../src/version.js';
4
5
  import { initCommand } from '../src/cli/init.js';
5
6
  import { reviewCommand } from '../src/cli/review.js';
6
7
  import { resumeCommand } from '../src/cli/resume.js';
@@ -12,7 +13,7 @@ const program = new Command();
12
13
  program
13
14
  .name('openairev')
14
15
  .description('OpenAIRev — cross-model AI code reviewer')
15
- .version('0.2.4');
16
+ .version(VERSION);
16
17
 
17
18
  program
18
19
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openairev",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Cross-model AI code reviewer — independent review for AI-assisted coding workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,9 +38,9 @@ export class ClaudeCodeAdapter {
38
38
  if (!this.sessionName && parsed.session_id) {
39
39
  this.sessionName = parsed.session_id;
40
40
  }
41
- return parsed;
41
+ return { ...parsed, raw_output: result.stdout };
42
42
  } catch {
43
- return { raw: result.stdout, error: 'Failed to parse JSON output' };
43
+ return { raw: result.stdout, raw_output: result.stdout, error: 'Failed to parse JSON output' };
44
44
  }
45
45
  }
46
46
  }
@@ -1,6 +1,7 @@
1
1
  import { join, dirname } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { exec } from './exec-helper.js';
4
+ import { createCodexSummarizer } from './stream-summarizer.js';
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
 
@@ -15,7 +16,7 @@ export class CodexAdapter {
15
16
  this.sessionId = id;
16
17
  }
17
18
 
18
- async run(prompt, { useSchema = false, schemaFile = 'verdict-schema.json', continueSession = false, sessionName = null } = {}) {
19
+ async run(prompt, { useSchema = false, schemaFile = 'verdict-schema.json', continueSession = false, sessionName = null, stream = false } = {}) {
19
20
  const args = ['exec'];
20
21
 
21
22
  if (continueSession && this.sessionId) {
@@ -30,7 +31,11 @@ export class CodexAdapter {
30
31
  args.push('--output-schema', schemaPath);
31
32
  }
32
33
 
33
- const result = await exec(this.cmd, args);
34
+ const summarizer = stream ? createCodexSummarizer({
35
+ reviewerName: stream.reviewerName || 'codex',
36
+ tty: stream.tty !== false,
37
+ }) : undefined;
38
+ const result = await exec(this.cmd, args, { onData: summarizer });
34
39
 
35
40
  try {
36
41
  const lines = result.stdout.trim().split('\n');
@@ -60,17 +65,19 @@ export class CodexAdapter {
60
65
  this.sessionId = sessionId;
61
66
  }
62
67
 
68
+ const progress = summarizer?.getProgress() || [];
69
+
63
70
  if (agentMessage) {
64
71
  try {
65
- return { result: JSON.parse(agentMessage), session_id: this.sessionId };
72
+ return { result: JSON.parse(agentMessage), raw_output: result.stdout, progress, session_id: this.sessionId };
66
73
  } catch {
67
- return { result: agentMessage, session_id: this.sessionId };
74
+ return { result: agentMessage, raw_output: result.stdout, progress, session_id: this.sessionId };
68
75
  }
69
76
  }
70
77
 
71
- return { raw: result.stdout, session_id: this.sessionId };
78
+ return { raw: result.stdout, raw_output: result.stdout, progress, session_id: this.sessionId };
72
79
  } catch {
73
- return { raw: result.stdout, error: 'Failed to parse output' };
80
+ return { raw: result.stdout, raw_output: result.stdout, error: 'Failed to parse output' };
74
81
  }
75
82
  }
76
83
  }
@@ -1,13 +1,51 @@
1
- import { execFile } from 'child_process';
1
+ import { spawn } from 'child_process';
2
2
 
3
- export function exec(cmd, args) {
3
+ const MAX_BUFFER = 10 * 1024 * 1024;
4
+
5
+ export function exec(cmd, args, { onData } = {}) {
4
6
  return new Promise((resolve, reject) => {
5
- execFile(cmd, args, {
6
- maxBuffer: 10 * 1024 * 1024,
7
+ const child = spawn(cmd, args, {
7
8
  timeout: 300_000,
8
- }, (error, stdout, stderr) => {
9
- if (error && !stdout) {
10
- reject(new Error(`${cmd} failed: ${stderr || error.message}`));
9
+ });
10
+
11
+ const stdoutChunks = [];
12
+ const stderrChunks = [];
13
+ let stdoutLen = 0;
14
+ let stderrLen = 0;
15
+ let killed = false;
16
+
17
+ child.stdout.on('data', (chunk) => {
18
+ if (onData) onData(chunk.toString());
19
+ stdoutLen += chunk.length;
20
+ if (stdoutLen <= MAX_BUFFER) {
21
+ stdoutChunks.push(chunk);
22
+ } else if (!killed) {
23
+ killed = true;
24
+ child.kill();
25
+ }
26
+ });
27
+
28
+ child.stderr.on('data', (chunk) => {
29
+ stderrLen += chunk.length;
30
+ if (stderrLen <= MAX_BUFFER) {
31
+ stderrChunks.push(chunk);
32
+ } else if (!killed) {
33
+ killed = true;
34
+ child.kill();
35
+ }
36
+ });
37
+
38
+ child.on('error', (err) => {
39
+ reject(new Error(`${cmd} failed: ${err.message}`));
40
+ });
41
+
42
+ child.on('close', (code) => {
43
+ const stdout = Buffer.concat(stdoutChunks).toString();
44
+ const stderr = Buffer.concat(stderrChunks).toString();
45
+ if (killed) {
46
+ reject(new Error(`${cmd} output exceeded ${MAX_BUFFER} bytes`));
47
+ } else if (code !== 0 && !stdout) {
48
+ reject(new Error(`${cmd} failed (exit ${code}): ${stderr}`));
11
49
  } else {
12
50
  resolve({ stdout, stderr });
13
51
  }
@@ -0,0 +1,139 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Creates a summarizer callback for Codex NDJSON event streams.
5
+ * tty=true: prints colored progress to stderr.
6
+ * tty=false: collects plain-text progress lines silently.
7
+ * Both modes always collect lines for the final summary.
8
+ */
9
+ export function createCodexSummarizer({ reviewerName, tty = true } = {}) {
10
+ const seenFiles = new Set();
11
+ const progressLines = [];
12
+ let buffer = '';
13
+ let started = false;
14
+
15
+ const summarizer = (chunk) => {
16
+ if (!started) {
17
+ started = true;
18
+ if (reviewerName) emit(`reviewer: ${reviewerName}`, 'cyan');
19
+ }
20
+ buffer += chunk;
21
+ const lines = buffer.split('\n');
22
+ buffer = lines.pop();
23
+
24
+ for (const line of lines) {
25
+ if (!line.trim()) continue;
26
+ try {
27
+ const event = JSON.parse(line);
28
+ summarizeCodexEvent(event, seenFiles, { emit });
29
+ } catch {
30
+ // skip non-JSON
31
+ }
32
+ }
33
+ };
34
+
35
+ summarizer.getProgress = () => progressLines;
36
+
37
+ function emit(msg, color) {
38
+ if (progressLines.length < 200) progressLines.push(msg);
39
+ if (tty) {
40
+ const colorFn = color === 'cyan' ? chalk.cyan
41
+ : color === 'green' ? chalk.green
42
+ : color === 'yellow' ? chalk.yellow
43
+ : color === 'red' ? chalk.red
44
+ : chalk.dim;
45
+ process.stderr.write(` ${colorFn(msg)}\n`);
46
+ }
47
+ }
48
+
49
+ return summarizer;
50
+ }
51
+
52
+ function summarizeCodexEvent(event, seenFiles, { emit }) {
53
+ const type = event.type;
54
+
55
+ if (type === 'thread.started') {
56
+ emit(`session: ${event.thread_id}`);
57
+ }
58
+
59
+ if (type === 'item.started' && event.item?.type === 'todo_list') {
60
+ const items = event.item.items?.map(i => i.text) || [];
61
+ emit('plan:', 'cyan');
62
+ for (const item of items) emit(` • ${item}`);
63
+ }
64
+
65
+ if (type === 'item.completed' && event.item?.type === 'todo_list') {
66
+ const items = event.item.items || [];
67
+ const done = items.filter(i => i.completed).length;
68
+ emit(`progress: ${done}/${items.length} tasks done`);
69
+ }
70
+
71
+ if (type === 'item.started' && event.item?.type === 'command_execution') {
72
+ const cmd = event.item.command || '';
73
+ if (isInternalCmd(cmd)) return;
74
+ const file = extractFileFromCmd(cmd);
75
+ if (file && !seenFiles.has(file)) {
76
+ seenFiles.add(file);
77
+ emit(`reading: ${file}`);
78
+ } else if (!file) {
79
+ const short = summarizeCmd(cmd);
80
+ if (short) emit(`running: ${short}`);
81
+ }
82
+ }
83
+
84
+ if (type === 'item.completed' && event.item?.type === 'command_execution') {
85
+ const exit = event.item.exit_code;
86
+ if (exit !== null && exit !== 0) {
87
+ const cmd = summarizeCmd(event.item.command || '');
88
+ emit(`command failed (exit ${exit}): ${cmd}`, 'yellow');
89
+ }
90
+ }
91
+
92
+ if (type === 'item.started' && event.item?.type === 'agent_message') {
93
+ emit('generating verdict...', 'cyan');
94
+ }
95
+
96
+ if (type === 'item.completed' && event.item?.type === 'agent_message') {
97
+ emit('verdict ready', 'green');
98
+ }
99
+
100
+ if (type === 'turn.completed' && event.usage) {
101
+ const { input_tokens, output_tokens } = event.usage;
102
+ const total = input_tokens + output_tokens;
103
+ emit(`tokens: ${fmt(total)} total (${fmt(input_tokens)} in / ${fmt(output_tokens)} out)`);
104
+ }
105
+
106
+ if (type === 'error' || type === 'turn.failed') {
107
+ const msg = event.message || event.error?.message || 'unknown error';
108
+ emit(`error: ${msg}`, 'red');
109
+ }
110
+ }
111
+
112
+ function isInternalCmd(cmd) {
113
+ return /node\s+-e\s/.test(cmd) ||
114
+ /require\(['"]child_process['"]\)/.test(cmd) ||
115
+ /spawn\(/.test(cmd) ||
116
+ /execFile\(/.test(cmd) ||
117
+ /process\.exec/.test(cmd) ||
118
+ /<<'NODE'/.test(cmd) ||
119
+ /echo\s/.test(cmd);
120
+ }
121
+
122
+ function extractFileFromCmd(cmd) {
123
+ const match = cmd.match(/(?:cat|sed|nl|head|tail|less)\s+(?:-[^\s]*\s+)*([^\s|>"']+\.\w+)/);
124
+ if (match) return match[1];
125
+ const match2 = cmd.match(/\s([a-zA-Z][\w/.-]+\.\w{1,5})(?:\s|$|\|)/);
126
+ return match2 ? match2[1] : null;
127
+ }
128
+
129
+ function summarizeCmd(cmd) {
130
+ const inner = cmd.replace(/^\/bin\/\w+\s+-\w+\s+["'](.+)["']$/, '$1');
131
+ const clean = inner.replace(/\\"/g, '"').trim();
132
+ return clean.length > 80 ? clean.slice(0, 77) + '...' : clean;
133
+ }
134
+
135
+ function fmt(n) {
136
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
137
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
138
+ return String(n);
139
+ }
package/src/cli/review.js CHANGED
@@ -25,8 +25,8 @@ export async function reviewCommand(options) {
25
25
  const skipPlan = options.once || options.quick || !options.plan;
26
26
 
27
27
  console.log(chalk.bold(`\nOpenAIRev\n`));
28
- console.log(` Executor: ${chalk.cyan(executor)}`);
29
28
  console.log(` Reviewer: ${chalk.cyan(reviewerName)}`);
29
+ console.log(` Executor: ${chalk.dim(executor)}`);
30
30
  console.log(` Max rounds: ${chalk.cyan(maxRounds)}`);
31
31
 
32
32
  if (options.once) {
@@ -62,7 +62,7 @@ export async function reviewCommand(options) {
62
62
  }
63
63
  try {
64
64
  console.log(chalk.dim('Starting review...\n'));
65
- const review = await runReview(diff, { config, reviewerName, cwd });
65
+ const review = await runReview(diff, { config, reviewerName, cwd, stream: true });
66
66
 
67
67
  const session = createSession({ executor, reviewer: reviewerName, diff_ref: options.diff || 'auto' });
68
68
  session.iterations.push({ round: 1, review, timestamp: new Date().toISOString() });
@@ -8,13 +8,14 @@ import { getDiff } from '../tools/git-tools.js';
8
8
  import { runToolGates } from '../tools/tool-runner.js';
9
9
  import { runReview } from '../review/review-runner.js';
10
10
  import { createSession, saveSession } from '../session/session-manager.js';
11
+ import { VERSION } from '../version.js';
11
12
 
12
13
  const cwd = process.cwd();
13
14
  const config = loadConfig(cwd);
14
15
 
15
16
  const server = new McpServer({
16
17
  name: 'openairev',
17
- version: '0.2.4',
18
+ version: VERSION,
18
19
  });
19
20
 
20
21
  server.tool(
@@ -52,6 +53,7 @@ server.tool(
52
53
  reviewerName,
53
54
  taskDescription: task_description,
54
55
  cwd,
56
+ stream: 'silent',
55
57
  });
56
58
 
57
59
  const session = createSession({ executor: execAgent, reviewer: reviewerName });
@@ -60,8 +62,16 @@ server.tool(
60
62
  session.status = 'completed';
61
63
  saveSession(session, cwd);
62
64
 
63
- const text = review.executor_feedback || JSON.stringify(review.verdict || review, null, 2);
64
- return { content: [{ type: 'text', text }] };
65
+ const parts = [];
66
+
67
+ if (review.progress?.length > 0) {
68
+ parts.push({ type: 'text', text: `Review progress:\n${review.progress.map(l => ` ${l}`).join('\n')}` });
69
+ }
70
+
71
+ const feedback = review.executor_feedback || JSON.stringify(review.verdict || review, null, 2);
72
+ parts.push({ type: 'text', text: feedback });
73
+
74
+ return { content: parts };
65
75
  }
66
76
  );
67
77
 
@@ -263,7 +263,7 @@ async function runReviewRound(reviewerName, config, content, { kind, chain, spec
263
263
  config, reviewerName, promptFile,
264
264
  taskDescription: chain.task?.user_request,
265
265
  specRef: specRef || chain.task?.spec_ref,
266
- cwd, sessionId,
266
+ cwd, sessionId, stream: true,
267
267
  });
268
268
  }
269
269
 
@@ -1,3 +1,5 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
1
3
  import { createAdapter } from '../agents/registry.js';
2
4
  import { stageInput, buildInputReference } from './input-stager.js';
3
5
  import { loadPromptFile } from './prompt-loader.js';
@@ -10,6 +12,7 @@ export async function runReview(content, {
10
12
  specRef,
11
13
  cwd = process.cwd(),
12
14
  sessionId = null,
15
+ stream = false,
13
16
  }) {
14
17
  const adapter = createAdapter(reviewerName, config, { cwd });
15
18
 
@@ -37,19 +40,37 @@ export async function runReview(content, {
37
40
  schemaFile,
38
41
  continueSession: !!sessionId,
39
42
  sessionName: sessionId ? undefined : `review-${Date.now()}`,
43
+ stream: stream ? { reviewerName, tty: stream === true } : false,
40
44
  });
41
45
 
42
46
  const verdict = extractVerdict(result);
43
47
  const executorFeedback = buildExecutorFeedback(verdict, cwd);
48
+ const rawOutput = result?.raw_output || result?.raw || '';
49
+
50
+ logReviewerOutput(rawOutput, reviewerName, cwd);
44
51
 
45
52
  return {
46
53
  reviewer: reviewerName,
47
54
  verdict,
48
55
  executor_feedback: executorFeedback,
56
+ reviewer_output: rawOutput,
57
+ progress: result?.progress || [],
49
58
  session_id: adapter.sessionName || adapter.sessionId,
50
59
  };
51
60
  }
52
61
 
62
+ function logReviewerOutput(rawOutput, reviewerName, cwd) {
63
+ if (!rawOutput) return;
64
+ try {
65
+ const logDir = join(cwd, '.openairev', 'logs');
66
+ mkdirSync(logDir, { recursive: true });
67
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
68
+ writeFileSync(join(logDir, `review-${reviewerName}-${ts}.log`), rawOutput);
69
+ } catch {
70
+ // non-critical, don't fail the review
71
+ }
72
+ }
73
+
53
74
  function buildExecutorFeedback(verdict, cwd) {
54
75
  const feedbackPrompt = loadPromptFile('executor-feedback.md', cwd);
55
76
  if (!verdict) return null;
@@ -1,5 +1,8 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { existsSync, readdirSync, readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
3
6
 
4
7
  // Test verdict extraction logic (mirrors the function in review-runner.js)
5
8
 
@@ -77,3 +80,42 @@ describe('verdict extraction', () => {
77
80
  assert.equal(verdict, null);
78
81
  });
79
82
  });
83
+
84
+ describe('reviewer output logging', () => {
85
+ function logReviewerOutput(rawOutput, reviewerName, cwd) {
86
+ if (!rawOutput) return;
87
+ try {
88
+ const logDir = join(cwd, '.openairev', 'logs');
89
+ mkdirSync(logDir, { recursive: true });
90
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
91
+ writeFileSync(join(logDir, `review-${reviewerName}-${ts}.log`), rawOutput);
92
+ } catch {
93
+ // non-critical
94
+ }
95
+ }
96
+
97
+ it('writes reviewer output to log file', () => {
98
+ const tmp = mkdtempSync(join(tmpdir(), 'orev-log-'));
99
+ try {
100
+ logReviewerOutput('raw reviewer thinking here', 'codex', tmp);
101
+ const logDir = join(tmp, '.openairev', 'logs');
102
+ assert.ok(existsSync(logDir));
103
+ const files = readdirSync(logDir);
104
+ assert.equal(files.length, 1);
105
+ assert.ok(files[0].startsWith('review-codex-'));
106
+ assert.equal(readFileSync(join(logDir, files[0]), 'utf-8'), 'raw reviewer thinking here');
107
+ } finally {
108
+ rmSync(tmp, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ it('skips logging when output is empty', () => {
113
+ const tmp = mkdtempSync(join(tmpdir(), 'orev-log-'));
114
+ try {
115
+ logReviewerOutput('', 'codex', tmp);
116
+ assert.ok(!existsSync(join(tmp, '.openairev', 'logs')));
117
+ } finally {
118
+ rmSync(tmp, { recursive: true, force: true });
119
+ }
120
+ });
121
+ });
@@ -18,7 +18,7 @@ const EXCLUDE_PATTERNS = [
18
18
  * Uses minimal context (1 line) and excludes irrelevant files.
19
19
  */
20
20
  export function getDiff(ref, { context = 1, excludes = EXCLUDE_PATTERNS } = {}) {
21
- const excludeArgs = excludes.flatMap(p => ['--', `:!${p}`]);
21
+ const excludeArgs = ['--', ...excludes.map(p => `:!${p}`)];
22
22
  const contextArgs = [`-U${context}`];
23
23
 
24
24
  if (ref) {
package/src/version.js ADDED
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
7
+
8
+ export const VERSION = pkg.version;