agileflow 2.79.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.
@@ -18,12 +18,14 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { execSync } = require('child_process');
21
+ const { c: C, box } = require('../lib/colors');
22
+ const { isValidCommandName } = require('../lib/validate');
21
23
 
22
24
  const DISPLAY_LIMIT = 30000; // Claude Code's Bash tool display limit
23
25
 
24
26
  // Optional: Register command for PreCompact context preservation
25
27
  const commandName = process.argv[2];
26
- if (commandName) {
28
+ if (commandName && isValidCommandName(commandName)) {
27
29
  const sessionStatePath = 'docs/09-agents/session-state.json';
28
30
  if (fs.existsSync(sessionStatePath)) {
29
31
  try {
@@ -44,8 +46,10 @@ if (commandName) {
44
46
  state: {},
45
47
  });
46
48
 
47
- // Keep backwards compatibility - also set singular active_command to most recent
48
- state.active_command = state.active_commands[state.active_commands.length - 1];
49
+ // Remove legacy active_command field (only use active_commands array now)
50
+ if (state.active_command !== undefined) {
51
+ delete state.active_command;
52
+ }
49
53
 
50
54
  fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
51
55
  } catch (e) {
@@ -54,33 +58,6 @@ if (commandName) {
54
58
  }
55
59
  }
56
60
 
57
- // ANSI colors
58
- const C = {
59
- reset: '\x1b[0m',
60
- dim: '\x1b[2m',
61
- bold: '\x1b[1m',
62
- cyan: '\x1b[36m',
63
- yellow: '\x1b[33m',
64
- green: '\x1b[32m',
65
- red: '\x1b[31m',
66
- magenta: '\x1b[35m',
67
- blue: '\x1b[34m',
68
- brightCyan: '\x1b[96m',
69
- brightYellow: '\x1b[93m',
70
- brightGreen: '\x1b[92m',
71
- brand: '\x1b[38;2;232;104;58m', // AgileFlow brand orange
72
-
73
- // Vibrant 256-color palette (modern, sleek look)
74
- mintGreen: '\x1b[38;5;158m', // Healthy/success states
75
- peach: '\x1b[38;5;215m', // Warning states
76
- coral: '\x1b[38;5;203m', // Critical/error states
77
- lightGreen: '\x1b[38;5;194m', // Session healthy
78
- lightYellow: '\x1b[38;5;228m', // Session warning
79
- skyBlue: '\x1b[38;5;117m', // Directories/paths
80
- lavender: '\x1b[38;5;147m', // Model info
81
- softGold: '\x1b[38;5;222m', // Cost/money
82
- };
83
-
84
61
  function safeRead(filePath) {
85
62
  try {
86
63
  return fs.readFileSync(filePath, 'utf8');
@@ -225,7 +202,8 @@ function generateSummary() {
225
202
 
226
203
  // Header row (full width, no column divider)
227
204
  const title = commandName ? `Context [${commandName}]` : 'Context Summary';
228
- const branchColor = branch === 'main' ? C.mintGreen : branch.startsWith('fix') ? C.coral : C.skyBlue;
205
+ const branchColor =
206
+ branch === 'main' ? C.mintGreen : branch.startsWith('fix') ? C.coral : C.skyBlue;
229
207
  const maxBranchLen = 20;
230
208
  const branchDisplay =
231
209
  branch.length > maxBranchLen ? branch.substring(0, maxBranchLen - 2) + '..' : branch;
@@ -300,7 +278,12 @@ function generateSummary() {
300
278
 
301
279
  // Research
302
280
  const researchText = researchFiles.length > 0 ? `${researchFiles.length} notes` : 'none';
303
- summary += row('Research', researchText, C.lavender, researchFiles.length > 0 ? C.skyBlue : C.dim);
281
+ summary += row(
282
+ 'Research',
283
+ researchText,
284
+ C.lavender,
285
+ researchFiles.length > 0 ? C.skyBlue : C.dim
286
+ );
304
287
 
305
288
  // Epics
306
289
  const epicText = epicFiles.length > 0 ? `${epicFiles.length} epics` : 'none';
@@ -396,7 +379,90 @@ function generateFullContent() {
396
379
  content += `${C.dim}No session-state.json found${C.reset}\n`;
397
380
  }
398
381
 
399
- // 4. DOCS STRUCTURE (using vibrant 256-color palette)
382
+ // 4. SESSION CONTEXT (multi-session awareness)
383
+ content += `\n${C.skyBlue}${C.bold}═══ Session Context ═══${C.reset}\n`;
384
+ const sessionManagerPath = path.join(__dirname, 'session-manager.js');
385
+ const altSessionManagerPath = '.agileflow/scripts/session-manager.js';
386
+
387
+ if (fs.existsSync(sessionManagerPath) || fs.existsSync(altSessionManagerPath)) {
388
+ const managerPath = fs.existsSync(sessionManagerPath)
389
+ ? sessionManagerPath
390
+ : altSessionManagerPath;
391
+ const sessionStatus = safeExec(`node "${managerPath}" status`);
392
+
393
+ if (sessionStatus) {
394
+ try {
395
+ const statusData = JSON.parse(sessionStatus);
396
+ if (statusData.current) {
397
+ const session = statusData.current;
398
+ const isMain = session.is_main === true;
399
+
400
+ if (isMain) {
401
+ content += `Session: ${C.mintGreen}Main project${C.reset} (Session ${session.id || 1})\n`;
402
+ } else {
403
+ // NON-MAIN SESSION - Show prominent banner
404
+ const sessionName = session.nickname
405
+ ? `${session.id} "${session.nickname}"`
406
+ : `${session.id}`;
407
+ content += `${C.teal}${C.bold}🔀 SESSION ${sessionName} (worktree)${C.reset}\n`;
408
+ content += `Branch: ${C.skyBlue}${session.branch || 'unknown'}${C.reset}\n`;
409
+ content += `Path: ${C.dim}${session.path || process.cwd()}${C.reset}\n`;
410
+
411
+ // Calculate relative path to main
412
+ const mainPath = process.cwd().replace(/-[^/]+$/, ''); // Heuristic: strip session suffix
413
+ content += `Main project: ${C.dim}${mainPath}${C.reset}\n`;
414
+
415
+ // Remind about merge flow
416
+ content += `${C.lavender}💡 When done: /agileflow:session:end → merge to main${C.reset}\n`;
417
+ }
418
+
419
+ // Show other active sessions
420
+ if (statusData.otherActive > 0) {
421
+ content += `${C.peach}⚠️ ${statusData.otherActive} other session(s) active${C.reset}\n`;
422
+ }
423
+ } else {
424
+ content += `${C.dim}No session registered${C.reset}\n`;
425
+ }
426
+ } catch (e) {
427
+ content += `${C.dim}Session manager available but status parse failed${C.reset}\n`;
428
+ }
429
+ } else {
430
+ content += `${C.dim}Session manager available${C.reset}\n`;
431
+ }
432
+ } else {
433
+ content += `${C.dim}Multi-session not configured${C.reset}\n`;
434
+ }
435
+
436
+ // 5. INTERACTION MODE (AskUserQuestion guidance)
437
+ const metadata = safeReadJSON('docs/00-meta/agileflow-metadata.json');
438
+ const askUserQuestionConfig = metadata?.features?.askUserQuestion;
439
+
440
+ if (askUserQuestionConfig?.enabled) {
441
+ content += `\n${C.brand}${C.bold}═══ ⚡ INTERACTION MODE: AskUserQuestion ENABLED ═══${C.reset}\n`;
442
+ content += `${C.dim}${'─'.repeat(60)}${C.reset}\n`;
443
+ content += `${C.bold}CRITICAL RULE:${C.reset} End ${C.skyBlue}EVERY${C.reset} response with the AskUserQuestion tool.\n\n`;
444
+ content += `${C.mintGreen}✓ CORRECT:${C.reset} Call the actual AskUserQuestion tool\n`;
445
+ content += `${C.coral}✗ WRONG:${C.reset} Text like "Want me to continue?" or "What's next?"\n\n`;
446
+ content += `${C.lavender}Required format:${C.reset}\n`;
447
+ content += `${C.dim}\`\`\`xml
448
+ <invoke name="AskUserQuestion">
449
+ <parameter name="questions">[{
450
+ "question": "What would you like to do next?",
451
+ "header": "Next step",
452
+ "multiSelect": false,
453
+ "options": [
454
+ {"label": "Option A (Recommended)", "description": "Why this is best"},
455
+ {"label": "Option B", "description": "Alternative approach"},
456
+ {"label": "Pause", "description": "Stop here for now"}
457
+ ]
458
+ }]</parameter>
459
+ </invoke>
460
+ \`\`\`${C.reset}\n`;
461
+ content += `${C.dim}${'─'.repeat(60)}${C.reset}\n`;
462
+ content += `${C.dim}Mode: ${askUserQuestionConfig.mode || 'all'} | Configure: /agileflow:configure${C.reset}\n\n`;
463
+ }
464
+
465
+ // 5. DOCS STRUCTURE (using vibrant 256-color palette)
400
466
  content += `\n${C.skyBlue}${C.bold}═══ Documentation ═══${C.reset}\n`;
401
467
  const docsDir = 'docs';
402
468
  const docFolders = safeLs(docsDir).filter(f => {
@@ -420,7 +486,7 @@ function generateFullContent() {
420
486
  });
421
487
  }
422
488
 
423
- // 5. RESEARCH NOTES - List + Full content of most recent (using vibrant 256-color palette)
489
+ // 6. RESEARCH NOTES - List + Full content of most recent (using vibrant 256-color palette)
424
490
  content += `\n${C.skyBlue}${C.bold}═══ Research Notes ═══${C.reset}\n`;
425
491
  const researchDir = 'docs/10-research';
426
492
  const researchFiles = safeLs(researchDir).filter(f => f.endsWith('.md') && f !== 'README.md');
@@ -443,7 +509,7 @@ function generateFullContent() {
443
509
  content += `${C.dim}No research notes${C.reset}\n`;
444
510
  }
445
511
 
446
- // 6. BUS MESSAGES (using vibrant 256-color palette)
512
+ // 7. BUS MESSAGES (using vibrant 256-color palette)
447
513
  content += `\n${C.skyBlue}${C.bold}═══ Recent Agent Messages ═══${C.reset}\n`;
448
514
  const busPath = 'docs/09-agents/bus/log.jsonl';
449
515
  const busContent = safeRead(busPath);
@@ -467,7 +533,7 @@ function generateFullContent() {
467
533
  content += `${C.dim}No bus log found${C.reset}\n`;
468
534
  }
469
535
 
470
- // 7. KEY FILES - Full content
536
+ // 8. KEY FILES - Full content
471
537
  content += `\n${C.cyan}${C.bold}═══ Key Context Files (Full Content) ═══${C.reset}\n`;
472
538
 
473
539
  const keyFilesToRead = [
@@ -493,7 +559,7 @@ function generateFullContent() {
493
559
  const settingsExists = fs.existsSync('.claude/settings.json');
494
560
  content += `\n ${settingsExists ? `${C.green}✓${C.reset}` : `${C.dim}○${C.reset}`} .claude/settings.json\n`;
495
561
 
496
- // 8. EPICS FOLDER
562
+ // 9. EPICS FOLDER
497
563
  content += `\n${C.cyan}${C.bold}═══ Epic Files ═══${C.reset}\n`;
498
564
  const epicFiles = safeLs('docs/05-epics').filter(f => f.endsWith('.md') && f !== 'README.md');
499
565
  if (epicFiles.length > 0) {
@@ -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 {
@@ -134,7 +230,7 @@ function verifyScreenshots(rootDir) {
134
230
  const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
135
231
  let files;
136
232
  try {
137
- files = fs.readdirSync(fullPath).filter((file) => {
233
+ files = fs.readdirSync(fullPath).filter(file => {
138
234
  const ext = path.extname(file).toLowerCase();
139
235
  return imageExtensions.includes(ext);
140
236
  });
@@ -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 minIterations = visualMode ? 2 : 1; // Visual mode requires at least 2 iterations
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
- const modeLabel = visualMode ? ' [VISUAL MODE]' : '';
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
  );
@@ -320,44 +421,84 @@ function handleLoop(rootDir) {
320
421
  console.log(`${c.yellow}⚠ ${screenshotResult.output}${c.reset}`);
321
422
  if (screenshotResult.unverified.length > 0) {
322
423
  console.log(`${c.dim}Unverified screenshots:${c.reset}`);
323
- screenshotResult.unverified.slice(0, 5).forEach((file) => {
424
+ screenshotResult.unverified.slice(0, 5).forEach(file => {
324
425
  console.log(` ${c.yellow}- ${file}${c.reset}`);
325
426
  });
326
427
  if (screenshotResult.unverified.length > 5) {
327
- console.log(` ${c.dim}... and ${screenshotResult.unverified.length - 5} more${c.reset}`);
428
+ console.log(
429
+ ` ${c.dim}... and ${screenshotResult.unverified.length - 5} more${c.reset}`
430
+ );
328
431
  }
329
432
  }
330
433
  state.ralph_loop.screenshots_verified = false;
331
434
  }
332
435
  }
333
436
 
334
- // Visual Mode: Enforce minimum iterations
335
- if (visualMode && iteration < minIterations) {
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
+
336
462
  console.log('');
337
- console.log(`${c.yellow}⚠ Visual Mode requires ${minIterations}+ iterations for confirmation${c.reset}`);
338
- console.log(`${c.dim}Current: iteration ${iteration}. Let loop run once more to confirm.${c.reset}`);
463
+ console.log(
464
+ `${c.yellow} ${modeNames.join(' + ')} Mode requires ${minIterations}+ iterations for confirmation${c.reset}`
465
+ );
466
+ console.log(
467
+ `${c.dim}Current: iteration ${iteration}. Let loop run once more to confirm.${c.reset}`
468
+ );
339
469
 
340
470
  state.ralph_loop.iteration = iteration;
341
471
  saveSessionState(rootDir, state);
342
472
 
343
473
  console.log('');
344
- console.log(`${c.brand}▶ Continue reviewing. Loop will verify again.${c.reset}`);
474
+ console.log(`${c.brand}▶ Continue working. Loop will verify again.${c.reset}`);
345
475
  return;
346
476
  }
347
477
 
348
- // Check if both tests AND screenshots (in visual mode) passed
349
- const canComplete = testResult.passed && (!visualMode || screenshotResult.passed);
478
+ // Check if all verification modes passed
479
+ const canComplete =
480
+ testResult.passed &&
481
+ (!visualMode || screenshotResult.passed) &&
482
+ (!coverageMode || coverageResult.passed);
350
483
 
351
484
  if (!canComplete) {
352
- // Screenshots not verified yet
485
+ // Something not verified yet
353
486
  state.ralph_loop.iteration = iteration;
354
487
  saveSessionState(rootDir, state);
355
488
 
356
489
  console.log('');
357
- console.log(`${c.cyan}▶ Review unverified screenshots:${c.reset}`);
358
- console.log(`${c.dim} 1. View each screenshot in screenshots/ directory${c.reset}`);
359
- console.log(`${c.dim} 2. Rename verified files with 'verified-' prefix${c.reset}`);
360
- console.log(`${c.dim} 3. Loop will re-verify when you stop${c.reset}`);
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
+ }
361
502
  return;
362
503
  }
363
504
  console.log('');
@@ -452,15 +593,29 @@ function handleCLI() {
452
593
  if (!loop || !loop.enabled) {
453
594
  console.log(`${c.dim}Ralph Loop: not active${c.reset}`);
454
595
  } else {
455
- const modeLabel = loop.visual_mode ? ` ${c.cyan}[VISUAL]${c.reset}` : '';
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}`;
456
600
  console.log(`${c.green}Ralph Loop: active${c.reset}${modeLabel}`);
457
601
  console.log(` Epic: ${loop.epic}`);
458
602
  console.log(` Current Story: ${loop.current_story}`);
459
603
  console.log(` Iteration: ${loop.iteration || 0}/${loop.max_iterations || 20}`);
460
604
  if (loop.visual_mode) {
461
- const verified = loop.screenshots_verified ? `${c.green}yes${c.reset}` : `${c.yellow}no${c.reset}`;
605
+ const verified = loop.screenshots_verified
606
+ ? `${c.green}yes${c.reset}`
607
+ : `${c.yellow}no${c.reset}`;
462
608
  console.log(` Screenshots Verified: ${verified}`);
463
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
+ }
464
619
  }
465
620
  return true;
466
621
  }
@@ -491,6 +646,7 @@ function handleCLI() {
491
646
  const epicArg = args.find(a => a.startsWith('--epic='));
492
647
  const maxArg = args.find(a => a.startsWith('--max='));
493
648
  const visualArg = args.includes('--visual') || args.includes('-v');
649
+ const coverageArg = args.find(a => a.startsWith('--coverage='));
494
650
 
495
651
  if (!epicArg) {
496
652
  console.log(`${c.red}Error: --epic=EP-XXXX is required${c.reset}`);
@@ -498,9 +654,28 @@ function handleCLI() {
498
654
  }
499
655
 
500
656
  const epicId = epicArg.split('=')[1];
501
- const maxIterations = maxArg ? parseInt(maxArg.split('=')[1]) : 20;
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);
502
666
  const visualMode = visualArg;
503
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
+
504
679
  // Find first ready story in epic
505
680
  const status = getStatus(rootDir);
506
681
  const stories = status.stories || {};
@@ -524,6 +699,18 @@ function handleCLI() {
524
699
  // Mark first story as in_progress
525
700
  markStoryInProgress(rootDir, storyId);
526
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
+
527
714
  // Initialize loop state
528
715
  const state = getSessionState(rootDir);
529
716
  state.ralph_loop = {
@@ -534,6 +721,11 @@ function handleCLI() {
534
721
  max_iterations: maxIterations,
535
722
  visual_mode: visualMode,
536
723
  screenshots_verified: false,
724
+ coverage_mode: coverageMode,
725
+ coverage_threshold: coverageThreshold,
726
+ coverage_baseline: coverageBaseline,
727
+ coverage_current: coverageBaseline,
728
+ coverage_verified: false,
537
729
  started_at: new Date().toISOString(),
538
730
  };
539
731
  saveSessionState(rootDir, state);
@@ -541,7 +733,9 @@ function handleCLI() {
541
733
  const progress = getEpicProgress(status, epicId);
542
734
 
543
735
  console.log('');
544
- const modeLabel = visualMode ? ` ${c.cyan}[VISUAL MODE]${c.reset}` : '';
736
+ let modeLabel = '';
737
+ if (visualMode) modeLabel += ` ${c.cyan}[VISUAL]${c.reset}`;
738
+ if (coverageMode) modeLabel += ` ${c.magenta}[COVERAGE ≥${coverageThreshold}%]${c.reset}`;
545
739
  console.log(`${c.green}${c.bold}Ralph Loop Initialized${c.reset}${modeLabel}`);
546
740
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
547
741
  console.log(` Epic: ${c.cyan}${epicId}${c.reset}`);
@@ -549,6 +743,14 @@ function handleCLI() {
549
743
  console.log(` Max Iterations: ${maxIterations}`);
550
744
  if (visualMode) {
551
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) {
552
754
  console.log(` Min Iterations: 2 (for confirmation)`);
553
755
  }
554
756
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
@@ -577,17 +779,19 @@ function handleCLI() {
577
779
  ${c.brand}${c.bold}ralph-loop.js${c.reset} - Autonomous Story Processing
578
780
 
579
781
  ${c.bold}Usage:${c.reset}
580
- node scripts/ralph-loop.js Run loop check (Stop hook)
581
- node scripts/ralph-loop.js --init --epic=EP-XXX Initialize loop for epic
582
- node scripts/ralph-loop.js --init --epic=EP-XXX --visual Initialize with Visual Mode
583
- node scripts/ralph-loop.js --status Check loop status
584
- node scripts/ralph-loop.js --stop Stop the loop
585
- node scripts/ralph-loop.js --reset Reset loop state
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
586
789
 
587
790
  ${c.bold}Options:${c.reset}
588
791
  --epic=EP-XXXX Epic ID to process (required for --init)
589
792
  --max=N Max iterations (default: 20)
590
793
  --visual, -v Enable Visual Mode (screenshot verification)
794
+ --coverage=N Enable Coverage Mode (iterate until N% coverage)
591
795
 
592
796
  ${c.bold}Visual Mode:${c.reset}
593
797
  When --visual is enabled, the loop also verifies that all screenshots
@@ -596,16 +800,24 @@ ${c.bold}Visual Mode:${c.reset}
596
800
  This ensures Claude actually looks at UI screenshots before declaring
597
801
  completion. Requires minimum 2 iterations for confirmation.
598
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
+
599
811
  Workflow:
600
812
  1. Tests run → must pass
601
- 2. Screenshots verifiedall must have 'verified-' prefix
602
- 3. Minimum 2 iterations → prevents premature completion
813
+ 2. Coverage checked → must meet threshold
814
+ 3. Minimum 2 iterations → confirms coverage is stable
603
815
  4. Only then → story marked complete
604
816
 
605
817
  ${c.bold}How it works:${c.reset}
606
- 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop VISUAL=true
818
+ 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop COVERAGE=80
607
819
  2. Work on the current story
608
- 3. When you stop, this hook runs tests (and screenshot verification in Visual Mode)
820
+ 3. When you stop, this hook runs tests and verifications
609
821
  4. If all pass → story marked complete, next story loaded
610
822
  5. If any fail → failures shown, you continue fixing
611
823
  6. Loop repeats until epic done or max iterations
@@ -97,7 +97,7 @@ function getImageFiles(dir) {
97
97
 
98
98
  try {
99
99
  const files = fs.readdirSync(dir);
100
- return files.filter((file) => {
100
+ return files.filter(file => {
101
101
  const ext = path.extname(file).toLowerCase();
102
102
  return imageExtensions.includes(ext);
103
103
  });
@@ -172,7 +172,9 @@ function formatResult(result, options) {
172
172
 
173
173
  if (result.success) {
174
174
  console.log(`${c.green}${c.bold}All screenshots verified${c.reset}`);
175
- console.log(`${c.dim}${result.verified}/${result.total} screenshots have 'verified-' prefix${c.reset}`);
175
+ console.log(
176
+ `${c.dim}${result.verified}/${result.total} screenshots have 'verified-' prefix${c.reset}`
177
+ );
176
178
  } else {
177
179
  console.log(`${c.red}${c.bold}Unverified screenshots found${c.reset}`);
178
180
  console.log(`${c.dim}${result.verified}/${result.total} verified${c.reset}`);