openairev 0.3.5 → 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/bin/openairev.js +1 -0
- package/package.json +3 -2
- package/src/agents/claude-code.js +120 -3
- package/src/agents/claude-code.test.js +100 -0
- package/src/agents/exec-helper.js +2 -1
- package/src/agents/mock-reviewer.js +80 -0
- package/src/agents/mock-reviewer.test.js +36 -0
- package/src/agents/registry.js +2 -0
- package/src/agents/stream-summarizer.js +204 -18
- package/src/agents/stream-summarizer.test.js +143 -0
- package/src/cli/wait.js +11 -24
- package/src/mcp/mcp-server.js +3 -1
package/bin/openairev.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openairev",
|
|
3
|
-
"version": "0.3.
|
|
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, {
|
|
20
|
-
|
|
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
|
|
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
|
+
});
|
package/src/agents/registry.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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/wait.js
CHANGED
|
@@ -1,47 +1,34 @@
|
|
|
1
|
-
import { readFileSync, existsSync
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { getConfigDir } from '../config/config-loader.js';
|
|
3
4
|
|
|
4
|
-
export async function waitCommand() {
|
|
5
|
-
const
|
|
6
|
-
const progressFile = join(cwd, '.openairev', 'progress.json');
|
|
5
|
+
export async function waitCommand(options) {
|
|
6
|
+
const progressFile = options.file || join(getConfigDir(), 'progress.json');
|
|
7
7
|
|
|
8
8
|
if (!existsSync(progressFile)) {
|
|
9
9
|
console.log('No review in progress. Call openairev_review first.');
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// Check if already done
|
|
14
|
-
const initial = readProgress(progressFile);
|
|
15
|
-
if (initial?.status === 'completed' || initial?.status === 'error') {
|
|
16
|
-
printResult(initial);
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Watch for changes
|
|
21
13
|
let lastLen = 0;
|
|
14
|
+
|
|
22
15
|
return new Promise((resolve) => {
|
|
23
|
-
const
|
|
16
|
+
const timer = setInterval(() => {
|
|
24
17
|
const data = readProgress(progressFile);
|
|
25
18
|
if (!data) return;
|
|
26
19
|
|
|
27
|
-
// Print new progress lines
|
|
28
20
|
const lines = data.progress || [];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
console.log(` ${lines[i]}`);
|
|
32
|
-
}
|
|
33
|
-
lastLen = lines.length;
|
|
21
|
+
for (let i = lastLen; i < lines.length; i++) {
|
|
22
|
+
console.log(` ${lines[i]}`);
|
|
34
23
|
}
|
|
24
|
+
lastLen = lines.length;
|
|
35
25
|
|
|
36
26
|
if (data.status === 'completed' || data.status === 'error') {
|
|
37
|
-
|
|
27
|
+
clearInterval(timer);
|
|
38
28
|
printResult(data);
|
|
39
29
|
resolve();
|
|
40
30
|
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
watchFile(progressFile, { interval: 1000 }, check);
|
|
44
|
-
check(); // initial check
|
|
31
|
+
}, 2000);
|
|
45
32
|
});
|
|
46
33
|
}
|
|
47
34
|
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -90,7 +90,7 @@ server.tool(
|
|
|
90
90
|
return {
|
|
91
91
|
content: [{
|
|
92
92
|
type: 'text',
|
|
93
|
-
text: `Review started. Reviewer: ${reviewerName}\n\nRun \`openairev wait\`
|
|
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
|
}
|
|
@@ -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
|
}
|