@ai-sdlc/orchestrator 0.5.0 → 0.6.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/dist/action-enforcement.d.ts +26 -0
- package/dist/action-enforcement.js +70 -0
- package/dist/admission-score.d.ts +58 -0
- package/dist/admission-score.js +164 -0
- package/dist/cycle-utils.d.ts +51 -0
- package/dist/cycle-utils.js +77 -0
- package/dist/defaults.d.ts +5 -0
- package/dist/defaults.js +5 -0
- package/dist/execute.js +121 -26
- package/dist/fix-ci.js +32 -2
- package/dist/fix-review.d.ts +66 -0
- package/dist/fix-review.js +441 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +13 -1
- package/dist/pipeline-cycle-detector.d.ts +70 -0
- package/dist/pipeline-cycle-detector.js +111 -0
- package/dist/priority.d.ts +2 -76
- package/dist/review.d.ts +31 -0
- package/dist/review.js +74 -0
- package/dist/runners/claude-code.js +314 -32
- package/dist/runners/index.d.ts +2 -1
- package/dist/runners/index.js +1 -0
- package/dist/runners/review-agent.d.ts +47 -0
- package/dist/runners/review-agent.js +220 -0
- package/dist/runners/security-triage.js +4 -0
- package/dist/runners/types.d.ts +19 -0
- package/dist/state/index.d.ts +1 -1
- package/dist/state/schema.d.ts +2 -1
- package/dist/state/schema.js +54 -1
- package/dist/state/store.d.ts +17 -1
- package/dist/state/store.js +122 -0
- package/dist/state/types.d.ts +35 -0
- package/dist/workflow-patterns/artifact-writer.d.ts +16 -0
- package/dist/workflow-patterns/artifact-writer.js +34 -0
- package/dist/workflow-patterns/classifiers.d.ts +10 -0
- package/dist/workflow-patterns/classifiers.js +72 -0
- package/dist/workflow-patterns/detector.d.ts +27 -0
- package/dist/workflow-patterns/detector.js +186 -0
- package/dist/workflow-patterns/index.d.ts +8 -0
- package/dist/workflow-patterns/index.js +7 -0
- package/dist/workflow-patterns/proposal-generator.d.ts +15 -0
- package/dist/workflow-patterns/proposal-generator.js +183 -0
- package/dist/workflow-patterns/telemetry-ingest.d.ts +27 -0
- package/dist/workflow-patterns/telemetry-ingest.js +103 -0
- package/dist/workflow-patterns/types.d.ts +61 -0
- package/dist/workflow-patterns/types.js +11 -0
- package/package.json +2 -2
|
@@ -7,9 +7,31 @@ import { promisify } from 'node:util';
|
|
|
7
7
|
import { DEFAULT_MODEL, DEFAULT_ALLOWED_TOOLS, DEFAULT_RUNNER_TIMEOUT_MS, DEFAULT_LINT_COMMAND, DEFAULT_FORMAT_COMMAND, DEFAULT_COMMIT_MESSAGE_TEMPLATE, DEFAULT_COMMIT_CO_AUTHOR, } from '../defaults.js';
|
|
8
8
|
import { formatContextForPrompt } from '../analysis/context-builder.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* Build verification step instructions (lint, format, typecheck).
|
|
12
|
+
* Returns an array of numbered step strings starting from the given step number.
|
|
13
|
+
*/
|
|
14
|
+
function buildVerificationSteps(startStep, lintCmd, fmtCmd, typecheckCmd) {
|
|
15
|
+
let step = startStep;
|
|
16
|
+
const lines = [];
|
|
17
|
+
if (lintCmd && fmtCmd) {
|
|
18
|
+
lines.push(`${++step}. After making code changes, run \`${lintCmd}\` and \`${fmtCmd}\` to catch issues before committing.`);
|
|
19
|
+
}
|
|
20
|
+
else if (lintCmd) {
|
|
21
|
+
lines.push(`${++step}. After making code changes, run \`${lintCmd}\` to catch issues before committing.`);
|
|
22
|
+
}
|
|
23
|
+
else if (fmtCmd) {
|
|
24
|
+
lines.push(`${++step}. After making code changes, run \`${fmtCmd}\` to catch issues before committing.`);
|
|
25
|
+
}
|
|
26
|
+
if (typecheckCmd) {
|
|
27
|
+
lines.push(`${++step}. IMPORTANT: Run \`${typecheckCmd}\` to verify there are no TypeScript errors. The pre-commit hook will reject your commit if there are type errors. Fix ALL type errors before committing.`);
|
|
28
|
+
}
|
|
29
|
+
return { lines, nextStep: step };
|
|
30
|
+
}
|
|
10
31
|
export function buildPrompt(ctx) {
|
|
11
32
|
const lintCmd = ctx.lintCommand ?? DEFAULT_LINT_COMMAND;
|
|
12
33
|
const fmtCmd = ctx.formatCommand ?? DEFAULT_FORMAT_COMMAND;
|
|
34
|
+
const typecheckCmd = ctx.typecheckCommand;
|
|
13
35
|
const lines = [
|
|
14
36
|
`You are fixing issue ${/^\d+$/.test(ctx.issueId) ? '#' : ''}${ctx.issueId}: ${ctx.issueTitle}`,
|
|
15
37
|
'',
|
|
@@ -23,29 +45,25 @@ export function buildPrompt(ctx) {
|
|
|
23
45
|
if (fmtCmd) {
|
|
24
46
|
lines.push(`${++step}. If the failure is a formatting/prettier error, run \`${fmtCmd}\` to auto-fix it.`);
|
|
25
47
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
else if (lintCmd) {
|
|
30
|
-
lines.push(`${++step}. After making ANY code changes, always run \`${lintCmd}\` to catch issues before committing.`);
|
|
31
|
-
}
|
|
32
|
-
else if (fmtCmd) {
|
|
33
|
-
lines.push(`${++step}. After making ANY code changes, always run \`${fmtCmd}\` to catch issues before committing.`);
|
|
34
|
-
}
|
|
48
|
+
const ciVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
|
|
49
|
+
lines.push(...ciVerify.lines);
|
|
50
|
+
step = ciVerify.nextStep;
|
|
35
51
|
lines.push(`${++step}. Write or update tests if needed to cover your fix.`, `${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
|
|
36
52
|
}
|
|
53
|
+
else if (ctx.reviewFindings) {
|
|
54
|
+
let step = 0;
|
|
55
|
+
lines.push('## Review Findings', '', ctx.reviewFindings, '', '## Instructions', `${++step}. Read the review findings above carefully.`, `${++step}. Read the relevant source files to understand the context.`, `${++step}. Address all the review findings by making necessary code changes.`, `${++step}. Write or update tests if requested by the reviewers.`);
|
|
56
|
+
const reviewVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
|
|
57
|
+
lines.push(...reviewVerify.lines);
|
|
58
|
+
step = reviewVerify.nextStep;
|
|
59
|
+
lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
|
|
60
|
+
}
|
|
37
61
|
else {
|
|
38
62
|
let step = 0;
|
|
39
63
|
lines.push('## Instructions', `${++step}. Read the relevant source files to understand the codebase.`, `${++step}. Implement the fix or feature described in the issue.`, `${++step}. Write or update tests to cover your changes.`);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else if (lintCmd) {
|
|
44
|
-
lines.push(`${++step}. After making code changes, run \`${lintCmd}\` to ensure CI will pass.`);
|
|
45
|
-
}
|
|
46
|
-
else if (fmtCmd) {
|
|
47
|
-
lines.push(`${++step}. After making code changes, run \`${fmtCmd}\` to ensure CI will pass.`);
|
|
48
|
-
}
|
|
64
|
+
const defaultVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
|
|
65
|
+
lines.push(...defaultVerify.lines);
|
|
66
|
+
step = defaultVerify.nextStep;
|
|
49
67
|
lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
|
|
50
68
|
}
|
|
51
69
|
lines.push('', '## Constraints (enforced — violations will be automatically rejected)', `- Maximum files to change: ${ctx.constraints.maxFilesPerChange}`, `- Tests required: ${ctx.constraints.requireTests}`, `- Blocked paths (NEVER modify — changes will be rejected): ${ctx.constraints.blockedPaths.join(', ') || 'none'}`);
|
|
@@ -76,16 +94,161 @@ async function gitExec(workDir, args) {
|
|
|
76
94
|
const { stdout } = await execFileAsync('git', args, { cwd: workDir });
|
|
77
95
|
return stdout.trim();
|
|
78
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Format a stream-json event into a concise, human-readable log line.
|
|
99
|
+
*/
|
|
100
|
+
function formatEventForLog(line) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(line);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const type = parsed.type;
|
|
109
|
+
if (type === 'system') {
|
|
110
|
+
return `init session=${parsed.session_id?.slice(0, 8)}`;
|
|
111
|
+
}
|
|
112
|
+
if (type === 'assistant') {
|
|
113
|
+
const message = parsed.message;
|
|
114
|
+
const content = message?.content;
|
|
115
|
+
if (!content?.length)
|
|
116
|
+
return null;
|
|
117
|
+
const parts = [];
|
|
118
|
+
for (const block of content) {
|
|
119
|
+
if (block.type === 'thinking') {
|
|
120
|
+
const text = String(block.thinking ?? '');
|
|
121
|
+
parts.push(`thinking: ${text.slice(0, 120)}${text.length > 120 ? '...' : ''}`);
|
|
122
|
+
}
|
|
123
|
+
else if (block.type === 'text') {
|
|
124
|
+
const text = String(block.text ?? '');
|
|
125
|
+
parts.push(`text: ${text.slice(0, 120)}${text.length > 120 ? '...' : ''}`);
|
|
126
|
+
}
|
|
127
|
+
else if (block.type === 'tool_use') {
|
|
128
|
+
const name = String(block.name ?? '');
|
|
129
|
+
const input = (block.input ?? {});
|
|
130
|
+
const file = extractFilePath(input);
|
|
131
|
+
parts.push(file ? `${name}: ${file}` : name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return parts.join(' | ');
|
|
135
|
+
}
|
|
136
|
+
if (type === 'user') {
|
|
137
|
+
const message = parsed.message;
|
|
138
|
+
const content = message?.content;
|
|
139
|
+
if (!content?.length)
|
|
140
|
+
return null;
|
|
141
|
+
const block = content[0];
|
|
142
|
+
if (block.type === 'tool_result') {
|
|
143
|
+
const text = String(block.content ?? '');
|
|
144
|
+
const truncated = text.slice(0, 80);
|
|
145
|
+
return `result: ${truncated}${text.length > 80 ? '...' : ''}`;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (type === 'result') {
|
|
150
|
+
const cost = parsed.total_cost_usd;
|
|
151
|
+
const turns = parsed.num_turns;
|
|
152
|
+
const duration = parsed.duration_ms;
|
|
153
|
+
return `done — ${turns ?? '?'} turns, ${duration ? Math.round(duration / 1000) + 's' : '?'}, $${cost?.toFixed(4) ?? '?'}`;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extract a file path from a tool_use input object.
|
|
159
|
+
*/
|
|
160
|
+
function extractFilePath(input) {
|
|
161
|
+
return ((typeof input.file_path === 'string' ? input.file_path : undefined) ??
|
|
162
|
+
(typeof input.path === 'string' ? input.path : undefined) ??
|
|
163
|
+
(typeof input.pattern === 'string' ? input.pattern : undefined) ??
|
|
164
|
+
(typeof input.command === 'string' ? input.command.slice(0, 80) : undefined));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse a single NDJSON line from Claude Code stream-json output
|
|
168
|
+
* and emit a progress event if applicable.
|
|
169
|
+
*/
|
|
170
|
+
function parseStreamEvent(line, onProgress) {
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(line);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const type = parsed.type;
|
|
179
|
+
if (type === 'assistant') {
|
|
180
|
+
const message = parsed.message;
|
|
181
|
+
const content = message?.content;
|
|
182
|
+
if (!content)
|
|
183
|
+
return undefined;
|
|
184
|
+
for (const block of content) {
|
|
185
|
+
if (block.type === 'tool_use') {
|
|
186
|
+
const toolName = String(block.name ?? '');
|
|
187
|
+
const input = (block.input ?? {});
|
|
188
|
+
const filePath = extractFilePath(input);
|
|
189
|
+
onProgress({
|
|
190
|
+
type: 'tool_start',
|
|
191
|
+
tool: toolName,
|
|
192
|
+
file: filePath,
|
|
193
|
+
message: filePath ? `${toolName}: ${filePath}` : toolName,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else if (block.type === 'text') {
|
|
197
|
+
const text = String(block.text ?? '');
|
|
198
|
+
if (text.length > 0) {
|
|
199
|
+
onProgress({
|
|
200
|
+
type: 'text',
|
|
201
|
+
message: text.slice(0, 200),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (type === 'result') {
|
|
208
|
+
const resultText = parsed.result;
|
|
209
|
+
const costUsd = parsed.total_cost_usd;
|
|
210
|
+
// Extract token usage from result event
|
|
211
|
+
const modelUsage = parsed.modelUsage;
|
|
212
|
+
let tokenUsage;
|
|
213
|
+
if (modelUsage) {
|
|
214
|
+
const firstModel = Object.keys(modelUsage)[0];
|
|
215
|
+
if (firstModel) {
|
|
216
|
+
const usage = modelUsage[firstModel];
|
|
217
|
+
tokenUsage = {
|
|
218
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
219
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
220
|
+
cacheReadTokens: usage.cacheReadInputTokens ?? undefined,
|
|
221
|
+
model: firstModel,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (costUsd !== undefined) {
|
|
226
|
+
onProgress({ type: 'cost', costUsd, message: `Total cost: $${costUsd.toFixed(4)}` });
|
|
227
|
+
}
|
|
228
|
+
return { resultText: resultText ?? undefined, costUsd, tokenUsage };
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
79
232
|
function runClaude(prompt, workDir, opts) {
|
|
80
233
|
const tools = opts?.allowedTools?.join(',') ?? DEFAULT_ALLOWED_TOOLS;
|
|
81
234
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_RUNNER_TIMEOUT_MS;
|
|
82
235
|
return new Promise((resolve, reject) => {
|
|
83
236
|
const model = opts?.model ?? process.env.AI_SDLC_MODEL ?? DEFAULT_MODEL;
|
|
84
|
-
const claudeArgs = [
|
|
85
|
-
|
|
237
|
+
const claudeArgs = [
|
|
238
|
+
'-p',
|
|
239
|
+
'--output-format',
|
|
240
|
+
'stream-json',
|
|
241
|
+
'--verbose',
|
|
242
|
+
'--model',
|
|
243
|
+
model,
|
|
244
|
+
'--allowedTools',
|
|
245
|
+
tools,
|
|
246
|
+
];
|
|
247
|
+
// When running inside an OpenShell sandbox, prefix with sandbox connect.
|
|
248
|
+
const useOpenShell = opts?.sandboxId && process.env.AI_SDLC_SANDBOX_PROVIDER === 'openshell';
|
|
86
249
|
let cmd;
|
|
87
250
|
let args;
|
|
88
|
-
if (
|
|
251
|
+
if (useOpenShell) {
|
|
89
252
|
cmd = 'openshell';
|
|
90
253
|
args = ['sandbox', 'connect', opts.sandboxId, '--', 'claude', ...claudeArgs];
|
|
91
254
|
}
|
|
@@ -93,27 +256,114 @@ function runClaude(prompt, workDir, opts) {
|
|
|
93
256
|
cmd = 'claude';
|
|
94
257
|
args = claudeArgs;
|
|
95
258
|
}
|
|
259
|
+
const startTime = Date.now();
|
|
260
|
+
const logPrefix = `[ai-sdlc:runner]`;
|
|
261
|
+
process.stderr.write(`${logPrefix} spawning: ${cmd} ${args.join(' ')}\n`);
|
|
262
|
+
process.stderr.write(`${logPrefix} workDir: ${workDir}\n`);
|
|
263
|
+
process.stderr.write(`${logPrefix} timeout: ${timeoutMs}ms\n`);
|
|
96
264
|
const child = spawn(cmd, args, {
|
|
97
265
|
cwd: workDir,
|
|
98
266
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
267
|
env: { ...process.env },
|
|
100
268
|
timeout: timeoutMs,
|
|
101
269
|
});
|
|
102
|
-
|
|
270
|
+
process.stderr.write(`${logPrefix} pid: ${child.pid}\n`);
|
|
271
|
+
let lastActivity = Date.now();
|
|
272
|
+
let resultText;
|
|
273
|
+
let resultCost;
|
|
274
|
+
let resultTokenUsage;
|
|
275
|
+
// Buffer for incomplete NDJSON lines
|
|
276
|
+
let lineBuffer = '';
|
|
277
|
+
const onProgress = opts?.onProgress;
|
|
278
|
+
child.stdout.on('data', (data) => {
|
|
279
|
+
lastActivity = Date.now();
|
|
280
|
+
// Parse NDJSON lines from stream-json output
|
|
281
|
+
lineBuffer += data.toString('utf-8');
|
|
282
|
+
const lines = lineBuffer.split('\n');
|
|
283
|
+
// Keep the last (possibly incomplete) line in the buffer
|
|
284
|
+
lineBuffer = lines.pop() ?? '';
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
const trimmed = line.trim();
|
|
287
|
+
if (!trimmed)
|
|
288
|
+
continue;
|
|
289
|
+
// Log formatted events to stderr for CI visibility
|
|
290
|
+
const formatted = formatEventForLog(trimmed);
|
|
291
|
+
if (formatted) {
|
|
292
|
+
process.stderr.write(`${logPrefix} ${formatted}\n`);
|
|
293
|
+
}
|
|
294
|
+
if (onProgress) {
|
|
295
|
+
const result = parseStreamEvent(trimmed, onProgress);
|
|
296
|
+
if (result) {
|
|
297
|
+
if (result.resultText !== undefined)
|
|
298
|
+
resultText = result.resultText;
|
|
299
|
+
if (result.costUsd !== undefined)
|
|
300
|
+
resultCost = result.costUsd;
|
|
301
|
+
if (result.tokenUsage)
|
|
302
|
+
resultTokenUsage = result.tokenUsage;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// Still parse result event even without progress callback
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(trimmed);
|
|
309
|
+
if (parsed.type === 'result') {
|
|
310
|
+
resultText = parsed.result;
|
|
311
|
+
resultCost = parsed.total_cost_usd;
|
|
312
|
+
const modelUsage = parsed.modelUsage;
|
|
313
|
+
if (modelUsage) {
|
|
314
|
+
const firstModel = Object.keys(modelUsage)[0];
|
|
315
|
+
if (firstModel) {
|
|
316
|
+
const usage = modelUsage[firstModel];
|
|
317
|
+
resultTokenUsage = {
|
|
318
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
319
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
320
|
+
cacheReadTokens: usage.cacheReadInputTokens ?? undefined,
|
|
321
|
+
model: firstModel,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// Not JSON — ignore
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
103
333
|
const errChunks = [];
|
|
104
|
-
child.
|
|
105
|
-
|
|
334
|
+
child.stderr.on('data', (data) => {
|
|
335
|
+
errChunks.push(data);
|
|
336
|
+
lastActivity = Date.now();
|
|
337
|
+
process.stderr.write(data);
|
|
338
|
+
});
|
|
339
|
+
// Heartbeat: log progress every 30s so CI knows the process is alive
|
|
340
|
+
const heartbeat = setInterval(() => {
|
|
341
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
342
|
+
const idle = Math.round((Date.now() - lastActivity) / 1000);
|
|
343
|
+
process.stderr.write(`${logPrefix} heartbeat: ${elapsed}s elapsed, ${idle}s idle\n`);
|
|
344
|
+
}, 30_000);
|
|
106
345
|
child.on('close', (code) => {
|
|
107
|
-
|
|
346
|
+
clearInterval(heartbeat);
|
|
347
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
108
348
|
const stderr = Buffer.concat(errChunks).toString('utf-8');
|
|
349
|
+
process.stderr.write(`${logPrefix} exited: code=${code} elapsed=${elapsed}s\n`);
|
|
109
350
|
if (code === 0) {
|
|
110
|
-
resolve({
|
|
351
|
+
resolve({
|
|
352
|
+
stdout: resultText ?? '',
|
|
353
|
+
stderr,
|
|
354
|
+
model: resultTokenUsage?.model ?? model,
|
|
355
|
+
costUsd: resultCost,
|
|
356
|
+
});
|
|
111
357
|
}
|
|
112
358
|
else {
|
|
113
|
-
reject(new Error(`claude exited with code ${code}: ${stderr ||
|
|
359
|
+
reject(new Error(`claude exited with code ${code}: ${stderr || resultText || ''}`));
|
|
114
360
|
}
|
|
115
361
|
});
|
|
116
|
-
child.on('error',
|
|
362
|
+
child.on('error', (err) => {
|
|
363
|
+
clearInterval(heartbeat);
|
|
364
|
+
process.stderr.write(`${logPrefix} spawn error: ${err.message}\n`);
|
|
365
|
+
reject(err);
|
|
366
|
+
});
|
|
117
367
|
// Send prompt via stdin and close it
|
|
118
368
|
child.stdin.write(prompt);
|
|
119
369
|
child.stdin.end();
|
|
@@ -176,26 +426,49 @@ export class ClaudeCodeRunner {
|
|
|
176
426
|
async run(ctx) {
|
|
177
427
|
const prompt = buildPrompt(ctx);
|
|
178
428
|
try {
|
|
179
|
-
// Invoke Claude Code CLI
|
|
429
|
+
// Invoke Claude Code CLI with stream-json for real-time progress
|
|
180
430
|
const result = await runClaude(prompt, ctx.workDir, {
|
|
181
431
|
allowedTools: ctx.allowedTools,
|
|
182
432
|
timeoutMs: ctx.timeoutMs,
|
|
183
433
|
model: ctx.model,
|
|
184
434
|
sandboxId: ctx.sandboxId,
|
|
435
|
+
onProgress: ctx.onProgress,
|
|
185
436
|
});
|
|
186
|
-
//
|
|
437
|
+
// Token usage from stream-json result event (falls back to stderr parsing)
|
|
187
438
|
const tokenUsage = parseTokenUsage(result.stderr, result.model);
|
|
188
|
-
// Collect changed files
|
|
439
|
+
// Collect changed files — check both uncommitted and already-committed changes
|
|
440
|
+
// The agent may have committed on its own (Claude Code can run git commit)
|
|
189
441
|
const diffOutput = await gitExec(ctx.workDir, ['diff', '--name-only']);
|
|
190
442
|
const untrackedOutput = await gitExec(ctx.workDir, [
|
|
191
443
|
'ls-files',
|
|
192
444
|
'--others',
|
|
193
445
|
'--exclude-standard',
|
|
194
446
|
]);
|
|
195
|
-
const
|
|
447
|
+
const uncommittedFiles = [
|
|
196
448
|
...diffOutput.split('\n').filter(Boolean),
|
|
197
449
|
...untrackedOutput.split('\n').filter(Boolean),
|
|
198
450
|
];
|
|
451
|
+
// Check if agent already committed (and possibly pushed) —
|
|
452
|
+
// compare against the merge base with the target branch (main)
|
|
453
|
+
let committedFiles = [];
|
|
454
|
+
let agentAlreadyCommitted = false;
|
|
455
|
+
try {
|
|
456
|
+
// Find the merge base with main to see all new commits on this branch
|
|
457
|
+
const mergeBase = (await gitExec(ctx.workDir, ['merge-base', 'HEAD', 'origin/main'])).trim();
|
|
458
|
+
if (mergeBase) {
|
|
459
|
+
const commitDiff = await gitExec(ctx.workDir, [
|
|
460
|
+
'diff',
|
|
461
|
+
'--name-only',
|
|
462
|
+
`${mergeBase}..HEAD`,
|
|
463
|
+
]);
|
|
464
|
+
committedFiles = commitDiff.split('\n').filter(Boolean);
|
|
465
|
+
agentAlreadyCommitted = committedFiles.length > 0 && uncommittedFiles.length === 0;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// merge-base may fail if main doesn't exist locally — that's fine
|
|
470
|
+
}
|
|
471
|
+
const filesChanged = agentAlreadyCommitted ? committedFiles : uncommittedFiles;
|
|
199
472
|
if (filesChanged.length === 0) {
|
|
200
473
|
return {
|
|
201
474
|
success: false,
|
|
@@ -205,6 +478,15 @@ export class ClaudeCodeRunner {
|
|
|
205
478
|
tokenUsage,
|
|
206
479
|
};
|
|
207
480
|
}
|
|
481
|
+
// If the agent already committed, skip our commit step
|
|
482
|
+
if (agentAlreadyCommitted) {
|
|
483
|
+
return {
|
|
484
|
+
success: true,
|
|
485
|
+
filesChanged,
|
|
486
|
+
summary: result.stdout.slice(0, 2000),
|
|
487
|
+
tokenUsage,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
208
490
|
// Stage, lint/format, and commit
|
|
209
491
|
await gitExec(ctx.workDir, ['add', '-A']);
|
|
210
492
|
// Run lint and format before committing to avoid pre-commit hook failures
|
package/dist/runners/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { AgentRunner, AgentContext, AgentResult, TokenUsage } from './types.js';
|
|
1
|
+
export type { AgentRunner, AgentContext, AgentResult, AgentProgressEvent, TokenUsage, } from './types.js';
|
|
2
2
|
export { ClaudeCodeRunner, GitHubActionsRunner } from './claude-code.js';
|
|
3
3
|
export { GenericLLMRunner, type GenericLLMConfig, type ChatCompletionResponse, } from './generic-llm.js';
|
|
4
4
|
export { CopilotRunner } from './copilot.js';
|
|
@@ -6,4 +6,5 @@ export { CursorRunner } from './cursor.js';
|
|
|
6
6
|
export { CodexRunner } from './codex.js';
|
|
7
7
|
export { RunnerRegistry, createRunnerRegistry, type RegisteredRunner } from './runner-registry.js';
|
|
8
8
|
export { SecurityTriageRunner, type SecurityTriageConfig, type TriageVerdict, TRIAGE_SYSTEM_PROMPT, } from './security-triage.js';
|
|
9
|
+
export { ReviewAgentRunner, REVIEW_PROMPTS, type ReviewAgentConfig, type ReviewType, type ReviewFinding, type ReviewVerdict, } from './review-agent.js';
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/runners/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { CursorRunner } from './cursor.js';
|
|
|
5
5
|
export { CodexRunner } from './codex.js';
|
|
6
6
|
export { RunnerRegistry, createRunnerRegistry } from './runner-registry.js';
|
|
7
7
|
export { SecurityTriageRunner, TRIAGE_SYSTEM_PROMPT, } from './security-triage.js';
|
|
8
|
+
export { ReviewAgentRunner, REVIEW_PROMPTS, } from './review-agent.js';
|
|
8
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Review agent runner — analyzes pull request diffs for testing coverage,
|
|
3
|
+
* code quality, and security issues. Read-only: never modifies files.
|
|
4
|
+
*
|
|
5
|
+
* Uses the Anthropic Messages API directly (not Claude Code CLI)
|
|
6
|
+
* to produce a structured review verdict. Follows the SecurityTriageRunner
|
|
7
|
+
* pattern exactly.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentRunner, AgentContext, AgentResult } from './types.js';
|
|
10
|
+
export type ReviewType = 'testing' | 'critic' | 'security';
|
|
11
|
+
export interface ReviewFinding {
|
|
12
|
+
severity: 'critical' | 'major' | 'minor' | 'suggestion';
|
|
13
|
+
file?: string;
|
|
14
|
+
line?: number;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ReviewVerdict {
|
|
18
|
+
type: ReviewType;
|
|
19
|
+
approved: boolean;
|
|
20
|
+
findings: ReviewFinding[];
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ReviewAgentConfig {
|
|
24
|
+
/** Anthropic API URL. Defaults to https://api.anthropic.com/v1/messages */
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
/** Anthropic API key. Defaults to ANTHROPIC_API_KEY env var. */
|
|
27
|
+
apiKey?: string;
|
|
28
|
+
/** Model to use. Defaults to claude-sonnet-4-5. */
|
|
29
|
+
model?: string;
|
|
30
|
+
/** Request timeout in ms. Defaults to 120_000. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
/** Which review perspective to use. */
|
|
33
|
+
reviewType: ReviewType;
|
|
34
|
+
/** Project-specific review policy to prepend to the system prompt (calibration context). */
|
|
35
|
+
reviewPolicy?: string;
|
|
36
|
+
}
|
|
37
|
+
declare const REVIEW_PROMPTS: Record<ReviewType, string>;
|
|
38
|
+
export declare class ReviewAgentRunner implements AgentRunner {
|
|
39
|
+
private config;
|
|
40
|
+
constructor(config: ReviewAgentConfig);
|
|
41
|
+
get reviewType(): ReviewType;
|
|
42
|
+
run(ctx: AgentContext): Promise<AgentResult>;
|
|
43
|
+
private callAPI;
|
|
44
|
+
parseVerdict(text: string): ReviewVerdict;
|
|
45
|
+
}
|
|
46
|
+
export { REVIEW_PROMPTS };
|
|
47
|
+
//# sourceMappingURL=review-agent.d.ts.map
|