agileflow 2.80.0 → 2.81.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 +6 -6
- package/package.json +1 -1
- package/scripts/agent-loop.js +765 -0
- package/scripts/agileflow-configure.js +3 -1
- package/scripts/agileflow-welcome.js +65 -0
- package/scripts/damage-control-bash.js +22 -115
- package/scripts/damage-control-edit.js +19 -156
- package/scripts/damage-control-write.js +19 -156
- package/scripts/lib/damage-control-utils.js +251 -0
- package/scripts/obtain-context.js +57 -2
- package/scripts/ralph-loop.js +230 -26
- package/scripts/session-manager.js +434 -20
- package/src/core/agents/configuration-visual-e2e.md +300 -0
- package/src/core/agents/orchestrator.md +166 -0
- package/src/core/commands/babysit.md +61 -15
- package/src/core/commands/configure.md +372 -100
- package/src/core/commands/session/end.md +332 -103
- package/src/core/commands/setup/visual-e2e.md +0 -462
package/scripts/ralph-loop.js
CHANGED
|
@@ -36,6 +36,7 @@ const { execSync, spawnSync } = require('child_process');
|
|
|
36
36
|
const { c } = require('../lib/colors');
|
|
37
37
|
const { getProjectRoot } = require('../lib/paths');
|
|
38
38
|
const { safeReadJSON, safeWriteJSON } = require('../lib/errors');
|
|
39
|
+
const { isValidEpicId, parseIntBounded } = require('../lib/validate');
|
|
39
40
|
|
|
40
41
|
// Read session state
|
|
41
42
|
function getSessionState(rootDir) {
|
|
@@ -103,6 +104,101 @@ function runTests(rootDir, testCommand) {
|
|
|
103
104
|
return result;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
// Get coverage command from metadata or default
|
|
108
|
+
function getCoverageCommand(rootDir) {
|
|
109
|
+
const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
|
|
110
|
+
const result = safeReadJSON(metadataPath, { defaultValue: {} });
|
|
111
|
+
|
|
112
|
+
if (result.ok && result.data?.ralph_loop?.coverage_command) {
|
|
113
|
+
return result.data.ralph_loop.coverage_command;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Default: try common coverage commands
|
|
117
|
+
return 'npm run test:coverage || npm test -- --coverage';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get coverage report path from metadata or default
|
|
121
|
+
function getCoverageReportPath(rootDir) {
|
|
122
|
+
const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
|
|
123
|
+
const result = safeReadJSON(metadataPath, { defaultValue: {} });
|
|
124
|
+
|
|
125
|
+
if (result.ok && result.data?.ralph_loop?.coverage_report_path) {
|
|
126
|
+
return result.data.ralph_loop.coverage_report_path;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return 'coverage/coverage-summary.json';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Parse coverage report (Jest/NYC format)
|
|
133
|
+
function parseCoverageReport(rootDir) {
|
|
134
|
+
const reportPath = getCoverageReportPath(rootDir);
|
|
135
|
+
const fullPath = path.join(rootDir, reportPath);
|
|
136
|
+
const report = safeReadJSON(fullPath, { defaultValue: null });
|
|
137
|
+
|
|
138
|
+
if (!report.ok || !report.data) {
|
|
139
|
+
return { passed: false, coverage: 0, error: 'Coverage report not found at ' + reportPath };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Jest/NYC format: { total: { lines: { pct: 80 }, statements: { pct: 80 } } }
|
|
143
|
+
const total = report.data.total;
|
|
144
|
+
if (!total) {
|
|
145
|
+
return { passed: false, coverage: 0, error: 'Invalid coverage report format' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const coverage = total.lines?.pct || total.statements?.pct || 0;
|
|
149
|
+
|
|
150
|
+
return { passed: true, coverage, raw: report.data };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Verify coverage meets threshold
|
|
154
|
+
function verifyCoverage(rootDir, threshold) {
|
|
155
|
+
const result = parseCoverageReport(rootDir);
|
|
156
|
+
|
|
157
|
+
if (!result.passed) {
|
|
158
|
+
return {
|
|
159
|
+
passed: false,
|
|
160
|
+
coverage: 0,
|
|
161
|
+
message: `${c.red}✗ ${result.error}${c.reset}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const met = result.coverage >= threshold;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
passed: met,
|
|
169
|
+
coverage: result.coverage,
|
|
170
|
+
threshold: threshold,
|
|
171
|
+
message: met
|
|
172
|
+
? `${c.green}✓ Coverage ${result.coverage.toFixed(1)}% ≥ ${threshold}%${c.reset}`
|
|
173
|
+
: `${c.yellow}⏳ Coverage ${result.coverage.toFixed(1)}% < ${threshold}% (need ${(threshold - result.coverage).toFixed(1)}% more)${c.reset}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Run coverage command
|
|
178
|
+
function runCoverage(rootDir) {
|
|
179
|
+
const coverageCmd = getCoverageCommand(rootDir);
|
|
180
|
+
const result = { passed: false, output: '', duration: 0 };
|
|
181
|
+
const startTime = Date.now();
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const output = execSync(coverageCmd, {
|
|
185
|
+
cwd: rootDir,
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
188
|
+
timeout: 300000, // 5 minute timeout
|
|
189
|
+
});
|
|
190
|
+
result.passed = true;
|
|
191
|
+
result.output = output;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
// Coverage command might fail but still generate report
|
|
194
|
+
result.passed = true; // We'll check the report
|
|
195
|
+
result.output = e.stdout || '' + '\n' + (e.stderr || '');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
result.duration = Date.now() - startTime;
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
106
202
|
// Get screenshots directory from metadata or default
|
|
107
203
|
function getScreenshotsDir(rootDir) {
|
|
108
204
|
try {
|
|
@@ -253,13 +349,18 @@ function handleLoop(rootDir) {
|
|
|
253
349
|
const iteration = (loop.iteration || 0) + 1;
|
|
254
350
|
const maxIterations = loop.max_iterations || 20;
|
|
255
351
|
const visualMode = loop.visual_mode || false;
|
|
256
|
-
const
|
|
352
|
+
const coverageMode = loop.coverage_mode || false;
|
|
353
|
+
const coverageThreshold = loop.coverage_threshold || 80;
|
|
354
|
+
// Visual and Coverage modes require at least 2 iterations for confirmation
|
|
355
|
+
const minIterations = visualMode || coverageMode ? 2 : 1;
|
|
257
356
|
|
|
258
357
|
console.log('');
|
|
259
358
|
console.log(
|
|
260
359
|
`${c.brand}${c.bold}══════════════════════════════════════════════════════════${c.reset}`
|
|
261
360
|
);
|
|
262
|
-
|
|
361
|
+
let modeLabel = '';
|
|
362
|
+
if (visualMode) modeLabel += ' [VISUAL]';
|
|
363
|
+
if (coverageMode) modeLabel += ` [COVERAGE ≥${coverageThreshold}%]`;
|
|
263
364
|
console.log(
|
|
264
365
|
`${c.brand}${c.bold} RALPH LOOP - Iteration ${iteration}/${maxIterations}${modeLabel}${c.reset}`
|
|
265
366
|
);
|
|
@@ -333,11 +434,34 @@ function handleLoop(rootDir) {
|
|
|
333
434
|
}
|
|
334
435
|
}
|
|
335
436
|
|
|
336
|
-
//
|
|
337
|
-
|
|
437
|
+
// Coverage Mode: Run coverage and verify threshold
|
|
438
|
+
let coverageResult = { passed: true };
|
|
439
|
+
if (coverageMode) {
|
|
440
|
+
console.log('');
|
|
441
|
+
console.log(`${c.blue}Running coverage check...${c.reset}`);
|
|
442
|
+
runCoverage(rootDir);
|
|
443
|
+
coverageResult = verifyCoverage(rootDir, coverageThreshold);
|
|
444
|
+
console.log(coverageResult.message);
|
|
445
|
+
|
|
446
|
+
// Update state with current coverage
|
|
447
|
+
state.ralph_loop.coverage_current = coverageResult.coverage;
|
|
448
|
+
|
|
449
|
+
if (coverageResult.passed) {
|
|
450
|
+
state.ralph_loop.coverage_verified = true;
|
|
451
|
+
} else {
|
|
452
|
+
state.ralph_loop.coverage_verified = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Enforce minimum iterations for Visual and Coverage modes
|
|
457
|
+
if ((visualMode || coverageMode) && iteration < minIterations) {
|
|
458
|
+
const modeNames = [];
|
|
459
|
+
if (visualMode) modeNames.push('Visual');
|
|
460
|
+
if (coverageMode) modeNames.push('Coverage');
|
|
461
|
+
|
|
338
462
|
console.log('');
|
|
339
463
|
console.log(
|
|
340
|
-
`${c.yellow}⚠
|
|
464
|
+
`${c.yellow}⚠ ${modeNames.join(' + ')} Mode requires ${minIterations}+ iterations for confirmation${c.reset}`
|
|
341
465
|
);
|
|
342
466
|
console.log(
|
|
343
467
|
`${c.dim}Current: iteration ${iteration}. Let loop run once more to confirm.${c.reset}`
|
|
@@ -347,23 +471,34 @@ function handleLoop(rootDir) {
|
|
|
347
471
|
saveSessionState(rootDir, state);
|
|
348
472
|
|
|
349
473
|
console.log('');
|
|
350
|
-
console.log(`${c.brand}▶ Continue
|
|
474
|
+
console.log(`${c.brand}▶ Continue working. Loop will verify again.${c.reset}`);
|
|
351
475
|
return;
|
|
352
476
|
}
|
|
353
477
|
|
|
354
|
-
// Check if
|
|
355
|
-
const canComplete =
|
|
478
|
+
// Check if all verification modes passed
|
|
479
|
+
const canComplete =
|
|
480
|
+
testResult.passed &&
|
|
481
|
+
(!visualMode || screenshotResult.passed) &&
|
|
482
|
+
(!coverageMode || coverageResult.passed);
|
|
356
483
|
|
|
357
484
|
if (!canComplete) {
|
|
358
|
-
//
|
|
485
|
+
// Something not verified yet
|
|
359
486
|
state.ralph_loop.iteration = iteration;
|
|
360
487
|
saveSessionState(rootDir, state);
|
|
361
488
|
|
|
362
489
|
console.log('');
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
490
|
+
if (visualMode && !screenshotResult.passed) {
|
|
491
|
+
console.log(`${c.cyan}▶ Review unverified screenshots:${c.reset}`);
|
|
492
|
+
console.log(`${c.dim} 1. View each screenshot in screenshots/ directory${c.reset}`);
|
|
493
|
+
console.log(`${c.dim} 2. Rename verified files with 'verified-' prefix${c.reset}`);
|
|
494
|
+
console.log(`${c.dim} 3. Loop will re-verify when you stop${c.reset}`);
|
|
495
|
+
}
|
|
496
|
+
if (coverageMode && !coverageResult.passed) {
|
|
497
|
+
console.log(`${c.cyan}▶ Increase test coverage:${c.reset}`);
|
|
498
|
+
console.log(`${c.dim} Current: ${coverageResult.coverage?.toFixed(1) || 0}%${c.reset}`);
|
|
499
|
+
console.log(`${c.dim} Target: ${coverageThreshold}%${c.reset}`);
|
|
500
|
+
console.log(`${c.dim} Write more tests to cover uncovered code paths.${c.reset}`);
|
|
501
|
+
}
|
|
367
502
|
return;
|
|
368
503
|
}
|
|
369
504
|
console.log('');
|
|
@@ -458,7 +593,10 @@ function handleCLI() {
|
|
|
458
593
|
if (!loop || !loop.enabled) {
|
|
459
594
|
console.log(`${c.dim}Ralph Loop: not active${c.reset}`);
|
|
460
595
|
} else {
|
|
461
|
-
|
|
596
|
+
let modeLabel = '';
|
|
597
|
+
if (loop.visual_mode) modeLabel += ` ${c.cyan}[VISUAL]${c.reset}`;
|
|
598
|
+
if (loop.coverage_mode)
|
|
599
|
+
modeLabel += ` ${c.magenta}[COVERAGE ≥${loop.coverage_threshold}%]${c.reset}`;
|
|
462
600
|
console.log(`${c.green}Ralph Loop: active${c.reset}${modeLabel}`);
|
|
463
601
|
console.log(` Epic: ${loop.epic}`);
|
|
464
602
|
console.log(` Current Story: ${loop.current_story}`);
|
|
@@ -469,6 +607,15 @@ function handleCLI() {
|
|
|
469
607
|
: `${c.yellow}no${c.reset}`;
|
|
470
608
|
console.log(` Screenshots Verified: ${verified}`);
|
|
471
609
|
}
|
|
610
|
+
if (loop.coverage_mode) {
|
|
611
|
+
const verified = loop.coverage_verified
|
|
612
|
+
? `${c.green}yes${c.reset}`
|
|
613
|
+
: `${c.yellow}no${c.reset}`;
|
|
614
|
+
console.log(
|
|
615
|
+
` Coverage: ${(loop.coverage_current || 0).toFixed(1)}% / ${loop.coverage_threshold}% (Verified: ${verified})`
|
|
616
|
+
);
|
|
617
|
+
console.log(` Baseline: ${(loop.coverage_baseline || 0).toFixed(1)}%`);
|
|
618
|
+
}
|
|
472
619
|
}
|
|
473
620
|
return true;
|
|
474
621
|
}
|
|
@@ -499,6 +646,7 @@ function handleCLI() {
|
|
|
499
646
|
const epicArg = args.find(a => a.startsWith('--epic='));
|
|
500
647
|
const maxArg = args.find(a => a.startsWith('--max='));
|
|
501
648
|
const visualArg = args.includes('--visual') || args.includes('-v');
|
|
649
|
+
const coverageArg = args.find(a => a.startsWith('--coverage='));
|
|
502
650
|
|
|
503
651
|
if (!epicArg) {
|
|
504
652
|
console.log(`${c.red}Error: --epic=EP-XXXX is required${c.reset}`);
|
|
@@ -506,9 +654,28 @@ function handleCLI() {
|
|
|
506
654
|
}
|
|
507
655
|
|
|
508
656
|
const epicId = epicArg.split('=')[1];
|
|
509
|
-
|
|
657
|
+
|
|
658
|
+
// Validate epic ID format
|
|
659
|
+
if (!isValidEpicId(epicId)) {
|
|
660
|
+
console.log(`${c.red}Error: Invalid epic ID "${epicId}". Expected format: EP-XXXX${c.reset}`);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Validate and bound max iterations (1-100)
|
|
665
|
+
const maxIterations = parseIntBounded(maxArg ? maxArg.split('=')[1] : null, 20, 1, 100);
|
|
510
666
|
const visualMode = visualArg;
|
|
511
667
|
|
|
668
|
+
// Parse coverage threshold (0-100)
|
|
669
|
+
let coverageMode = false;
|
|
670
|
+
let coverageThreshold = 80;
|
|
671
|
+
if (coverageArg) {
|
|
672
|
+
coverageMode = true;
|
|
673
|
+
const threshold = parseFloat(coverageArg.split('=')[1]);
|
|
674
|
+
if (!isNaN(threshold)) {
|
|
675
|
+
coverageThreshold = Math.max(0, Math.min(100, threshold));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
512
679
|
// Find first ready story in epic
|
|
513
680
|
const status = getStatus(rootDir);
|
|
514
681
|
const stories = status.stories || {};
|
|
@@ -532,6 +699,18 @@ function handleCLI() {
|
|
|
532
699
|
// Mark first story as in_progress
|
|
533
700
|
markStoryInProgress(rootDir, storyId);
|
|
534
701
|
|
|
702
|
+
// Get baseline coverage if coverage mode is enabled
|
|
703
|
+
let coverageBaseline = 0;
|
|
704
|
+
if (coverageMode) {
|
|
705
|
+
console.log(`${c.dim}Running baseline coverage check...${c.reset}`);
|
|
706
|
+
runCoverage(rootDir);
|
|
707
|
+
const baseline = parseCoverageReport(rootDir);
|
|
708
|
+
if (baseline.passed) {
|
|
709
|
+
coverageBaseline = baseline.coverage;
|
|
710
|
+
console.log(`${c.dim}Baseline coverage: ${coverageBaseline.toFixed(1)}%${c.reset}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
535
714
|
// Initialize loop state
|
|
536
715
|
const state = getSessionState(rootDir);
|
|
537
716
|
state.ralph_loop = {
|
|
@@ -542,6 +721,11 @@ function handleCLI() {
|
|
|
542
721
|
max_iterations: maxIterations,
|
|
543
722
|
visual_mode: visualMode,
|
|
544
723
|
screenshots_verified: false,
|
|
724
|
+
coverage_mode: coverageMode,
|
|
725
|
+
coverage_threshold: coverageThreshold,
|
|
726
|
+
coverage_baseline: coverageBaseline,
|
|
727
|
+
coverage_current: coverageBaseline,
|
|
728
|
+
coverage_verified: false,
|
|
545
729
|
started_at: new Date().toISOString(),
|
|
546
730
|
};
|
|
547
731
|
saveSessionState(rootDir, state);
|
|
@@ -549,7 +733,9 @@ function handleCLI() {
|
|
|
549
733
|
const progress = getEpicProgress(status, epicId);
|
|
550
734
|
|
|
551
735
|
console.log('');
|
|
552
|
-
|
|
736
|
+
let modeLabel = '';
|
|
737
|
+
if (visualMode) modeLabel += ` ${c.cyan}[VISUAL]${c.reset}`;
|
|
738
|
+
if (coverageMode) modeLabel += ` ${c.magenta}[COVERAGE ≥${coverageThreshold}%]${c.reset}`;
|
|
553
739
|
console.log(`${c.green}${c.bold}Ralph Loop Initialized${c.reset}${modeLabel}`);
|
|
554
740
|
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
555
741
|
console.log(` Epic: ${c.cyan}${epicId}${c.reset}`);
|
|
@@ -557,6 +743,14 @@ function handleCLI() {
|
|
|
557
743
|
console.log(` Max Iterations: ${maxIterations}`);
|
|
558
744
|
if (visualMode) {
|
|
559
745
|
console.log(` Visual Mode: ${c.cyan}enabled${c.reset} (screenshot verification)`);
|
|
746
|
+
}
|
|
747
|
+
if (coverageMode) {
|
|
748
|
+
console.log(
|
|
749
|
+
` Coverage Mode: ${c.magenta}enabled${c.reset} (threshold: ${coverageThreshold}%)`
|
|
750
|
+
);
|
|
751
|
+
console.log(` Baseline: ${coverageBaseline.toFixed(1)}%`);
|
|
752
|
+
}
|
|
753
|
+
if (visualMode || coverageMode) {
|
|
560
754
|
console.log(` Min Iterations: 2 (for confirmation)`);
|
|
561
755
|
}
|
|
562
756
|
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
@@ -585,17 +779,19 @@ function handleCLI() {
|
|
|
585
779
|
${c.brand}${c.bold}ralph-loop.js${c.reset} - Autonomous Story Processing
|
|
586
780
|
|
|
587
781
|
${c.bold}Usage:${c.reset}
|
|
588
|
-
node scripts/ralph-loop.js
|
|
589
|
-
node scripts/ralph-loop.js --init --epic=EP-XXX
|
|
590
|
-
node scripts/ralph-loop.js --init --epic=EP-XXX --visual
|
|
591
|
-
node scripts/ralph-loop.js --
|
|
592
|
-
node scripts/ralph-loop.js --
|
|
593
|
-
node scripts/ralph-loop.js --
|
|
782
|
+
node scripts/ralph-loop.js Run loop check (Stop hook)
|
|
783
|
+
node scripts/ralph-loop.js --init --epic=EP-XXX Initialize loop for epic
|
|
784
|
+
node scripts/ralph-loop.js --init --epic=EP-XXX --visual Initialize with Visual Mode
|
|
785
|
+
node scripts/ralph-loop.js --init --epic=EP-XXX --coverage=80 Initialize with Coverage Mode
|
|
786
|
+
node scripts/ralph-loop.js --status Check loop status
|
|
787
|
+
node scripts/ralph-loop.js --stop Stop the loop
|
|
788
|
+
node scripts/ralph-loop.js --reset Reset loop state
|
|
594
789
|
|
|
595
790
|
${c.bold}Options:${c.reset}
|
|
596
791
|
--epic=EP-XXXX Epic ID to process (required for --init)
|
|
597
792
|
--max=N Max iterations (default: 20)
|
|
598
793
|
--visual, -v Enable Visual Mode (screenshot verification)
|
|
794
|
+
--coverage=N Enable Coverage Mode (iterate until N% coverage)
|
|
599
795
|
|
|
600
796
|
${c.bold}Visual Mode:${c.reset}
|
|
601
797
|
When --visual is enabled, the loop also verifies that all screenshots
|
|
@@ -604,16 +800,24 @@ ${c.bold}Visual Mode:${c.reset}
|
|
|
604
800
|
This ensures Claude actually looks at UI screenshots before declaring
|
|
605
801
|
completion. Requires minimum 2 iterations for confirmation.
|
|
606
802
|
|
|
803
|
+
${c.bold}Coverage Mode:${c.reset}
|
|
804
|
+
When --coverage=N is enabled, the loop verifies test coverage meets
|
|
805
|
+
the threshold N% before completing stories.
|
|
806
|
+
|
|
807
|
+
Coverage is read from coverage/coverage-summary.json (Jest/NYC format).
|
|
808
|
+
Configure in docs/00-meta/agileflow-metadata.json:
|
|
809
|
+
{ "ralph_loop": { "coverage_command": "npm run test:coverage" } }
|
|
810
|
+
|
|
607
811
|
Workflow:
|
|
608
812
|
1. Tests run → must pass
|
|
609
|
-
2.
|
|
610
|
-
3. Minimum 2 iterations →
|
|
813
|
+
2. Coverage checked → must meet threshold
|
|
814
|
+
3. Minimum 2 iterations → confirms coverage is stable
|
|
611
815
|
4. Only then → story marked complete
|
|
612
816
|
|
|
613
817
|
${c.bold}How it works:${c.reset}
|
|
614
|
-
1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop
|
|
818
|
+
1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop COVERAGE=80
|
|
615
819
|
2. Work on the current story
|
|
616
|
-
3. When you stop, this hook runs tests
|
|
820
|
+
3. When you stop, this hook runs tests and verifications
|
|
617
821
|
4. If all pass → story marked complete, next story loaded
|
|
618
822
|
5. If any fail → failures shown, you continue fixing
|
|
619
823
|
6. Loop repeats until epic done or max iterations
|