@bradtaylorsf/alpha-loop 1.2.0 → 1.3.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 +60 -19
- package/dist/cli.js +4 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.js +1 -1
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +199 -30
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/resume.js +1 -0
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/run.js +31 -12
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/scan.d.ts +1 -1
- package/dist/commands/scan.js +12 -9
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/sync.d.ts +5 -0
- package/dist/commands/sync.js +24 -5
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/vision.js +5 -3
- package/dist/commands/vision.js.map +1 -1
- package/dist/engine/agents.d.ts +6 -1
- package/dist/engine/agents.js +14 -12
- package/dist/engine/agents.js.map +1 -1
- package/dist/engine/prerequisites.d.ts +4 -7
- package/dist/engine/prerequisites.js +12 -36
- package/dist/engine/prerequisites.js.map +1 -1
- package/dist/lib/agent.d.ts +10 -0
- package/dist/lib/agent.js +184 -28
- package/dist/lib/agent.js.map +1 -1
- package/dist/lib/config.d.ts +3 -2
- package/dist/lib/config.js +17 -7
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/learning.js +2 -2
- package/dist/lib/learning.js.map +1 -1
- package/dist/lib/pipeline.d.ts +35 -0
- package/dist/lib/pipeline.js +424 -131
- package/dist/lib/pipeline.js.map +1 -1
- package/dist/lib/prompts.d.ts +1 -0
- package/dist/lib/prompts.js +8 -5
- package/dist/lib/prompts.js.map +1 -1
- package/dist/lib/session.js +54 -19
- package/dist/lib/session.js.map +1 -1
- package/dist/lib/verify.d.ts +7 -1
- package/dist/lib/verify.js +109 -157
- package/dist/lib/verify.js.map +1 -1
- package/dist/lib/worktree.d.ts +1 -0
- package/dist/lib/worktree.js +9 -1
- package/dist/lib/worktree.js.map +1 -1
- package/package.json +1 -1
- package/templates/agents/implementer.md +1 -1
- package/templates/agents/reviewer.md +1 -1
- package/dist/engine/config.d.ts +0 -71
- package/dist/engine/config.js +0 -73
- package/dist/engine/config.js.map +0 -1
package/dist/lib/pipeline.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process Issue Pipeline — the 12-step orchestration for a single issue.
|
|
3
3
|
*/
|
|
4
|
-
import { mkdirSync, readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { log } from './logger.js';
|
|
7
7
|
import { exec } from './shell.js';
|
|
@@ -15,6 +15,120 @@ import { extractLearnings, getLearningContext } from './learning.js';
|
|
|
15
15
|
import { saveResult, getPreviousResult } from './session.js';
|
|
16
16
|
/** Max diff size to include in learning analysis. */
|
|
17
17
|
const MAX_DIFF_CHARS = 10_000;
|
|
18
|
+
/** Patterns that indicate a transient agent error (re-queue, don't mark as failed). */
|
|
19
|
+
const TRANSIENT_ERROR_PATTERNS = [
|
|
20
|
+
/usage limit/i,
|
|
21
|
+
/rate limit/i,
|
|
22
|
+
/too many requests/i,
|
|
23
|
+
/quota exceeded/i,
|
|
24
|
+
/capacity/i,
|
|
25
|
+
/try again/i,
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Check if agent output indicates a transient error (usage limits, rate limits).
|
|
29
|
+
* These issues should be re-queued, not marked as permanently failed.
|
|
30
|
+
*/
|
|
31
|
+
function isTransientError(output) {
|
|
32
|
+
return TRANSIENT_ERROR_PATTERNS.some((p) => p.test(output));
|
|
33
|
+
}
|
|
34
|
+
/** Default gate result when agent doesn't write one (assume pass). */
|
|
35
|
+
const DEFAULT_GATE = {
|
|
36
|
+
passed: true,
|
|
37
|
+
summary: 'Gate agent did not write a result file — assuming pass',
|
|
38
|
+
findings: [],
|
|
39
|
+
};
|
|
40
|
+
/** Default plan when planning fails or is skipped. */
|
|
41
|
+
const DEFAULT_PLAN = {
|
|
42
|
+
summary: '',
|
|
43
|
+
files: [],
|
|
44
|
+
implementation: '',
|
|
45
|
+
testing: { needed: true, reason: 'Default: run project test command' },
|
|
46
|
+
verification: { needed: false, reason: 'Default: skip verification unless plan requests it' },
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Read and validate a plan JSON file written by the planning agent.
|
|
50
|
+
* Falls back to DEFAULT_PLAN if the file doesn't exist or is invalid.
|
|
51
|
+
*/
|
|
52
|
+
function readPlan(planFile) {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(planFile))
|
|
55
|
+
return DEFAULT_PLAN;
|
|
56
|
+
const raw = readFileSync(planFile, 'utf-8');
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
return {
|
|
59
|
+
summary: String(parsed.summary ?? ''),
|
|
60
|
+
files: Array.isArray(parsed.files) ? parsed.files.map(String) : [],
|
|
61
|
+
implementation: String(parsed.implementation ?? ''),
|
|
62
|
+
testing: {
|
|
63
|
+
needed: parsed.testing?.needed !== false,
|
|
64
|
+
reason: String(parsed.testing?.reason ?? 'No reason given'),
|
|
65
|
+
},
|
|
66
|
+
verification: {
|
|
67
|
+
needed: parsed.verification?.needed === true,
|
|
68
|
+
instructions: parsed.verification?.instructions || undefined,
|
|
69
|
+
reason: String(parsed.verification?.reason ?? 'No reason given'),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return DEFAULT_PLAN;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read and validate a gate result JSON file written by review/verify agents.
|
|
79
|
+
* Falls back to DEFAULT_GATE if the file doesn't exist or is invalid.
|
|
80
|
+
*/
|
|
81
|
+
function readGateResult(gateFile) {
|
|
82
|
+
try {
|
|
83
|
+
if (!existsSync(gateFile))
|
|
84
|
+
return DEFAULT_GATE;
|
|
85
|
+
const raw = readFileSync(gateFile, 'utf-8');
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
return {
|
|
88
|
+
passed: parsed.passed === true,
|
|
89
|
+
summary: String(parsed.summary ?? ''),
|
|
90
|
+
findings: Array.isArray(parsed.findings)
|
|
91
|
+
? parsed.findings.map((f) => ({
|
|
92
|
+
severity: (['critical', 'warning', 'info'].includes(String(f.severity)) ? f.severity : 'info'),
|
|
93
|
+
description: String(f.description ?? ''),
|
|
94
|
+
fixed: f.fixed === true,
|
|
95
|
+
file: f.file ? String(f.file) : undefined,
|
|
96
|
+
}))
|
|
97
|
+
: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return DEFAULT_GATE;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Move a JSON file from worktree to session logs dir (for inspection).
|
|
106
|
+
* Deletes the source file from the worktree. Non-fatal on failure.
|
|
107
|
+
*/
|
|
108
|
+
function moveToSessionLogs(src, dest) {
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(src))
|
|
111
|
+
return;
|
|
112
|
+
const content = readFileSync(src, 'utf-8');
|
|
113
|
+
writeFileSync(dest, content);
|
|
114
|
+
unlinkSync(src);
|
|
115
|
+
}
|
|
116
|
+
catch { /* non-fatal */ }
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Format gate findings into a prompt section for the implementer.
|
|
120
|
+
*/
|
|
121
|
+
function formatGateFindings(gate, gateType) {
|
|
122
|
+
const unfixed = gate.findings.filter((f) => !f.fixed);
|
|
123
|
+
if (unfixed.length === 0)
|
|
124
|
+
return '';
|
|
125
|
+
const lines = [`## ${gateType} Findings (MUST FIX)`, '', gate.summary, ''];
|
|
126
|
+
for (const f of unfixed) {
|
|
127
|
+
const fileRef = f.file ? ` (${f.file})` : '';
|
|
128
|
+
lines.push(`- [${f.severity.toUpperCase()}]${fileRef} ${f.description}`);
|
|
129
|
+
}
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
18
132
|
/**
|
|
19
133
|
* Process a single issue through the full pipeline.
|
|
20
134
|
* Steps: status → worktree → plan → implement → test+retry → verify+retry →
|
|
@@ -49,6 +163,7 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
49
163
|
sessionBranch: session.branch,
|
|
50
164
|
autoMerge: config.autoMerge,
|
|
51
165
|
skipInstall: config.skipInstall,
|
|
166
|
+
setupCommand: config.setupCommand,
|
|
52
167
|
dryRun: config.dryRun,
|
|
53
168
|
});
|
|
54
169
|
worktreePath = wt.path;
|
|
@@ -62,25 +177,72 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
62
177
|
}
|
|
63
178
|
return failureResult(issueNum, title, startTime);
|
|
64
179
|
}
|
|
65
|
-
// --- Step 3: Plan (
|
|
180
|
+
// --- Step 3: Plan (structured JSON — controls test/verify steps) ---
|
|
66
181
|
log.step('Step 3: Planning');
|
|
67
|
-
let
|
|
182
|
+
let plan = DEFAULT_PLAN;
|
|
183
|
+
// Write plan inside the worktree (agents sandbox to their CWD), then move to sessions dir
|
|
184
|
+
const planFileInWorktree = join(worktreePath, `plan-issue-${issueNum}.json`);
|
|
185
|
+
const planFileInSession = join(session.logsDir, `plan-issue-${issueNum}.json`);
|
|
68
186
|
if (!config.dryRun) {
|
|
69
187
|
try {
|
|
188
|
+
const planPrompt = `Analyze this GitHub issue and produce a structured implementation plan.
|
|
189
|
+
|
|
190
|
+
Issue #${issueNum}: ${title}
|
|
191
|
+
|
|
192
|
+
${body}
|
|
193
|
+
|
|
194
|
+
Write a JSON file to: plan-issue-${issueNum}.json
|
|
195
|
+
|
|
196
|
+
The file must contain ONLY valid JSON with this exact schema:
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
"summary": "One-line description of what needs to be done",
|
|
200
|
+
"files": ["src/path/to/file.ts", "..."],
|
|
201
|
+
"implementation": "Concise step-by-step plan. What to create, modify, wire up. No issue restatement.",
|
|
202
|
+
"testing": {
|
|
203
|
+
"needed": true,
|
|
204
|
+
"reason": "Why tests are or aren't needed for this change"
|
|
205
|
+
},
|
|
206
|
+
"verification": {
|
|
207
|
+
"needed": false,
|
|
208
|
+
"instructions": "If needed: specific playwright-cli steps to verify the feature. If not needed: omit this field.",
|
|
209
|
+
"reason": "Why verification is or isn't needed (e.g. no UI changes, API-only, config change)"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
Rules:
|
|
214
|
+
- testing.needed: true if ANY code changes could affect behavior. false only for docs, config, or comments.
|
|
215
|
+
- verification.needed: true ONLY if the issue changes user-visible UI that can be tested in a browser.
|
|
216
|
+
- verification.instructions: if needed, list the exact playwright-cli commands to verify (open URL, click elements, check content).
|
|
217
|
+
- implementation: be concise and actionable. List files to modify and what to change in each.
|
|
218
|
+
- Write ONLY the JSON file. Do not create any other files or make any code changes.`;
|
|
70
219
|
const planResult = await spawnAgent({
|
|
71
|
-
agent:
|
|
220
|
+
agent: config.agent,
|
|
72
221
|
model: config.model,
|
|
73
|
-
prompt:
|
|
222
|
+
prompt: planPrompt,
|
|
74
223
|
cwd: worktreePath,
|
|
75
224
|
logFile: join(session.logsDir, `issue-${issueNum}-plan.log`),
|
|
76
225
|
verbose: config.verbose,
|
|
77
226
|
});
|
|
78
|
-
|
|
79
|
-
|
|
227
|
+
// Detect transient errors (usage limits) during planning
|
|
228
|
+
if (planResult.exitCode !== 0 && isTransientError(planResult.output)) {
|
|
229
|
+
log.warn(`Agent hit a transient error during planning for #${issueNum} — re-queuing`);
|
|
230
|
+
requeueIssue(config, issueNum);
|
|
231
|
+
await cleanupWorktree({ issueNum, projectDir, autoCleanup: config.autoCleanup });
|
|
232
|
+
return failureResult(issueNum, title, startTime, 'transient');
|
|
233
|
+
}
|
|
234
|
+
plan = readPlan(planFileInWorktree);
|
|
235
|
+
if (plan.summary) {
|
|
236
|
+
// Move plan from worktree to sessions dir for inspection, clean up worktree
|
|
237
|
+
moveToSessionLogs(planFileInWorktree, planFileInSession);
|
|
238
|
+
log.success(`Plan: ${plan.summary} | Tests: ${plan.testing.needed ? 'yes' : 'skip'} | Verify: ${plan.verification.needed ? 'yes' : 'skip'}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
log.warn('Planning agent did not write plan file, using defaults (run all tests, skip verify)');
|
|
80
242
|
}
|
|
81
243
|
}
|
|
82
244
|
catch {
|
|
83
|
-
log.warn('Planning stage failed,
|
|
245
|
+
log.warn('Planning stage failed, using defaults');
|
|
84
246
|
}
|
|
85
247
|
}
|
|
86
248
|
else {
|
|
@@ -97,14 +259,15 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
97
259
|
const implementPrompt = buildImplementPrompt({
|
|
98
260
|
issueNum,
|
|
99
261
|
title,
|
|
100
|
-
body
|
|
262
|
+
body,
|
|
263
|
+
planContent: plan.implementation || undefined,
|
|
101
264
|
visionContext: visionContext ?? undefined,
|
|
102
265
|
projectContext: projectContext ?? undefined,
|
|
103
266
|
previousResult: previousResult ?? undefined,
|
|
104
267
|
learningContext: learningContext || undefined,
|
|
105
268
|
});
|
|
106
269
|
const implResult = await spawnAgent({
|
|
107
|
-
agent:
|
|
270
|
+
agent: config.agent,
|
|
108
271
|
model: config.model,
|
|
109
272
|
prompt: implementPrompt,
|
|
110
273
|
cwd: worktreePath,
|
|
@@ -112,11 +275,17 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
112
275
|
verbose: config.verbose,
|
|
113
276
|
});
|
|
114
277
|
if (implResult.exitCode !== 0) {
|
|
278
|
+
if (isTransientError(implResult.output)) {
|
|
279
|
+
log.warn(`Agent hit a transient error during implementation for #${issueNum} — re-queuing`);
|
|
280
|
+
requeueIssue(config, issueNum);
|
|
281
|
+
await cleanupWorktree({ issueNum, projectDir, autoCleanup: config.autoCleanup });
|
|
282
|
+
return failureResult(issueNum, title, startTime, 'transient');
|
|
283
|
+
}
|
|
115
284
|
log.error(`Implementation failed for issue #${issueNum}`);
|
|
116
285
|
labelIssue(config.repo, issueNum, 'failed', 'in-progress');
|
|
117
286
|
commentIssue(config.repo, issueNum, 'Agent loop failed during implementation. See logs for details.');
|
|
118
287
|
await cleanupWorktree({ issueNum, projectDir, autoCleanup: config.autoCleanup });
|
|
119
|
-
return failureResult(issueNum, title, startTime);
|
|
288
|
+
return failureResult(issueNum, title, startTime, 'permanent');
|
|
120
289
|
}
|
|
121
290
|
// Auto-commit if agent didn't
|
|
122
291
|
const statusResult = exec('git status --porcelain', { cwd: worktreePath });
|
|
@@ -132,7 +301,13 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
132
301
|
log.step('Step 5: Running tests');
|
|
133
302
|
let testOutput = '';
|
|
134
303
|
let testsPassing = false;
|
|
135
|
-
|
|
304
|
+
let testRetries = 0;
|
|
305
|
+
if (!plan.testing.needed) {
|
|
306
|
+
log.info(`Tests skipped by plan: ${plan.testing.reason}`);
|
|
307
|
+
testsPassing = true;
|
|
308
|
+
testOutput = `Tests skipped by plan: ${plan.testing.reason}`;
|
|
309
|
+
}
|
|
310
|
+
for (let attempt = 1; testsPassing ? false : attempt <= config.maxTestRetries; attempt++) {
|
|
136
311
|
log.info(`Test attempt ${attempt} of ${config.maxTestRetries}`);
|
|
137
312
|
const testResult = runTests(worktreePath, config, logFile);
|
|
138
313
|
testOutput = testResult.output;
|
|
@@ -142,14 +317,16 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
142
317
|
break;
|
|
143
318
|
}
|
|
144
319
|
if (attempt < config.maxTestRetries) {
|
|
320
|
+
testRetries++;
|
|
145
321
|
log.warn(`Tests failed on attempt ${attempt}, invoking agent to fix...`);
|
|
146
322
|
if (!config.dryRun) {
|
|
147
|
-
const fixPrompt = `Tests are failing for issue #${issueNum} (attempt ${attempt} of ${config.maxTestRetries}). Fix the failing tests.\n\nTest output:\n${testOutput}\n\nInstructions:\n1. Read the failing test output carefully and identify the ROOT CAUSE\n2. Fix
|
|
323
|
+
const fixPrompt = `Tests are failing for issue #${issueNum} (attempt ${attempt} of ${config.maxTestRetries}). Fix the failing tests.\n\nTest output:\n${testOutput}\n\nInstructions:\n1. Read the failing test output carefully and identify the ROOT CAUSE\n2. Fix ONLY code related to issue #${issueNum} — do NOT modify test infrastructure, build scripts, or unrelated files\n3. If tests fail due to environment issues (missing venv, wrong port, missing deps), fix only YOUR code — do NOT rewrite the test runner or package.json scripts\n4. Run the tests again to verify\n5. Commit your fixes with a DESCRIPTIVE message that explains WHAT you fixed and WHY it failed.\n Format: fix(#${issueNum}): <what you changed> — <why it was failing>\n Example: fix(#${issueNum}): use port 5435 for postgres — default 5432 conflicts with host service\n DO NOT use generic messages like "fix: resolve test failures"`;
|
|
148
324
|
await spawnAgent({
|
|
149
|
-
agent:
|
|
325
|
+
agent: config.agent,
|
|
150
326
|
model: config.model,
|
|
151
327
|
prompt: fixPrompt,
|
|
152
328
|
cwd: worktreePath,
|
|
329
|
+
resume: true,
|
|
153
330
|
logFile: join(session.logsDir, `issue-${issueNum}-fix-${attempt}.log`),
|
|
154
331
|
verbose: config.verbose,
|
|
155
332
|
});
|
|
@@ -166,95 +343,179 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
166
343
|
testOutput = `TESTS FAILED after ${config.maxTestRetries} fix attempts. Latest output:\n${testOutput}`;
|
|
167
344
|
}
|
|
168
345
|
}
|
|
169
|
-
// --- Step 6:
|
|
170
|
-
log.step('Step 6:
|
|
171
|
-
let
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
log.info(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
346
|
+
// --- Step 6: Review gate (JSON-based) ---
|
|
347
|
+
log.step('Step 6: Code review');
|
|
348
|
+
let reviewOutput = '';
|
|
349
|
+
let reviewGate = DEFAULT_GATE;
|
|
350
|
+
if (config.skipReview) {
|
|
351
|
+
log.info('Code review skipped');
|
|
352
|
+
}
|
|
353
|
+
else if (config.dryRun) {
|
|
354
|
+
log.dry('Would run code review');
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const reviewFileInWorktree = join(worktreePath, `review-issue-${issueNum}.json`);
|
|
358
|
+
const reviewFileInSession = join(session.logsDir, `review-issue-${issueNum}.json`);
|
|
359
|
+
for (let attempt = 1; attempt <= config.maxTestRetries; attempt++) {
|
|
360
|
+
log.info(`Review attempt ${attempt} of ${config.maxTestRetries}`);
|
|
361
|
+
try {
|
|
362
|
+
const reviewPrompt = buildReviewPrompt({
|
|
363
|
+
issueNum,
|
|
364
|
+
title,
|
|
365
|
+
body,
|
|
366
|
+
baseBranch: config.baseBranch,
|
|
367
|
+
visionContext: loadFileIfExists(join(projectDir, '.alpha-loop', 'vision.md')) ?? undefined,
|
|
368
|
+
});
|
|
369
|
+
const reviewResult = await spawnAgent({
|
|
370
|
+
agent: config.agent,
|
|
371
|
+
model: config.reviewModel,
|
|
372
|
+
prompt: reviewPrompt,
|
|
373
|
+
cwd: worktreePath,
|
|
374
|
+
logFile: join(session.logsDir, `issue-${issueNum}-review${attempt > 1 ? `-${attempt}` : ''}.log`),
|
|
375
|
+
verbose: config.verbose,
|
|
376
|
+
});
|
|
377
|
+
reviewOutput = reviewResult.output;
|
|
195
378
|
}
|
|
196
|
-
|
|
197
|
-
log.warn(
|
|
198
|
-
|
|
379
|
+
catch {
|
|
380
|
+
log.warn('Code review failed, continuing without review');
|
|
381
|
+
reviewOutput = 'Code review could not be completed';
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
// Read the gate JSON
|
|
385
|
+
reviewGate = readGateResult(reviewFileInWorktree);
|
|
386
|
+
moveToSessionLogs(reviewFileInWorktree, reviewFileInSession);
|
|
387
|
+
if (reviewGate.passed) {
|
|
388
|
+
log.success(`Review passed: ${reviewGate.summary || 'no issues found'}`);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
// Review found unfixed issues — loop back to implementer
|
|
392
|
+
const unfixedCount = reviewGate.findings.filter((f) => !f.fixed).length;
|
|
393
|
+
log.warn(`Review found ${unfixedCount} unfixed issue(s), sending back to implementer...`);
|
|
394
|
+
if (attempt < config.maxTestRetries) {
|
|
395
|
+
const findings = formatGateFindings(reviewGate, 'Code Review');
|
|
396
|
+
const fixPrompt = `The code review for issue #${issueNum} found problems that need to be fixed.\n\n${findings}\n\nInstructions:\n1. Address each finding listed above\n2. Run tests to make sure nothing is broken\n3. Commit your fixes with: git commit -m "fix(#${issueNum}): address review findings"`;
|
|
199
397
|
await spawnAgent({
|
|
200
|
-
agent:
|
|
398
|
+
agent: config.agent,
|
|
201
399
|
model: config.model,
|
|
202
|
-
prompt:
|
|
400
|
+
prompt: fixPrompt,
|
|
203
401
|
cwd: worktreePath,
|
|
204
|
-
|
|
402
|
+
resume: true,
|
|
403
|
+
logFile: join(session.logsDir, `issue-${issueNum}-review-fix-${attempt}.log`),
|
|
205
404
|
verbose: config.verbose,
|
|
206
405
|
});
|
|
406
|
+
// Auto-commit if agent didn't
|
|
407
|
+
const fixStatus = exec('git status --porcelain', { cwd: worktreePath });
|
|
408
|
+
if (fixStatus.stdout.trim()) {
|
|
409
|
+
exec('git add -A', { cwd: worktreePath });
|
|
410
|
+
exec(`git commit -m "fix(#${issueNum}): address review findings (attempt ${attempt})"`, { cwd: worktreePath });
|
|
411
|
+
}
|
|
412
|
+
// Re-run tests before next review attempt
|
|
413
|
+
const retest = runTests(worktreePath, config, logFile);
|
|
414
|
+
if (!retest.passed) {
|
|
415
|
+
log.warn('Tests failed after review fixes — will be caught in final status');
|
|
416
|
+
testOutput = retest.output;
|
|
417
|
+
testsPassing = false;
|
|
418
|
+
}
|
|
207
419
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (fixStatus.stdout.trim()) {
|
|
211
|
-
exec('git add -A', { cwd: worktreePath });
|
|
212
|
-
exec(`git commit -m "fix(#${issueNum}): resolve verification failures (attempt ${attempt})"`, { cwd: worktreePath });
|
|
420
|
+
else {
|
|
421
|
+
log.warn(`Review still failing after ${config.maxTestRetries} attempts`);
|
|
213
422
|
}
|
|
214
423
|
}
|
|
215
|
-
else {
|
|
216
|
-
log.warn(`Verification still failing after ${config.maxTestRetries} attempts`);
|
|
217
|
-
}
|
|
218
424
|
}
|
|
219
|
-
// --- Step 7:
|
|
220
|
-
log.step('Step 7:
|
|
221
|
-
let
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
425
|
+
// --- Step 7: Verify gate (JSON-based) ---
|
|
426
|
+
log.step('Step 7: Live verification');
|
|
427
|
+
let verifyOutput = '';
|
|
428
|
+
let verifyPassing = false;
|
|
429
|
+
let verifySkipped = false;
|
|
430
|
+
if (!plan.verification.needed) {
|
|
431
|
+
log.info(`Verification skipped by plan: ${plan.verification.reason}`);
|
|
432
|
+
verifyPassing = true;
|
|
433
|
+
verifySkipped = true;
|
|
434
|
+
verifyOutput = `Verification skipped by plan: ${plan.verification.reason}`;
|
|
227
435
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
436
|
+
if (!verifySkipped && !config.dryRun) {
|
|
437
|
+
const verifyFileInWorktree = join(worktreePath, `verify-issue-${issueNum}.json`);
|
|
438
|
+
const verifyFileInSession = join(session.logsDir, `verify-issue-${issueNum}.json`);
|
|
439
|
+
for (let attempt = 1; attempt <= config.maxTestRetries; attempt++) {
|
|
440
|
+
log.info(`Verification attempt ${attempt} of ${config.maxTestRetries}`);
|
|
441
|
+
const verifyResult = await runVerify({
|
|
442
|
+
worktree: worktreePath,
|
|
443
|
+
logFile,
|
|
231
444
|
issueNum,
|
|
232
445
|
title,
|
|
233
446
|
body,
|
|
234
|
-
|
|
235
|
-
|
|
447
|
+
config,
|
|
448
|
+
sessionDir: session.resultsDir,
|
|
449
|
+
verifyInstructions: plan.verification.instructions,
|
|
236
450
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
451
|
+
verifyOutput = verifyResult.output;
|
|
452
|
+
if (verifyResult.skipped) {
|
|
453
|
+
verifyPassing = true;
|
|
454
|
+
verifySkipped = true;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
// Read verify gate JSON (if the verify agent wrote one)
|
|
458
|
+
const verifyGate = readGateResult(verifyFileInWorktree);
|
|
459
|
+
moveToSessionLogs(verifyFileInWorktree, verifyFileInSession);
|
|
460
|
+
// Use gate JSON if available, otherwise fall back to runVerify's pass/fail
|
|
461
|
+
const passed = verifyGate !== DEFAULT_GATE ? verifyGate.passed : verifyResult.passed;
|
|
462
|
+
if (passed) {
|
|
463
|
+
verifyPassing = true;
|
|
464
|
+
log.success(`Verification passed on attempt ${attempt}`);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
if (attempt < config.maxTestRetries) {
|
|
468
|
+
const timedOut = verifyOutput.includes('[TIMEOUT]');
|
|
469
|
+
if (timedOut) {
|
|
470
|
+
log.warn(`Verification timed out on attempt ${attempt}, retrying...`);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
log.warn(`Verification failed on attempt ${attempt}, sending back to implementer...`);
|
|
474
|
+
// Use gate findings if available, otherwise use raw verify output
|
|
475
|
+
const findings = verifyGate !== DEFAULT_GATE
|
|
476
|
+
? formatGateFindings(verifyGate, 'Verification')
|
|
477
|
+
: `## Verification Findings (MUST FIX)\n\n${verifyOutput}`;
|
|
478
|
+
const fixPrompt = `Live verification failed for issue #${issueNum} (attempt ${attempt} of ${config.maxTestRetries}).\n\n${findings}\n\nInstructions:\n1. Read the verification findings and identify the ROOT CAUSE\n2. Fix ONLY code related to issue #${issueNum}\n3. Run tests to make sure nothing is broken\n4. Commit your fixes with: git commit -m "fix(#${issueNum}): address verification findings"`;
|
|
479
|
+
await spawnAgent({
|
|
480
|
+
agent: config.agent,
|
|
481
|
+
model: config.model,
|
|
482
|
+
prompt: fixPrompt,
|
|
483
|
+
cwd: worktreePath,
|
|
484
|
+
resume: true,
|
|
485
|
+
logFile: join(session.logsDir, `issue-${issueNum}-verify-fix-${attempt}.log`),
|
|
486
|
+
verbose: config.verbose,
|
|
487
|
+
});
|
|
488
|
+
// Auto-commit if agent didn't
|
|
489
|
+
const fixStatus = exec('git status --porcelain', { cwd: worktreePath });
|
|
490
|
+
if (fixStatus.stdout.trim()) {
|
|
491
|
+
exec('git add -A', { cwd: worktreePath });
|
|
492
|
+
exec(`git commit -m "fix(#${issueNum}): address verification findings (attempt ${attempt})"`, { cwd: worktreePath });
|
|
493
|
+
}
|
|
494
|
+
// Re-run tests before next verify attempt
|
|
495
|
+
const retest = runTests(worktreePath, config, logFile);
|
|
496
|
+
if (!retest.passed) {
|
|
497
|
+
log.warn('Tests failed after verify fixes');
|
|
498
|
+
testOutput = retest.output;
|
|
499
|
+
testsPassing = false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
log.warn(`Verification still failing after ${config.maxTestRetries} attempts`);
|
|
505
|
+
}
|
|
250
506
|
}
|
|
251
507
|
}
|
|
508
|
+
else if (config.dryRun && !verifySkipped) {
|
|
509
|
+
log.dry('Would run live verification');
|
|
510
|
+
verifyPassing = true;
|
|
511
|
+
verifySkipped = true;
|
|
512
|
+
}
|
|
252
513
|
// --- Step 8: Create PR ---
|
|
253
514
|
log.step('Step 8: Creating PR');
|
|
254
515
|
let prUrl;
|
|
255
516
|
if (!config.dryRun) {
|
|
256
517
|
const prBase = config.autoMerge ? session.branch : config.baseBranch;
|
|
257
|
-
const prBody = buildPRBody(issueNum, title,
|
|
518
|
+
const prBody = buildPRBody(issueNum, title, reviewGate, testOutput, testsPassing, verifyPassing, verifySkipped, body);
|
|
258
519
|
try {
|
|
259
520
|
prUrl = createPR({
|
|
260
521
|
repo: config.repo,
|
|
@@ -286,15 +547,19 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
286
547
|
const diffResult = exec(`git diff "origin/${config.baseBranch}...HEAD"`, { cwd: worktreePath });
|
|
287
548
|
runDiff = diffResult.stdout.slice(0, MAX_DIFF_CHARS);
|
|
288
549
|
}
|
|
550
|
+
// Format review gate for learnings
|
|
551
|
+
const reviewForLearnings = reviewGate.findings.length > 0
|
|
552
|
+
? `Review: ${reviewGate.summary}\n${reviewGate.findings.map((f) => `- [${f.severity}] ${f.description} (${f.fixed ? 'fixed' : 'unfixed'})`).join('\n')}`
|
|
553
|
+
: `Review: ${reviewGate.summary || 'passed'}`;
|
|
289
554
|
await extractLearnings({
|
|
290
555
|
issueNum,
|
|
291
556
|
title,
|
|
292
557
|
status: testsPassing ? 'success' : 'failure',
|
|
293
|
-
retries:
|
|
558
|
+
retries: testRetries,
|
|
294
559
|
duration,
|
|
295
560
|
diff: runDiff,
|
|
296
561
|
testOutput,
|
|
297
|
-
reviewOutput,
|
|
562
|
+
reviewOutput: reviewForLearnings,
|
|
298
563
|
verifyOutput,
|
|
299
564
|
body,
|
|
300
565
|
config,
|
|
@@ -303,7 +568,7 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
303
568
|
log.step('Step 10: Updating issue status');
|
|
304
569
|
if (!config.dryRun) {
|
|
305
570
|
const testsStatus = testsPassing ? 'PASSING' : 'FAILING';
|
|
306
|
-
updateProjectStatus(config.repo, config.project, config.repoOwner, issueNum, '
|
|
571
|
+
updateProjectStatus(config.repo, config.project, config.repoOwner, issueNum, 'In Review');
|
|
307
572
|
labelIssue(config.repo, issueNum, 'in-review', 'in-progress');
|
|
308
573
|
commentIssue(config.repo, issueNum, `Automated implementation complete.\n\n**PR**: ${prUrl ?? 'N/A'}\n**Tests**: ${testsStatus}\n**Review**: Attached to PR body.\n\n---\n*Processed by alpha-loop in ${duration}s*`);
|
|
309
574
|
}
|
|
@@ -350,6 +615,7 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
350
615
|
prUrl,
|
|
351
616
|
testsPassing,
|
|
352
617
|
verifyPassing,
|
|
618
|
+
verifySkipped,
|
|
353
619
|
duration,
|
|
354
620
|
filesChanged,
|
|
355
621
|
};
|
|
@@ -360,17 +626,30 @@ export async function processIssue(issueNum, title, body, config, session) {
|
|
|
360
626
|
log.info(`PR: ${prUrl}`);
|
|
361
627
|
return result;
|
|
362
628
|
}
|
|
363
|
-
function failureResult(issueNum, title, startTime) {
|
|
629
|
+
function failureResult(issueNum, title, startTime, reason) {
|
|
364
630
|
return {
|
|
365
631
|
issueNum,
|
|
366
632
|
title,
|
|
367
633
|
status: 'failure',
|
|
634
|
+
failureReason: reason,
|
|
368
635
|
testsPassing: false,
|
|
369
636
|
verifyPassing: false,
|
|
637
|
+
verifySkipped: false,
|
|
370
638
|
duration: Math.round((Date.now() - startTime) / 1000),
|
|
371
639
|
filesChanged: 0,
|
|
372
640
|
};
|
|
373
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Re-queue an issue back to ready state after a transient failure.
|
|
644
|
+
* Restores the label to ready and project status to Todo.
|
|
645
|
+
*/
|
|
646
|
+
function requeueIssue(config, issueNum) {
|
|
647
|
+
if (config.dryRun)
|
|
648
|
+
return;
|
|
649
|
+
labelIssue(config.repo, issueNum, config.labelReady, 'in-progress');
|
|
650
|
+
updateProjectStatus(config.repo, config.project, config.repoOwner, issueNum, 'Todo');
|
|
651
|
+
log.info(`Issue #${issueNum} re-queued for next run`);
|
|
652
|
+
}
|
|
374
653
|
function loadFileIfExists(filePath) {
|
|
375
654
|
if (!existsSync(filePath))
|
|
376
655
|
return null;
|
|
@@ -381,57 +660,57 @@ function loadFileIfExists(filePath) {
|
|
|
381
660
|
return null;
|
|
382
661
|
}
|
|
383
662
|
}
|
|
384
|
-
/**
|
|
385
|
-
* Extract just the review summary from the full agent output.
|
|
386
|
-
* Looks for the structured report section the reviewer agent produces.
|
|
387
|
-
*/
|
|
388
|
-
function extractReviewSummary(reviewOutput) {
|
|
389
|
-
if (!reviewOutput)
|
|
390
|
-
return 'No review available';
|
|
391
|
-
// Look for the structured review report (reviewer agent outputs this format)
|
|
392
|
-
const patterns = [
|
|
393
|
-
/### Review Summary[\s\S]*$/m,
|
|
394
|
-
/### Findings Fixed[\s\S]*$/m,
|
|
395
|
-
/## Review Report[\s\S]*$/m,
|
|
396
|
-
/\*\*Verdict:.*$/m,
|
|
397
|
-
];
|
|
398
|
-
for (const pattern of patterns) {
|
|
399
|
-
const match = reviewOutput.match(pattern);
|
|
400
|
-
if (match)
|
|
401
|
-
return match[0].trim();
|
|
402
|
-
}
|
|
403
|
-
// Fallback: take the last 500 chars which usually has the summary
|
|
404
|
-
const lines = reviewOutput.trim().split('\n');
|
|
405
|
-
const lastLines = lines.slice(-20).join('\n');
|
|
406
|
-
if (lastLines.length > 0)
|
|
407
|
-
return lastLines;
|
|
408
|
-
return 'Review completed — see logs for details';
|
|
409
|
-
}
|
|
410
663
|
/**
|
|
411
664
|
* Extract a one-line test summary from raw test output.
|
|
412
|
-
*
|
|
665
|
+
* Aggregates results across multiple test runners (pytest, Jest, Vitest).
|
|
666
|
+
* Handles concurrent output like: [pytest] 189 passed, [frontend] Tests 6 passed, etc.
|
|
413
667
|
*/
|
|
414
668
|
function extractTestSummary(testOutput) {
|
|
415
669
|
if (!testOutput)
|
|
416
670
|
return '';
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
671
|
+
let totalPassed = 0;
|
|
672
|
+
let totalFailed = 0;
|
|
673
|
+
let totalSkipped = 0;
|
|
674
|
+
// Pytest summary line: "189 passed, 1 skipped in 7.05s" or "5 failed, 184 passed"
|
|
675
|
+
// Match the "=== ... ===" summary line format
|
|
676
|
+
for (const match of testOutput.matchAll(/=+\s*(.*?)\s*=+/g)) {
|
|
677
|
+
const line = match[1];
|
|
678
|
+
const passed = line.match(/(\d+) passed/);
|
|
679
|
+
const failed = line.match(/(\d+) failed/);
|
|
680
|
+
const skipped = line.match(/(\d+) skipped/);
|
|
681
|
+
if (passed)
|
|
682
|
+
totalPassed += parseInt(passed[1], 10);
|
|
683
|
+
if (failed)
|
|
684
|
+
totalFailed += parseInt(failed[1], 10);
|
|
685
|
+
if (skipped)
|
|
686
|
+
totalSkipped += parseInt(skipped[1], 10);
|
|
687
|
+
}
|
|
688
|
+
// Jest summary: "Tests: 30 passed, 30 total" or "Tests: 2 failed, 28 passed, 30 total"
|
|
689
|
+
for (const match of testOutput.matchAll(/Tests:\s+(?:(\d+) failed,\s+)?(\d+) passed/g)) {
|
|
690
|
+
if (match[1])
|
|
691
|
+
totalFailed += parseInt(match[1], 10);
|
|
692
|
+
totalPassed += parseInt(match[2], 10);
|
|
693
|
+
}
|
|
694
|
+
// Vitest summary: "Tests 6 passed (6)" — uses spaces not colon, has parens
|
|
695
|
+
for (const match of testOutput.matchAll(/Tests\s+(?:(\d+) failed\s+)?(\d+) passed\s+\(\d+\)/g)) {
|
|
696
|
+
if (match[1])
|
|
697
|
+
totalFailed += parseInt(match[1], 10);
|
|
698
|
+
totalPassed += parseInt(match[2], 10);
|
|
699
|
+
}
|
|
700
|
+
if (totalPassed === 0 && totalFailed === 0)
|
|
701
|
+
return '';
|
|
702
|
+
const parts = [];
|
|
703
|
+
parts.push(`${totalPassed} passed`);
|
|
704
|
+
if (totalFailed > 0)
|
|
705
|
+
parts.push(`${totalFailed} failed`);
|
|
706
|
+
if (totalSkipped > 0)
|
|
707
|
+
parts.push(`${totalSkipped} skipped`);
|
|
708
|
+
return parts.join(', ');
|
|
431
709
|
}
|
|
432
|
-
function buildPRBody(issueNum, title,
|
|
710
|
+
function buildPRBody(issueNum, title, reviewGate, testOutput, testsPassing, verifyPassing, verifySkipped, body) {
|
|
433
711
|
const testSummary = extractTestSummary(testOutput);
|
|
434
|
-
const
|
|
712
|
+
const verifyStatus = verifySkipped ? 'SKIPPED' : verifyPassing ? 'PASS' : 'FAIL';
|
|
713
|
+
const reviewStatus = reviewGate.passed ? 'PASS' : 'FAIL';
|
|
435
714
|
const lines = [
|
|
436
715
|
`Closes #${issueNum}`,
|
|
437
716
|
'',
|
|
@@ -444,14 +723,28 @@ function buildPRBody(issueNum, title, reviewOutput, testOutput, testsPassing, ve
|
|
|
444
723
|
`| Check | Status |`,
|
|
445
724
|
`|-------|--------|`,
|
|
446
725
|
`| Unit tests | ${testsPassing ? 'PASS' : 'FAIL'} |`,
|
|
447
|
-
`|
|
|
726
|
+
`| Code review | ${reviewStatus} |`,
|
|
727
|
+
`| Verification | ${verifyStatus} |`,
|
|
448
728
|
];
|
|
449
729
|
if (testSummary) {
|
|
450
730
|
lines.push(`| Details | ${testSummary} |`);
|
|
451
731
|
}
|
|
452
732
|
lines.push('');
|
|
453
|
-
// Code review —
|
|
454
|
-
|
|
733
|
+
// Code review — structured from gate result
|
|
734
|
+
if (reviewGate.findings.length > 0) {
|
|
735
|
+
lines.push('## Code Review', '');
|
|
736
|
+
lines.push(reviewGate.summary || 'Review completed');
|
|
737
|
+
lines.push('');
|
|
738
|
+
for (const f of reviewGate.findings) {
|
|
739
|
+
const status = f.fixed ? 'FIXED' : 'OPEN';
|
|
740
|
+
const fileRef = f.file ? ` \`${f.file}\`` : '';
|
|
741
|
+
lines.push(`- **${f.severity.toUpperCase()}** [${status}]${fileRef}: ${f.description}`);
|
|
742
|
+
}
|
|
743
|
+
lines.push('');
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
lines.push('## Code Review', '', reviewGate.summary || 'No issues found', '');
|
|
747
|
+
}
|
|
455
748
|
// What to test — from issue body or generic
|
|
456
749
|
const whatToTestMatch = body.match(/## Test Requirements[\s\S]*?(?=\n## |$)/);
|
|
457
750
|
if (whatToTestMatch) {
|