openairev 0.2.4 → 0.3.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.
package/bin/openairev.js CHANGED
@@ -12,7 +12,7 @@ const program = new Command();
12
12
  program
13
13
  .name('openairev')
14
14
  .description('OpenAIRev — cross-model AI code reviewer')
15
- .version('0.2.4');
15
+ .version('0.3.0');
16
16
 
17
17
  program
18
18
  .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.0",
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,8 @@ export class CodexAdapter {
30
31
  args.push('--output-schema', schemaPath);
31
32
  }
32
33
 
33
- const result = await exec(this.cmd, args);
34
+ const onData = stream ? createCodexSummarizer({ reviewerName: typeof stream === 'string' ? stream : 'codex' }) : undefined;
35
+ const result = await exec(this.cmd, args, { onData });
34
36
 
35
37
  try {
36
38
  const lines = result.stdout.trim().split('\n');
@@ -62,15 +64,15 @@ export class CodexAdapter {
62
64
 
63
65
  if (agentMessage) {
64
66
  try {
65
- return { result: JSON.parse(agentMessage), session_id: this.sessionId };
67
+ return { result: JSON.parse(agentMessage), raw_output: result.stdout, session_id: this.sessionId };
66
68
  } catch {
67
- return { result: agentMessage, session_id: this.sessionId };
69
+ return { result: agentMessage, raw_output: result.stdout, session_id: this.sessionId };
68
70
  }
69
71
  }
70
72
 
71
- return { raw: result.stdout, session_id: this.sessionId };
73
+ return { raw: result.stdout, raw_output: result.stdout, session_id: this.sessionId };
72
74
  } catch {
73
- return { raw: result.stdout, error: 'Failed to parse output' };
75
+ return { raw: result.stdout, raw_output: result.stdout, error: 'Failed to parse output' };
74
76
  }
75
77
  }
76
78
  }
@@ -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,124 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Creates a summarizer callback for Codex NDJSON event streams.
5
+ * Prints concise progress lines to stderr instead of raw JSON.
6
+ */
7
+ export function createCodexSummarizer({ reviewerName } = {}) {
8
+ const seenFiles = new Set();
9
+ let buffer = '';
10
+ let started = false;
11
+
12
+ return (chunk) => {
13
+ if (!started) {
14
+ started = true;
15
+ if (reviewerName) log(chalk.cyan(` reviewer: ${reviewerName}`));
16
+ }
17
+ buffer += chunk;
18
+ const lines = buffer.split('\n');
19
+ buffer = lines.pop(); // keep incomplete last line
20
+
21
+ for (const line of lines) {
22
+ if (!line.trim()) continue;
23
+ try {
24
+ const event = JSON.parse(line);
25
+ summarizeCodexEvent(event, seenFiles);
26
+ } catch {
27
+ // skip non-JSON
28
+ }
29
+ }
30
+ };
31
+ }
32
+
33
+ function summarizeCodexEvent(event, seenFiles) {
34
+ const type = event.type;
35
+
36
+ if (type === 'thread.started') {
37
+ log(chalk.dim(` session: ${event.thread_id}`));
38
+ }
39
+
40
+ if (type === 'item.started' && event.item?.type === 'todo_list') {
41
+ const items = event.item.items?.map(i => i.text) || [];
42
+ log(chalk.cyan(' plan:'));
43
+ for (const item of items) log(chalk.dim(` • ${item}`));
44
+ }
45
+
46
+ if (type === 'item.completed' && event.item?.type === 'todo_list') {
47
+ const items = event.item.items || [];
48
+ const done = items.filter(i => i.completed).length;
49
+ log(chalk.dim(` progress: ${done}/${items.length} tasks done`));
50
+ }
51
+
52
+ if (type === 'item.started' && event.item?.type === 'command_execution') {
53
+ const cmd = event.item.command || '';
54
+ if (isInternalCmd(cmd)) return;
55
+ const file = extractFileFromCmd(cmd);
56
+ if (file && !seenFiles.has(file)) {
57
+ seenFiles.add(file);
58
+ log(chalk.dim(` reading: ${file}`));
59
+ } else if (!file) {
60
+ const short = summarizeCmd(cmd);
61
+ if (short) log(chalk.dim(` running: ${short}`));
62
+ }
63
+ }
64
+
65
+ if (type === 'item.completed' && event.item?.type === 'command_execution') {
66
+ const exit = event.item.exit_code;
67
+ if (exit !== null && exit !== 0) {
68
+ const cmd = summarizeCmd(event.item.command || '');
69
+ log(chalk.yellow(` command failed (exit ${exit}): ${cmd}`));
70
+ }
71
+ }
72
+
73
+ if (type === 'item.started' && event.item?.type === 'agent_message') {
74
+ log(chalk.cyan(' generating verdict...'));
75
+ }
76
+
77
+ if (type === 'item.completed' && event.item?.type === 'agent_message') {
78
+ log(chalk.green(' verdict ready'));
79
+ }
80
+
81
+ if (type === 'turn.completed' && event.usage) {
82
+ const { input_tokens, output_tokens } = event.usage;
83
+ const total = input_tokens + output_tokens;
84
+ log(chalk.dim(` tokens: ${fmt(total)} total (${fmt(input_tokens)} in / ${fmt(output_tokens)} out)`));
85
+ }
86
+
87
+ if (type === 'error' || type === 'turn.failed') {
88
+ const msg = event.message || event.error?.message || 'unknown error';
89
+ log(chalk.red(` error: ${msg}`));
90
+ }
91
+ }
92
+
93
+ function isInternalCmd(cmd) {
94
+ return /node\s+-e\s/.test(cmd) ||
95
+ /require\(['"]child_process['"]\)/.test(cmd) ||
96
+ /spawn\(/.test(cmd) ||
97
+ /execFile\(/.test(cmd) ||
98
+ /process\.exec/.test(cmd) ||
99
+ /<<'NODE'/.test(cmd) ||
100
+ /echo\s/.test(cmd);
101
+ }
102
+
103
+ function extractFileFromCmd(cmd) {
104
+ const match = cmd.match(/(?:cat|sed|nl|head|tail|less)\s+(?:-[^\s]*\s+)*([^\s|>"']+\.\w+)/);
105
+ if (match) return match[1];
106
+ const match2 = cmd.match(/\s([a-zA-Z][\w/.-]+\.\w{1,5})(?:\s|$|\|)/);
107
+ return match2 ? match2[1] : null;
108
+ }
109
+
110
+ function summarizeCmd(cmd) {
111
+ const inner = cmd.replace(/^\/bin\/\w+\s+-\w+\s+["'](.+)["']$/, '$1');
112
+ const clean = inner.replace(/\\"/g, '"').trim();
113
+ return clean.length > 80 ? clean.slice(0, 77) + '...' : clean;
114
+ }
115
+
116
+ function fmt(n) {
117
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
118
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
119
+ return String(n);
120
+ }
121
+
122
+ function log(msg) {
123
+ process.stderr.write(msg + '\n');
124
+ }
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() });
@@ -14,7 +14,7 @@ const config = loadConfig(cwd);
14
14
 
15
15
  const server = new McpServer({
16
16
  name: 'openairev',
17
- version: '0.2.4',
17
+ version: '0.3.0',
18
18
  });
19
19
 
20
20
  server.tool(
@@ -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,36 @@ export async function runReview(content, {
37
40
  schemaFile,
38
41
  continueSession: !!sessionId,
39
42
  sessionName: sessionId ? undefined : `review-${Date.now()}`,
43
+ stream: stream ? reviewerName : 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,
49
57
  session_id: adapter.sessionName || adapter.sessionId,
50
58
  };
51
59
  }
52
60
 
61
+ function logReviewerOutput(rawOutput, reviewerName, cwd) {
62
+ if (!rawOutput) return;
63
+ try {
64
+ const logDir = join(cwd, '.openairev', 'logs');
65
+ mkdirSync(logDir, { recursive: true });
66
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
67
+ writeFileSync(join(logDir, `review-${reviewerName}-${ts}.log`), rawOutput);
68
+ } catch {
69
+ // non-critical, don't fail the review
70
+ }
71
+ }
72
+
53
73
  function buildExecutorFeedback(verdict, cwd) {
54
74
  const feedbackPrompt = loadPromptFile('executor-feedback.md', cwd);
55
75
  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) {