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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/scripts/agent-loop.js +765 -0
- package/scripts/agileflow-configure.js +129 -18
- package/scripts/agileflow-welcome.js +113 -16
- package/scripts/damage-control/bash-tool-damage-control.js +7 -6
- package/scripts/damage-control/edit-tool-damage-control.js +4 -24
- package/scripts/damage-control/patterns.yaml +32 -32
- package/scripts/damage-control/write-tool-damage-control.js +4 -24
- package/scripts/damage-control-bash.js +38 -125
- package/scripts/damage-control-edit.js +22 -165
- package/scripts/damage-control-write.js +22 -165
- package/scripts/get-env.js +6 -6
- package/scripts/lib/damage-control-utils.js +251 -0
- package/scripts/obtain-context.js +103 -37
- package/scripts/ralph-loop.js +243 -31
- package/scripts/screenshot-verifier.js +4 -2
- package/scripts/session-manager.js +434 -20
- package/src/core/agents/configuration-visual-e2e.md +300 -0
- package/src/core/agents/orchestrator.md +166 -0
- package/src/core/commands/babysit.md +61 -15
- package/src/core/commands/configure.md +408 -99
- package/src/core/commands/session/end.md +332 -103
- package/src/core/experts/documentation/expertise.yaml +25 -0
- package/tools/cli/commands/start.js +19 -21
- package/tools/cli/installers/ide/claude-code.js +32 -19
- package/tools/cli/tui/Dashboard.js +3 -4
- package/tools/postinstall.js +1 -9
- package/src/core/commands/setup/visual-e2e.md +0 -462
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agent-loop.js - Isolated loop manager for domain agents
|
|
4
|
+
*
|
|
5
|
+
* Enables agents to run their own quality-gate loops independently,
|
|
6
|
+
* with state isolation to prevent race conditions when multiple
|
|
7
|
+
* agents run in parallel.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* agent-loop.js --init --gate=coverage --threshold=80 --max=5 --loop-id=uuid
|
|
11
|
+
* agent-loop.js --check --loop-id=uuid
|
|
12
|
+
* agent-loop.js --status --loop-id=uuid
|
|
13
|
+
* agent-loop.js --complete --loop-id=uuid
|
|
14
|
+
* agent-loop.js --abort --loop-id=uuid --reason=timeout
|
|
15
|
+
*
|
|
16
|
+
* State stored in: .agileflow/sessions/agent-loops/{loop-id}.json
|
|
17
|
+
* Events emitted to: docs/09-agents/bus/log.jsonl
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { execSync, spawnSync } = require('child_process');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
|
|
25
|
+
// Shared utilities
|
|
26
|
+
const { c } = require('../lib/colors');
|
|
27
|
+
const { getProjectRoot } = require('../lib/paths');
|
|
28
|
+
const { safeReadJSON, safeWriteJSON } = require('../lib/errors');
|
|
29
|
+
|
|
30
|
+
const ROOT = getProjectRoot();
|
|
31
|
+
const LOOPS_DIR = path.join(ROOT, '.agileflow', 'sessions', 'agent-loops');
|
|
32
|
+
const BUS_PATH = path.join(ROOT, 'docs', '09-agents', 'bus', 'log.jsonl');
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// CONSTANTS
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const MAX_ITERATIONS_HARD_LIMIT = 5;
|
|
39
|
+
const MAX_AGENTS_HARD_LIMIT = 3;
|
|
40
|
+
const TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per loop
|
|
41
|
+
const STALL_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes without progress
|
|
42
|
+
|
|
43
|
+
const GATES = {
|
|
44
|
+
tests: { name: 'Tests', metric: 'pass/fail' },
|
|
45
|
+
coverage: { name: 'Coverage', metric: 'percentage' },
|
|
46
|
+
visual: { name: 'Visual', metric: 'verified/unverified' },
|
|
47
|
+
lint: { name: 'Lint', metric: 'pass/fail' },
|
|
48
|
+
types: { name: 'TypeScript', metric: 'pass/fail' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// UTILITY FUNCTIONS
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
function ensureLoopsDir() {
|
|
56
|
+
if (!fs.existsSync(LOOPS_DIR)) {
|
|
57
|
+
fs.mkdirSync(LOOPS_DIR, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getLoopPath(loopId) {
|
|
62
|
+
return path.join(LOOPS_DIR, `${loopId}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function generateLoopId() {
|
|
66
|
+
return crypto.randomUUID().split('-')[0]; // Short UUID (8 chars)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadLoop(loopId) {
|
|
70
|
+
const loopPath = getLoopPath(loopId);
|
|
71
|
+
const result = safeReadJSON(loopPath, { defaultValue: null });
|
|
72
|
+
return result.ok ? result.data : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function saveLoop(loopId, state) {
|
|
76
|
+
ensureLoopsDir();
|
|
77
|
+
const loopPath = getLoopPath(loopId);
|
|
78
|
+
state.updated_at = new Date().toISOString();
|
|
79
|
+
safeWriteJSON(loopPath, state, { createDir: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emitEvent(event) {
|
|
83
|
+
const busDir = path.dirname(BUS_PATH);
|
|
84
|
+
if (!fs.existsSync(busDir)) {
|
|
85
|
+
fs.mkdirSync(busDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const line =
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
...event,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
}) + '\n';
|
|
93
|
+
|
|
94
|
+
fs.appendFileSync(BUS_PATH, line);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// QUALITY GATE CHECKS
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
function getTestCommand() {
|
|
102
|
+
const metadataPath = path.join(ROOT, 'docs/00-meta/agileflow-metadata.json');
|
|
103
|
+
const result = safeReadJSON(metadataPath, { defaultValue: {} });
|
|
104
|
+
|
|
105
|
+
if (result.ok && result.data?.ralph_loop?.test_command) {
|
|
106
|
+
return result.data.ralph_loop.test_command;
|
|
107
|
+
}
|
|
108
|
+
return 'npm test';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getCoverageCommand() {
|
|
112
|
+
const metadataPath = path.join(ROOT, 'docs/00-meta/agileflow-metadata.json');
|
|
113
|
+
const result = safeReadJSON(metadataPath, { defaultValue: {} });
|
|
114
|
+
|
|
115
|
+
if (result.ok && result.data?.ralph_loop?.coverage_command) {
|
|
116
|
+
return result.data.ralph_loop.coverage_command;
|
|
117
|
+
}
|
|
118
|
+
return 'npm run test:coverage || npm test -- --coverage';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getCoverageReportPath() {
|
|
122
|
+
const metadataPath = path.join(ROOT, '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
|
+
return 'coverage/coverage-summary.json';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runCommand(cmd) {
|
|
132
|
+
try {
|
|
133
|
+
execSync(cmd, { cwd: ROOT, stdio: 'inherit' });
|
|
134
|
+
return { passed: true, exitCode: 0 };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { passed: false, exitCode: error.status || 1 };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkTestsGate() {
|
|
141
|
+
const cmd = getTestCommand();
|
|
142
|
+
console.log(`${c.dim}Running: ${cmd}${c.reset}`);
|
|
143
|
+
const result = runCommand(cmd);
|
|
144
|
+
return {
|
|
145
|
+
passed: result.passed,
|
|
146
|
+
value: result.passed ? 100 : 0,
|
|
147
|
+
message: result.passed ? 'All tests passing' : 'Tests failing',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkCoverageGate(threshold) {
|
|
152
|
+
// Run coverage command
|
|
153
|
+
const cmd = getCoverageCommand();
|
|
154
|
+
console.log(`${c.dim}Running: ${cmd}${c.reset}`);
|
|
155
|
+
runCommand(cmd);
|
|
156
|
+
|
|
157
|
+
// Parse coverage report
|
|
158
|
+
const reportPath = path.join(ROOT, getCoverageReportPath());
|
|
159
|
+
const report = safeReadJSON(reportPath, { defaultValue: null });
|
|
160
|
+
|
|
161
|
+
if (!report.ok || !report.data) {
|
|
162
|
+
return {
|
|
163
|
+
passed: false,
|
|
164
|
+
value: 0,
|
|
165
|
+
message: `Coverage report not found at ${getCoverageReportPath()}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const total = report.data.total;
|
|
170
|
+
const coverage = total?.lines?.pct || total?.statements?.pct || 0;
|
|
171
|
+
const passed = coverage >= threshold;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
passed,
|
|
175
|
+
value: coverage,
|
|
176
|
+
message: passed
|
|
177
|
+
? `Coverage ${coverage.toFixed(1)}% >= ${threshold}%`
|
|
178
|
+
: `Coverage ${coverage.toFixed(1)}% < ${threshold}% (need ${(threshold - coverage).toFixed(1)}% more)`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function checkVisualGate() {
|
|
183
|
+
const screenshotsDir = path.join(ROOT, 'screenshots');
|
|
184
|
+
|
|
185
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
186
|
+
return {
|
|
187
|
+
passed: false,
|
|
188
|
+
value: 0,
|
|
189
|
+
message: 'Screenshots directory not found',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const files = fs
|
|
194
|
+
.readdirSync(screenshotsDir)
|
|
195
|
+
.filter(f => f.endsWith('.png') || f.endsWith('.jpg') || f.endsWith('.jpeg'));
|
|
196
|
+
|
|
197
|
+
if (files.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
passed: false,
|
|
200
|
+
value: 0,
|
|
201
|
+
message: 'No screenshots found',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const verified = files.filter(f => f.startsWith('verified-'));
|
|
206
|
+
const allVerified = verified.length === files.length;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
passed: allVerified,
|
|
210
|
+
value: (verified.length / files.length) * 100,
|
|
211
|
+
message: allVerified
|
|
212
|
+
? `All ${files.length} screenshots verified`
|
|
213
|
+
: `${verified.length}/${files.length} screenshots verified (missing: ${files.filter(f => !f.startsWith('verified-')).join(', ')})`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function checkLintGate() {
|
|
218
|
+
console.log(`${c.dim}Running: npm run lint${c.reset}`);
|
|
219
|
+
const result = runCommand('npm run lint');
|
|
220
|
+
return {
|
|
221
|
+
passed: result.passed,
|
|
222
|
+
value: result.passed ? 100 : 0,
|
|
223
|
+
message: result.passed ? 'Lint passing' : 'Lint errors found',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function checkTypesGate() {
|
|
228
|
+
console.log(`${c.dim}Running: npx tsc --noEmit${c.reset}`);
|
|
229
|
+
const result = runCommand('npx tsc --noEmit');
|
|
230
|
+
return {
|
|
231
|
+
passed: result.passed,
|
|
232
|
+
value: result.passed ? 100 : 0,
|
|
233
|
+
message: result.passed ? 'No type errors' : 'Type errors found',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function checkGate(gate, threshold) {
|
|
238
|
+
switch (gate) {
|
|
239
|
+
case 'tests':
|
|
240
|
+
return checkTestsGate();
|
|
241
|
+
case 'coverage':
|
|
242
|
+
return checkCoverageGate(threshold);
|
|
243
|
+
case 'visual':
|
|
244
|
+
return checkVisualGate();
|
|
245
|
+
case 'lint':
|
|
246
|
+
return checkLintGate();
|
|
247
|
+
case 'types':
|
|
248
|
+
return checkTypesGate();
|
|
249
|
+
default:
|
|
250
|
+
return { passed: false, value: 0, message: `Unknown gate: ${gate}` };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================================================
|
|
255
|
+
// CORE LOOP FUNCTIONS
|
|
256
|
+
// ============================================================================
|
|
257
|
+
|
|
258
|
+
function initLoop(options) {
|
|
259
|
+
const {
|
|
260
|
+
loopId = generateLoopId(),
|
|
261
|
+
gate,
|
|
262
|
+
threshold = 0,
|
|
263
|
+
maxIterations = MAX_ITERATIONS_HARD_LIMIT,
|
|
264
|
+
agentType = 'unknown',
|
|
265
|
+
parentId = null,
|
|
266
|
+
} = options;
|
|
267
|
+
|
|
268
|
+
// Validate gate
|
|
269
|
+
if (!GATES[gate]) {
|
|
270
|
+
console.error(`${c.red}Invalid gate: ${gate}${c.reset}`);
|
|
271
|
+
console.error(`Available gates: ${Object.keys(GATES).join(', ')}`);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Enforce hard limits
|
|
276
|
+
const maxIter = Math.min(maxIterations, MAX_ITERATIONS_HARD_LIMIT);
|
|
277
|
+
|
|
278
|
+
// Check if we're under the agent limit
|
|
279
|
+
ensureLoopsDir();
|
|
280
|
+
const existingLoops = fs
|
|
281
|
+
.readdirSync(LOOPS_DIR)
|
|
282
|
+
.filter(f => f.endsWith('.json'))
|
|
283
|
+
.map(f => {
|
|
284
|
+
const loop = safeReadJSON(path.join(LOOPS_DIR, f), { defaultValue: null });
|
|
285
|
+
return loop.ok ? loop.data : null;
|
|
286
|
+
})
|
|
287
|
+
.filter(l => l && l.status === 'running');
|
|
288
|
+
|
|
289
|
+
if (existingLoops.length >= MAX_AGENTS_HARD_LIMIT) {
|
|
290
|
+
console.error(
|
|
291
|
+
`${c.red}Max concurrent agent loops (${MAX_AGENTS_HARD_LIMIT}) reached${c.reset}`
|
|
292
|
+
);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const state = {
|
|
297
|
+
loop_id: loopId,
|
|
298
|
+
agent_type: agentType,
|
|
299
|
+
parent_orchestration: parentId,
|
|
300
|
+
quality_gate: gate,
|
|
301
|
+
threshold,
|
|
302
|
+
iteration: 0,
|
|
303
|
+
max_iterations: maxIter,
|
|
304
|
+
current_value: 0,
|
|
305
|
+
status: 'running',
|
|
306
|
+
regression_count: 0,
|
|
307
|
+
started_at: new Date().toISOString(),
|
|
308
|
+
last_progress_at: new Date().toISOString(),
|
|
309
|
+
events: [],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
saveLoop(loopId, state);
|
|
313
|
+
|
|
314
|
+
emitEvent({
|
|
315
|
+
type: 'agent_loop',
|
|
316
|
+
event: 'init',
|
|
317
|
+
loop_id: loopId,
|
|
318
|
+
agent: agentType,
|
|
319
|
+
gate,
|
|
320
|
+
threshold,
|
|
321
|
+
max_iterations: maxIter,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
console.log(`${c.green}${c.bold}Agent Loop Initialized${c.reset}`);
|
|
325
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
326
|
+
console.log(` Loop ID: ${c.cyan}${loopId}${c.reset}`);
|
|
327
|
+
console.log(` Gate: ${c.magenta}${GATES[gate].name}${c.reset}`);
|
|
328
|
+
console.log(` Threshold: ${threshold > 0 ? threshold + '%' : 'pass/fail'}`);
|
|
329
|
+
console.log(` Max Iterations: ${maxIter}`);
|
|
330
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
331
|
+
|
|
332
|
+
return loopId;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function checkLoop(loopId) {
|
|
336
|
+
const state = loadLoop(loopId);
|
|
337
|
+
|
|
338
|
+
if (!state) {
|
|
339
|
+
console.error(`${c.red}Loop not found: ${loopId}${c.reset}`);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (state.status !== 'running') {
|
|
344
|
+
console.log(`${c.yellow}Loop already ${state.status}${c.reset}`);
|
|
345
|
+
return state;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check timeout
|
|
349
|
+
const elapsed = Date.now() - new Date(state.started_at).getTime();
|
|
350
|
+
if (elapsed > TIMEOUT_MS) {
|
|
351
|
+
state.status = 'aborted';
|
|
352
|
+
state.stopped_reason = 'timeout';
|
|
353
|
+
saveLoop(loopId, state);
|
|
354
|
+
|
|
355
|
+
emitEvent({
|
|
356
|
+
type: 'agent_loop',
|
|
357
|
+
event: 'abort',
|
|
358
|
+
loop_id: loopId,
|
|
359
|
+
agent: state.agent_type,
|
|
360
|
+
reason: 'timeout',
|
|
361
|
+
iteration: state.iteration,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
console.log(`${c.red}Loop aborted: timeout (${Math.round(elapsed / 1000)}s)${c.reset}`);
|
|
365
|
+
return state;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Increment iteration
|
|
369
|
+
state.iteration++;
|
|
370
|
+
|
|
371
|
+
// Check max iterations
|
|
372
|
+
if (state.iteration > state.max_iterations) {
|
|
373
|
+
state.status = 'failed';
|
|
374
|
+
state.stopped_reason = 'max_iterations';
|
|
375
|
+
saveLoop(loopId, state);
|
|
376
|
+
|
|
377
|
+
emitEvent({
|
|
378
|
+
type: 'agent_loop',
|
|
379
|
+
event: 'failed',
|
|
380
|
+
loop_id: loopId,
|
|
381
|
+
agent: state.agent_type,
|
|
382
|
+
reason: 'max_iterations',
|
|
383
|
+
final_value: state.current_value,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
console.log(`${c.red}Loop failed: max iterations (${state.max_iterations}) reached${c.reset}`);
|
|
387
|
+
return state;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
console.log(
|
|
391
|
+
`\n${c.cyan}${c.bold}Agent Loop - Iteration ${state.iteration}/${state.max_iterations}${c.reset}`
|
|
392
|
+
);
|
|
393
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
394
|
+
|
|
395
|
+
// Run gate check
|
|
396
|
+
const result = checkGate(state.quality_gate, state.threshold);
|
|
397
|
+
const previousValue = state.current_value;
|
|
398
|
+
state.current_value = result.value;
|
|
399
|
+
|
|
400
|
+
// Record event
|
|
401
|
+
state.events.push({
|
|
402
|
+
iter: state.iteration,
|
|
403
|
+
value: result.value,
|
|
404
|
+
passed: result.passed,
|
|
405
|
+
at: new Date().toISOString(),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Emit progress
|
|
409
|
+
emitEvent({
|
|
410
|
+
type: 'agent_loop',
|
|
411
|
+
event: 'iteration',
|
|
412
|
+
loop_id: loopId,
|
|
413
|
+
agent: state.agent_type,
|
|
414
|
+
gate: state.quality_gate,
|
|
415
|
+
iter: state.iteration,
|
|
416
|
+
value: result.value,
|
|
417
|
+
threshold: state.threshold,
|
|
418
|
+
passed: result.passed,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Check for regression
|
|
422
|
+
if (state.iteration > 1 && result.value < previousValue) {
|
|
423
|
+
state.regression_count++;
|
|
424
|
+
console.log(
|
|
425
|
+
`${c.yellow}Warning: Regression detected (${previousValue} → ${result.value})${c.reset}`
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (state.regression_count >= 2) {
|
|
429
|
+
state.status = 'failed';
|
|
430
|
+
state.stopped_reason = 'regression_detected';
|
|
431
|
+
saveLoop(loopId, state);
|
|
432
|
+
|
|
433
|
+
emitEvent({
|
|
434
|
+
type: 'agent_loop',
|
|
435
|
+
event: 'failed',
|
|
436
|
+
loop_id: loopId,
|
|
437
|
+
agent: state.agent_type,
|
|
438
|
+
reason: 'regression_detected',
|
|
439
|
+
final_value: result.value,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
console.log(`${c.red}Loop failed: regression detected 2+ times${c.reset}`);
|
|
443
|
+
return state;
|
|
444
|
+
}
|
|
445
|
+
} else if (result.value > previousValue) {
|
|
446
|
+
state.last_progress_at = new Date().toISOString();
|
|
447
|
+
state.regression_count = 0; // Reset on progress
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Check for stall
|
|
451
|
+
const timeSinceProgress = Date.now() - new Date(state.last_progress_at).getTime();
|
|
452
|
+
if (timeSinceProgress > STALL_THRESHOLD_MS) {
|
|
453
|
+
state.status = 'failed';
|
|
454
|
+
state.stopped_reason = 'stalled';
|
|
455
|
+
saveLoop(loopId, state);
|
|
456
|
+
|
|
457
|
+
emitEvent({
|
|
458
|
+
type: 'agent_loop',
|
|
459
|
+
event: 'failed',
|
|
460
|
+
loop_id: loopId,
|
|
461
|
+
agent: state.agent_type,
|
|
462
|
+
reason: 'stalled',
|
|
463
|
+
final_value: result.value,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
console.log(`${c.red}Loop failed: stalled (no progress for 5+ minutes)${c.reset}`);
|
|
467
|
+
return state;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Output result
|
|
471
|
+
const statusIcon = result.passed ? `${c.green}✓${c.reset}` : `${c.yellow}⏳${c.reset}`;
|
|
472
|
+
console.log(` ${statusIcon} ${result.message}`);
|
|
473
|
+
|
|
474
|
+
if (result.passed) {
|
|
475
|
+
// Gate passed - check if we need multi-iteration confirmation
|
|
476
|
+
const passedIterations = state.events.filter(e => e.passed).length;
|
|
477
|
+
|
|
478
|
+
if (passedIterations >= 2) {
|
|
479
|
+
// Confirmed pass
|
|
480
|
+
state.status = 'passed';
|
|
481
|
+
state.completed_at = new Date().toISOString();
|
|
482
|
+
saveLoop(loopId, state);
|
|
483
|
+
|
|
484
|
+
emitEvent({
|
|
485
|
+
type: 'agent_loop',
|
|
486
|
+
event: 'passed',
|
|
487
|
+
loop_id: loopId,
|
|
488
|
+
agent: state.agent_type,
|
|
489
|
+
gate: state.quality_gate,
|
|
490
|
+
final_value: result.value,
|
|
491
|
+
iterations: state.iteration,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
console.log(`\n${c.green}${c.bold}Loop PASSED${c.reset} after ${state.iteration} iterations`);
|
|
495
|
+
console.log(`Final value: ${result.value}${state.threshold > 0 ? '%' : ''}`);
|
|
496
|
+
} else {
|
|
497
|
+
// Need confirmation iteration
|
|
498
|
+
console.log(`${c.dim}Gate passed - need 1 more iteration to confirm${c.reset}`);
|
|
499
|
+
saveLoop(loopId, state);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
saveLoop(loopId, state);
|
|
503
|
+
console.log(`${c.dim}Continue iterating...${c.reset}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}\n`);
|
|
507
|
+
|
|
508
|
+
return state;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getStatus(loopId) {
|
|
512
|
+
const state = loadLoop(loopId);
|
|
513
|
+
|
|
514
|
+
if (!state) {
|
|
515
|
+
console.error(`${c.red}Loop not found: ${loopId}${c.reset}`);
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const elapsed = Date.now() - new Date(state.started_at).getTime();
|
|
520
|
+
const elapsedStr = `${Math.floor(elapsed / 60000)}m ${Math.floor((elapsed % 60000) / 1000)}s`;
|
|
521
|
+
|
|
522
|
+
console.log(`\n${c.cyan}${c.bold}Agent Loop Status${c.reset}`);
|
|
523
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
524
|
+
console.log(` Loop ID: ${state.loop_id}`);
|
|
525
|
+
console.log(` Agent: ${state.agent_type}`);
|
|
526
|
+
console.log(` Gate: ${GATES[state.quality_gate]?.name || state.quality_gate}`);
|
|
527
|
+
console.log(
|
|
528
|
+
` Status: ${state.status === 'passed' ? c.green : state.status === 'running' ? c.yellow : c.red}${state.status}${c.reset}`
|
|
529
|
+
);
|
|
530
|
+
console.log(` Iteration: ${state.iteration}/${state.max_iterations}`);
|
|
531
|
+
console.log(` Current Value: ${state.current_value}${state.threshold > 0 ? '%' : ''}`);
|
|
532
|
+
console.log(` Threshold: ${state.threshold > 0 ? state.threshold + '%' : 'pass/fail'}`);
|
|
533
|
+
console.log(` Elapsed: ${elapsedStr}`);
|
|
534
|
+
|
|
535
|
+
if (state.events.length > 0) {
|
|
536
|
+
console.log(`\n ${c.dim}History:${c.reset}`);
|
|
537
|
+
state.events.forEach(e => {
|
|
538
|
+
const icon = e.passed ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
|
|
539
|
+
console.log(` ${icon} Iter ${e.iter}: ${e.value}${state.threshold > 0 ? '%' : ''}`);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
console.log(`${c.dim}${'─'.repeat(40)}${c.reset}\n`);
|
|
544
|
+
|
|
545
|
+
return state;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function abortLoop(loopId, reason = 'manual') {
|
|
549
|
+
const state = loadLoop(loopId);
|
|
550
|
+
|
|
551
|
+
if (!state) {
|
|
552
|
+
console.error(`${c.red}Loop not found: ${loopId}${c.reset}`);
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (state.status !== 'running') {
|
|
557
|
+
console.log(`${c.yellow}Loop already ${state.status}${c.reset}`);
|
|
558
|
+
return state;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
state.status = 'aborted';
|
|
562
|
+
state.stopped_reason = reason;
|
|
563
|
+
state.completed_at = new Date().toISOString();
|
|
564
|
+
saveLoop(loopId, state);
|
|
565
|
+
|
|
566
|
+
emitEvent({
|
|
567
|
+
type: 'agent_loop',
|
|
568
|
+
event: 'abort',
|
|
569
|
+
loop_id: loopId,
|
|
570
|
+
agent: state.agent_type,
|
|
571
|
+
reason,
|
|
572
|
+
final_value: state.current_value,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
console.log(`${c.yellow}Loop aborted: ${reason}${c.reset}`);
|
|
576
|
+
return state;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function listLoops() {
|
|
580
|
+
ensureLoopsDir();
|
|
581
|
+
|
|
582
|
+
const files = fs.readdirSync(LOOPS_DIR).filter(f => f.endsWith('.json'));
|
|
583
|
+
|
|
584
|
+
if (files.length === 0) {
|
|
585
|
+
console.log(`${c.dim}No agent loops found${c.reset}`);
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const loops = files
|
|
590
|
+
.map(f => {
|
|
591
|
+
const result = safeReadJSON(path.join(LOOPS_DIR, f), { defaultValue: null });
|
|
592
|
+
return result.ok ? result.data : null;
|
|
593
|
+
})
|
|
594
|
+
.filter(Boolean);
|
|
595
|
+
|
|
596
|
+
console.log(`\n${c.cyan}${c.bold}Agent Loops${c.reset}`);
|
|
597
|
+
console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
|
|
598
|
+
|
|
599
|
+
loops.forEach(loop => {
|
|
600
|
+
const statusColor =
|
|
601
|
+
loop.status === 'passed' ? c.green : loop.status === 'running' ? c.yellow : c.red;
|
|
602
|
+
|
|
603
|
+
console.log(` ${statusColor}●${c.reset} [${loop.loop_id}] ${loop.agent_type}`);
|
|
604
|
+
console.log(
|
|
605
|
+
` ${GATES[loop.quality_gate]?.name || loop.quality_gate}: ${loop.current_value}${loop.threshold > 0 ? '%' : ''} / ${loop.threshold > 0 ? loop.threshold + '%' : 'pass'} | Iter: ${loop.iteration}/${loop.max_iterations} | ${loop.status}`
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
console.log(`${c.dim}${'─'.repeat(60)}${c.reset}\n`);
|
|
610
|
+
|
|
611
|
+
return loops;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function cleanupLoops() {
|
|
615
|
+
ensureLoopsDir();
|
|
616
|
+
|
|
617
|
+
const files = fs.readdirSync(LOOPS_DIR).filter(f => f.endsWith('.json'));
|
|
618
|
+
let cleaned = 0;
|
|
619
|
+
|
|
620
|
+
files.forEach(f => {
|
|
621
|
+
const result = safeReadJSON(path.join(LOOPS_DIR, f), { defaultValue: null });
|
|
622
|
+
if (result.ok && result.data && result.data.status !== 'running') {
|
|
623
|
+
fs.unlinkSync(path.join(LOOPS_DIR, f));
|
|
624
|
+
cleaned++;
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
console.log(`${c.green}Cleaned ${cleaned} completed loop(s)${c.reset}`);
|
|
629
|
+
return cleaned;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ============================================================================
|
|
633
|
+
// CLI
|
|
634
|
+
// ============================================================================
|
|
635
|
+
|
|
636
|
+
function main() {
|
|
637
|
+
const args = process.argv.slice(2);
|
|
638
|
+
|
|
639
|
+
// Parse arguments
|
|
640
|
+
const getArg = name => {
|
|
641
|
+
const arg = args.find(a => a.startsWith(`--${name}=`));
|
|
642
|
+
return arg ? arg.split('=')[1] : null;
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const hasFlag = name => args.includes(`--${name}`);
|
|
646
|
+
|
|
647
|
+
if (hasFlag('init')) {
|
|
648
|
+
const loopId = initLoop({
|
|
649
|
+
loopId: getArg('loop-id'),
|
|
650
|
+
gate: getArg('gate'),
|
|
651
|
+
threshold: parseFloat(getArg('threshold') || '0'),
|
|
652
|
+
maxIterations: parseInt(getArg('max') || '5', 10),
|
|
653
|
+
agentType: getArg('agent') || 'unknown',
|
|
654
|
+
parentId: getArg('parent'),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (loopId) {
|
|
658
|
+
console.log(`\n${c.dim}Use in agent prompt:${c.reset}`);
|
|
659
|
+
console.log(` node .agileflow/scripts/agent-loop.js --check --loop-id=${loopId}`);
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (hasFlag('check')) {
|
|
665
|
+
const loopId = getArg('loop-id');
|
|
666
|
+
if (!loopId) {
|
|
667
|
+
console.error(`${c.red}--loop-id required${c.reset}`);
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
const state = checkLoop(loopId);
|
|
671
|
+
process.exit(state?.status === 'passed' ? 0 : state?.status === 'running' ? 2 : 1);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (hasFlag('status')) {
|
|
675
|
+
const loopId = getArg('loop-id');
|
|
676
|
+
if (!loopId) {
|
|
677
|
+
console.error(`${c.red}--loop-id required${c.reset}`);
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
getStatus(loopId);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (hasFlag('abort')) {
|
|
685
|
+
const loopId = getArg('loop-id');
|
|
686
|
+
if (!loopId) {
|
|
687
|
+
console.error(`${c.red}--loop-id required${c.reset}`);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
abortLoop(loopId, getArg('reason') || 'manual');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (hasFlag('list')) {
|
|
695
|
+
listLoops();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (hasFlag('cleanup')) {
|
|
700
|
+
cleanupLoops();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Help
|
|
705
|
+
console.log(`
|
|
706
|
+
${c.brand}${c.bold}Agent Loop Manager${c.reset} - Isolated quality-gate loops for domain agents
|
|
707
|
+
|
|
708
|
+
${c.cyan}Commands:${c.reset}
|
|
709
|
+
--init Initialize a new agent loop
|
|
710
|
+
--gate=<gate> Quality gate: tests, coverage, visual, lint, types
|
|
711
|
+
--threshold=<n> Target percentage (for coverage gate)
|
|
712
|
+
--max=<n> Max iterations (default: 5, hard limit: 5)
|
|
713
|
+
--agent=<type> Agent type (for logging)
|
|
714
|
+
--loop-id=<id> Custom loop ID (optional, auto-generated if omitted)
|
|
715
|
+
--parent=<id> Parent orchestration ID (optional)
|
|
716
|
+
|
|
717
|
+
--check --loop-id=<id> Run gate check and update loop state
|
|
718
|
+
--status --loop-id=<id> Show loop status
|
|
719
|
+
--abort --loop-id=<id> Abort the loop
|
|
720
|
+
--reason=<reason> Abort reason (default: manual)
|
|
721
|
+
|
|
722
|
+
--list List all agent loops
|
|
723
|
+
--cleanup Remove completed/aborted loops
|
|
724
|
+
|
|
725
|
+
${c.cyan}Exit Codes:${c.reset}
|
|
726
|
+
0 = Loop passed (gate satisfied)
|
|
727
|
+
1 = Loop failed/aborted
|
|
728
|
+
2 = Loop still running (gate not yet satisfied)
|
|
729
|
+
|
|
730
|
+
${c.cyan}Examples:${c.reset}
|
|
731
|
+
# Initialize coverage loop
|
|
732
|
+
node agent-loop.js --init --gate=coverage --threshold=80 --agent=agileflow-api
|
|
733
|
+
|
|
734
|
+
# Check loop progress
|
|
735
|
+
node agent-loop.js --check --loop-id=abc123
|
|
736
|
+
|
|
737
|
+
# View status
|
|
738
|
+
node agent-loop.js --status --loop-id=abc123
|
|
739
|
+
|
|
740
|
+
${c.cyan}State Storage:${c.reset}
|
|
741
|
+
.agileflow/sessions/agent-loops/{loop-id}.json
|
|
742
|
+
|
|
743
|
+
${c.cyan}Event Bus:${c.reset}
|
|
744
|
+
docs/09-agents/bus/log.jsonl
|
|
745
|
+
`);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Export for module use
|
|
749
|
+
module.exports = {
|
|
750
|
+
initLoop,
|
|
751
|
+
checkLoop,
|
|
752
|
+
getStatus,
|
|
753
|
+
abortLoop,
|
|
754
|
+
listLoops,
|
|
755
|
+
cleanupLoops,
|
|
756
|
+
loadLoop,
|
|
757
|
+
GATES,
|
|
758
|
+
MAX_ITERATIONS_HARD_LIMIT,
|
|
759
|
+
MAX_AGENTS_HARD_LIMIT,
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
// Run CLI if executed directly
|
|
763
|
+
if (require.main === module) {
|
|
764
|
+
main();
|
|
765
|
+
}
|