@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.
Files changed (54) hide show
  1. package/README.md +60 -19
  2. package/dist/cli.js +4 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/auth.js +1 -1
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/init.d.ts +14 -0
  7. package/dist/commands/init.js +199 -30
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/resume.js +1 -0
  10. package/dist/commands/resume.js.map +1 -1
  11. package/dist/commands/run.js +31 -12
  12. package/dist/commands/run.js.map +1 -1
  13. package/dist/commands/scan.d.ts +1 -1
  14. package/dist/commands/scan.js +12 -9
  15. package/dist/commands/scan.js.map +1 -1
  16. package/dist/commands/sync.d.ts +5 -0
  17. package/dist/commands/sync.js +24 -5
  18. package/dist/commands/sync.js.map +1 -1
  19. package/dist/commands/vision.js +5 -3
  20. package/dist/commands/vision.js.map +1 -1
  21. package/dist/engine/agents.d.ts +6 -1
  22. package/dist/engine/agents.js +14 -12
  23. package/dist/engine/agents.js.map +1 -1
  24. package/dist/engine/prerequisites.d.ts +4 -7
  25. package/dist/engine/prerequisites.js +12 -36
  26. package/dist/engine/prerequisites.js.map +1 -1
  27. package/dist/lib/agent.d.ts +10 -0
  28. package/dist/lib/agent.js +184 -28
  29. package/dist/lib/agent.js.map +1 -1
  30. package/dist/lib/config.d.ts +3 -2
  31. package/dist/lib/config.js +17 -7
  32. package/dist/lib/config.js.map +1 -1
  33. package/dist/lib/learning.js +2 -2
  34. package/dist/lib/learning.js.map +1 -1
  35. package/dist/lib/pipeline.d.ts +35 -0
  36. package/dist/lib/pipeline.js +424 -131
  37. package/dist/lib/pipeline.js.map +1 -1
  38. package/dist/lib/prompts.d.ts +1 -0
  39. package/dist/lib/prompts.js +8 -5
  40. package/dist/lib/prompts.js.map +1 -1
  41. package/dist/lib/session.js +54 -19
  42. package/dist/lib/session.js.map +1 -1
  43. package/dist/lib/verify.d.ts +7 -1
  44. package/dist/lib/verify.js +109 -157
  45. package/dist/lib/verify.js.map +1 -1
  46. package/dist/lib/worktree.d.ts +1 -0
  47. package/dist/lib/worktree.js +9 -1
  48. package/dist/lib/worktree.js.map +1 -1
  49. package/package.json +1 -1
  50. package/templates/agents/implementer.md +1 -1
  51. package/templates/agents/reviewer.md +1 -1
  52. package/dist/engine/config.d.ts +0 -71
  53. package/dist/engine/config.js +0 -73
  54. package/dist/engine/config.js.map +0 -1
@@ -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 (optional, non-fatal) ---
180
+ // --- Step 3: Plan (structured JSON — controls test/verify steps) ---
66
181
  log.step('Step 3: Planning');
67
- let implBody = body;
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: 'claude',
220
+ agent: config.agent,
72
221
  model: config.model,
73
- prompt: `Analyze this GitHub issue and enrich it with implementation details.\n\nIssue #${issueNum}: ${title}\n\n${body}\n\nOutput the enriched issue body with acceptance criteria, implementation notes, and any edge cases to handle.`,
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
- if (planResult.exitCode === 0 && planResult.output.trim()) {
79
- implBody = body + '\n\n## Agent Planning Notes\n\n' + planResult.output.trim();
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, proceeding with original issue description');
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: implBody,
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: 'claude',
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
- for (let attempt = 1; attempt <= config.maxTestRetries; attempt++) {
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 the implementation code or the tests\n3. Run the tests again to verify\n4. 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"`;
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: 'claude',
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: Live verification with playwright-cli ---
170
- log.step('Step 6: Live verification');
171
- let verifyOutput = '';
172
- let verifyPassing = false;
173
- for (let attempt = 1; attempt <= config.maxTestRetries; attempt++) {
174
- log.info(`Verification attempt ${attempt} of ${config.maxTestRetries}`);
175
- const verifyResult = await runVerify({
176
- worktree: worktreePath,
177
- logFile,
178
- issueNum,
179
- title,
180
- body,
181
- config,
182
- sessionDir: session.resultsDir,
183
- });
184
- verifyOutput = verifyResult.output;
185
- if (verifyResult.passed) {
186
- verifyPassing = true;
187
- log.success(`Verification passed on attempt ${attempt}`);
188
- break;
189
- }
190
- if (attempt < config.maxTestRetries) {
191
- // If the agent timed out, retrying with a fix agent won't help — just retry verification
192
- const timedOut = verifyOutput.includes('[TIMEOUT]');
193
- if (timedOut) {
194
- log.warn(`Verification timed out on attempt ${attempt}, retrying without fix agent...`);
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
- else {
197
- log.warn(`Verification failed on attempt ${attempt}, invoking agent to fix...`);
198
- const verifyFixPrompt = `Build verification failed after implementing issue #${issueNum} (attempt ${attempt} of ${config.maxTestRetries}).\nThe app was started and tested with playwright-cli, but verification failed.\n\nVerification output:\n${verifyOutput}\n\nInstructions:\n1. Read the verification output above and identify the ROOT CAUSE of each failure\n2. Fix the implementation code so the feature works correctly\n3. Run the test command to make sure unit tests still pass\n4. Commit your fixes with a DESCRIPTIVE message that explains WHAT you fixed and WHY it failed.\n Format: fix(#${issueNum}): <what you changed> — <why verification failed>\n Example: fix(#${issueNum}): add ENCRYPTION_KEY to langfuse config — service requires 32+ char secret\n DO NOT use generic messages like "fix: resolve verification failures"`;
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: 'claude',
398
+ agent: config.agent,
201
399
  model: config.model,
202
- prompt: verifyFixPrompt,
400
+ prompt: fixPrompt,
203
401
  cwd: worktreePath,
204
- logFile: join(session.logsDir, `issue-${issueNum}-verify-fix-${attempt}.log`),
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
- // Auto-commit fixes
209
- const fixStatus = exec('git status --porcelain', { cwd: worktreePath });
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: Review ---
220
- log.step('Step 7: Code review');
221
- let reviewOutput = '';
222
- if (config.skipReview) {
223
- log.info('Code review skipped');
224
- }
225
- else if (config.dryRun) {
226
- log.dry('Would run code review');
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
- else {
229
- try {
230
- const reviewPrompt = buildReviewPrompt({
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
- baseBranch: config.baseBranch,
235
- visionContext: loadFileIfExists(join(projectDir, '.alpha-loop', 'vision.md')) ?? undefined,
447
+ config,
448
+ sessionDir: session.resultsDir,
449
+ verifyInstructions: plan.verification.instructions,
236
450
  });
237
- const reviewResult = await spawnAgent({
238
- agent: 'claude',
239
- model: config.reviewModel,
240
- prompt: reviewPrompt,
241
- cwd: worktreePath,
242
- logFile: join(session.logsDir, `issue-${issueNum}-review.log`),
243
- verbose: config.verbose,
244
- });
245
- reviewOutput = reviewResult.output;
246
- }
247
- catch {
248
- log.warn('Code review failed, continuing without review');
249
- reviewOutput = 'Code review could not be completed';
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, reviewOutput, testOutput, testsPassing, verifyPassing, body);
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: config.maxTestRetries,
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, 'Done');
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
- * e.g., "30 passed, 0 failed" from Jest/Vitest output.
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
- // Jest: "Tests: 30 passed, 30 total"
418
- const jestMatch = testOutput.match(/Tests:\s+(.+total)/);
419
- if (jestMatch)
420
- return jestMatch[1].trim();
421
- // Vitest: "Tests 30 passed (30)"
422
- const vitestMatch = testOutput.match(/Tests\s+(.+\(\d+\))/);
423
- if (vitestMatch)
424
- return vitestMatch[1].trim();
425
- // Fallback: count "passed" and "failed" lines
426
- const passed = (testOutput.match(/passed/gi) || []).length;
427
- const failed = (testOutput.match(/failed/gi) || []).length;
428
- if (passed > 0 || failed > 0)
429
- return `${passed} passed, ${failed} failed`;
430
- return '';
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, reviewOutput, testOutput, testsPassing, verifyPassing, body) {
710
+ function buildPRBody(issueNum, title, reviewGate, testOutput, testsPassing, verifyPassing, verifySkipped, body) {
433
711
  const testSummary = extractTestSummary(testOutput);
434
- const reviewSummary = extractReviewSummary(reviewOutput);
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
- `| Verification | ${verifyPassing ? 'PASS' : 'FAIL'} |`,
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 — just the summary, not the full agent output
454
- lines.push('## Code Review', '', reviewSummary, '');
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) {