openairev 0.3.11 → 0.3.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openairev",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Cross-model AI code reviewer — independent review for AI-assisted coding workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@ export class ClaudeCodeAdapter {
25
25
  stream = false,
26
26
  signal,
27
27
  } = {}) {
28
- const args = ['-p', prompt];
28
+ const args = ['-p', prompt, '--max-budget-usd', '5'];
29
29
 
30
30
  if (stream) {
31
31
  args.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
package/src/cli/review.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
- import { readFileSync } from 'fs';
3
2
  import { loadConfig, getReviewer, getMaxIterations } from '../config/config-loader.js';
4
- import { getDiff } from '../tools/git-tools.js';
3
+ import { hasDiff, buildDiffCmd } from '../tools/git-tools.js';
5
4
  import { runReview } from '../review/review-runner.js';
6
5
  import { runWorkflow } from '../orchestrator/orchestrator.js';
7
6
  import { createSession, saveSession } from '../session/session-manager.js';
@@ -37,13 +36,17 @@ export async function reviewCommand(options) {
37
36
  console.log(` Mode: ${chalk.cyan('implement → review loop')}`);
38
37
  }
39
38
 
40
- let diff = '';
39
+ let diffCmd = '';
40
+ let hasChanges = false;
41
41
  if (options.file) {
42
42
  console.log(` Source: ${chalk.cyan(options.file)}`);
43
- diff = readFileSync(options.file, 'utf-8');
43
+ diffCmd = `cat ${JSON.stringify(options.file)}`;
44
+ hasChanges = true;
44
45
  } else {
45
- try { diff = getDiff(options.diff); } catch { /* no diff yet is ok for workflow mode */ }
46
- if (diff?.trim()) console.log(` Diff lines: ${chalk.cyan(diff.split('\n').length)}`);
46
+ const ref = options.diff || '';
47
+ try { hasChanges = hasDiff(ref); } catch { /* no diff yet is ok for workflow mode */ }
48
+ diffCmd = buildDiffCmd(ref);
49
+ if (hasChanges) console.log(` Diff cmd: ${chalk.cyan(diffCmd)}`);
47
50
  }
48
51
 
49
52
  if (options.specRef) console.log(` Spec: ${chalk.cyan(options.specRef)}`);
@@ -56,13 +59,13 @@ export async function reviewCommand(options) {
56
59
  console.log('');
57
60
 
58
61
  if (options.once) {
59
- if (!diff?.trim()) {
62
+ if (!hasChanges) {
60
63
  console.log(chalk.yellow('No changes found. Stage some changes or specify --diff <ref>.'));
61
64
  process.exit(0);
62
65
  }
63
66
  try {
64
67
  console.log(chalk.dim('Starting review...\n'));
65
- const review = await runReview(diff, { config, reviewerName, cwd, stream: true });
68
+ const review = await runReview(diffCmd, { config, reviewerName, cwd, stream: true });
66
69
 
67
70
  const session = createSession({ executor, reviewer: reviewerName, diff_ref: options.diff || 'auto' });
68
71
  session.iterations.push({ round: 1, review, timestamp: new Date().toISOString() });
@@ -83,7 +86,7 @@ export async function reviewCommand(options) {
83
86
  } else {
84
87
  try {
85
88
  const result = await runWorkflow({
86
- config, executor, reviewerName, maxRounds, diff, diffRef: options.diff,
89
+ config, executor, reviewerName, maxRounds, diffRef: options.diff,
87
90
  taskDescription: options.task, specRef: options.specRef, tools: config.tools,
88
91
  cwd, skipAnalyze, skipPlan,
89
92
  onStageChange: (stage) => console.log(chalk.bold(`\n[${stageLabel(stage)}]\n`)),
@@ -6,7 +6,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { z } from 'zod';
8
8
  import { loadConfig, getReviewer } from '../config/config-loader.js';
9
- import { getDiff } from '../tools/git-tools.js';
9
+ import { getDiff, hasDiff, buildDiffCmd } from '../tools/git-tools.js';
10
10
  import { runToolGates } from '../tools/tool-runner.js';
11
11
  import { runReview } from '../review/review-runner.js';
12
12
  import { createSession, saveSession } from '../session/session-manager.js';
@@ -29,28 +29,20 @@ server.tool(
29
29
  '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.',
30
30
  {
31
31
  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".'),
32
- 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.'),
33
- diff_cmd: z.string().optional().describe('The git command used to get the diff, e.g. "git diff HEAD -- src/auth.ts src/routes.ts". If provided instead of diff, the server will run this command to get the diff.'),
32
+ diff_cmd: z.string().optional().describe('The git diff command for the reviewer to run, e.g. "git diff HEAD -- src/auth.ts src/routes.ts". The reviewer runs in the same repo and executes this itself. If omitted, auto-detects staged or unstaged changes.'),
34
33
  task_description: z.string().optional().describe('What the code is supposed to do. Used for requirement checking.'),
35
34
  },
36
- async ({ executor, diff, diff_cmd, task_description }) => {
35
+ async ({ executor, diff_cmd, task_description }) => {
37
36
  const execAgent = executor || Object.keys(config.agents || {}).find(a => config.agents[a].available);
38
37
  const reviewerName = getReviewer(config, execAgent);
39
38
  if (!reviewerName) {
40
39
  return { content: [{ type: 'text', text: `No reviewer configured for executor "${execAgent}"` }] };
41
40
  }
42
41
 
43
- let diffContent = diff;
44
- if (!diffContent && diff_cmd) {
45
- try {
46
- const { execSync } = await import('child_process');
47
- diffContent = execSync(diff_cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, cwd });
48
- } catch (e) {
49
- return { content: [{ type: 'text', text: `diff_cmd failed: ${e.message}` }] };
50
- }
51
- }
52
- if (!diffContent) diffContent = getDiff();
53
- if (!diffContent?.trim()) {
42
+ const reviewDiffCmd = diff_cmd || buildDiffCmd();
43
+
44
+ // Only validate when auto-detecting — if caller provided diff_cmd, trust it
45
+ if (!diff_cmd && !hasDiff()) {
54
46
  return { content: [{ type: 'text', text: 'No changes found to review.' }] };
55
47
  }
56
48
 
@@ -62,7 +54,7 @@ server.tool(
62
54
  };
63
55
 
64
56
  activeAbort = new AbortController();
65
- activeReview = runReview(diffContent, {
57
+ activeReview = runReview(reviewDiffCmd, {
66
58
  config,
67
59
  reviewerName,
68
60
  taskDescription: task_description,
@@ -1,7 +1,7 @@
1
1
  import { createAdapter } from '../agents/registry.js';
2
2
  import { runReview } from '../review/review-runner.js';
3
3
  import { loadPromptFile } from '../review/prompt-loader.js';
4
- import { getDiff } from '../tools/git-tools.js';
4
+ import { hasDiff, buildDiffCmd } from '../tools/git-tools.js';
5
5
  import { runToolGates } from '../tools/tool-runner.js';
6
6
  import {
7
7
  createChain, transitionTo, addRound, setArtifact,
@@ -44,7 +44,7 @@ export async function runWorkflow({
44
44
  }
45
45
  }
46
46
 
47
- let currentDiff = initialDiff;
47
+ let hasChanges = !!initialDiff;
48
48
  let codeReviewCount = 0;
49
49
  let planReviewCount = 0;
50
50
 
@@ -140,13 +140,7 @@ export async function runWorkflow({
140
140
  const prompt = buildImplementationPrompt(chain, specRef);
141
141
  await runExecutor(executor, config, prompt, chain, cwd);
142
142
 
143
- try {
144
- currentDiff = getDiff(diffRef);
145
- } catch {
146
- currentDiff = '';
147
- }
148
-
149
- if (currentDiff?.trim()) {
143
+ if (hasDiff(diffRef)) {
150
144
  setArtifact(chain, 'current_diff_ref', diffRef || 'auto', cwd);
151
145
  transitionTo(chain, 'code_review', cwd);
152
146
  } else {
@@ -168,7 +162,8 @@ export async function runWorkflow({
168
162
  toolResults = runToolGates(Object.keys(tools), cwd, tools);
169
163
  }
170
164
 
171
- const review = await runReviewRound(reviewerName, config, currentDiff, {
165
+ const reviewDiffCmd = diffRef ? `git diff ${diffRef}` : 'git diff --staged || git diff';
166
+ const review = await runReviewRound(reviewerName, config, reviewDiffCmd, {
172
167
  kind: 'code_review', chain, specRef, cwd,
173
168
  });
174
169
 
@@ -205,14 +200,7 @@ export async function runWorkflow({
205
200
  const feedback = buildFeedback(lastVerdict, cwd);
206
201
  await runExecutor(executor, config, feedback, chain, cwd);
207
202
 
208
- try {
209
- currentDiff = getDiff(diffRef);
210
- } catch (e) {
211
- closeChain(chain, 'error', cwd);
212
- return { chain, status: 'error', message: `Failed to get diff: ${e.message}` };
213
- }
214
-
215
- if (!currentDiff?.trim()) {
203
+ if (!hasDiff(diffRef)) {
216
204
  closeChain(chain, 'error', cwd);
217
205
  return { chain, status: 'error', message: 'No changes after fix attempt' };
218
206
  }
@@ -255,15 +243,16 @@ async function runExecutor(executor, config, prompt, chain, cwd) {
255
243
  return { output: result?.result || result?.raw || null, session_id: sessionId };
256
244
  }
257
245
 
258
- async function runReviewRound(reviewerName, config, content, { kind, chain, specRef, cwd }) {
246
+ async function runReviewRound(reviewerName, config, input, { kind, chain, specRef, cwd }) {
259
247
  const promptFile = kind === 'plan_review' ? 'plan-reviewer.md' : 'reviewer.md';
260
248
  const sessionId = getReviewerSession(chain, kind);
261
249
 
262
- return runReview(content, {
250
+ return runReview(input, {
263
251
  config, reviewerName, promptFile,
264
252
  taskDescription: chain.task?.user_request,
265
253
  specRef: specRef || chain.task?.spec_ref,
266
254
  cwd, sessionId, stream: true,
255
+ inputMode: kind === 'plan_review' ? 'inline' : 'diff_cmd',
267
256
  });
268
257
  }
269
258
 
@@ -1,10 +1,9 @@
1
1
  import { mkdirSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { createAdapter } from '../agents/registry.js';
4
- import { stageInput, buildInputReference, compactDiff, getReviewerBudget, estimateTokens } from './input-stager.js';
5
4
  import { loadPromptFile } from './prompt-loader.js';
6
5
 
7
- export async function runReview(content, {
6
+ export async function runReview(input, {
8
7
  config,
9
8
  reviewerName,
10
9
  promptFile = 'reviewer.md',
@@ -14,6 +13,7 @@ export async function runReview(content, {
14
13
  sessionId = null,
15
14
  stream = false,
16
15
  signal,
16
+ inputMode = 'diff_cmd',
17
17
  }) {
18
18
  const adapter = createAdapter(reviewerName, config, { cwd });
19
19
 
@@ -23,13 +23,6 @@ export async function runReview(content, {
23
23
 
24
24
  const reviewerPrompt = loadPromptFile(promptFile, cwd);
25
25
 
26
- // Compact diff if it exceeds the reviewer's context budget
27
- const budget = getReviewerBudget(reviewerName);
28
- const { content: compactedContent, compacted, stats } = compactDiff(content, { maxTokens: budget });
29
-
30
- const staged = stageInput(compactedContent, { cwd });
31
- const inputRef = buildInputReference(staged);
32
-
33
26
  let prompt = reviewerPrompt;
34
27
  if (taskDescription) {
35
28
  prompt = `Task: ${taskDescription}\n\n${prompt}`;
@@ -37,7 +30,12 @@ export async function runReview(content, {
37
30
  if (specRef) {
38
31
  prompt += `\n\nSpec reference: ${specRef}\nRead the spec file for requirements and acceptance criteria.`;
39
32
  }
40
- prompt = `${prompt}${inputRef}`;
33
+
34
+ if (inputMode === 'diff_cmd') {
35
+ prompt += `\n\n--- CHANGES TO REVIEW ---\nRun this command to get the diff:\n\`${input}\`\n\nReview the changed files. You are in the same repo as the executor — run the diff command yourself, read the files, and produce your verdict.`;
36
+ } else {
37
+ prompt += `\n\n--- CONTENT TO REVIEW ---\n${input}`;
38
+ }
41
39
 
42
40
  const schemaFile = promptFile === 'plan-reviewer.md' ? 'plan-verdict-schema.json' : 'verdict-schema.json';
43
41
 
@@ -65,26 +63,12 @@ export async function runReview(content, {
65
63
  session_id: adapter.sessionName || adapter.sessionId,
66
64
  };
67
65
 
68
- if (compacted) {
69
- reviewResult.context_stats = stats;
70
- }
71
-
72
- // Mark partial reviews so callers know files were omitted
73
- if (compacted && stats.filesDropped > 0 && verdict) {
74
- reviewResult.partial = true;
75
- reviewResult.partial_notice = `PARTIAL REVIEW: ${stats.filesDropped} files were omitted to fit the reviewer's context budget (${stats.droppedFiles.join(', ')}). Re-run with a smaller diff to cover them.`;
76
- }
77
-
78
- // Diagnose null verdicts — check for explicit errors before blaming context budget
79
66
  if (!verdict) {
80
67
  const explicitError = result?.error;
81
68
  if (explicitError) {
82
69
  reviewResult.error = `Reviewer (${reviewerName}) failed: ${explicitError}`;
83
70
  } else {
84
- reviewResult.error = `Reviewer (${reviewerName}) produced no verdict. ` +
85
- `Diff was ~${stats.originalTokens} tokens` +
86
- (compacted ? `, compacted to ~${stats.finalTokens} tokens (${stats.filesDropped} files dropped)` : '') +
87
- `. Possible causes: context budget exceeded, auth failure, or schema mismatch. Check .openairev/logs/ for details.`;
71
+ reviewResult.error = `Reviewer (${reviewerName}) produced no verdict. Possible causes: context budget exceeded, auth failure, or schema mismatch. Check .openairev/logs/ for details.`;
88
72
  }
89
73
  }
90
74
 
@@ -34,6 +34,38 @@ export function getDiff(ref, { context = 1, excludes = EXCLUDE_PATTERNS } = {})
34
34
  return '';
35
35
  }
36
36
 
37
+ /**
38
+ * Check if there are changes without reading the diff content.
39
+ * Uses git diff --quiet (exit code only, no output).
40
+ */
41
+ export function hasDiff(ref) {
42
+ try {
43
+ if (ref) {
44
+ execFileSync('git', ['diff', '--quiet', ref]);
45
+ return false;
46
+ }
47
+ // Try staged first
48
+ try {
49
+ execFileSync('git', ['diff', '--quiet', '--cached']);
50
+ // exit 0 = no staged changes, try unstaged
51
+ execFileSync('git', ['diff', '--quiet']);
52
+ return false; // no changes at all
53
+ } catch {
54
+ return true; // either staged or unstaged has changes
55
+ }
56
+ } catch {
57
+ return true; // exit 1 = has changes
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Build a diff command string for the reviewer to run.
63
+ */
64
+ export function buildDiffCmd(ref) {
65
+ if (ref) return `git diff ${ref}`;
66
+ return 'git diff --staged || git diff';
67
+ }
68
+
37
69
  function gitExec(args) {
38
70
  try {
39
71
  return execFileSync('git', args, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
@@ -1,137 +0,0 @@
1
- import { writeFileSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- const INLINE_THRESHOLD = 8_000; // characters — inline if under this
5
-
6
- // Rough estimate: ~4 characters per token for code/diffs
7
- const CHARS_PER_TOKEN = 4;
8
-
9
- // Reviewer context budgets (in tokens). Leave room for prompt, schema, and output.
10
- // These are conservative — better to compact than to blow the budget.
11
- const REVIEWER_BUDGETS = {
12
- codex: 100_000, // Codex has ~200k context, reserve half for output + tools
13
- claude_code: 150_000, // Claude has ~200k context, needs less reserve
14
- };
15
-
16
- const DEFAULT_BUDGET = 100_000;
17
-
18
- /**
19
- * Estimate token count from character length.
20
- */
21
- export function estimateTokens(text) {
22
- return Math.ceil(text.length / CHARS_PER_TOKEN);
23
- }
24
-
25
- /**
26
- * Get the token budget for a reviewer.
27
- */
28
- export function getReviewerBudget(reviewerName) {
29
- return REVIEWER_BUDGETS[reviewerName] || DEFAULT_BUDGET;
30
- }
31
-
32
- /**
33
- * Compact a diff to fit within a token budget.
34
- * Strategy: parse into per-file hunks, sort by size ascending,
35
- * drop the largest files first until it fits.
36
- * Returns { content, compacted, stats }.
37
- */
38
- export function compactDiff(diff, { maxTokens }) {
39
- const tokens = estimateTokens(diff);
40
- if (tokens <= maxTokens) {
41
- return { content: diff, compacted: false, partial: false, stats: { originalTokens: tokens, finalTokens: tokens, filesDropped: 0 } };
42
- }
43
-
44
- const files = parseDiffFiles(diff);
45
- // Sort by size descending — drop largest first
46
- files.sort((a, b) => b.content.length - a.content.length);
47
-
48
- // Reserve chars for the omission notice (worst case)
49
- const noticeOverhead = 500;
50
- const maxChars = (maxTokens * CHARS_PER_TOKEN) - noticeOverhead;
51
- let totalChars = diff.length;
52
- const dropped = [];
53
-
54
- while (totalChars > maxChars && files.length > 1) {
55
- const largest = files.shift();
56
- totalChars -= largest.content.length;
57
- dropped.push(largest.name);
58
- }
59
-
60
- // If single file still too large, truncate it
61
- if (files.length === 1 && totalChars > maxChars) {
62
- const file = files[0];
63
- const truncateAt = maxChars - 200;
64
- file.content = file.content.slice(0, Math.max(0, truncateAt)) + `\n\n... [TRUNCATED — file too large for reviewer context] ...\n`;
65
- }
66
-
67
- let compactedDiff = files.map(f => f.content).join('\n');
68
- if (dropped.length > 0) {
69
- const notice = `\n\n--- FILES OMITTED (too large for reviewer context) ---\n${dropped.map(f => ` ${f}`).join('\n')}\n--- This is a PARTIAL review. Only the files below were included. ---\n`;
70
- compactedDiff = notice + compactedDiff;
71
- }
72
-
73
- const finalTokens = estimateTokens(compactedDiff);
74
-
75
- return {
76
- content: compactedDiff,
77
- compacted: true,
78
- partial: dropped.length > 0,
79
- stats: {
80
- originalTokens: tokens,
81
- finalTokens,
82
- filesDropped: dropped.length,
83
- droppedFiles: dropped,
84
- },
85
- };
86
- }
87
-
88
- /**
89
- * Parse a unified diff into per-file sections.
90
- */
91
- function parseDiffFiles(diff) {
92
- const files = [];
93
- // Split on diff headers (diff --git, or --- a/ lines preceded by blank)
94
- const parts = diff.split(/(?=^diff --git )/m);
95
-
96
- for (const part of parts) {
97
- if (!part.trim()) continue;
98
- const nameMatch = part.match(/^diff --git a\/(.+?) b\//m)
99
- || part.match(/^---\s+a\/(.+)/m)
100
- || part.match(/^\+\+\+\s+b\/(.+)/m);
101
- const name = nameMatch ? nameMatch[1] : 'unknown';
102
- files.push({ name, content: part });
103
- }
104
-
105
- return files;
106
- }
107
-
108
- /**
109
- * Stage review input. For small content, returns it inline.
110
- * For large content, writes to .openairev/tmp/ and returns a file reference.
111
- */
112
- export function stageInput(content, { cwd = process.cwd(), label = 'review-input' } = {}) {
113
- if (content.length <= INLINE_THRESHOLD) {
114
- return { mode: 'inline', content };
115
- }
116
-
117
- const tmpDir = join(cwd, '.openairev', 'tmp');
118
- mkdirSync(tmpDir, { recursive: true });
119
-
120
- const filename = `${label}-${Date.now()}.diff`;
121
- const filePath = join(tmpDir, filename);
122
- const relativePath = `.openairev/tmp/${filename}`;
123
-
124
- writeFileSync(filePath, content);
125
-
126
- return { mode: 'file', filePath, relativePath };
127
- }
128
-
129
- /**
130
- * Build the prompt prefix for the first pass based on staging result.
131
- */
132
- export function buildInputReference(staged) {
133
- if (staged.mode === 'inline') {
134
- return `\n\n--- DIFF ---\n${staged.content}`;
135
- }
136
- return `\n\nThe diff to review is stored at: ${staged.relativePath}\nRead that file to see the full changes. It is too large to include inline.`;
137
- }
@@ -1,53 +0,0 @@
1
- import { describe, it, beforeEach, afterEach } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { stageInput, buildInputReference } from './input-stager.js';
6
-
7
- const TMP = join(process.cwd(), '.test-tmp-stager');
8
-
9
- describe('input-stager', () => {
10
- beforeEach(() => {
11
- mkdirSync(TMP, { recursive: true });
12
- });
13
-
14
- afterEach(() => {
15
- rmSync(TMP, { recursive: true, force: true });
16
- });
17
-
18
- it('inlines small content', () => {
19
- const result = stageInput('small diff', { cwd: TMP });
20
- assert.equal(result.mode, 'inline');
21
- assert.equal(result.content, 'small diff');
22
- });
23
-
24
- it('writes large content to file', () => {
25
- const large = 'x'.repeat(10_000);
26
- const result = stageInput(large, { cwd: TMP });
27
- assert.equal(result.mode, 'file');
28
- assert.ok(result.filePath);
29
- assert.ok(result.relativePath.startsWith('.openairev/tmp/'));
30
- assert.ok(existsSync(result.filePath));
31
- assert.equal(readFileSync(result.filePath, 'utf-8'), large);
32
- });
33
-
34
- it('buildInputReference returns inline content for small input', () => {
35
- const staged = { mode: 'inline', content: 'diff here' };
36
- const ref = buildInputReference(staged);
37
- assert.ok(ref.includes('diff here'));
38
- assert.ok(ref.includes('--- DIFF ---'));
39
- });
40
-
41
- it('buildInputReference returns file path for large input', () => {
42
- const staged = { mode: 'file', relativePath: '.openairev/tmp/test.diff' };
43
- const ref = buildInputReference(staged);
44
- assert.ok(ref.includes('.openairev/tmp/test.diff'));
45
- assert.ok(ref.includes('Read that file'));
46
- });
47
-
48
- it('uses custom label for filename', () => {
49
- const large = 'y'.repeat(10_000);
50
- const result = stageInput(large, { cwd: TMP, label: 'my-review' });
51
- assert.ok(result.relativePath.includes('my-review'));
52
- });
53
- });