openairev 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -213,7 +213,7 @@ Restart your agent CLI after adding.
213
213
  | Tool | Description |
214
214
  |------|-------------|
215
215
  | `openairev_review` | Start a review in the background. Returns immediately. |
216
- | `openairev_status` | Check review progress and get the verdict when ready. |
216
+ | `openairev_status` | Check review progress (prefer `openairev wait` instead). |
217
217
  | `openairev_run_tests` | Run project test suite |
218
218
  | `openairev_run_lint` | Run linter |
219
219
  | `openairev_get_diff` | Get current git diff |
@@ -223,10 +223,9 @@ Restart your agent CLI after adding.
223
223
  The review runs asynchronously so you can see progress:
224
224
 
225
225
  1. Call `openairev_review` → starts review in background, returns immediately
226
- 2. Read `.openairev/progress.json` → shows live progress (files read, commands run, tokens)
227
- 3. When `status` is `"completed"`, the verdict and feedback are in the same file
226
+ 2. Run `openairev wait` via Bash streams progress (files read, commands run, tokens) and outputs the verdict when done
228
227
 
229
- The executor AI can read the progress file directly (no MCP round-trip needed), or use `openairev_status` as an alternative. The AI can also launch the review in a sub-agent and continue other work while it runs.
228
+ One MCP call + one Bash call. No polling, no sleep loops. The AI can also launch the review in a sub-agent and continue other work while it runs.
230
229
 
231
230
  ## Config
232
231
 
package/bin/openairev.js CHANGED
@@ -7,6 +7,7 @@ import { reviewCommand } from '../src/cli/review.js';
7
7
  import { resumeCommand } from '../src/cli/resume.js';
8
8
  import { statusCommand } from '../src/cli/status.js';
9
9
  import { historyCommand } from '../src/cli/history.js';
10
+ import { waitCommand } from '../src/cli/wait.js';
10
11
 
11
12
  const program = new Command();
12
13
 
@@ -54,4 +55,10 @@ program
54
55
  .option('--chains', 'Show chains instead of sessions')
55
56
  .action(historyCommand);
56
57
 
58
+ program
59
+ .command('wait')
60
+ .description('Wait for a background review to finish, streaming progress')
61
+ .option('--file <path>', 'Path to progress.json (auto-detected from .openairev/)')
62
+ .action(waitCommand);
63
+
57
64
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openairev",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Cross-model AI code reviewer — independent review for AI-assisted coding workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node bin/openairev.js",
16
- "test": "node --test src/**/*.test.js"
16
+ "test": "node --test src/**/*.test.js",
17
+ "smoke:mcp": "node scripts/mcp-smoke.mjs"
17
18
  },
18
19
  "dependencies": {
19
20
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { exec } from './exec-helper.js';
5
+ import { createClaudeSummarizer } from './stream-summarizer.js';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
 
@@ -16,8 +17,20 @@ export class ClaudeCodeAdapter {
16
17
  this.sessionName = id;
17
18
  }
18
19
 
19
- async run(prompt, { useSchema = false, schemaFile = 'verdict-schema.json', continueSession = false, sessionName = null } = {}) {
20
- const args = ['-p', prompt, '--output-format', 'json'];
20
+ async run(prompt, {
21
+ useSchema = false,
22
+ schemaFile = 'verdict-schema.json',
23
+ continueSession = false,
24
+ sessionName = null,
25
+ stream = false,
26
+ } = {}) {
27
+ const args = ['-p', prompt];
28
+
29
+ if (stream) {
30
+ args.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
31
+ } else {
32
+ args.push('--output-format', 'json');
33
+ }
21
34
 
22
35
  if (useSchema) {
23
36
  const schemaPath = join(__dirname, '../config', schemaFile);
@@ -32,7 +45,21 @@ export class ClaudeCodeAdapter {
32
45
  this.sessionName = sessionName;
33
46
  }
34
47
 
35
- const result = await exec(this.cmd, args);
48
+ const summarizer = stream ? createClaudeSummarizer({
49
+ reviewerName: stream.reviewerName || 'claude_code',
50
+ tty: stream.tty !== false,
51
+ onProgress: stream.onProgress,
52
+ }) : undefined;
53
+
54
+ const result = await exec(this.cmd, args, { onData: summarizer, cwd: this.cwd });
55
+
56
+ if (stream) {
57
+ return parseClaudeStreamOutput(result.stdout, {
58
+ progress: summarizer?.getProgress() || [],
59
+ fallbackSessionId: this.sessionName,
60
+ });
61
+ }
62
+
36
63
  try {
37
64
  const parsed = JSON.parse(result.stdout);
38
65
  if (!this.sessionName && parsed.session_id) {
@@ -44,3 +71,93 @@ export class ClaudeCodeAdapter {
44
71
  }
45
72
  }
46
73
  }
74
+
75
+ export function parseClaudeStreamOutput(stdout, { progress = [], fallbackSessionId = null } = {}) {
76
+ let sessionId = fallbackSessionId;
77
+ let assistantText = null;
78
+ let assistantStructuredOutput = null;
79
+ let resultText = null;
80
+ let structuredOutput = null;
81
+ let resultError = null;
82
+
83
+ for (const line of stdout.split('\n')) {
84
+ if (!line.trim()) continue;
85
+
86
+ let event;
87
+ try {
88
+ event = JSON.parse(line);
89
+ } catch {
90
+ continue;
91
+ }
92
+
93
+ if (!sessionId && event.session_id) {
94
+ sessionId = event.session_id;
95
+ }
96
+
97
+ if (event.type === 'assistant') {
98
+ const text = extractAssistantText(event);
99
+ if (text) assistantText = text;
100
+ const toolInput = extractStructuredToolInput(event);
101
+ if (toolInput) assistantStructuredOutput = toolInput;
102
+ if (event.error) resultError = event.error;
103
+ }
104
+
105
+ if (event.type === 'result') {
106
+ if (event.structured_output && typeof event.structured_output === 'object') {
107
+ structuredOutput = event.structured_output;
108
+ }
109
+ if (typeof event.result === 'string' && event.result.trim()) {
110
+ resultText = event.result;
111
+ }
112
+ if (event.is_error) {
113
+ resultError = event.result || resultError || 'Claude review failed';
114
+ }
115
+ }
116
+ }
117
+
118
+ const finalText = resultText || assistantText;
119
+ const parsed = tryParseJson(finalText);
120
+ const verdict = structuredOutput || assistantStructuredOutput || parsed;
121
+
122
+ if (resultError && !verdict) {
123
+ return {
124
+ raw: stdout,
125
+ raw_output: stdout,
126
+ progress,
127
+ session_id: sessionId,
128
+ error: resultError,
129
+ };
130
+ }
131
+
132
+ return {
133
+ result: verdict || finalText,
134
+ raw_output: stdout,
135
+ progress,
136
+ session_id: sessionId,
137
+ };
138
+ }
139
+
140
+ function extractAssistantText(event) {
141
+ const parts = event.message?.content;
142
+ if (!Array.isArray(parts)) return null;
143
+ return parts
144
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
145
+ .map((part) => part.text)
146
+ .join('');
147
+ }
148
+
149
+ function extractStructuredToolInput(event) {
150
+ const parts = event.message?.content;
151
+ if (!Array.isArray(parts)) return null;
152
+ const toolUse = parts.find((part) => part?.type === 'tool_use' && part.name === 'StructuredOutput');
153
+ return toolUse?.input && typeof toolUse.input === 'object' ? toolUse.input : null;
154
+ }
155
+
156
+ function tryParseJson(text) {
157
+ if (!text || typeof text !== 'string') return null;
158
+ try {
159
+ return JSON.parse(text);
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseClaudeStreamOutput } from './claude-code.js';
4
+
5
+ describe('parseClaudeStreamOutput', () => {
6
+ it('parses a successful stream-json response into a verdict object', () => {
7
+ const stdout = [
8
+ JSON.stringify({
9
+ type: 'system',
10
+ subtype: 'init',
11
+ session_id: 'sess-123',
12
+ }),
13
+ JSON.stringify({
14
+ type: 'assistant',
15
+ session_id: 'sess-123',
16
+ message: {
17
+ content: [{
18
+ type: 'text',
19
+ text: '{"status":"approved","critical_issues":[],"test_gaps":[],"requirement_mismatches":[],"rule_violations":[],"risk_level":"low","confidence":0.99,"repair_instructions":[],"false_positives_reconsidered":[]}',
20
+ }],
21
+ },
22
+ }),
23
+ JSON.stringify({
24
+ type: 'result',
25
+ is_error: false,
26
+ session_id: 'sess-123',
27
+ result: '{"status":"approved","critical_issues":[],"test_gaps":[],"requirement_mismatches":[],"rule_violations":[],"risk_level":"low","confidence":0.99,"repair_instructions":[],"false_positives_reconsidered":[]}',
28
+ }),
29
+ ].join('\n');
30
+
31
+ const result = parseClaudeStreamOutput(stdout, { progress: ['reviewer: claude_code'] });
32
+
33
+ assert.equal(result.session_id, 'sess-123');
34
+ assert.equal(result.result.status, 'approved');
35
+ assert.deepEqual(result.progress, ['reviewer: claude_code']);
36
+ });
37
+
38
+ it('prefers structured_output from the final result event', () => {
39
+ const stdout = [
40
+ JSON.stringify({
41
+ type: 'system',
42
+ subtype: 'init',
43
+ session_id: 'sess-789',
44
+ }),
45
+ JSON.stringify({
46
+ type: 'result',
47
+ is_error: false,
48
+ session_id: 'sess-789',
49
+ result: '',
50
+ structured_output: {
51
+ status: 'approved',
52
+ critical_issues: [],
53
+ test_gaps: [],
54
+ requirement_mismatches: [],
55
+ rule_violations: [],
56
+ risk_level: 'low',
57
+ confidence: 0.98,
58
+ repair_instructions: [],
59
+ false_positives_reconsidered: [],
60
+ },
61
+ }),
62
+ ].join('\n');
63
+
64
+ const result = parseClaudeStreamOutput(stdout);
65
+
66
+ assert.equal(result.session_id, 'sess-789');
67
+ assert.equal(result.result.status, 'approved');
68
+ assert.equal(result.result.confidence, 0.98);
69
+ });
70
+
71
+ it('returns an error when the stream reports failure without a verdict payload', () => {
72
+ const stdout = [
73
+ JSON.stringify({
74
+ type: 'system',
75
+ subtype: 'init',
76
+ session_id: 'sess-456',
77
+ }),
78
+ JSON.stringify({
79
+ type: 'assistant',
80
+ session_id: 'sess-456',
81
+ error: 'authentication_failed',
82
+ message: {
83
+ content: [{ type: 'text', text: 'Not logged in' }],
84
+ },
85
+ }),
86
+ JSON.stringify({
87
+ type: 'result',
88
+ is_error: true,
89
+ session_id: 'sess-456',
90
+ result: 'Not logged in',
91
+ }),
92
+ ].join('\n');
93
+
94
+ const result = parseClaudeStreamOutput(stdout);
95
+
96
+ assert.equal(result.session_id, 'sess-456');
97
+ assert.equal(result.error, 'Not logged in');
98
+ assert.ok(result.raw_output.includes('authentication_failed'));
99
+ });
100
+ });
@@ -2,10 +2,11 @@ import { spawn } from 'child_process';
2
2
 
3
3
  const MAX_BUFFER = 10 * 1024 * 1024;
4
4
 
5
- export function exec(cmd, args, { onData } = {}) {
5
+ export function exec(cmd, args, { onData, cwd } = {}) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const child = spawn(cmd, args, {
8
8
  timeout: 300_000,
9
+ cwd,
9
10
  });
10
11
 
11
12
  const stdoutChunks = [];
@@ -0,0 +1,80 @@
1
+ function delay(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
4
+
5
+ function buildVerdict(schemaFile, status) {
6
+ if (schemaFile === 'plan-verdict-schema.json') {
7
+ return {
8
+ status,
9
+ critical_issues: [],
10
+ missing_requirements: [],
11
+ sequencing_issues: [],
12
+ risks: [],
13
+ risk_level: 'low',
14
+ confidence: 0.99,
15
+ repair_instructions: [],
16
+ false_positives_reconsidered: [
17
+ 'Mock reviewer found no plan issues in smoke-test mode.',
18
+ ],
19
+ };
20
+ }
21
+
22
+ return {
23
+ status,
24
+ critical_issues: [],
25
+ test_gaps: [],
26
+ requirement_mismatches: [],
27
+ rule_violations: [],
28
+ risk_level: 'low',
29
+ confidence: 0.99,
30
+ repair_instructions: [],
31
+ false_positives_reconsidered: [
32
+ 'Mock reviewer found no code issues in smoke-test mode.',
33
+ ],
34
+ };
35
+ }
36
+
37
+ export class MockReviewerAdapter {
38
+ constructor(options = {}) {
39
+ this.cmd = options.cmd || 'mock';
40
+ this.cwd = options.cwd || process.cwd();
41
+ this.sessionId = null;
42
+ }
43
+
44
+ restoreSession(id) {
45
+ this.sessionId = id;
46
+ }
47
+
48
+ async run(_prompt, {
49
+ schemaFile = 'verdict-schema.json',
50
+ sessionName = null,
51
+ stream = false,
52
+ } = {}) {
53
+ const status = process.env.OPENAIREV_MOCK_REVIEW_STATUS || 'approved';
54
+ const delayMs = Number.parseInt(process.env.OPENAIREV_MOCK_PROGRESS_DELAY_MS || '40', 10);
55
+ const sessionId = this.sessionId || sessionName || `mock-session-${Date.now()}`;
56
+ this.sessionId = sessionId;
57
+
58
+ const progress = [
59
+ 'reviewer: mock',
60
+ `session: ${sessionId}`,
61
+ 'reading diff',
62
+ 'verdict ready',
63
+ ];
64
+
65
+ if (stream?.onProgress) {
66
+ for (let i = 0; i < progress.length; i++) {
67
+ await delay(delayMs);
68
+ stream.onProgress(progress.slice(0, i + 1));
69
+ }
70
+ }
71
+
72
+ const verdict = buildVerdict(schemaFile, status);
73
+ return {
74
+ result: verdict,
75
+ raw_output: JSON.stringify(verdict, null, 2),
76
+ progress,
77
+ session_id: sessionId,
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { MockReviewerAdapter } from './mock-reviewer.js';
4
+
5
+ describe('MockReviewerAdapter', () => {
6
+ it('returns a code-review verdict with progress updates', async () => {
7
+ const adapter = new MockReviewerAdapter();
8
+ const snapshots = [];
9
+
10
+ const result = await adapter.run('review this', {
11
+ stream: {
12
+ onProgress(lines) {
13
+ snapshots.push([...lines]);
14
+ },
15
+ },
16
+ });
17
+
18
+ assert.equal(result.result.status, 'approved');
19
+ assert.equal(result.progress.at(-1), 'verdict ready');
20
+ assert.equal(snapshots.length, 4);
21
+ assert.equal(snapshots.at(-1).at(-1), 'verdict ready');
22
+ assert.equal(result.session_id, adapter.sessionId);
23
+ });
24
+
25
+ it('returns the plan-review shape when plan schema is requested', async () => {
26
+ const adapter = new MockReviewerAdapter();
27
+ const result = await adapter.run('review this plan', {
28
+ schemaFile: 'plan-verdict-schema.json',
29
+ });
30
+
31
+ assert.equal(result.result.status, 'approved');
32
+ assert.deepEqual(result.result.missing_requirements, []);
33
+ assert.deepEqual(result.result.sequencing_issues, []);
34
+ assert.deepEqual(result.result.risks, []);
35
+ });
36
+ });
@@ -1,9 +1,11 @@
1
1
  import { ClaudeCodeAdapter } from './claude-code.js';
2
2
  import { CodexAdapter } from './codex.js';
3
+ import { MockReviewerAdapter } from './mock-reviewer.js';
3
4
 
4
5
  const ADAPTERS = {
5
6
  claude_code: ClaudeCodeAdapter,
6
7
  codex: CodexAdapter,
8
+ mock: MockReviewerAdapter,
7
9
  };
8
10
 
9
11
  export function createAdapter(agentName, config, { cwd } = {}) {
@@ -1,13 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ const MAX_PROGRESS_LINES = 200;
4
+
5
+ const COLORS = {
6
+ cyan: chalk.cyan,
7
+ green: chalk.green,
8
+ yellow: chalk.yellow,
9
+ red: chalk.red,
10
+ };
11
+
3
12
  /**
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.
13
+ * Shared factory for NDJSON stream summarizers.
14
+ * Handles buffering, line splitting, progress collection, and TTY output.
15
+ * Each adapter provides its own handleEvent callback.
8
16
  */
9
- export function createCodexSummarizer({ reviewerName, tty = true, onProgress } = {}) {
10
- const seenFiles = new Set();
17
+ function createSummarizer({ reviewerName, tty = true, onProgress, handleEvent }) {
11
18
  const progressLines = [];
12
19
  let buffer = '';
13
20
  let started = false;
@@ -15,36 +22,42 @@ export function createCodexSummarizer({ reviewerName, tty = true, onProgress } =
15
22
  const summarizer = (chunk) => {
16
23
  if (!started) {
17
24
  started = true;
18
- if (reviewerName) emit(`reviewer: ${reviewerName}`, 'cyan');
25
+ if (reviewerName) emit(reviewerName, 'cyan');
19
26
  }
20
27
  buffer += chunk;
28
+ processLines();
29
+ };
30
+
31
+ summarizer.getProgress = () => {
32
+ processLines(true);
33
+ return progressLines;
34
+ };
35
+
36
+ function processLines(flush = false) {
21
37
  const lines = buffer.split('\n');
22
38
  buffer = lines.pop();
39
+ if (flush && buffer.trim()) {
40
+ lines.push(buffer);
41
+ buffer = '';
42
+ }
23
43
 
24
44
  for (const line of lines) {
25
45
  if (!line.trim()) continue;
26
46
  try {
27
- const event = JSON.parse(line);
28
- summarizeCodexEvent(event, seenFiles, { emit });
47
+ handleEvent(JSON.parse(line), emit);
29
48
  } catch {
30
49
  // skip non-JSON
31
50
  }
32
51
  }
33
- };
34
-
35
- summarizer.getProgress = () => progressLines;
52
+ }
36
53
 
37
54
  function emit(msg, color) {
38
- if (progressLines.length < 200) {
55
+ if (progressLines.length < MAX_PROGRESS_LINES && progressLines.at(-1) !== msg) {
39
56
  progressLines.push(msg);
40
57
  if (onProgress) onProgress(progressLines);
41
58
  }
42
59
  if (tty) {
43
- const colorFn = color === 'cyan' ? chalk.cyan
44
- : color === 'green' ? chalk.green
45
- : color === 'yellow' ? chalk.yellow
46
- : color === 'red' ? chalk.red
47
- : chalk.dim;
60
+ const colorFn = COLORS[color] || chalk.dim;
48
61
  process.stderr.write(` ${colorFn(msg)}\n`);
49
62
  }
50
63
  }
@@ -52,6 +65,33 @@ export function createCodexSummarizer({ reviewerName, tty = true, onProgress } =
52
65
  return summarizer;
53
66
  }
54
67
 
68
+ /**
69
+ * Creates a summarizer callback for Codex NDJSON event streams.
70
+ */
71
+ export function createCodexSummarizer({ reviewerName, tty, onProgress } = {}) {
72
+ const seenFiles = new Set();
73
+ return createSummarizer({
74
+ reviewerName: reviewerName && `reviewer: ${reviewerName}`,
75
+ tty,
76
+ onProgress,
77
+ handleEvent: (event, emit) => summarizeCodexEvent(event, seenFiles, { emit }),
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Creates a summarizer callback for Claude stream-json event streams.
83
+ */
84
+ export function createClaudeSummarizer({ reviewerName, tty, onProgress } = {}) {
85
+ const seenFiles = new Set();
86
+ const state = { sawMessageStart: false, sawDraft: false, currentToolUse: null };
87
+ return createSummarizer({
88
+ reviewerName: reviewerName && `reviewer: ${reviewerName}`,
89
+ tty,
90
+ onProgress,
91
+ handleEvent: (event, emit) => summarizeClaudeEvent(event, { emit, seenFiles, state }),
92
+ });
93
+ }
94
+
55
95
  function summarizeCodexEvent(event, seenFiles, { emit }) {
56
96
  const type = event.type;
57
97
 
@@ -112,6 +152,152 @@ function summarizeCodexEvent(event, seenFiles, { emit }) {
112
152
  }
113
153
  }
114
154
 
155
+ function summarizeClaudeEvent(wrapper, { emit, seenFiles, state }) {
156
+ if (wrapper.type === 'system' && wrapper.subtype === 'init') {
157
+ emit(`session: ${wrapper.session_id}`);
158
+ return;
159
+ }
160
+
161
+ if (wrapper.type === 'assistant' && wrapper.error) {
162
+ emit(`error: ${wrapper.error}`, 'red');
163
+ return;
164
+ }
165
+
166
+ if (wrapper.type === 'stream_event') {
167
+ const event = wrapper.event;
168
+
169
+ if (event?.type === 'message_start' && !state.sawMessageStart) {
170
+ state.sawMessageStart = true;
171
+ emit('analyzing diff...', 'cyan');
172
+ return;
173
+ }
174
+
175
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
176
+ state.currentToolUse = {
177
+ name: event.content_block.name || 'tool',
178
+ index: event.index,
179
+ input: '',
180
+ };
181
+ return;
182
+ }
183
+
184
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'input_json_delta' && state.currentToolUse) {
185
+ state.currentToolUse.input += event.delta.partial_json || '';
186
+ return;
187
+ }
188
+
189
+ if (event?.type === 'content_block_stop' && state.currentToolUse) {
190
+ const tool = state.currentToolUse;
191
+ state.currentToolUse = null;
192
+ emitToolSummary(tool, { emit, seenFiles });
193
+ return;
194
+ }
195
+
196
+ if (
197
+ event?.type === 'content_block_delta' &&
198
+ event.delta?.type === 'text_delta' &&
199
+ event.delta.text &&
200
+ !state.sawDraft
201
+ ) {
202
+ state.sawDraft = true;
203
+ emit('drafting verdict...', 'cyan');
204
+ return;
205
+ }
206
+
207
+ if (event?.type === 'message_delta' && event.usage?.output_tokens != null) {
208
+ emit(`output: ${fmt(event.usage.output_tokens)} tokens`);
209
+ return;
210
+ }
211
+ }
212
+
213
+ if (wrapper.type === 'tool_result') {
214
+ if (wrapper.is_error) {
215
+ const name = wrapper.tool_name || 'tool';
216
+ emit(`${name} failed: ${truncate(wrapper.error || 'unknown error', 80)}`, 'yellow');
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (wrapper.type === 'result') {
222
+ if (wrapper.is_error) {
223
+ emit(`error: ${wrapper.result || 'unknown error'}`, 'red');
224
+ return;
225
+ }
226
+ emit('verdict ready', 'green');
227
+ if (wrapper.usage) {
228
+ const input = (wrapper.usage.input_tokens || 0) +
229
+ (wrapper.usage.cache_creation_input_tokens || 0) +
230
+ (wrapper.usage.cache_read_input_tokens || 0);
231
+ const output = wrapper.usage.output_tokens || 0;
232
+ emit(`tokens: ${fmt(input + output)} total (${fmt(input)} in / ${fmt(output)} out)`);
233
+ }
234
+ }
235
+ }
236
+
237
+ function emitToolSummary(tool, { emit, seenFiles }) {
238
+ let input;
239
+ try {
240
+ input = tool.input ? JSON.parse(tool.input) : {};
241
+ } catch {
242
+ emit(`running tool: ${tool.name}`);
243
+ return;
244
+ }
245
+
246
+ const name = tool.name;
247
+
248
+ if (name === 'Read') {
249
+ const file = shortPath(input.file_path);
250
+ if (file && !seenFiles.has(file)) {
251
+ seenFiles.add(file);
252
+ emit(`reading: ${file}`);
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (name === 'Glob') {
258
+ emit(`searching: ${input.pattern || 'files'}`);
259
+ return;
260
+ }
261
+
262
+ if (name === 'Grep') {
263
+ const target = input.path ? shortPath(input.path) : 'codebase';
264
+ emit(`grep: ${truncate(input.pattern || '', 50)} in ${target}`);
265
+ return;
266
+ }
267
+
268
+ if (name === 'Bash') {
269
+ const cmd = input.command || '';
270
+ if (isInternalCmd(cmd)) return;
271
+ const file = extractFileFromCmd(cmd);
272
+ if (file && !seenFiles.has(file)) {
273
+ seenFiles.add(file);
274
+ emit(`reading: ${file}`);
275
+ } else if (!file) {
276
+ const short = summarizeCmd(cmd);
277
+ if (short) emit(`running: ${short}`);
278
+ }
279
+ return;
280
+ }
281
+
282
+ if (name === 'Edit' || name === 'Write') {
283
+ const file = shortPath(input.file_path);
284
+ if (file) emit(`writing: ${file}`);
285
+ return;
286
+ }
287
+
288
+ emit(`running tool: ${name}`);
289
+ }
290
+
291
+ function shortPath(filePath) {
292
+ if (!filePath) return null;
293
+ return filePath.replace(/^.*\/(?=src\/|lib\/|bin\/|test\/|config\/|packages\/)/, '')
294
+ || filePath.split('/').slice(-2).join('/');
295
+ }
296
+
297
+ function truncate(str, max) {
298
+ return str.length > max ? str.slice(0, max - 3) + '...' : str;
299
+ }
300
+
115
301
  function isInternalCmd(cmd) {
116
302
  return /node\s+-e\s/.test(cmd) ||
117
303
  /require\(['"]child_process['"]\)/.test(cmd) ||
@@ -0,0 +1,143 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createClaudeSummarizer } from './stream-summarizer.js';
4
+
5
+ function feed(summarizer, events) {
6
+ summarizer(events.map(e => JSON.stringify(e)).join('\n') + '\n');
7
+ }
8
+
9
+ describe('createClaudeSummarizer', () => {
10
+ it('collects useful progress lines from Claude stream-json events', () => {
11
+ const snapshots = [];
12
+ const summarizer = createClaudeSummarizer({
13
+ reviewerName: 'claude_code',
14
+ tty: false,
15
+ onProgress(lines) {
16
+ snapshots.push([...lines]);
17
+ },
18
+ });
19
+
20
+ feed(summarizer, [
21
+ { type: 'system', subtype: 'init', session_id: 'sess-123' },
22
+ { type: 'stream_event', event: { type: 'message_start' } },
23
+ { type: 'stream_event', event: {
24
+ type: 'content_block_delta',
25
+ delta: { type: 'text_delta', text: '{"status":"approved"' },
26
+ }},
27
+ { type: 'result', is_error: false, usage: { input_tokens: 10, output_tokens: 5 } },
28
+ ]);
29
+
30
+ const progress = summarizer.getProgress();
31
+
32
+ assert.deepEqual(progress, [
33
+ 'reviewer: claude_code',
34
+ 'session: sess-123',
35
+ 'analyzing diff...',
36
+ 'drafting verdict...',
37
+ 'verdict ready',
38
+ 'tokens: 15 total (10 in / 5 out)',
39
+ ]);
40
+ assert.equal(snapshots.at(-1).at(-1), 'tokens: 15 total (10 in / 5 out)');
41
+ });
42
+
43
+ it('tracks tool usage with file dedup', () => {
44
+ const summarizer = createClaudeSummarizer({ tty: false });
45
+
46
+ feed(summarizer, [
47
+ { type: 'system', subtype: 'init', session_id: 's1' },
48
+ { type: 'stream_event', event: { type: 'message_start' } },
49
+ // Read tool
50
+ { type: 'stream_event', event: {
51
+ type: 'content_block_start', index: 1,
52
+ content_block: { type: 'tool_use', name: 'Read' },
53
+ }},
54
+ { type: 'stream_event', event: {
55
+ type: 'content_block_delta', index: 1,
56
+ delta: { type: 'input_json_delta', partial_json: '{"file_path":"/home/user/src/version.js"}' },
57
+ }},
58
+ { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
59
+ // Second Read of same file — should be deduped
60
+ { type: 'stream_event', event: {
61
+ type: 'content_block_start', index: 2,
62
+ content_block: { type: 'tool_use', name: 'Read' },
63
+ }},
64
+ { type: 'stream_event', event: {
65
+ type: 'content_block_delta', index: 2,
66
+ delta: { type: 'input_json_delta', partial_json: '{"file_path":"/home/user/src/version.js"}' },
67
+ }},
68
+ { type: 'stream_event', event: { type: 'content_block_stop', index: 2 } },
69
+ // Grep tool
70
+ { type: 'stream_event', event: {
71
+ type: 'content_block_start', index: 3,
72
+ content_block: { type: 'tool_use', name: 'Grep' },
73
+ }},
74
+ { type: 'stream_event', event: {
75
+ type: 'content_block_delta', index: 3,
76
+ delta: { type: 'input_json_delta', partial_json: '{"pattern":"VERSION","path":"src/"}' },
77
+ }},
78
+ { type: 'stream_event', event: { type: 'content_block_stop', index: 3 } },
79
+ ]);
80
+
81
+ const progress = summarizer.getProgress();
82
+
83
+ assert.ok(progress.includes('reading: src/version.js'));
84
+ // Only one "reading" for the same file
85
+ assert.equal(progress.filter(l => l.includes('reading: src/version.js')).length, 1);
86
+ assert.ok(progress.some(l => l.startsWith('grep: VERSION')));
87
+ });
88
+
89
+ it('tracks Bash commands and Glob searches', () => {
90
+ const summarizer = createClaudeSummarizer({ tty: false });
91
+
92
+ feed(summarizer, [
93
+ { type: 'system', subtype: 'init', session_id: 's2' },
94
+ // Bash tool
95
+ { type: 'stream_event', event: {
96
+ type: 'content_block_start', index: 1,
97
+ content_block: { type: 'tool_use', name: 'Bash' },
98
+ }},
99
+ { type: 'stream_event', event: {
100
+ type: 'content_block_delta', index: 1,
101
+ delta: { type: 'input_json_delta', partial_json: '{"command":"git diff HEAD~1"}' },
102
+ }},
103
+ { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
104
+ // Glob tool
105
+ { type: 'stream_event', event: {
106
+ type: 'content_block_start', index: 2,
107
+ content_block: { type: 'tool_use', name: 'Glob' },
108
+ }},
109
+ { type: 'stream_event', event: {
110
+ type: 'content_block_delta', index: 2,
111
+ delta: { type: 'input_json_delta', partial_json: '{"pattern":"**/*.test.js"}' },
112
+ }},
113
+ { type: 'stream_event', event: { type: 'content_block_stop', index: 2 } },
114
+ ]);
115
+
116
+ const progress = summarizer.getProgress();
117
+
118
+ assert.ok(progress.some(l => l.startsWith('running: git diff')));
119
+ assert.ok(progress.some(l => l === 'searching: **/*.test.js'));
120
+ });
121
+
122
+ it('reports tool errors', () => {
123
+ const summarizer = createClaudeSummarizer({ tty: false });
124
+
125
+ feed(summarizer, [
126
+ { type: 'tool_result', is_error: true, tool_name: 'Bash', error: 'command not found' },
127
+ ]);
128
+
129
+ const progress = summarizer.getProgress();
130
+ assert.ok(progress.some(l => l.includes('Bash failed') && l.includes('command not found')));
131
+ });
132
+
133
+ it('handles result errors', () => {
134
+ const summarizer = createClaudeSummarizer({ tty: false });
135
+
136
+ feed(summarizer, [
137
+ { type: 'result', is_error: true, result: 'rate limited' },
138
+ ]);
139
+
140
+ const progress = summarizer.getProgress();
141
+ assert.ok(progress.some(l => l === 'error: rate limited'));
142
+ });
143
+ });
package/src/cli/init.js CHANGED
@@ -258,8 +258,7 @@ ${marker}
258
258
  This project uses OpenAIRev for independent AI code review. When the user asks to "review", "get a review", or "check my changes":
259
259
 
260
260
  1. Call \`openairev_review\` with \`executor: "claude_code"\` and \`diff_cmd\` scoped to the files you changed (e.g. \`"git diff HEAD -- src/auth.ts src/routes.ts"\`). The review starts in the background and returns immediately.
261
- 2. To check progress, read \`.openairev/progress.json\` it updates in real-time showing what the reviewer is reading and doing.
262
- 3. When \`progress.json\` shows \`"status": "completed"\`, the verdict and feedback are in the same file.
261
+ 2. Run \`openairev wait\` via Bash to stream progress and get the verdict when done. This is a single blocking call — no polling needed.
263
262
 
264
263
  **Tips**:
265
264
  - Always use \`diff_cmd\` to scope the diff to your changed files. Do NOT let it auto-detect — the full repo diff may be too large.
@@ -313,8 +312,7 @@ ${marker}
313
312
  This project uses OpenAIRev for independent AI code review. When the user asks to "review", "get a review", or "check my changes":
314
313
 
315
314
  1. Call \`openairev_review\` with \`executor: "codex"\` and \`diff_cmd\` scoped to the files you changed (e.g. \`"git diff HEAD -- src/auth.ts src/routes.ts"\`). The review starts in the background and returns immediately.
316
- 2. To check progress, read \`.openairev/progress.json\` it updates in real-time showing what the reviewer is reading and doing.
317
- 3. When \`progress.json\` shows \`"status": "completed"\`, the verdict and feedback are in the same file.
315
+ 2. Run \`openairev wait\` to stream progress and get the verdict when done. This is a single blocking call — no polling needed.
318
316
 
319
317
  **Tips**:
320
318
  - Always use \`diff_cmd\` to scope the diff to your changed files. Do NOT let it auto-detect — the full repo diff may be too large.
@@ -0,0 +1,55 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getConfigDir } from '../config/config-loader.js';
4
+
5
+ export async function waitCommand(options) {
6
+ const progressFile = options.file || join(getConfigDir(), 'progress.json');
7
+
8
+ if (!existsSync(progressFile)) {
9
+ console.log('No review in progress. Call openairev_review first.');
10
+ process.exit(1);
11
+ }
12
+
13
+ let lastLen = 0;
14
+
15
+ return new Promise((resolve) => {
16
+ const timer = setInterval(() => {
17
+ const data = readProgress(progressFile);
18
+ if (!data) return;
19
+
20
+ const lines = data.progress || [];
21
+ for (let i = lastLen; i < lines.length; i++) {
22
+ console.log(` ${lines[i]}`);
23
+ }
24
+ lastLen = lines.length;
25
+
26
+ if (data.status === 'completed' || data.status === 'error') {
27
+ clearInterval(timer);
28
+ printResult(data);
29
+ resolve();
30
+ }
31
+ }, 2000);
32
+ });
33
+ }
34
+
35
+ function readProgress(path) {
36
+ try {
37
+ return JSON.parse(readFileSync(path, 'utf-8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function printResult(data) {
44
+ if (data.status === 'error') {
45
+ console.log(`\nReview failed: ${data.error}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ console.log('');
50
+ if (data.executor_feedback) {
51
+ console.log(data.executor_feedback);
52
+ } else if (data.verdict) {
53
+ console.log(JSON.stringify(data.verdict, null, 2));
54
+ }
55
+ }
@@ -25,7 +25,7 @@ const server = new McpServer({
25
25
 
26
26
  server.tool(
27
27
  'openairev_review',
28
- '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. The review runs in the background and returns immediately. To check progress, read .openairev/progress.json it updates in real-time. When status is "completed", the verdict is in the same file.',
28
+ '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. The review starts in the background and returns immediately. After calling this, run `openairev wait` via Bash to stream progress and get the verdict one blocking call, no polling needed.',
29
29
  {
30
30
  executor: z.string().optional().describe('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".'),
31
31
  diff: z.string().optional().describe('The diff to review. IMPORTANT: Pass only the diff for files YOU changed, not the entire repo. Use `git diff HEAD -- file1 file2` to scope it. If omitted, auto-detects from git which may be too large.'),
@@ -90,7 +90,7 @@ server.tool(
90
90
  return {
91
91
  content: [{
92
92
  type: 'text',
93
- text: `Review started. Reviewer: ${reviewerName}\n\nCall openairev_status to check progress and get the verdict when ready.`,
93
+ text: `Review started. Reviewer: ${reviewerName}\nProgress file: ${PROGRESS_FILE}\n\nRun \`openairev wait\` from ${cwd} or read ${PROGRESS_FILE} directly to stream progress and get the verdict.`,
94
94
  }],
95
95
  };
96
96
  }
@@ -98,7 +98,7 @@ server.tool(
98
98
 
99
99
  server.tool(
100
100
  'openairev_status',
101
- 'Check the progress and result of the current or most recent OpenAIRev review. Alternative to reading .openairev/progress.json directly.',
101
+ 'Check the progress and result of the current or most recent OpenAIRev review. Prefer running `openairev wait` via Bash instead — it streams progress and blocks until done.',
102
102
  {},
103
103
  async () => {
104
104
  const progress = readProgress();
@@ -109,8 +109,8 @@ server.tool(
109
109
  if (progress.status === 'running') {
110
110
  const lines = progress.progress || [];
111
111
  const text = lines.length > 0
112
- ? `Review in progress (reviewer: ${progress.reviewer}):\n${lines.map(l => ` ${l}`).join('\n')}\n\nStill running... Call openairev_status again in a few seconds.`
113
- : `Review in progress (reviewer: ${progress.reviewer}). Started: ${progress.started}\n\nCall openairev_status again in a few seconds.`;
112
+ ? `Review in progress (reviewer: ${progress.reviewer}):\n${lines.map(l => ` ${l}`).join('\n')}\n\nStill running. Run \`openairev wait\` via Bash to stream progress until done.`
113
+ : `Review in progress (reviewer: ${progress.reviewer}). Started: ${progress.started}\n\nRun \`openairev wait\` via Bash to stream progress until done.`;
114
114
  return { content: [{ type: 'text', text }] };
115
115
  }
116
116
 
@@ -164,6 +164,8 @@ server.tool(
164
164
 
165
165
  function writeProgress(data) {
166
166
  try {
167
+ data.cwd = cwd;
168
+ data.progress_file = PROGRESS_FILE;
167
169
  writeFileSync(PROGRESS_FILE, JSON.stringify(data, null, 2));
168
170
  } catch { /* non-critical */ }
169
171
  }