agileflow 2.78.0 → 2.79.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.
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * write-tool-damage-control.js - Enforce path protection for Write tool
5
+ *
6
+ * This PreToolUse hook runs before every Write tool execution.
7
+ * It checks the file path against patterns.yaml to block writes
8
+ * to protected paths.
9
+ *
10
+ * Path protection levels:
11
+ * zeroAccessPaths: Cannot read, write, edit, or delete
12
+ * readOnlyPaths: Can read, cannot write or delete
13
+ * noDeletePaths: Can read and write, cannot delete (Write is allowed)
14
+ *
15
+ * Exit codes:
16
+ * 0 = Allow write to proceed
17
+ * 2 = Block write
18
+ *
19
+ * Usage (as PreToolUse hook):
20
+ * node .claude/hooks/damage-control/write-tool-damage-control.js
21
+ *
22
+ * Environment:
23
+ * CLAUDE_TOOL_INPUT - JSON string with tool input (contains "file_path")
24
+ * CLAUDE_PROJECT_DIR - Project root directory
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ // ANSI colors for output
31
+ const c = {
32
+ reset: '\x1b[0m',
33
+ bold: '\x1b[1m',
34
+ red: '\x1b[31m',
35
+ yellow: '\x1b[33m',
36
+ cyan: '\x1b[36m',
37
+ };
38
+
39
+ // Exit codes
40
+ const EXIT_ALLOW = 0;
41
+ const EXIT_BLOCK = 2;
42
+
43
+ /**
44
+ * Load path protection rules from patterns.yaml
45
+ */
46
+ function loadPathRules(projectDir) {
47
+ const locations = [
48
+ path.join(projectDir, '.claude/hooks/damage-control/patterns.yaml'),
49
+ path.join(projectDir, '.agileflow/hooks/damage-control/patterns.yaml'),
50
+ path.join(projectDir, 'patterns.yaml'),
51
+ ];
52
+
53
+ for (const loc of locations) {
54
+ if (fs.existsSync(loc)) {
55
+ try {
56
+ const content = fs.readFileSync(loc, 'utf8');
57
+ return parsePathRules(content);
58
+ } catch (e) {
59
+ console.error(`Warning: Could not parse ${loc}: ${e.message}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ return getDefaultPathRules();
65
+ }
66
+
67
+ /**
68
+ * Parse path rules from YAML content
69
+ */
70
+ function parsePathRules(content) {
71
+ const rules = {
72
+ zeroAccessPaths: [],
73
+ readOnlyPaths: [],
74
+ noDeletePaths: [],
75
+ };
76
+
77
+ let currentSection = null;
78
+
79
+ const lines = content.split('\n');
80
+
81
+ for (const line of lines) {
82
+ if (line.trim().startsWith('#') || line.trim() === '') continue;
83
+
84
+ if (line.match(/^zeroAccessPaths:/)) {
85
+ currentSection = 'zeroAccessPaths';
86
+ continue;
87
+ }
88
+ if (line.match(/^readOnlyPaths:/)) {
89
+ currentSection = 'readOnlyPaths';
90
+ continue;
91
+ }
92
+ if (line.match(/^noDeletePaths:/)) {
93
+ currentSection = 'noDeletePaths';
94
+ continue;
95
+ }
96
+ if (line.match(/^(bashToolPatterns|askPatterns|agileflowPatterns|config):/)) {
97
+ currentSection = null;
98
+ continue;
99
+ }
100
+
101
+ if (currentSection && rules[currentSection]) {
102
+ const pathMatch = line.match(/^\s+-\s*['"]?(.+?)['"]?\s*$/);
103
+ if (pathMatch) {
104
+ rules[currentSection].push(pathMatch[1]);
105
+ }
106
+ }
107
+ }
108
+
109
+ return rules;
110
+ }
111
+
112
+ /**
113
+ * Default path rules if patterns.yaml not found
114
+ */
115
+ function getDefaultPathRules() {
116
+ return {
117
+ zeroAccessPaths: [
118
+ '~/.ssh/',
119
+ '~/.aws/credentials',
120
+ '.env',
121
+ '.env.local',
122
+ '.env.production',
123
+ ],
124
+ readOnlyPaths: [
125
+ '/etc/',
126
+ '~/.bashrc',
127
+ '~/.zshrc',
128
+ 'package-lock.json',
129
+ 'yarn.lock',
130
+ '.git/',
131
+ ],
132
+ noDeletePaths: [
133
+ '.agileflow/',
134
+ '.claude/',
135
+ 'docs/09-agents/status.json',
136
+ 'CLAUDE.md',
137
+ ],
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Expand home directory in path
143
+ */
144
+ function expandHome(filePath) {
145
+ if (filePath.startsWith('~/')) {
146
+ return path.join(process.env.HOME || '', filePath.slice(2));
147
+ }
148
+ return filePath;
149
+ }
150
+
151
+ /**
152
+ * Check if a file path matches a pattern
153
+ */
154
+ function pathMatches(filePath, pattern) {
155
+ const expandedPattern = expandHome(pattern);
156
+ const normalizedFile = path.normalize(filePath);
157
+ const normalizedPattern = path.normalize(expandedPattern);
158
+
159
+ // Exact match
160
+ if (normalizedFile === normalizedPattern) return true;
161
+
162
+ // Directory prefix match
163
+ if (pattern.endsWith('/')) {
164
+ if (normalizedFile.startsWith(normalizedPattern)) return true;
165
+ }
166
+
167
+ // Glob pattern (**)
168
+ if (pattern.includes('**/')) {
169
+ const globPart = pattern.split('**/')[1];
170
+ if (normalizedFile.includes(globPart)) return true;
171
+ }
172
+
173
+ // Wildcard at end
174
+ if (pattern.endsWith('*')) {
175
+ const prefix = normalizedPattern.slice(0, -1);
176
+ if (normalizedFile.startsWith(prefix)) return true;
177
+ }
178
+
179
+ // Basename match
180
+ if (!pattern.includes('/') && !pattern.includes(path.sep)) {
181
+ const basename = path.basename(normalizedFile);
182
+ if (basename === pattern) return true;
183
+ if (pattern.endsWith('*')) {
184
+ const patternBase = pattern.slice(0, -1);
185
+ if (basename.startsWith(patternBase)) return true;
186
+ }
187
+ }
188
+
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Check if file path is protected for writing
194
+ * Returns: { blocked: boolean, reason: string, level: string }
195
+ */
196
+ function checkPath(filePath, rules) {
197
+ // Check zero access paths (blocked completely)
198
+ for (const pattern of rules.zeroAccessPaths) {
199
+ if (pathMatches(filePath, pattern)) {
200
+ return {
201
+ blocked: true,
202
+ reason: `Path is in zero-access protected list: ${pattern}`,
203
+ level: 'zero-access',
204
+ };
205
+ }
206
+ }
207
+
208
+ // Check read-only paths (cannot write)
209
+ for (const pattern of rules.readOnlyPaths) {
210
+ if (pathMatches(filePath, pattern)) {
211
+ return {
212
+ blocked: true,
213
+ reason: `Path is read-only: ${pattern}`,
214
+ level: 'read-only',
215
+ };
216
+ }
217
+ }
218
+
219
+ // noDeletePaths allows writing, only blocks deletion
220
+ // so we don't block writes here
221
+
222
+ return { blocked: false, reason: null, level: null };
223
+ }
224
+
225
+ /**
226
+ * Main entry point
227
+ */
228
+ function main() {
229
+ const toolInput = process.env.CLAUDE_TOOL_INPUT;
230
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
231
+
232
+ if (!toolInput) {
233
+ process.exit(EXIT_ALLOW);
234
+ }
235
+
236
+ let input;
237
+ try {
238
+ input = JSON.parse(toolInput);
239
+ } catch (e) {
240
+ console.error('Error parsing CLAUDE_TOOL_INPUT:', e.message);
241
+ process.exit(EXIT_ALLOW);
242
+ }
243
+
244
+ const filePath = input.file_path;
245
+ if (!filePath) {
246
+ process.exit(EXIT_ALLOW);
247
+ }
248
+
249
+ // Resolve to absolute path
250
+ const absolutePath = path.isAbsolute(filePath)
251
+ ? filePath
252
+ : path.join(projectDir, filePath);
253
+
254
+ // Load rules
255
+ const rules = loadPathRules(projectDir);
256
+
257
+ // Check path
258
+ const result = checkPath(absolutePath, rules);
259
+
260
+ if (result.blocked) {
261
+ console.error(`${c.red}${c.bold}BLOCKED${c.reset}: ${result.reason}`);
262
+ console.error(`${c.yellow}File: ${filePath}${c.reset}`);
263
+ console.error(`${c.cyan}This file is protected by damage control (${result.level}).${c.reset}`);
264
+ process.exit(EXIT_BLOCK);
265
+ }
266
+
267
+ process.exit(EXIT_ALLOW);
268
+ }
269
+
270
+ if (require.main === module) {
271
+ main();
272
+ }
273
+
274
+ module.exports = { checkPath, loadPathRules, pathMatches };
@@ -28,11 +28,25 @@ if (commandName) {
28
28
  if (fs.existsSync(sessionStatePath)) {
29
29
  try {
30
30
  const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
31
- state.active_command = {
31
+
32
+ // Initialize active_commands array if not present
33
+ if (!Array.isArray(state.active_commands)) {
34
+ state.active_commands = [];
35
+ }
36
+
37
+ // Remove any existing entry for this command (avoid duplicates)
38
+ state.active_commands = state.active_commands.filter(c => c.name !== commandName);
39
+
40
+ // Add the new command
41
+ state.active_commands.push({
32
42
  name: commandName,
33
43
  activated_at: new Date().toISOString(),
34
44
  state: {},
35
- };
45
+ });
46
+
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
+
36
50
  fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
37
51
  } catch (e) {
38
52
  // Silently continue if session state can't be updated
@@ -370,7 +384,12 @@ function generateFullContent() {
370
384
  } else {
371
385
  content += `${C.dim}No active session${C.reset}\n`;
372
386
  }
373
- if (sessionState.active_command) {
387
+ // Show all active commands (array)
388
+ if (Array.isArray(sessionState.active_commands) && sessionState.active_commands.length > 0) {
389
+ const cmdNames = sessionState.active_commands.map(c => c.name).join(', ');
390
+ content += `Active commands: ${C.skyBlue}${cmdNames}${C.reset}\n`;
391
+ } else if (sessionState.active_command) {
392
+ // Backwards compatibility for old format
374
393
  content += `Active command: ${C.skyBlue}${sessionState.active_command.name}${C.reset}\n`;
375
394
  }
376
395
  } else {
@@ -7,12 +7,18 @@
7
7
  * It runs as a Stop hook and handles:
8
8
  * 1. Checking if loop mode is enabled
9
9
  * 2. Running test validation
10
- * 3. Updating story status on success
11
- * 4. Feeding context back for next iteration
12
- * 5. Tracking iterations and enforcing limits
10
+ * 3. Running screenshot verification (Visual Mode)
11
+ * 4. Updating story status on success
12
+ * 5. Feeding context back for next iteration
13
+ * 6. Tracking iterations and enforcing limits
13
14
  *
14
15
  * Named after the "Ralph Wiggum" pattern from Anthropic.
15
16
  *
17
+ * Visual Mode:
18
+ * When visual_mode is enabled, the loop also verifies that all
19
+ * screenshots have been reviewed (verified- prefix). This ensures
20
+ * Claude actually looks at UI screenshots before declaring completion.
21
+ *
16
22
  * Usage (as Stop hook):
17
23
  * node scripts/ralph-loop.js
18
24
  *
@@ -26,78 +32,46 @@ const fs = require('fs');
26
32
  const path = require('path');
27
33
  const { execSync, spawnSync } = require('child_process');
28
34
 
29
- // ANSI colors
30
- const c = {
31
- reset: '\x1b[0m',
32
- bold: '\x1b[1m',
33
- dim: '\x1b[2m',
34
- red: '\x1b[31m',
35
- green: '\x1b[32m',
36
- yellow: '\x1b[33m',
37
- blue: '\x1b[34m',
38
- cyan: '\x1b[36m',
39
- brand: '\x1b[38;2;232;104;58m',
40
- };
41
-
42
- // Find project root
43
- function getProjectRoot() {
44
- let dir = process.cwd();
45
- while (!fs.existsSync(path.join(dir, '.agileflow')) && dir !== '/') {
46
- dir = path.dirname(dir);
47
- }
48
- return dir !== '/' ? dir : process.cwd();
49
- }
35
+ // Shared utilities
36
+ const { c } = require('../lib/colors');
37
+ const { getProjectRoot } = require('../lib/paths');
38
+ const { safeReadJSON, safeWriteJSON } = require('../lib/errors');
50
39
 
51
40
  // Read session state
52
41
  function getSessionState(rootDir) {
53
42
  const statePath = path.join(rootDir, 'docs/09-agents/session-state.json');
54
- try {
55
- if (fs.existsSync(statePath)) {
56
- return JSON.parse(fs.readFileSync(statePath, 'utf8'));
57
- }
58
- } catch (e) {}
59
- return {};
43
+ const result = safeReadJSON(statePath, { defaultValue: {} });
44
+ return result.ok ? result.data : {};
60
45
  }
61
46
 
62
47
  // Write session state
63
48
  function saveSessionState(rootDir, state) {
64
49
  const statePath = path.join(rootDir, 'docs/09-agents/session-state.json');
65
- const dir = path.dirname(statePath);
66
- if (!fs.existsSync(dir)) {
67
- fs.mkdirSync(dir, { recursive: true });
68
- }
69
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
50
+ safeWriteJSON(statePath, state, { createDir: true });
70
51
  }
71
52
 
72
53
  // Read status.json for stories
73
54
  function getStatus(rootDir) {
74
55
  const statusPath = path.join(rootDir, 'docs/09-agents/status.json');
75
- try {
76
- if (fs.existsSync(statusPath)) {
77
- return JSON.parse(fs.readFileSync(statusPath, 'utf8'));
78
- }
79
- } catch (e) {}
80
- return { stories: {}, epics: {} };
56
+ const result = safeReadJSON(statusPath, { defaultValue: { stories: {}, epics: {} } });
57
+ return result.ok ? result.data : { stories: {}, epics: {} };
81
58
  }
82
59
 
83
60
  // Save status.json
84
61
  function saveStatus(rootDir, status) {
85
62
  const statusPath = path.join(rootDir, 'docs/09-agents/status.json');
86
- fs.writeFileSync(statusPath, JSON.stringify(status, null, 2) + '\n');
63
+ safeWriteJSON(statusPath, status);
87
64
  }
88
65
 
89
66
  // Get test command from package.json or metadata
90
67
  function getTestCommand(rootDir) {
91
68
  // Check agileflow metadata first
92
- try {
93
- const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
94
- if (fs.existsSync(metadataPath)) {
95
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
96
- if (metadata.ralph_loop?.test_command) {
97
- return metadata.ralph_loop.test_command;
98
- }
99
- }
100
- } catch (e) {}
69
+ const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
70
+ const result = safeReadJSON(metadataPath, { defaultValue: {} });
71
+
72
+ if (result.ok && result.data?.ralph_loop?.test_command) {
73
+ return result.data.ralph_loop.test_command;
74
+ }
101
75
 
102
76
  // Default to npm test
103
77
  return 'npm test';
@@ -129,6 +103,73 @@ function runTests(rootDir, testCommand) {
129
103
  return result;
130
104
  }
131
105
 
106
+ // Get screenshots directory from metadata or default
107
+ function getScreenshotsDir(rootDir) {
108
+ try {
109
+ const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
110
+ if (fs.existsSync(metadataPath)) {
111
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
112
+ if (metadata.ralph_loop?.screenshots_dir) {
113
+ return metadata.ralph_loop.screenshots_dir;
114
+ }
115
+ }
116
+ } catch (e) {}
117
+ return './screenshots';
118
+ }
119
+
120
+ // Run screenshot verification (Visual Mode)
121
+ function verifyScreenshots(rootDir) {
122
+ const result = { passed: false, output: '', total: 0, verified: 0, unverified: [] };
123
+ const screenshotsDir = getScreenshotsDir(rootDir);
124
+ const fullPath = path.resolve(rootDir, screenshotsDir);
125
+
126
+ // Check if directory exists
127
+ if (!fs.existsSync(fullPath)) {
128
+ result.passed = true; // No screenshots = nothing to verify
129
+ result.output = 'No screenshots directory found';
130
+ return result;
131
+ }
132
+
133
+ // Get all image files
134
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
135
+ let files;
136
+ try {
137
+ files = fs.readdirSync(fullPath).filter((file) => {
138
+ const ext = path.extname(file).toLowerCase();
139
+ return imageExtensions.includes(ext);
140
+ });
141
+ } catch (e) {
142
+ result.output = `Error reading screenshots directory: ${e.message}`;
143
+ return result;
144
+ }
145
+
146
+ if (files.length === 0) {
147
+ result.passed = true;
148
+ result.output = 'No screenshots found in directory';
149
+ return result;
150
+ }
151
+
152
+ // Check each file for verified- prefix
153
+ for (const file of files) {
154
+ if (file.startsWith('verified-')) {
155
+ result.verified++;
156
+ } else {
157
+ result.unverified.push(file);
158
+ }
159
+ }
160
+
161
+ result.total = files.length;
162
+ result.passed = result.unverified.length === 0;
163
+
164
+ if (result.passed) {
165
+ result.output = `All ${result.total} screenshots verified`;
166
+ } else {
167
+ result.output = `${result.unverified.length} screenshots missing 'verified-' prefix`;
168
+ }
169
+
170
+ return result;
171
+ }
172
+
132
173
  // Get next ready story in epic
133
174
  function getNextStory(status, epicId, currentStoryId) {
134
175
  const stories = status.stories || {};
@@ -211,13 +252,16 @@ function handleLoop(rootDir) {
211
252
  const status = getStatus(rootDir);
212
253
  const iteration = (loop.iteration || 0) + 1;
213
254
  const maxIterations = loop.max_iterations || 20;
255
+ const visualMode = loop.visual_mode || false;
256
+ const minIterations = visualMode ? 2 : 1; // Visual mode requires at least 2 iterations
214
257
 
215
258
  console.log('');
216
259
  console.log(
217
260
  `${c.brand}${c.bold}══════════════════════════════════════════════════════════${c.reset}`
218
261
  );
262
+ const modeLabel = visualMode ? ' [VISUAL MODE]' : '';
219
263
  console.log(
220
- `${c.brand}${c.bold} RALPH LOOP - Iteration ${iteration}/${maxIterations}${c.reset}`
264
+ `${c.brand}${c.bold} RALPH LOOP - Iteration ${iteration}/${maxIterations}${modeLabel}${c.reset}`
221
265
  );
222
266
  console.log(
223
267
  `${c.brand}${c.bold}══════════════════════════════════════════════════════════${c.reset}`
@@ -261,6 +305,61 @@ function handleLoop(rootDir) {
261
305
 
262
306
  if (testResult.passed) {
263
307
  console.log(`${c.green}✓ Tests passed${c.reset} (${(testResult.duration / 1000).toFixed(1)}s)`);
308
+
309
+ // Visual Mode: Also verify screenshots
310
+ let screenshotResult = { passed: true };
311
+ if (visualMode) {
312
+ console.log('');
313
+ console.log(`${c.blue}Verifying screenshots...${c.reset}`);
314
+ screenshotResult = verifyScreenshots(rootDir);
315
+
316
+ if (screenshotResult.passed) {
317
+ console.log(`${c.green}✓ ${screenshotResult.output}${c.reset}`);
318
+ state.ralph_loop.screenshots_verified = true;
319
+ } else {
320
+ console.log(`${c.yellow}⚠ ${screenshotResult.output}${c.reset}`);
321
+ if (screenshotResult.unverified.length > 0) {
322
+ console.log(`${c.dim}Unverified screenshots:${c.reset}`);
323
+ screenshotResult.unverified.slice(0, 5).forEach((file) => {
324
+ console.log(` ${c.yellow}- ${file}${c.reset}`);
325
+ });
326
+ if (screenshotResult.unverified.length > 5) {
327
+ console.log(` ${c.dim}... and ${screenshotResult.unverified.length - 5} more${c.reset}`);
328
+ }
329
+ }
330
+ state.ralph_loop.screenshots_verified = false;
331
+ }
332
+ }
333
+
334
+ // Visual Mode: Enforce minimum iterations
335
+ if (visualMode && iteration < minIterations) {
336
+ 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}`);
339
+
340
+ state.ralph_loop.iteration = iteration;
341
+ saveSessionState(rootDir, state);
342
+
343
+ console.log('');
344
+ console.log(`${c.brand}▶ Continue reviewing. Loop will verify again.${c.reset}`);
345
+ return;
346
+ }
347
+
348
+ // Check if both tests AND screenshots (in visual mode) passed
349
+ const canComplete = testResult.passed && (!visualMode || screenshotResult.passed);
350
+
351
+ if (!canComplete) {
352
+ // Screenshots not verified yet
353
+ state.ralph_loop.iteration = iteration;
354
+ saveSessionState(rootDir, state);
355
+
356
+ 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}`);
361
+ return;
362
+ }
264
363
  console.log('');
265
364
 
266
365
  // Mark story complete
@@ -353,10 +452,15 @@ function handleCLI() {
353
452
  if (!loop || !loop.enabled) {
354
453
  console.log(`${c.dim}Ralph Loop: not active${c.reset}`);
355
454
  } else {
356
- console.log(`${c.green}Ralph Loop: active${c.reset}`);
455
+ const modeLabel = loop.visual_mode ? ` ${c.cyan}[VISUAL]${c.reset}` : '';
456
+ console.log(`${c.green}Ralph Loop: active${c.reset}${modeLabel}`);
357
457
  console.log(` Epic: ${loop.epic}`);
358
458
  console.log(` Current Story: ${loop.current_story}`);
359
459
  console.log(` Iteration: ${loop.iteration || 0}/${loop.max_iterations || 20}`);
460
+ if (loop.visual_mode) {
461
+ const verified = loop.screenshots_verified ? `${c.green}yes${c.reset}` : `${c.yellow}no${c.reset}`;
462
+ console.log(` Screenshots Verified: ${verified}`);
463
+ }
360
464
  }
361
465
  return true;
362
466
  }
@@ -386,6 +490,7 @@ function handleCLI() {
386
490
  if (args.some(a => a.startsWith('--init'))) {
387
491
  const epicArg = args.find(a => a.startsWith('--epic='));
388
492
  const maxArg = args.find(a => a.startsWith('--max='));
493
+ const visualArg = args.includes('--visual') || args.includes('-v');
389
494
 
390
495
  if (!epicArg) {
391
496
  console.log(`${c.red}Error: --epic=EP-XXXX is required${c.reset}`);
@@ -394,6 +499,7 @@ function handleCLI() {
394
499
 
395
500
  const epicId = epicArg.split('=')[1];
396
501
  const maxIterations = maxArg ? parseInt(maxArg.split('=')[1]) : 20;
502
+ const visualMode = visualArg;
397
503
 
398
504
  // Find first ready story in epic
399
505
  const status = getStatus(rootDir);
@@ -426,6 +532,8 @@ function handleCLI() {
426
532
  current_story: storyId,
427
533
  iteration: 0,
428
534
  max_iterations: maxIterations,
535
+ visual_mode: visualMode,
536
+ screenshots_verified: false,
429
537
  started_at: new Date().toISOString(),
430
538
  };
431
539
  saveSessionState(rootDir, state);
@@ -433,11 +541,16 @@ function handleCLI() {
433
541
  const progress = getEpicProgress(status, epicId);
434
542
 
435
543
  console.log('');
436
- console.log(`${c.green}${c.bold}Ralph Loop Initialized${c.reset}`);
544
+ const modeLabel = visualMode ? ` ${c.cyan}[VISUAL MODE]${c.reset}` : '';
545
+ console.log(`${c.green}${c.bold}Ralph Loop Initialized${c.reset}${modeLabel}`);
437
546
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
438
547
  console.log(` Epic: ${c.cyan}${epicId}${c.reset}`);
439
548
  console.log(` Stories: ${progress.ready} ready, ${progress.total} total`);
440
549
  console.log(` Max Iterations: ${maxIterations}`);
550
+ if (visualMode) {
551
+ console.log(` Visual Mode: ${c.cyan}enabled${c.reset} (screenshot verification)`);
552
+ console.log(` Min Iterations: 2 (for confirmation)`);
553
+ }
441
554
  console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
442
555
  console.log('');
443
556
  console.log(`${c.brand}▶ Starting Story:${c.reset} ${storyId}`);
@@ -464,22 +577,37 @@ function handleCLI() {
464
577
  ${c.brand}${c.bold}ralph-loop.js${c.reset} - Autonomous Story Processing
465
578
 
466
579
  ${c.bold}Usage:${c.reset}
467
- node scripts/ralph-loop.js Run loop check (Stop hook)
468
- node scripts/ralph-loop.js --init --epic=EP-XXX Initialize loop for epic
469
- node scripts/ralph-loop.js --status Check loop status
470
- node scripts/ralph-loop.js --stop Stop the loop
471
- node scripts/ralph-loop.js --reset Reset loop state
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
472
586
 
473
587
  ${c.bold}Options:${c.reset}
474
588
  --epic=EP-XXXX Epic ID to process (required for --init)
475
589
  --max=N Max iterations (default: 20)
590
+ --visual, -v Enable Visual Mode (screenshot verification)
591
+
592
+ ${c.bold}Visual Mode:${c.reset}
593
+ When --visual is enabled, the loop also verifies that all screenshots
594
+ in the screenshots/ directory have been reviewed (verified- prefix).
595
+
596
+ This ensures Claude actually looks at UI screenshots before declaring
597
+ completion. Requires minimum 2 iterations for confirmation.
598
+
599
+ Workflow:
600
+ 1. Tests run → must pass
601
+ 2. Screenshots verified → all must have 'verified-' prefix
602
+ 3. Minimum 2 iterations → prevents premature completion
603
+ 4. Only then → story marked complete
476
604
 
477
605
  ${c.bold}How it works:${c.reset}
478
- 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop
606
+ 1. Start loop with /agileflow:babysit EPIC=EP-XXX MODE=loop VISUAL=true
479
607
  2. Work on the current story
480
- 3. When you stop, this hook runs tests
481
- 4. If tests pass → story marked complete, next story loaded
482
- 5. If tests fail → failures shown, you continue fixing
608
+ 3. When you stop, this hook runs tests (and screenshot verification in Visual Mode)
609
+ 4. If all pass → story marked complete, next story loaded
610
+ 5. If any fail → failures shown, you continue fixing
483
611
  6. Loop repeats until epic done or max iterations
484
612
  `);
485
613
  return true;