agileflow 2.80.0 → 2.82.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.
@@ -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,285 @@ 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
+ // ===== DISCRETION MARKERS =====
133
+ // Semantic conditions wrapped in **...**
134
+ // These are evaluated by the loop to determine completion
135
+
136
+ /**
137
+ * Built-in discretion conditions that can be evaluated programmatically
138
+ * Format: condition key -> evaluation function
139
+ */
140
+ const DISCRETION_CONDITIONS = {
141
+ // Test-related conditions
142
+ 'all tests passing': (rootDir, _ctx) => {
143
+ const testCommand = getTestCommand(rootDir);
144
+ const result = runTests(rootDir, testCommand);
145
+ return {
146
+ passed: result.passed,
147
+ message: result.passed
148
+ ? 'All tests passing'
149
+ : `Tests failing: ${result.output.split('\n').slice(-3).join(' ').substring(0, 100)}`,
150
+ };
151
+ },
152
+
153
+ 'tests pass': (rootDir, _ctx) => {
154
+ const testCommand = getTestCommand(rootDir);
155
+ const result = runTests(rootDir, testCommand);
156
+ return {
157
+ passed: result.passed,
158
+ message: result.passed ? 'Tests pass' : 'Tests failing',
159
+ };
160
+ },
161
+
162
+ // Coverage conditions (requires threshold in context)
163
+ 'coverage above threshold': (rootDir, ctx) => {
164
+ const threshold = ctx.coverageThreshold || 80;
165
+ const result = verifyCoverage(rootDir, threshold);
166
+ return {
167
+ passed: result.passed,
168
+ message: `Coverage: ${result.coverage?.toFixed(1) || 0}% (threshold: ${threshold}%)`,
169
+ };
170
+ },
171
+
172
+ // Lint conditions
173
+ 'no linting errors': (rootDir, _ctx) => {
174
+ try {
175
+ execSync('npm run lint', {
176
+ cwd: rootDir,
177
+ encoding: 'utf8',
178
+ stdio: ['pipe', 'pipe', 'pipe'],
179
+ timeout: 120000,
180
+ });
181
+ return { passed: true, message: 'No linting errors' };
182
+ } catch (e) {
183
+ return { passed: false, message: 'Linting errors found' };
184
+ }
185
+ },
186
+
187
+ // Type checking conditions
188
+ 'no type errors': (rootDir, _ctx) => {
189
+ try {
190
+ execSync('npx tsc --noEmit', {
191
+ cwd: rootDir,
192
+ encoding: 'utf8',
193
+ stdio: ['pipe', 'pipe', 'pipe'],
194
+ timeout: 120000,
195
+ });
196
+ return { passed: true, message: 'No type errors' };
197
+ } catch (e) {
198
+ return { passed: false, message: 'Type errors found' };
199
+ }
200
+ },
201
+
202
+ // Build conditions
203
+ 'build succeeds': (rootDir, _ctx) => {
204
+ try {
205
+ execSync('npm run build', {
206
+ cwd: rootDir,
207
+ encoding: 'utf8',
208
+ stdio: ['pipe', 'pipe', 'pipe'],
209
+ timeout: 300000,
210
+ });
211
+ return { passed: true, message: 'Build succeeds' };
212
+ } catch (e) {
213
+ return { passed: false, message: 'Build failed' };
214
+ }
215
+ },
216
+
217
+ // Screenshot/visual conditions
218
+ 'all screenshots verified': (rootDir, _ctx) => {
219
+ const result = verifyScreenshots(rootDir);
220
+ return {
221
+ passed: result.passed,
222
+ message: result.passed
223
+ ? 'All screenshots verified'
224
+ : `${result.unverified?.length || 0} unverified screenshots`,
225
+ };
226
+ },
227
+
228
+ // AC conditions (checks story acceptance criteria in status.json)
229
+ 'all acceptance criteria verified': (rootDir, ctx) => {
230
+ const storyId = ctx.currentStoryId;
231
+ if (!storyId) {
232
+ return { passed: false, message: 'No story ID in context' };
233
+ }
234
+ const status = getStatus(rootDir);
235
+ const story = status.stories?.[storyId];
236
+ if (!story) {
237
+ return { passed: false, message: `Story ${storyId} not found` };
238
+ }
239
+ // Check if story has AC and if they're marked complete
240
+ const ac = story.acceptance_criteria || story.ac || [];
241
+ if (!Array.isArray(ac) || ac.length === 0) {
242
+ return { passed: true, message: 'No AC defined (assuming complete)' };
243
+ }
244
+ // Check for ac_status field or assume AC are verified if tests pass
245
+ const acStatus = story.ac_status || {};
246
+ const allVerified = ac.every((_, i) => acStatus[i] === 'verified' || acStatus[i] === true);
247
+ return {
248
+ passed: allVerified,
249
+ message: allVerified
250
+ ? 'All AC verified'
251
+ : `${Object.values(acStatus).filter(v => v === 'verified' || v === true).length}/${ac.length} AC verified`,
252
+ };
253
+ },
254
+ };
255
+
256
+ /**
257
+ * Parse discretion condition from string
258
+ * @param {string} condition - e.g., "**all tests passing**" or "**coverage above 80%**"
259
+ * @returns {object} { key, threshold? }
260
+ */
261
+ function parseDiscretionCondition(condition) {
262
+ // Remove ** markers
263
+ const cleaned = condition.replace(/\*\*/g, '').trim().toLowerCase();
264
+
265
+ // Check for threshold patterns like "coverage above 80%"
266
+ const coverageMatch = cleaned.match(/coverage (?:above|>=?) (\d+)%?/);
267
+ if (coverageMatch) {
268
+ return { key: 'coverage above threshold', threshold: parseInt(coverageMatch[1]) };
269
+ }
270
+
271
+ return { key: cleaned };
272
+ }
273
+
274
+ /**
275
+ * Evaluate a discretion condition
276
+ * @param {string} condition - The condition string (with or without ** markers)
277
+ * @param {string} rootDir - Project root
278
+ * @param {object} ctx - Context (currentStoryId, coverageThreshold, etc.)
279
+ * @returns {object} { passed: boolean, message: string }
280
+ */
281
+ function evaluateDiscretionCondition(condition, rootDir, ctx = {}) {
282
+ const parsed = parseDiscretionCondition(condition);
283
+
284
+ // Set threshold in context if parsed from condition
285
+ if (parsed.threshold) {
286
+ ctx.coverageThreshold = parsed.threshold;
287
+ }
288
+
289
+ const evaluator = DISCRETION_CONDITIONS[parsed.key];
290
+ if (!evaluator) {
291
+ return {
292
+ passed: false,
293
+ message: `Unknown condition: "${parsed.key}". Available: ${Object.keys(DISCRETION_CONDITIONS).join(', ')}`,
294
+ };
295
+ }
296
+
297
+ return evaluator(rootDir, ctx);
298
+ }
299
+
300
+ /**
301
+ * Get discretion conditions from metadata
302
+ * @param {string} rootDir
303
+ * @returns {string[]} Array of condition strings
304
+ */
305
+ function getDiscretionConditions(rootDir) {
306
+ const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
307
+ const result = safeReadJSON(metadataPath, { defaultValue: {} });
308
+
309
+ if (result.ok && result.data?.ralph_loop?.conditions) {
310
+ return result.data.ralph_loop.conditions;
311
+ }
312
+
313
+ return [];
314
+ }
315
+
316
+ // Parse coverage report (Jest/NYC format)
317
+ function parseCoverageReport(rootDir) {
318
+ const reportPath = getCoverageReportPath(rootDir);
319
+ const fullPath = path.join(rootDir, reportPath);
320
+ const report = safeReadJSON(fullPath, { defaultValue: null });
321
+
322
+ if (!report.ok || !report.data) {
323
+ return { passed: false, coverage: 0, error: 'Coverage report not found at ' + reportPath };
324
+ }
325
+
326
+ // Jest/NYC format: { total: { lines: { pct: 80 }, statements: { pct: 80 } } }
327
+ const total = report.data.total;
328
+ if (!total) {
329
+ return { passed: false, coverage: 0, error: 'Invalid coverage report format' };
330
+ }
331
+
332
+ const coverage = total.lines?.pct || total.statements?.pct || 0;
333
+
334
+ return { passed: true, coverage, raw: report.data };
335
+ }
336
+
337
+ // Verify coverage meets threshold
338
+ function verifyCoverage(rootDir, threshold) {
339
+ const result = parseCoverageReport(rootDir);
340
+
341
+ if (!result.passed) {
342
+ return {
343
+ passed: false,
344
+ coverage: 0,
345
+ message: `${c.red}✗ ${result.error}${c.reset}`,
346
+ };
347
+ }
348
+
349
+ const met = result.coverage >= threshold;
350
+
351
+ return {
352
+ passed: met,
353
+ coverage: result.coverage,
354
+ threshold: threshold,
355
+ message: met
356
+ ? `${c.green}✓ Coverage ${result.coverage.toFixed(1)}% ≥ ${threshold}%${c.reset}`
357
+ : `${c.yellow}⏳ Coverage ${result.coverage.toFixed(1)}% < ${threshold}% (need ${(threshold - result.coverage).toFixed(1)}% more)${c.reset}`,
358
+ };
359
+ }
360
+
361
+ // Run coverage command
362
+ function runCoverage(rootDir) {
363
+ const coverageCmd = getCoverageCommand(rootDir);
364
+ const result = { passed: false, output: '', duration: 0 };
365
+ const startTime = Date.now();
366
+
367
+ try {
368
+ const output = execSync(coverageCmd, {
369
+ cwd: rootDir,
370
+ encoding: 'utf8',
371
+ stdio: ['pipe', 'pipe', 'pipe'],
372
+ timeout: 300000, // 5 minute timeout
373
+ });
374
+ result.passed = true;
375
+ result.output = output;
376
+ } catch (e) {
377
+ // Coverage command might fail but still generate report
378
+ result.passed = true; // We'll check the report
379
+ result.output = e.stdout || '' + '\n' + (e.stderr || '');
380
+ }
381
+
382
+ result.duration = Date.now() - startTime;
383
+ return result;
384
+ }
385
+
106
386
  // Get screenshots directory from metadata or default
107
387
  function getScreenshotsDir(rootDir) {
108
388
  try {
@@ -253,13 +533,21 @@ function handleLoop(rootDir) {
253
533
  const iteration = (loop.iteration || 0) + 1;
254
534
  const maxIterations = loop.max_iterations || 20;
255
535
  const visualMode = loop.visual_mode || false;
256
- const minIterations = visualMode ? 2 : 1; // Visual mode requires at least 2 iterations
536
+ const coverageMode = loop.coverage_mode || false;
537
+ const coverageThreshold = loop.coverage_threshold || 80;
538
+ const discretionConditions = loop.conditions || getDiscretionConditions(rootDir);
539
+ // Visual, Coverage, and Discretion modes require at least 2 iterations for confirmation
540
+ const hasDiscretionConditions = discretionConditions.length > 0;
541
+ const minIterations = visualMode || coverageMode || hasDiscretionConditions ? 2 : 1;
257
542
 
258
543
  console.log('');
259
544
  console.log(
260
545
  `${c.brand}${c.bold}══════════════════════════════════════════════════════════${c.reset}`
261
546
  );
262
- const modeLabel = visualMode ? ' [VISUAL MODE]' : '';
547
+ let modeLabel = '';
548
+ if (visualMode) modeLabel += ' [VISUAL]';
549
+ if (coverageMode) modeLabel += ` [COVERAGE ≥${coverageThreshold}%]`;
550
+ if (hasDiscretionConditions) modeLabel += ` [${discretionConditions.length} CONDITIONS]`;
263
551
  console.log(
264
552
  `${c.brand}${c.bold} RALPH LOOP - Iteration ${iteration}/${maxIterations}${modeLabel}${c.reset}`
265
553
  );
@@ -267,6 +555,8 @@ function handleLoop(rootDir) {
267
555
  `${c.brand}${c.bold}══════════════════════════════════════════════════════════${c.reset}`
268
556
  );
269
557
  console.log('');
558
+ // State Narration: Loop iteration marker
559
+ console.log(`🔄 Iteration ${iteration}/${maxIterations}`);
270
560
 
271
561
  // Check iteration limit
272
562
  if (iteration > maxIterations) {
@@ -291,9 +581,8 @@ function handleLoop(rootDir) {
291
581
  return;
292
582
  }
293
583
 
294
- console.log(
295
- `${c.cyan}Current Story:${c.reset} ${currentStoryId} - ${currentStory.title || 'Untitled'}`
296
- );
584
+ // State Narration: Current position marker
585
+ console.log(`📍 Working on: ${currentStoryId} - ${currentStory.title || 'Untitled'}`);
297
586
  console.log('');
298
587
 
299
588
  // Run tests
@@ -333,11 +622,34 @@ function handleLoop(rootDir) {
333
622
  }
334
623
  }
335
624
 
336
- // Visual Mode: Enforce minimum iterations
337
- if (visualMode && iteration < minIterations) {
625
+ // Coverage Mode: Run coverage and verify threshold
626
+ let coverageResult = { passed: true };
627
+ if (coverageMode) {
628
+ console.log('');
629
+ console.log(`${c.blue}Running coverage check...${c.reset}`);
630
+ runCoverage(rootDir);
631
+ coverageResult = verifyCoverage(rootDir, coverageThreshold);
632
+ console.log(coverageResult.message);
633
+
634
+ // Update state with current coverage
635
+ state.ralph_loop.coverage_current = coverageResult.coverage;
636
+
637
+ if (coverageResult.passed) {
638
+ state.ralph_loop.coverage_verified = true;
639
+ } else {
640
+ state.ralph_loop.coverage_verified = false;
641
+ }
642
+ }
643
+
644
+ // Enforce minimum iterations for Visual and Coverage modes
645
+ if ((visualMode || coverageMode) && iteration < minIterations) {
646
+ const modeNames = [];
647
+ if (visualMode) modeNames.push('Visual');
648
+ if (coverageMode) modeNames.push('Coverage');
649
+
338
650
  console.log('');
339
651
  console.log(
340
- `${c.yellow}⚠ Visual Mode requires ${minIterations}+ iterations for confirmation${c.reset}`
652
+ `${c.yellow}⚠ ${modeNames.join(' + ')} Mode requires ${minIterations}+ iterations for confirmation${c.reset}`
341
653
  );
342
654
  console.log(
343
655
  `${c.dim}Current: iteration ${iteration}. Let loop run once more to confirm.${c.reset}`
@@ -347,29 +659,76 @@ function handleLoop(rootDir) {
347
659
  saveSessionState(rootDir, state);
348
660
 
349
661
  console.log('');
350
- console.log(`${c.brand}▶ Continue reviewing. Loop will verify again.${c.reset}`);
662
+ console.log(`${c.brand}▶ Continue working. Loop will verify again.${c.reset}`);
351
663
  return;
352
664
  }
353
665
 
354
- // Check if both tests AND screenshots (in visual mode) passed
355
- const canComplete = testResult.passed && (!visualMode || screenshotResult.passed);
666
+ // Evaluate discretion conditions
667
+ let discretionResults = [];
668
+ if (hasDiscretionConditions) {
669
+ console.log('');
670
+ console.log(`${c.blue}Evaluating discretion conditions...${c.reset}`);
671
+ const ctx = { currentStoryId, coverageThreshold };
672
+
673
+ for (const condition of discretionConditions) {
674
+ const result = evaluateDiscretionCondition(condition, rootDir, ctx);
675
+ discretionResults.push({ condition, ...result });
676
+ const marker = result.passed ? `${c.green}✓` : `${c.yellow}⏳`;
677
+ console.log(` ${marker} **${condition.replace(/\*\*/g, '')}**: ${result.message}${c.reset}`);
678
+ }
679
+
680
+ // Track which conditions have been verified
681
+ const allConditionsPassed = discretionResults.every(r => r.passed);
682
+ state.ralph_loop.conditions_verified = allConditionsPassed;
683
+ state.ralph_loop.condition_results = discretionResults.map(r => ({
684
+ condition: r.condition,
685
+ passed: r.passed,
686
+ message: r.message,
687
+ }));
688
+ }
689
+
690
+ // Check if all verification modes passed
691
+ const allDiscretionPassed =
692
+ !hasDiscretionConditions || discretionResults.every(r => r.passed);
693
+ const canComplete =
694
+ testResult.passed &&
695
+ (!visualMode || screenshotResult.passed) &&
696
+ (!coverageMode || coverageResult.passed) &&
697
+ allDiscretionPassed;
356
698
 
357
699
  if (!canComplete) {
358
- // Screenshots not verified yet
700
+ // Something not verified yet
359
701
  state.ralph_loop.iteration = iteration;
360
702
  saveSessionState(rootDir, state);
361
703
 
362
704
  console.log('');
363
- console.log(`${c.cyan}▶ Review unverified screenshots:${c.reset}`);
364
- console.log(`${c.dim} 1. View each screenshot in screenshots/ directory${c.reset}`);
365
- console.log(`${c.dim} 2. Rename verified files with 'verified-' prefix${c.reset}`);
366
- console.log(`${c.dim} 3. Loop will re-verify when you stop${c.reset}`);
705
+ if (visualMode && !screenshotResult.passed) {
706
+ console.log(`${c.cyan} Review unverified screenshots:${c.reset}`);
707
+ console.log(`${c.dim} 1. View each screenshot in screenshots/ directory${c.reset}`);
708
+ console.log(`${c.dim} 2. Rename verified files with 'verified-' prefix${c.reset}`);
709
+ console.log(`${c.dim} 3. Loop will re-verify when you stop${c.reset}`);
710
+ }
711
+ if (coverageMode && !coverageResult.passed) {
712
+ console.log(`${c.cyan}▶ Increase test coverage:${c.reset}`);
713
+ console.log(`${c.dim} Current: ${coverageResult.coverage?.toFixed(1) || 0}%${c.reset}`);
714
+ console.log(`${c.dim} Target: ${coverageThreshold}%${c.reset}`);
715
+ console.log(`${c.dim} Write more tests to cover uncovered code paths.${c.reset}`);
716
+ }
717
+ if (hasDiscretionConditions && !allDiscretionPassed) {
718
+ const failedConditions = discretionResults.filter(r => !r.passed);
719
+ console.log(`${c.cyan}▶ Fix failing conditions:${c.reset}`);
720
+ for (const fc of failedConditions) {
721
+ console.log(`${c.dim} - ${fc.condition.replace(/\*\*/g, '')}: ${fc.message}${c.reset}`);
722
+ }
723
+ }
367
724
  return;
368
725
  }
369
726
  console.log('');
370
727
 
371
728
  // Mark story complete
372
729
  markStoryComplete(rootDir, currentStoryId);
730
+ // State Narration: Completion marker
731
+ console.log(`✅ Story complete: ${currentStoryId}`);
373
732
  console.log(`${c.green}✓ Marked ${currentStoryId} as completed${c.reset}`);
374
733
 
375
734
  // Get next story
@@ -423,6 +782,8 @@ function handleLoop(rootDir) {
423
782
  }
424
783
  } else {
425
784
  // Tests failed - feed back to Claude
785
+ // State Narration: Error marker
786
+ console.log(`⚠️ Error: Test failure - ${(testResult.duration / 1000).toFixed(1)}s`);
426
787
  console.log(`${c.red}✗ Tests failed${c.reset} (${(testResult.duration / 1000).toFixed(1)}s)`);
427
788
  console.log('');
428
789
 
@@ -458,7 +819,12 @@ function handleCLI() {
458
819
  if (!loop || !loop.enabled) {
459
820
  console.log(`${c.dim}Ralph Loop: not active${c.reset}`);
460
821
  } else {
461
- const modeLabel = loop.visual_mode ? ` ${c.cyan}[VISUAL]${c.reset}` : '';
822
+ let modeLabel = '';
823
+ if (loop.visual_mode) modeLabel += ` ${c.cyan}[VISUAL]${c.reset}`;
824
+ if (loop.coverage_mode)
825
+ modeLabel += ` ${c.magenta}[COVERAGE ≥${loop.coverage_threshold}%]${c.reset}`;
826
+ if (loop.conditions?.length > 0)
827
+ modeLabel += ` ${c.blue}[${loop.conditions.length} CONDITIONS]${c.reset}`;
462
828
  console.log(`${c.green}Ralph Loop: active${c.reset}${modeLabel}`);
463
829
  console.log(` Epic: ${loop.epic}`);
464
830
  console.log(` Current Story: ${loop.current_story}`);
@@ -469,6 +835,25 @@ function handleCLI() {
469
835
  : `${c.yellow}no${c.reset}`;
470
836
  console.log(` Screenshots Verified: ${verified}`);
471
837
  }
838
+ if (loop.coverage_mode) {
839
+ const verified = loop.coverage_verified
840
+ ? `${c.green}yes${c.reset}`
841
+ : `${c.yellow}no${c.reset}`;
842
+ console.log(
843
+ ` Coverage: ${(loop.coverage_current || 0).toFixed(1)}% / ${loop.coverage_threshold}% (Verified: ${verified})`
844
+ );
845
+ console.log(` Baseline: ${(loop.coverage_baseline || 0).toFixed(1)}%`);
846
+ }
847
+ if (loop.conditions?.length > 0) {
848
+ const verified = loop.conditions_verified
849
+ ? `${c.green}yes${c.reset}`
850
+ : `${c.yellow}no${c.reset}`;
851
+ console.log(` Discretion Conditions: ${loop.conditions.length} (All Verified: ${verified})`);
852
+ for (const result of loop.condition_results || []) {
853
+ const mark = result.passed ? `${c.green}✓${c.reset}` : `${c.yellow}⏳${c.reset}`;
854
+ console.log(` ${mark} ${result.condition.replace(/\*\*/g, '')}`);
855
+ }
856
+ }
472
857
  }
473
858
  return true;
474
859
  }
@@ -499,6 +884,10 @@ function handleCLI() {
499
884
  const epicArg = args.find(a => a.startsWith('--epic='));
500
885
  const maxArg = args.find(a => a.startsWith('--max='));
501
886
  const visualArg = args.includes('--visual') || args.includes('-v');
887
+ const coverageArg = args.find(a => a.startsWith('--coverage='));
888
+ // Parse conditions (--condition="**all tests passing**" or -c "...")
889
+ const conditionArgs = args.filter(a => a.startsWith('--condition=') || a.startsWith('-c='));
890
+ const conditions = conditionArgs.map(a => a.split('=').slice(1).join('=').replace(/"/g, ''));
502
891
 
503
892
  if (!epicArg) {
504
893
  console.log(`${c.red}Error: --epic=EP-XXXX is required${c.reset}`);
@@ -506,9 +895,28 @@ function handleCLI() {
506
895
  }
507
896
 
508
897
  const epicId = epicArg.split('=')[1];
509
- const maxIterations = maxArg ? parseInt(maxArg.split('=')[1]) : 20;
898
+
899
+ // Validate epic ID format
900
+ if (!isValidEpicId(epicId)) {
901
+ console.log(`${c.red}Error: Invalid epic ID "${epicId}". Expected format: EP-XXXX${c.reset}`);
902
+ return true;
903
+ }
904
+
905
+ // Validate and bound max iterations (1-100)
906
+ const maxIterations = parseIntBounded(maxArg ? maxArg.split('=')[1] : null, 20, 1, 100);
510
907
  const visualMode = visualArg;
511
908
 
909
+ // Parse coverage threshold (0-100)
910
+ let coverageMode = false;
911
+ let coverageThreshold = 80;
912
+ if (coverageArg) {
913
+ coverageMode = true;
914
+ const threshold = parseFloat(coverageArg.split('=')[1]);
915
+ if (!isNaN(threshold)) {
916
+ coverageThreshold = Math.max(0, Math.min(100, threshold));
917
+ }
918
+ }
919
+
512
920
  // Find first ready story in epic
513
921
  const status = getStatus(rootDir);
514
922
  const stories = status.stories || {};
@@ -532,6 +940,22 @@ function handleCLI() {
532
940
  // Mark first story as in_progress
533
941
  markStoryInProgress(rootDir, storyId);
534
942
 
943
+ // Get baseline coverage if coverage mode is enabled
944
+ let coverageBaseline = 0;
945
+ if (coverageMode) {
946
+ console.log(`${c.dim}Running baseline coverage check...${c.reset}`);
947
+ runCoverage(rootDir);
948
+ const baseline = parseCoverageReport(rootDir);
949
+ if (baseline.passed) {
950
+ coverageBaseline = baseline.coverage;
951
+ console.log(`${c.dim}Baseline coverage: ${coverageBaseline.toFixed(1)}%${c.reset}`);
952
+ }
953
+ }
954
+
955
+ // Get conditions from metadata if not provided via CLI
956
+ const allConditions =
957
+ conditions.length > 0 ? conditions : getDiscretionConditions(rootDir);
958
+
535
959
  // Initialize loop state
536
960
  const state = getSessionState(rootDir);
537
961
  state.ralph_loop = {
@@ -542,6 +966,14 @@ function handleCLI() {
542
966
  max_iterations: maxIterations,
543
967
  visual_mode: visualMode,
544
968
  screenshots_verified: false,
969
+ coverage_mode: coverageMode,
970
+ coverage_threshold: coverageThreshold,
971
+ coverage_baseline: coverageBaseline,
972
+ coverage_current: coverageBaseline,
973
+ coverage_verified: false,
974
+ conditions: allConditions,
975
+ conditions_verified: false,
976
+ condition_results: [],
545
977
  started_at: new Date().toISOString(),
546
978
  };
547
979
  saveSessionState(rootDir, state);
@@ -549,7 +981,9 @@ function handleCLI() {
549
981
  const progress = getEpicProgress(status, epicId);
550
982
 
551
983
  console.log('');
552
- const modeLabel = visualMode ? ` ${c.cyan}[VISUAL MODE]${c.reset}` : '';
984
+ let modeLabel = '';
985
+ if (visualMode) modeLabel += ` ${c.cyan}[VISUAL]${c.reset}`;
986
+ if (coverageMode) modeLabel += ` ${c.magenta}[COVERAGE ≥${coverageThreshold}%]${c.reset}`;
553
987
  console.log(`${c.green}${c.bold}Ralph Loop Initialized${c.reset}${modeLabel}`);
554
988
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
555
989
  console.log(` Epic: ${c.cyan}${epicId}${c.reset}`);
@@ -557,6 +991,20 @@ function handleCLI() {
557
991
  console.log(` Max Iterations: ${maxIterations}`);
558
992
  if (visualMode) {
559
993
  console.log(` Visual Mode: ${c.cyan}enabled${c.reset} (screenshot verification)`);
994
+ }
995
+ if (coverageMode) {
996
+ console.log(
997
+ ` Coverage Mode: ${c.magenta}enabled${c.reset} (threshold: ${coverageThreshold}%)`
998
+ );
999
+ console.log(` Baseline: ${coverageBaseline.toFixed(1)}%`);
1000
+ }
1001
+ if (allConditions.length > 0) {
1002
+ console.log(` Conditions: ${c.blue}${allConditions.length} discretion conditions${c.reset}`);
1003
+ for (const cond of allConditions) {
1004
+ console.log(` - **${cond.replace(/\*\*/g, '')}**`);
1005
+ }
1006
+ }
1007
+ if (visualMode || coverageMode || allConditions.length > 0) {
560
1008
  console.log(` Min Iterations: 2 (for confirmation)`);
561
1009
  }
562
1010
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
@@ -585,17 +1033,21 @@ function handleCLI() {
585
1033
  ${c.brand}${c.bold}ralph-loop.js${c.reset} - Autonomous Story Processing
586
1034
 
587
1035
  ${c.bold}Usage:${c.reset}
588
- node scripts/ralph-loop.js Run loop check (Stop hook)
589
- node scripts/ralph-loop.js --init --epic=EP-XXX Initialize loop for epic
590
- node scripts/ralph-loop.js --init --epic=EP-XXX --visual Initialize with Visual Mode
591
- node scripts/ralph-loop.js --status Check loop status
592
- node scripts/ralph-loop.js --stop Stop the loop
593
- node scripts/ralph-loop.js --reset Reset loop state
1036
+ node scripts/ralph-loop.js Run loop check (Stop hook)
1037
+ node scripts/ralph-loop.js --init --epic=EP-XXX Initialize loop for epic
1038
+ node scripts/ralph-loop.js --init --epic=EP-XXX --visual Initialize with Visual Mode
1039
+ node scripts/ralph-loop.js --init --epic=EP-XXX --coverage=80 Initialize with Coverage Mode
1040
+ node scripts/ralph-loop.js --init --epic=EP-XXX --condition="**all tests passing**"
1041
+ node scripts/ralph-loop.js --status Check loop status
1042
+ node scripts/ralph-loop.js --stop Stop the loop
1043
+ node scripts/ralph-loop.js --reset Reset loop state
594
1044
 
595
1045
  ${c.bold}Options:${c.reset}
596
- --epic=EP-XXXX Epic ID to process (required for --init)
597
- --max=N Max iterations (default: 20)
598
- --visual, -v Enable Visual Mode (screenshot verification)
1046
+ --epic=EP-XXXX Epic ID to process (required for --init)
1047
+ --max=N Max iterations (default: 20)
1048
+ --visual, -v Enable Visual Mode (screenshot verification)
1049
+ --coverage=N Enable Coverage Mode (iterate until N% coverage)
1050
+ --condition="..." Add discretion condition (can use multiple times)
599
1051
 
600
1052
  ${c.bold}Visual Mode:${c.reset}
601
1053
  When --visual is enabled, the loop also verifies that all screenshots
@@ -604,16 +1056,48 @@ ${c.bold}Visual Mode:${c.reset}
604
1056
  This ensures Claude actually looks at UI screenshots before declaring
605
1057
  completion. Requires minimum 2 iterations for confirmation.
606
1058
 
1059
+ ${c.bold}Coverage Mode:${c.reset}
1060
+ When --coverage=N is enabled, the loop verifies test coverage meets
1061
+ the threshold N% before completing stories.
1062
+
1063
+ Coverage is read from coverage/coverage-summary.json (Jest/NYC format).
1064
+ Configure in docs/00-meta/agileflow-metadata.json:
1065
+ { "ralph_loop": { "coverage_command": "npm run test:coverage" } }
1066
+
607
1067
  Workflow:
608
1068
  1. Tests run → must pass
609
- 2. Screenshots verifiedall must have 'verified-' prefix
610
- 3. Minimum 2 iterations → prevents premature completion
1069
+ 2. Coverage checked → must meet threshold
1070
+ 3. Minimum 2 iterations → confirms coverage is stable
611
1071
  4. Only then → story marked complete
612
1072
 
1073
+ ${c.bold}Discretion Conditions:${c.reset}
1074
+ Semantic conditions that must pass before story completion.
1075
+ Use --condition multiple times for multiple conditions.
1076
+
1077
+ Built-in conditions:
1078
+ **all tests passing** Tests must pass
1079
+ **tests pass** Tests must pass (alias)
1080
+ **coverage above 80%** Coverage must meet threshold
1081
+ **no linting errors** npm run lint must pass
1082
+ **no type errors** npx tsc --noEmit must pass
1083
+ **build succeeds** npm run build must pass
1084
+ **all screenshots verified** Screenshots need verified- prefix
1085
+ **all acceptance criteria verified** AC marked complete in status.json
1086
+
1087
+ Configure in docs/00-meta/agileflow-metadata.json:
1088
+ {
1089
+ "ralph_loop": {
1090
+ "conditions": [
1091
+ "**all tests passing**",
1092
+ "**no linting errors**"
1093
+ ]
1094
+ }
1095
+ }
1096
+
613
1097
  ${c.bold}How it works:${c.reset}
614
- 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop VISUAL=true
1098
+ 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop COVERAGE=80
615
1099
  2. Work on the current story
616
- 3. When you stop, this hook runs tests (and screenshot verification in Visual Mode)
1100
+ 3. When you stop, this hook runs tests and verifications
617
1101
  4. If all pass → story marked complete, next story loaded
618
1102
  5. If any fail → failures shown, you continue fixing
619
1103
  6. Loop repeats until epic done or max iterations