@ai-sdlc/orchestrator 0.4.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.
Files changed (72) hide show
  1. package/dist/action-enforcement.d.ts +26 -0
  2. package/dist/action-enforcement.js +70 -0
  3. package/dist/adapters.d.ts +18 -3
  4. package/dist/adapters.js +92 -2
  5. package/dist/admission-score.d.ts +58 -0
  6. package/dist/admission-score.js +164 -0
  7. package/dist/cli/commands/init.js +4 -8
  8. package/dist/cli/commands/run.js +2 -2
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +14 -5
  11. package/dist/cycle-utils.d.ts +51 -0
  12. package/dist/cycle-utils.js +77 -0
  13. package/dist/defaults.d.ts +5 -0
  14. package/dist/defaults.js +5 -0
  15. package/dist/execute.d.ts +5 -2
  16. package/dist/execute.js +212 -62
  17. package/dist/fix-ci.js +45 -13
  18. package/dist/fix-review.d.ts +66 -0
  19. package/dist/fix-review.js +441 -0
  20. package/dist/index.d.ts +14 -4
  21. package/dist/index.js +18 -3
  22. package/dist/orchestrator.d.ts +1 -1
  23. package/dist/orchestrator.js +31 -9
  24. package/dist/pipeline-cycle-detector.d.ts +70 -0
  25. package/dist/pipeline-cycle-detector.js +111 -0
  26. package/dist/plugin.d.ts +9 -3
  27. package/dist/priority.d.ts +28 -0
  28. package/dist/priority.js +230 -0
  29. package/dist/review.d.ts +31 -0
  30. package/dist/review.js +74 -0
  31. package/dist/runners/claude-code.js +367 -35
  32. package/dist/runners/codex.js +15 -4
  33. package/dist/runners/copilot.js +15 -4
  34. package/dist/runners/cursor.js +15 -4
  35. package/dist/runners/generic-llm.js +1 -1
  36. package/dist/runners/index.d.ts +3 -1
  37. package/dist/runners/index.js +2 -0
  38. package/dist/runners/review-agent.d.ts +47 -0
  39. package/dist/runners/review-agent.js +220 -0
  40. package/dist/runners/security-triage.d.ts +43 -0
  41. package/dist/runners/security-triage.js +158 -0
  42. package/dist/runners/types.d.ts +24 -1
  43. package/dist/security.d.ts +8 -3
  44. package/dist/security.js +13 -2
  45. package/dist/shared.d.ts +17 -0
  46. package/dist/shared.js +27 -0
  47. package/dist/state/index.d.ts +1 -1
  48. package/dist/state/schema.d.ts +4 -1
  49. package/dist/state/schema.js +89 -1
  50. package/dist/state/store.d.ts +31 -1
  51. package/dist/state/store.js +208 -13
  52. package/dist/state/types.d.ts +52 -0
  53. package/dist/triage.d.ts +36 -0
  54. package/dist/triage.js +133 -0
  55. package/dist/types.d.ts +1 -1
  56. package/dist/watch.d.ts +6 -2
  57. package/dist/watch.js +34 -6
  58. package/dist/workflow-patterns/artifact-writer.d.ts +16 -0
  59. package/dist/workflow-patterns/artifact-writer.js +34 -0
  60. package/dist/workflow-patterns/classifiers.d.ts +10 -0
  61. package/dist/workflow-patterns/classifiers.js +72 -0
  62. package/dist/workflow-patterns/detector.d.ts +27 -0
  63. package/dist/workflow-patterns/detector.js +186 -0
  64. package/dist/workflow-patterns/index.d.ts +8 -0
  65. package/dist/workflow-patterns/index.js +7 -0
  66. package/dist/workflow-patterns/proposal-generator.d.ts +15 -0
  67. package/dist/workflow-patterns/proposal-generator.js +183 -0
  68. package/dist/workflow-patterns/telemetry-ingest.d.ts +27 -0
  69. package/dist/workflow-patterns/telemetry-ingest.js +103 -0
  70. package/dist/workflow-patterns/types.d.ts +61 -0
  71. package/dist/workflow-patterns/types.js +11 -0
  72. package/package.json +4 -2
@@ -7,11 +7,33 @@ 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
- `You are fixing issue #${ctx.issueNumber}: ${ctx.issueTitle}`,
36
+ `You are fixing issue ${/^\d+$/.test(ctx.issueId) ? '#' : ''}${ctx.issueId}: ${ctx.issueTitle}`,
15
37
  '',
16
38
  '## Issue Description',
17
39
  ctx.issueBody,
@@ -23,35 +45,31 @@ 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
- if (lintCmd && fmtCmd) {
27
- lines.push(`${++step}. After making ANY code changes, always run \`${lintCmd}\` and \`${fmtCmd}\` to catch issues before committing.`);
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
- if (lintCmd && fmtCmd) {
41
- lines.push(`${++step}. After making code changes, run \`${lintCmd}\` and \`${fmtCmd}\` to ensure CI will pass.`);
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'}`);
52
70
  // Append relevant episodic memory if available
53
71
  if (ctx.memory) {
54
- const episodes = ctx.memory.episodic.search(`issue-${ctx.issueNumber}`);
72
+ const episodes = ctx.memory.episodic.search(`issue-${ctx.issueId}`);
55
73
  if (episodes.length > 0) {
56
74
  lines.push('', '## Previous Context');
57
75
  for (const ep of episodes.slice(0, 5)) {
@@ -76,32 +94,276 @@ 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 child = spawn('claude', ['-p', '--model', model, '--allowedTools', tools], {
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';
249
+ let cmd;
250
+ let args;
251
+ if (useOpenShell) {
252
+ cmd = 'openshell';
253
+ args = ['sandbox', 'connect', opts.sandboxId, '--', 'claude', ...claudeArgs];
254
+ }
255
+ else {
256
+ cmd = 'claude';
257
+ args = claudeArgs;
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`);
264
+ const child = spawn(cmd, args, {
85
265
  cwd: workDir,
86
266
  stdio: ['pipe', 'pipe', 'pipe'],
87
267
  env: { ...process.env },
88
268
  timeout: timeoutMs,
89
269
  });
90
- const chunks = [];
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
+ });
91
333
  const errChunks = [];
92
- child.stdout.on('data', (data) => chunks.push(data));
93
- child.stderr.on('data', (data) => errChunks.push(data));
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);
94
345
  child.on('close', (code) => {
95
- const stdout = Buffer.concat(chunks).toString('utf-8');
346
+ clearInterval(heartbeat);
347
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
96
348
  const stderr = Buffer.concat(errChunks).toString('utf-8');
349
+ process.stderr.write(`${logPrefix} exited: code=${code} elapsed=${elapsed}s\n`);
97
350
  if (code === 0) {
98
- resolve({ stdout, stderr, model });
351
+ resolve({
352
+ stdout: resultText ?? '',
353
+ stderr,
354
+ model: resultTokenUsage?.model ?? model,
355
+ costUsd: resultCost,
356
+ });
99
357
  }
100
358
  else {
101
- reject(new Error(`claude exited with code ${code}: ${stderr || stdout}`));
359
+ reject(new Error(`claude exited with code ${code}: ${stderr || resultText || ''}`));
102
360
  }
103
361
  });
104
- child.on('error', reject);
362
+ child.on('error', (err) => {
363
+ clearInterval(heartbeat);
364
+ process.stderr.write(`${logPrefix} spawn error: ${err.message}\n`);
365
+ reject(err);
366
+ });
105
367
  // Send prompt via stdin and close it
106
368
  child.stdin.write(prompt);
107
369
  child.stdin.end();
@@ -137,29 +399,76 @@ export function parseTokenUsage(stderr, model) {
137
399
  }
138
400
  return undefined;
139
401
  }
402
+ /**
403
+ * Run lint --fix and format commands (best-effort) so pre-commit hooks pass.
404
+ */
405
+ async function runAutoFix(workDir, lintCmd, fmtCmd) {
406
+ if (fmtCmd) {
407
+ try {
408
+ const [bin, ...args] = fmtCmd.split(' ');
409
+ await execFileAsync(bin, args, { cwd: workDir });
410
+ }
411
+ catch {
412
+ // Format failures are non-fatal — the commit hook will catch remaining issues
413
+ }
414
+ }
415
+ if (lintCmd) {
416
+ try {
417
+ const [bin, ...args] = lintCmd.split(' ');
418
+ await execFileAsync(bin, args, { cwd: workDir });
419
+ }
420
+ catch {
421
+ // Lint --fix failures are non-fatal
422
+ }
423
+ }
424
+ }
140
425
  export class ClaudeCodeRunner {
141
426
  async run(ctx) {
142
427
  const prompt = buildPrompt(ctx);
143
428
  try {
144
- // Invoke Claude Code CLI in print mode, sending prompt via stdin
429
+ // Invoke Claude Code CLI with stream-json for real-time progress
145
430
  const result = await runClaude(prompt, ctx.workDir, {
146
431
  allowedTools: ctx.allowedTools,
147
432
  timeoutMs: ctx.timeoutMs,
148
433
  model: ctx.model,
434
+ sandboxId: ctx.sandboxId,
435
+ onProgress: ctx.onProgress,
149
436
  });
150
- // Parse token usage from stderr
437
+ // Token usage from stream-json result event (falls back to stderr parsing)
151
438
  const tokenUsage = parseTokenUsage(result.stderr, result.model);
152
- // 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)
153
441
  const diffOutput = await gitExec(ctx.workDir, ['diff', '--name-only']);
154
442
  const untrackedOutput = await gitExec(ctx.workDir, [
155
443
  'ls-files',
156
444
  '--others',
157
445
  '--exclude-standard',
158
446
  ]);
159
- const filesChanged = [
447
+ const uncommittedFiles = [
160
448
  ...diffOutput.split('\n').filter(Boolean),
161
449
  ...untrackedOutput.split('\n').filter(Boolean),
162
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;
163
472
  if (filesChanged.length === 0) {
164
473
  return {
165
474
  success: false,
@@ -169,14 +478,37 @@ export class ClaudeCodeRunner {
169
478
  tokenUsage,
170
479
  };
171
480
  }
172
- // Stage and commit
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
+ }
490
+ // Stage, lint/format, and commit
491
+ await gitExec(ctx.workDir, ['add', '-A']);
492
+ // Run lint and format before committing to avoid pre-commit hook failures
493
+ const lintCmd = ctx.lintCommand ?? DEFAULT_LINT_COMMAND;
494
+ const fmtCmd = ctx.formatCommand ?? DEFAULT_FORMAT_COMMAND;
495
+ await runAutoFix(ctx.workDir, lintCmd, fmtCmd);
496
+ // Re-stage after auto-fix may have modified files
173
497
  await gitExec(ctx.workDir, ['add', '-A']);
174
498
  const tmpl = ctx.commitMessageTemplate ?? DEFAULT_COMMIT_MESSAGE_TEMPLATE;
175
499
  const coAuthor = ctx.commitCoAuthor ?? DEFAULT_COMMIT_CO_AUTHOR;
176
500
  const commitMsg = tmpl
177
- .replace(/\{issueNumber\}/g, String(ctx.issueNumber))
501
+ .replace(/\{issueNumber\}/g, ctx.issueId)
178
502
  .replace(/\{issueTitle\}/g, ctx.issueTitle);
179
- await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
503
+ try {
504
+ await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
505
+ }
506
+ catch {
507
+ // Pre-commit hook may have auto-fixed files — re-stage and retry once
508
+ await runAutoFix(ctx.workDir, lintCmd, fmtCmd);
509
+ await gitExec(ctx.workDir, ['add', '-A']);
510
+ await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
511
+ }
180
512
  return {
181
513
  success: true,
182
514
  filesChanged,
@@ -73,12 +73,23 @@ export class CodexRunner {
73
73
  const timeoutMs = ctx.timeoutMs ?? DEFAULT_RUNNER_TIMEOUT_MS;
74
74
  const model = DEFAULT_CODEX_MODEL ?? 'codex-default';
75
75
  try {
76
- const args = ['exec', '-', '--full-auto', '--json'];
76
+ const codexArgs = ['exec', '-', '--full-auto', '--json'];
77
77
  if (DEFAULT_CODEX_MODEL) {
78
- args.push('-m', DEFAULT_CODEX_MODEL);
78
+ codexArgs.push('-m', DEFAULT_CODEX_MODEL);
79
+ }
80
+ // When running inside an OpenShell sandbox, prefix with sandbox connect
81
+ let cmd;
82
+ let args;
83
+ if (ctx.sandboxId) {
84
+ cmd = 'openshell';
85
+ args = ['sandbox', 'connect', ctx.sandboxId, '--', 'codex', ...codexArgs];
86
+ }
87
+ else {
88
+ cmd = 'codex';
89
+ args = codexArgs;
79
90
  }
80
91
  const { stdout, stderr } = await new Promise((resolve, reject) => {
81
- const child = spawn('codex', args, {
92
+ const child = spawn(cmd, args, {
82
93
  cwd: ctx.workDir,
83
94
  stdio: ['pipe', 'pipe', 'pipe'],
84
95
  env: { ...process.env },
@@ -129,7 +140,7 @@ export class CodexRunner {
129
140
  const tmpl = ctx.commitMessageTemplate ?? DEFAULT_COMMIT_MESSAGE_TEMPLATE;
130
141
  const coAuthor = ctx.commitCoAuthor ?? DEFAULT_COMMIT_CO_AUTHOR;
131
142
  const commitMsg = tmpl
132
- .replace(/\{issueNumber\}/g, String(ctx.issueNumber))
143
+ .replace(/\{issueNumber\}/g, ctx.issueId)
133
144
  .replace(/\{issueTitle\}/g, ctx.issueTitle);
134
145
  await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
135
146
  return {
@@ -42,12 +42,23 @@ export class CopilotRunner {
42
42
  const timeoutMs = ctx.timeoutMs ?? DEFAULT_RUNNER_TIMEOUT_MS;
43
43
  const model = DEFAULT_COPILOT_MODEL ?? 'copilot-default';
44
44
  try {
45
- const args = ['-p', prompt, '--yolo'];
45
+ const copilotArgs = ['-p', prompt, '--yolo'];
46
46
  if (DEFAULT_COPILOT_MODEL) {
47
- args.push('--model', DEFAULT_COPILOT_MODEL);
47
+ copilotArgs.push('--model', DEFAULT_COPILOT_MODEL);
48
+ }
49
+ // When running inside an OpenShell sandbox, prefix with sandbox connect
50
+ let cmd;
51
+ let args;
52
+ if (ctx.sandboxId) {
53
+ cmd = 'openshell';
54
+ args = ['sandbox', 'connect', ctx.sandboxId, '--', 'copilot', ...copilotArgs];
55
+ }
56
+ else {
57
+ cmd = 'copilot';
58
+ args = copilotArgs;
48
59
  }
49
60
  const { stdout, stderr } = await new Promise((resolve, reject) => {
50
- const child = spawn('copilot', args, {
61
+ const child = spawn(cmd, args, {
51
62
  cwd: ctx.workDir,
52
63
  stdio: ['ignore', 'pipe', 'pipe'],
53
64
  env: { ...process.env },
@@ -95,7 +106,7 @@ export class CopilotRunner {
95
106
  const tmpl = ctx.commitMessageTemplate ?? DEFAULT_COMMIT_MESSAGE_TEMPLATE;
96
107
  const coAuthor = ctx.commitCoAuthor ?? DEFAULT_COMMIT_CO_AUTHOR;
97
108
  const commitMsg = tmpl
98
- .replace(/\{issueNumber\}/g, String(ctx.issueNumber))
109
+ .replace(/\{issueNumber\}/g, ctx.issueId)
99
110
  .replace(/\{issueTitle\}/g, ctx.issueTitle);
100
111
  await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
101
112
  return {
@@ -62,12 +62,23 @@ export class CursorRunner {
62
62
  const timeoutMs = ctx.timeoutMs ?? DEFAULT_RUNNER_TIMEOUT_MS;
63
63
  const model = DEFAULT_CURSOR_MODEL ?? 'cursor-default';
64
64
  try {
65
- const args = ['--print', prompt, '--force', '--output-format=stream-json'];
65
+ const cursorArgs = ['--print', prompt, '--force', '--output-format=stream-json'];
66
66
  if (DEFAULT_CURSOR_MODEL) {
67
- args.push('-m', DEFAULT_CURSOR_MODEL);
67
+ cursorArgs.push('-m', DEFAULT_CURSOR_MODEL);
68
+ }
69
+ // When running inside an OpenShell sandbox, prefix with sandbox connect
70
+ let cmd;
71
+ let args;
72
+ if (ctx.sandboxId) {
73
+ cmd = 'openshell';
74
+ args = ['sandbox', 'connect', ctx.sandboxId, '--', 'cursor-agent', ...cursorArgs];
75
+ }
76
+ else {
77
+ cmd = 'cursor-agent';
78
+ args = cursorArgs;
68
79
  }
69
80
  const { stdout, stderr } = await new Promise((resolve, reject) => {
70
- const child = spawn('cursor-agent', args, {
81
+ const child = spawn(cmd, args, {
71
82
  cwd: ctx.workDir,
72
83
  stdio: ['ignore', 'pipe', 'pipe'],
73
84
  env: { ...process.env },
@@ -116,7 +127,7 @@ export class CursorRunner {
116
127
  const tmpl = ctx.commitMessageTemplate ?? DEFAULT_COMMIT_MESSAGE_TEMPLATE;
117
128
  const coAuthor = ctx.commitCoAuthor ?? DEFAULT_COMMIT_CO_AUTHOR;
118
129
  const commitMsg = tmpl
119
- .replace(/\{issueNumber\}/g, String(ctx.issueNumber))
130
+ .replace(/\{issueNumber\}/g, ctx.issueId)
120
131
  .replace(/\{issueTitle\}/g, ctx.issueTitle);
121
132
  await gitExec(ctx.workDir, ['commit', '-m', `${commitMsg}\n\nCo-Authored-By: ${coAuthor}`]);
122
133
  return {
@@ -52,7 +52,7 @@ export class GenericLLMRunner {
52
52
  });
53
53
  }
54
54
  const userContent = [
55
- `Issue #${ctx.issueNumber}: ${ctx.issueTitle}`,
55
+ `Issue ${/^\d+$/.test(ctx.issueId) ? '#' : ''}${ctx.issueId}: ${ctx.issueTitle}`,
56
56
  '',
57
57
  ctx.issueBody,
58
58
  '',
@@ -1,8 +1,10 @@
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';
5
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
+ 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';
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,6 @@ export { CopilotRunner } from './copilot.js';
4
4
  export { CursorRunner } from './cursor.js';
5
5
  export { CodexRunner } from './codex.js';
6
6
  export { RunnerRegistry, createRunnerRegistry } from './runner-registry.js';
7
+ export { SecurityTriageRunner, TRIAGE_SYSTEM_PROMPT, } from './security-triage.js';
8
+ export { ReviewAgentRunner, REVIEW_PROMPTS, } from './review-agent.js';
7
9
  //# sourceMappingURL=index.js.map