openairev 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -0
- package/bin/openairev.js +56 -0
- package/package.json +33 -0
- package/prompts/executor-feedback.md +25 -0
- package/prompts/plan-reviewer.md +40 -0
- package/prompts/reviewer.md +50 -0
- package/src/agents/claude-code.js +46 -0
- package/src/agents/codex.js +71 -0
- package/src/agents/detect.js +9 -0
- package/src/agents/exec-helper.js +16 -0
- package/src/agents/registry.js +20 -0
- package/src/cli/format-helpers.js +50 -0
- package/src/cli/history.js +76 -0
- package/src/cli/init.js +211 -0
- package/src/cli/resume.js +135 -0
- package/src/cli/review.js +151 -0
- package/src/cli/status.js +73 -0
- package/src/config/config-loader.js +74 -0
- package/src/config/config-loader.test.js +113 -0
- package/src/config/defaults.js +38 -0
- package/src/config/plan-verdict-schema.json +44 -0
- package/src/config/verdict-schema.json +44 -0
- package/src/mcp/mcp-server.js +261 -0
- package/src/orchestrator/orchestrator.js +344 -0
- package/src/review/input-stager.js +35 -0
- package/src/review/input-stager.test.js +53 -0
- package/src/review/prompt-loader.js +29 -0
- package/src/review/review-runner.js +82 -0
- package/src/review/review-runner.test.js +79 -0
- package/src/session/chain-manager.js +292 -0
- package/src/session/chain-manager.test.js +188 -0
- package/src/session/session-manager.js +66 -0
- package/src/session/session-manager.test.js +72 -0
- package/src/tools/git-tools.js +27 -0
- package/src/tools/tool-runner.js +47 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { createAdapter } from '../agents/registry.js';
|
|
2
|
+
import { runReview } from '../review/review-runner.js';
|
|
3
|
+
import { loadPromptFile } from '../review/prompt-loader.js';
|
|
4
|
+
import { getDiff } from '../tools/git-tools.js';
|
|
5
|
+
import { runToolGates } from '../tools/tool-runner.js';
|
|
6
|
+
import {
|
|
7
|
+
createChain, transitionTo, addRound, setArtifact,
|
|
8
|
+
setExecutorSession, getExecutorSession, getReviewerSession,
|
|
9
|
+
setPhases, closeChain, advancePhase, getCurrentPhase,
|
|
10
|
+
addQuestion,
|
|
11
|
+
} from '../session/chain-manager.js';
|
|
12
|
+
|
|
13
|
+
export async function runWorkflow({
|
|
14
|
+
config,
|
|
15
|
+
executor,
|
|
16
|
+
reviewerName,
|
|
17
|
+
maxRounds,
|
|
18
|
+
diff: initialDiff,
|
|
19
|
+
diffRef,
|
|
20
|
+
taskDescription,
|
|
21
|
+
specRef,
|
|
22
|
+
tools,
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
existingChain = null,
|
|
25
|
+
skipAnalyze = false,
|
|
26
|
+
skipPlan = false,
|
|
27
|
+
onStageChange,
|
|
28
|
+
onRoundEnd,
|
|
29
|
+
}) {
|
|
30
|
+
const chain = existingChain || createChain({
|
|
31
|
+
executor,
|
|
32
|
+
reviewer: reviewerName,
|
|
33
|
+
topic: taskDescription,
|
|
34
|
+
maxRounds,
|
|
35
|
+
specRef,
|
|
36
|
+
cwd,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!existingChain) {
|
|
40
|
+
if (skipAnalyze && skipPlan) {
|
|
41
|
+
transitionTo(chain, 'implementation', cwd);
|
|
42
|
+
} else if (skipAnalyze) {
|
|
43
|
+
transitionTo(chain, 'planning', cwd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let currentDiff = initialDiff;
|
|
48
|
+
let codeReviewCount = 0;
|
|
49
|
+
let planReviewCount = 0;
|
|
50
|
+
|
|
51
|
+
while (chain.status === 'active' || chain.stage === 'done') {
|
|
52
|
+
if (onStageChange) onStageChange(chain.stage, chain);
|
|
53
|
+
|
|
54
|
+
switch (chain.stage) {
|
|
55
|
+
case 'analyze': {
|
|
56
|
+
const prompt = buildAnalysisPrompt(chain, specRef);
|
|
57
|
+
const result = await runExecutor(executor, config, prompt, chain, cwd);
|
|
58
|
+
const output = result.output || '';
|
|
59
|
+
|
|
60
|
+
setArtifact(chain, 'analysis', output || 'Analysis complete', cwd);
|
|
61
|
+
|
|
62
|
+
const questions = extractQuestions(output);
|
|
63
|
+
if (questions.length > 0) {
|
|
64
|
+
for (const q of questions) addQuestion(chain, q, cwd);
|
|
65
|
+
transitionTo(chain, 'awaiting_user', cwd);
|
|
66
|
+
} else if (skipPlan) {
|
|
67
|
+
transitionTo(chain, 'implementation', cwd);
|
|
68
|
+
} else {
|
|
69
|
+
transitionTo(chain, 'planning', cwd);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'awaiting_user':
|
|
75
|
+
return { chain, status: 'blocked', stage: 'awaiting_user' };
|
|
76
|
+
|
|
77
|
+
case 'planning': {
|
|
78
|
+
const prompt = buildPlanPrompt(chain, specRef);
|
|
79
|
+
const result = await runExecutor(executor, config, prompt, chain, cwd);
|
|
80
|
+
const output = result.output || '';
|
|
81
|
+
|
|
82
|
+
setArtifact(chain, 'plan', output || 'Plan created', cwd);
|
|
83
|
+
|
|
84
|
+
const phases = extractPhases(output);
|
|
85
|
+
if (phases.length > 0) setPhases(chain, phases, cwd);
|
|
86
|
+
|
|
87
|
+
transitionTo(chain, 'plan_review', cwd);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'plan_review': {
|
|
92
|
+
planReviewCount++;
|
|
93
|
+
if (planReviewCount > maxRounds) {
|
|
94
|
+
closeChain(chain, 'error', cwd);
|
|
95
|
+
return { chain, status: 'error', message: 'Plan review exceeded max rounds' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const review = await runReviewRound(reviewerName, config, chain.artifacts.plan || '', {
|
|
99
|
+
kind: 'plan_review', chain, cwd,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
addRound(chain, { kind: 'plan_review', review, cwd });
|
|
103
|
+
|
|
104
|
+
if (!review.verdict) {
|
|
105
|
+
closeChain(chain, 'error', cwd);
|
|
106
|
+
return { chain, status: 'error', message: 'Plan reviewer did not return a verdict' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (onRoundEnd) onRoundEnd(chain.stage, review);
|
|
110
|
+
|
|
111
|
+
if (review.verdict.status === 'approved') {
|
|
112
|
+
transitionTo(chain, 'implementation', cwd);
|
|
113
|
+
} else if (review.verdict.status === 'needs_changes') {
|
|
114
|
+
transitionTo(chain, 'plan_fix', cwd);
|
|
115
|
+
} else {
|
|
116
|
+
closeChain(chain, 'rejected', cwd);
|
|
117
|
+
return { chain, status: 'rejected', message: 'Plan rejected', verdict: review.verdict };
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'plan_fix': {
|
|
123
|
+
const lastVerdict = chain.rounds[chain.rounds.length - 1]?.review?.verdict;
|
|
124
|
+
const feedback = buildFeedback(lastVerdict, cwd);
|
|
125
|
+
const result = await runExecutor(executor, config, feedback, chain, cwd);
|
|
126
|
+
|
|
127
|
+
setArtifact(chain, 'plan', result.output || chain.artifacts.plan, cwd);
|
|
128
|
+
|
|
129
|
+
const phases = extractPhases(result.output || '');
|
|
130
|
+
if (phases.length > 0) setPhases(chain, phases, cwd);
|
|
131
|
+
|
|
132
|
+
transitionTo(chain, 'plan_review', cwd);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case 'implementation': {
|
|
137
|
+
const phase = getCurrentPhase(chain);
|
|
138
|
+
if (phase) phase.status = 'in_progress';
|
|
139
|
+
|
|
140
|
+
const prompt = buildImplementationPrompt(chain, specRef);
|
|
141
|
+
await runExecutor(executor, config, prompt, chain, cwd);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
currentDiff = getDiff(diffRef);
|
|
145
|
+
} catch {
|
|
146
|
+
currentDiff = '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (currentDiff?.trim()) {
|
|
150
|
+
setArtifact(chain, 'current_diff_ref', diffRef || 'auto', cwd);
|
|
151
|
+
transitionTo(chain, 'code_review', cwd);
|
|
152
|
+
} else {
|
|
153
|
+
closeChain(chain, 'error', cwd);
|
|
154
|
+
return { chain, status: 'error', message: 'No changes after implementation' };
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'code_review': {
|
|
160
|
+
codeReviewCount++;
|
|
161
|
+
if (codeReviewCount > maxRounds) {
|
|
162
|
+
closeChain(chain, 'max_rounds_reached', cwd);
|
|
163
|
+
return { chain, status: 'max_rounds_reached', rounds: codeReviewCount, verdict: getLastVerdict(chain) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let toolResults = null;
|
|
167
|
+
if (tools && typeof tools === 'object' && Object.keys(tools).length > 0) {
|
|
168
|
+
toolResults = runToolGates(Object.keys(tools), cwd, tools);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const review = await runReviewRound(reviewerName, config, currentDiff, {
|
|
172
|
+
kind: 'code_review', chain, specRef, cwd,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const phaseId = getCurrentPhase(chain)?.id;
|
|
176
|
+
addRound(chain, { kind: 'code_review', review, toolResults, phaseId, cwd });
|
|
177
|
+
|
|
178
|
+
if (!review.verdict) {
|
|
179
|
+
closeChain(chain, 'error', cwd);
|
|
180
|
+
return { chain, status: 'error', message: 'Code reviewer did not return a verdict' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (onRoundEnd) onRoundEnd(chain.stage, review, toolResults);
|
|
184
|
+
|
|
185
|
+
if (review.verdict.status === 'approved') {
|
|
186
|
+
const hasMore = advancePhase(chain, cwd);
|
|
187
|
+
if (hasMore) {
|
|
188
|
+
transitionTo(chain, 'implementation', cwd);
|
|
189
|
+
} else {
|
|
190
|
+
// Set stage directly so the done case can execute before status flips
|
|
191
|
+
chain.stage = 'done';
|
|
192
|
+
chain.updated = new Date().toISOString();
|
|
193
|
+
}
|
|
194
|
+
} else if (review.verdict.status === 'needs_changes') {
|
|
195
|
+
transitionTo(chain, 'code_fix', cwd);
|
|
196
|
+
} else {
|
|
197
|
+
closeChain(chain, 'rejected', cwd);
|
|
198
|
+
return { chain, status: 'rejected', rounds: codeReviewCount, verdict: review.verdict };
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'code_fix': {
|
|
204
|
+
const lastVerdict = chain.rounds[chain.rounds.length - 1]?.review?.verdict;
|
|
205
|
+
const feedback = buildFeedback(lastVerdict, cwd);
|
|
206
|
+
await runExecutor(executor, config, feedback, chain, cwd);
|
|
207
|
+
|
|
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()) {
|
|
216
|
+
closeChain(chain, 'error', cwd);
|
|
217
|
+
return { chain, status: 'error', message: 'No changes after fix attempt' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
transitionTo(chain, 'code_review', cwd);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'done': {
|
|
225
|
+
closeChain(chain, 'completed', cwd);
|
|
226
|
+
return { chain, status: 'completed', rounds: codeReviewCount, verdict: getLastVerdict(chain) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
default: {
|
|
230
|
+
closeChain(chain, 'error', cwd);
|
|
231
|
+
return { chain, status: 'error', message: `Unknown stage: ${chain.stage}` };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { chain, status: chain.status, stage: chain.stage };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Helpers ---
|
|
240
|
+
|
|
241
|
+
async function runExecutor(executor, config, prompt, chain, cwd) {
|
|
242
|
+
const adapter = createAdapter(executor, config, { cwd });
|
|
243
|
+
|
|
244
|
+
const existingSession = getExecutorSession(chain, chain.stage);
|
|
245
|
+
if (existingSession) adapter.restoreSession(existingSession);
|
|
246
|
+
|
|
247
|
+
const result = await adapter.run(prompt, {
|
|
248
|
+
continueSession: !!existingSession,
|
|
249
|
+
sessionName: existingSession ? undefined : `${chain.chain_id}-${chain.stage}`,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const sessionId = adapter.sessionName || adapter.sessionId || result?.session_id;
|
|
253
|
+
if (sessionId) setExecutorSession(chain, sessionId, chain.stage, cwd);
|
|
254
|
+
|
|
255
|
+
return { output: result?.result || result?.raw || null, session_id: sessionId };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function runReviewRound(reviewerName, config, content, { kind, chain, specRef, cwd }) {
|
|
259
|
+
const promptFile = kind === 'plan_review' ? 'plan-reviewer.md' : 'reviewer.md';
|
|
260
|
+
const sessionId = getReviewerSession(chain, kind);
|
|
261
|
+
|
|
262
|
+
return runReview(content, {
|
|
263
|
+
config, reviewerName, promptFile,
|
|
264
|
+
taskDescription: chain.task?.user_request,
|
|
265
|
+
specRef: specRef || chain.task?.spec_ref,
|
|
266
|
+
cwd, sessionId,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildFeedback(verdict, cwd) {
|
|
271
|
+
const feedbackPrompt = loadPromptFile('executor-feedback.md', cwd) ||
|
|
272
|
+
'The following is feedback from an independent AI reviewer. Use your judgment.';
|
|
273
|
+
|
|
274
|
+
if (!verdict) return feedbackPrompt;
|
|
275
|
+
return `${feedbackPrompt}\n\n\`\`\`json\n${JSON.stringify(verdict, null, 2)}\n\`\`\`\n\nPlease fix the issues identified above. Edit the files directly.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildAnalysisPrompt(chain, specRef) {
|
|
279
|
+
let prompt = `Analyze the codebase for the following task: ${chain.task?.user_request || 'unknown task'}\n\n`;
|
|
280
|
+
prompt += 'Identify relevant files, dependencies, and potential challenges.\n';
|
|
281
|
+
prompt += 'If you need clarification from the user, list your questions as lines starting with "QUESTION: ".\n';
|
|
282
|
+
prompt += 'Be concise.';
|
|
283
|
+
if (specRef) prompt += `\n\nThe spec for this task is at: ${specRef}\nRead it for requirements and acceptance criteria.`;
|
|
284
|
+
return prompt;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function buildPlanPrompt(chain, specRef) {
|
|
288
|
+
let prompt = `Create an implementation plan for: ${chain.task?.user_request || 'unknown task'}\n\n`;
|
|
289
|
+
if (chain.artifacts.analysis) prompt += `Analysis:\n${chain.artifacts.analysis}\n\n`;
|
|
290
|
+
|
|
291
|
+
const answered = chain.questions?.filter(q => q.status === 'answered') || [];
|
|
292
|
+
if (answered.length > 0) {
|
|
293
|
+
prompt += 'Clarifications from user:\n';
|
|
294
|
+
for (const q of answered) prompt += `Q: ${q.question}\nA: ${q.answer}\n`;
|
|
295
|
+
prompt += '\n';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
prompt += 'Break the work into phases. For each phase, use this format:\nPHASE: <name>\nGOAL: <goal>\n\n';
|
|
299
|
+
prompt += 'If the task is simple enough for one phase, that is fine.';
|
|
300
|
+
if (specRef) prompt += `\n\nThe spec for this task is at: ${specRef}\nEnsure the plan covers all requirements and scenarios.`;
|
|
301
|
+
return prompt;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildImplementationPrompt(chain, specRef) {
|
|
305
|
+
const phase = getCurrentPhase(chain);
|
|
306
|
+
let prompt = phase
|
|
307
|
+
? `Implement phase: ${phase.name}\nGoal: ${phase.goal || phase.name}\n\n`
|
|
308
|
+
: `Implement: ${chain.task?.user_request || 'the task'}\n\n`;
|
|
309
|
+
|
|
310
|
+
if (chain.artifacts.plan) prompt += `Plan:\n${chain.artifacts.plan}\n\n`;
|
|
311
|
+
prompt += 'Write the code. Edit files directly.';
|
|
312
|
+
if (specRef) prompt += `\n\nThe spec is at: ${specRef}\nEnsure the implementation satisfies the spec scenarios.`;
|
|
313
|
+
return prompt;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function extractQuestions(output) {
|
|
317
|
+
if (!output || typeof output !== 'string') return [];
|
|
318
|
+
return output.split('\n')
|
|
319
|
+
.filter(line => line.trim().startsWith('QUESTION:'))
|
|
320
|
+
.map(line => line.trim().replace(/^QUESTION:\s*/, ''));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function extractPhases(output) {
|
|
324
|
+
if (!output || typeof output !== 'string') return [];
|
|
325
|
+
const phases = [];
|
|
326
|
+
const lines = output.split('\n');
|
|
327
|
+
for (let i = 0; i < lines.length; i++) {
|
|
328
|
+
const line = lines[i].trim();
|
|
329
|
+
if (line.startsWith('PHASE:')) {
|
|
330
|
+
const name = line.replace(/^PHASE:\s*/, '').trim();
|
|
331
|
+
let goal = name;
|
|
332
|
+
if (i + 1 < lines.length && lines[i + 1].trim().startsWith('GOAL:')) {
|
|
333
|
+
goal = lines[i + 1].trim().replace(/^GOAL:\s*/, '').trim();
|
|
334
|
+
}
|
|
335
|
+
phases.push({ name, goal });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return phases;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function getLastVerdict(chain) {
|
|
342
|
+
if (chain.rounds.length === 0) return null;
|
|
343
|
+
return chain.rounds[chain.rounds.length - 1].review?.verdict || null;
|
|
344
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Stage review input. For small content, returns it inline.
|
|
8
|
+
* For large content, writes to .openairev/tmp/ and returns a file reference.
|
|
9
|
+
*/
|
|
10
|
+
export function stageInput(content, { cwd = process.cwd(), label = 'review-input' } = {}) {
|
|
11
|
+
if (content.length <= INLINE_THRESHOLD) {
|
|
12
|
+
return { mode: 'inline', content };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tmpDir = join(cwd, '.openairev', 'tmp');
|
|
16
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
const filename = `${label}-${Date.now()}.diff`;
|
|
19
|
+
const filePath = join(tmpDir, filename);
|
|
20
|
+
const relativePath = `.openairev/tmp/${filename}`;
|
|
21
|
+
|
|
22
|
+
writeFileSync(filePath, content);
|
|
23
|
+
|
|
24
|
+
return { mode: 'file', filePath, relativePath };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the prompt prefix for the first pass based on staging result.
|
|
29
|
+
*/
|
|
30
|
+
export function buildInputReference(staged) {
|
|
31
|
+
if (staged.mode === 'inline') {
|
|
32
|
+
return `\n\n--- DIFF ---\n${staged.content}`;
|
|
33
|
+
}
|
|
34
|
+
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.`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load a prompt file with caching. Checks .openairev/prompts/ first, then builtin prompts/.
|
|
8
|
+
*/
|
|
9
|
+
export function loadPromptFile(filename, cwd) {
|
|
10
|
+
const key = `${cwd}:${filename}`;
|
|
11
|
+
if (cache.has(key)) return cache.get(key);
|
|
12
|
+
|
|
13
|
+
const userPath = join(cwd, '.openairev', 'prompts', filename);
|
|
14
|
+
const builtinPath = join(cwd, 'prompts', filename);
|
|
15
|
+
|
|
16
|
+
let content = '';
|
|
17
|
+
try {
|
|
18
|
+
content = readFileSync(userPath, 'utf-8').trim();
|
|
19
|
+
} catch {
|
|
20
|
+
try {
|
|
21
|
+
content = readFileSync(builtinPath, 'utf-8').trim();
|
|
22
|
+
} catch {
|
|
23
|
+
// No prompt file found
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cache.set(key, content);
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createAdapter } from '../agents/registry.js';
|
|
2
|
+
import { stageInput, buildInputReference } from './input-stager.js';
|
|
3
|
+
import { loadPromptFile } from './prompt-loader.js';
|
|
4
|
+
|
|
5
|
+
export async function runReview(content, {
|
|
6
|
+
config,
|
|
7
|
+
reviewerName,
|
|
8
|
+
promptFile = 'reviewer.md',
|
|
9
|
+
taskDescription,
|
|
10
|
+
specRef,
|
|
11
|
+
cwd = process.cwd(),
|
|
12
|
+
sessionId = null,
|
|
13
|
+
}) {
|
|
14
|
+
const adapter = createAdapter(reviewerName, config, { cwd });
|
|
15
|
+
|
|
16
|
+
if (sessionId) {
|
|
17
|
+
adapter.restoreSession(sessionId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const reviewerPrompt = loadPromptFile(promptFile, cwd);
|
|
21
|
+
const staged = stageInput(content, { cwd });
|
|
22
|
+
const inputRef = buildInputReference(staged);
|
|
23
|
+
|
|
24
|
+
let prompt = reviewerPrompt;
|
|
25
|
+
if (taskDescription) {
|
|
26
|
+
prompt = `Task: ${taskDescription}\n\n${prompt}`;
|
|
27
|
+
}
|
|
28
|
+
if (specRef) {
|
|
29
|
+
prompt += `\n\nSpec reference: ${specRef}\nRead the spec file for requirements and acceptance criteria.`;
|
|
30
|
+
}
|
|
31
|
+
prompt = `${prompt}${inputRef}`;
|
|
32
|
+
|
|
33
|
+
const schemaFile = promptFile === 'plan-reviewer.md' ? 'plan-verdict-schema.json' : 'verdict-schema.json';
|
|
34
|
+
|
|
35
|
+
const result = await adapter.run(prompt, {
|
|
36
|
+
useSchema: true,
|
|
37
|
+
schemaFile,
|
|
38
|
+
continueSession: !!sessionId,
|
|
39
|
+
sessionName: sessionId ? undefined : `review-${Date.now()}`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const verdict = extractVerdict(result);
|
|
43
|
+
const executorFeedback = buildExecutorFeedback(verdict, cwd);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
reviewer: reviewerName,
|
|
47
|
+
verdict,
|
|
48
|
+
executor_feedback: executorFeedback,
|
|
49
|
+
session_id: adapter.sessionName || adapter.sessionId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildExecutorFeedback(verdict, cwd) {
|
|
54
|
+
const feedbackPrompt = loadPromptFile('executor-feedback.md', cwd);
|
|
55
|
+
if (!verdict) return null;
|
|
56
|
+
return `${feedbackPrompt}\n\`\`\`json\n${JSON.stringify(verdict, null, 2)}\n\`\`\``;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractVerdict(result) {
|
|
60
|
+
if (!result) return null;
|
|
61
|
+
|
|
62
|
+
if (result.structured_output) return result.structured_output;
|
|
63
|
+
if (result.result && typeof result.result === 'object' && result.result.status) return result.result;
|
|
64
|
+
|
|
65
|
+
if (result.status && ['approved', 'needs_changes', 'reject'].includes(result.status)) {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const raw = result.result || result.raw || '';
|
|
70
|
+
if (typeof raw === 'string') {
|
|
71
|
+
const jsonMatch = raw.match(/\{[\s\S]*"status"\s*:\s*"(approved|needs_changes|reject)"[\s\S]*\}/);
|
|
72
|
+
if (jsonMatch) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(jsonMatch[0]);
|
|
75
|
+
} catch {
|
|
76
|
+
// fall through
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
// Test verdict extraction logic (mirrors the function in review-runner.js)
|
|
5
|
+
|
|
6
|
+
describe('verdict extraction', () => {
|
|
7
|
+
function extractVerdict(result) {
|
|
8
|
+
if (!result) return null;
|
|
9
|
+
|
|
10
|
+
if (result.structured_output) return result.structured_output;
|
|
11
|
+
if (result.result && typeof result.result === 'object' && result.result.status) return result.result;
|
|
12
|
+
|
|
13
|
+
if (result.status && ['approved', 'needs_changes', 'reject'].includes(result.status)) {
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = result.result || result.raw || '';
|
|
18
|
+
if (typeof raw === 'string') {
|
|
19
|
+
const jsonMatch = raw.match(/\{[\s\S]*"status"\s*:\s*"(approved|needs_changes|reject)"[\s\S]*\}/);
|
|
20
|
+
if (jsonMatch) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(jsonMatch[0]);
|
|
23
|
+
} catch {
|
|
24
|
+
// fall through
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('extracts from structured_output (claude --json-schema)', () => {
|
|
33
|
+
const verdict = extractVerdict({
|
|
34
|
+
structured_output: { status: 'approved', confidence: 0.95, critical_issues: [], risk_level: 'low' },
|
|
35
|
+
result: 'some text',
|
|
36
|
+
session_id: 'abc',
|
|
37
|
+
});
|
|
38
|
+
assert.equal(verdict.status, 'approved');
|
|
39
|
+
assert.equal(verdict.confidence, 0.95);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('extracts from nested result object (codex)', () => {
|
|
43
|
+
const verdict = extractVerdict({
|
|
44
|
+
result: { status: 'needs_changes', confidence: 0.7, critical_issues: ['bug'], risk_level: 'high' },
|
|
45
|
+
session_id: 'def',
|
|
46
|
+
});
|
|
47
|
+
assert.equal(verdict.status, 'needs_changes');
|
|
48
|
+
assert.equal(verdict.critical_issues[0], 'bug');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('extracts direct verdict object', () => {
|
|
52
|
+
const verdict = extractVerdict(
|
|
53
|
+
{ status: 'reject', confidence: 0.3, critical_issues: [], risk_level: 'high' },
|
|
54
|
+
);
|
|
55
|
+
assert.equal(verdict.status, 'reject');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('extracts from raw text containing JSON', () => {
|
|
59
|
+
const verdict = extractVerdict({
|
|
60
|
+
raw: 'Here is my verdict:\n{"status": "approved", "critical_issues": [], "risk_level": "low", "confidence": 0.9}\nDone.',
|
|
61
|
+
});
|
|
62
|
+
assert.equal(verdict.status, 'approved');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns null for missing result', () => {
|
|
66
|
+
assert.equal(extractVerdict(null), null);
|
|
67
|
+
assert.equal(extractVerdict(undefined), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns null for unrecognized format', () => {
|
|
71
|
+
const verdict = extractVerdict({ raw: 'no json here at all' });
|
|
72
|
+
assert.equal(verdict, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null for invalid status in raw JSON', () => {
|
|
76
|
+
const verdict = extractVerdict({ raw: '{"status": "unknown_status"}' });
|
|
77
|
+
assert.equal(verdict, null);
|
|
78
|
+
});
|
|
79
|
+
});
|