agileflow 2.80.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.
@@ -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
+ }