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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/scripts/agileflow-welcome.js +31 -84
- package/scripts/auto-self-improve.js +23 -45
- package/scripts/check-update.js +35 -42
- package/scripts/damage-control/bash-tool-damage-control.js +257 -0
- package/scripts/damage-control/edit-tool-damage-control.js +279 -0
- package/scripts/damage-control/patterns.yaml +227 -0
- package/scripts/damage-control/write-tool-damage-control.js +274 -0
- package/scripts/obtain-context.js +22 -3
- package/scripts/ralph-loop.js +191 -63
- package/scripts/screenshot-verifier.js +213 -0
- package/scripts/session-manager.js +12 -33
- package/src/core/agents/configuration-damage-control.md +248 -0
- package/src/core/commands/babysit.md +30 -2
- package/src/core/commands/setup/visual-e2e.md +462 -0
- package/src/core/skills/_learnings/code-review.yaml +118 -0
- package/src/core/skills/_learnings/story-writer.yaml +71 -0
- package/tools/cli/installers/ide/claude-code.js +127 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/scripts/ralph-loop.js
CHANGED
|
@@ -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.
|
|
11
|
-
* 4.
|
|
12
|
-
* 5.
|
|
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
|
-
//
|
|
30
|
-
const c =
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
node scripts/ralph-loop.js --init --epic=EP-XXX
|
|
469
|
-
node scripts/ralph-loop.js --
|
|
470
|
-
node scripts/ralph-loop.js --
|
|
471
|
-
node scripts/ralph-loop.js --
|
|
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
|
|
482
|
-
5. If
|
|
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;
|