agileflow 2.88.0 → 2.89.1

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/lib/validate.js CHANGED
@@ -2,9 +2,12 @@
2
2
  * AgileFlow CLI - Input Validation Utilities
3
3
  *
4
4
  * Centralized validation patterns and helpers to prevent
5
- * command injection and invalid input handling.
5
+ * command injection, path traversal, and invalid input handling.
6
6
  */
7
7
 
8
+ const path = require('node:path');
9
+ const fs = require('node:fs');
10
+
8
11
  /**
9
12
  * Validation patterns for common input types.
10
13
  * All patterns use strict whitelisting approach.
@@ -320,7 +323,276 @@ function validateArgs(args, schema) {
320
323
  return { ok: true, data: result };
321
324
  }
322
325
 
326
+ // =============================================================================
327
+ // Path Traversal Protection
328
+ // =============================================================================
329
+
330
+ /**
331
+ * Path validation error with context.
332
+ */
333
+ class PathValidationError extends Error {
334
+ /**
335
+ * @param {string} message - Error message
336
+ * @param {string} inputPath - The problematic path
337
+ * @param {string} reason - Reason for rejection
338
+ */
339
+ constructor(message, inputPath, reason) {
340
+ super(message);
341
+ this.name = 'PathValidationError';
342
+ this.inputPath = inputPath;
343
+ this.reason = reason;
344
+ Error.captureStackTrace(this, this.constructor);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Validate that a path is safe and within the allowed base directory.
350
+ * Prevents path traversal attacks by:
351
+ * 1. Resolving the path to absolute form
352
+ * 2. Ensuring it stays within the base directory
353
+ * 3. Rejecting symbolic links (optional)
354
+ *
355
+ * @param {string} inputPath - The path to validate (can be relative or absolute)
356
+ * @param {string} baseDir - The allowed base directory (must be absolute)
357
+ * @param {Object} options - Validation options
358
+ * @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
359
+ * @param {boolean} [options.mustExist=false] - Path must exist on filesystem
360
+ * @returns {{ ok: boolean, resolvedPath?: string, error?: PathValidationError }}
361
+ *
362
+ * @example
363
+ * // Validate a file path within project directory
364
+ * const result = validatePath('./config.yaml', '/home/user/project');
365
+ * if (result.ok) {
366
+ * console.log('Safe path:', result.resolvedPath);
367
+ * }
368
+ *
369
+ * @example
370
+ * // Reject path traversal attempt
371
+ * const result = validatePath('../../../etc/passwd', '/home/user/project');
372
+ * // result.ok === false
373
+ * // result.error.reason === 'path_traversal'
374
+ */
375
+ function validatePath(inputPath, baseDir, options = {}) {
376
+ const { allowSymlinks = false, mustExist = false } = options;
377
+
378
+ // Input validation
379
+ if (!inputPath || typeof inputPath !== 'string') {
380
+ return {
381
+ ok: false,
382
+ error: new PathValidationError(
383
+ 'Path is required and must be a string',
384
+ String(inputPath),
385
+ 'invalid_input'
386
+ ),
387
+ };
388
+ }
389
+
390
+ if (!baseDir || typeof baseDir !== 'string') {
391
+ return {
392
+ ok: false,
393
+ error: new PathValidationError(
394
+ 'Base directory is required and must be a string',
395
+ inputPath,
396
+ 'invalid_base'
397
+ ),
398
+ };
399
+ }
400
+
401
+ // Base directory must be absolute
402
+ if (!path.isAbsolute(baseDir)) {
403
+ return {
404
+ ok: false,
405
+ error: new PathValidationError(
406
+ 'Base directory must be an absolute path',
407
+ inputPath,
408
+ 'relative_base'
409
+ ),
410
+ };
411
+ }
412
+
413
+ // Normalize the base directory
414
+ const normalizedBase = path.resolve(baseDir);
415
+
416
+ // Resolve the input path relative to base directory
417
+ let resolvedPath;
418
+ if (path.isAbsolute(inputPath)) {
419
+ resolvedPath = path.resolve(inputPath);
420
+ } else {
421
+ resolvedPath = path.resolve(normalizedBase, inputPath);
422
+ }
423
+
424
+ // Check for path traversal: resolved path must start with base directory
425
+ // Add trailing separator to prevent prefix attacks (e.g., /home/user vs /home/user2)
426
+ const baseWithSep = normalizedBase.endsWith(path.sep)
427
+ ? normalizedBase
428
+ : normalizedBase + path.sep;
429
+
430
+ const isWithinBase = resolvedPath === normalizedBase || resolvedPath.startsWith(baseWithSep);
431
+
432
+ if (!isWithinBase) {
433
+ return {
434
+ ok: false,
435
+ error: new PathValidationError(
436
+ `Path escapes base directory: ${inputPath}`,
437
+ inputPath,
438
+ 'path_traversal'
439
+ ),
440
+ };
441
+ }
442
+
443
+ // Check if path exists (if required)
444
+ if (mustExist) {
445
+ try {
446
+ fs.accessSync(resolvedPath);
447
+ } catch {
448
+ return {
449
+ ok: false,
450
+ error: new PathValidationError(
451
+ `Path does not exist: ${resolvedPath}`,
452
+ inputPath,
453
+ 'not_found'
454
+ ),
455
+ };
456
+ }
457
+ }
458
+
459
+ // Check for symbolic links (if not allowed)
460
+ if (!allowSymlinks) {
461
+ try {
462
+ const stats = fs.lstatSync(resolvedPath);
463
+ if (stats.isSymbolicLink()) {
464
+ return {
465
+ ok: false,
466
+ error: new PathValidationError(
467
+ `Symbolic links are not allowed: ${inputPath}`,
468
+ inputPath,
469
+ 'symlink_rejected'
470
+ ),
471
+ };
472
+ }
473
+ } catch {
474
+ // Path doesn't exist yet, which is fine if mustExist is false
475
+ // Check parent directories for symlinks
476
+ const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
477
+ let currentPath = normalizedBase;
478
+
479
+ for (const part of parts) {
480
+ currentPath = path.join(currentPath, part);
481
+ try {
482
+ const stats = fs.lstatSync(currentPath);
483
+ if (stats.isSymbolicLink()) {
484
+ return {
485
+ ok: false,
486
+ error: new PathValidationError(
487
+ `Path contains symbolic link: ${currentPath}`,
488
+ inputPath,
489
+ 'symlink_in_path'
490
+ ),
491
+ };
492
+ }
493
+ } catch {
494
+ // Part of path doesn't exist, stop checking
495
+ break;
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ return {
502
+ ok: true,
503
+ resolvedPath,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Synchronous version that throws on invalid paths.
509
+ * Use when you want exceptions rather than result objects.
510
+ *
511
+ * @param {string} inputPath - The path to validate
512
+ * @param {string} baseDir - The allowed base directory
513
+ * @param {Object} options - Validation options
514
+ * @returns {string} The validated absolute path
515
+ * @throws {PathValidationError} If path is invalid
516
+ */
517
+ function validatePathSync(inputPath, baseDir, options = {}) {
518
+ const result = validatePath(inputPath, baseDir, options);
519
+ if (!result.ok) {
520
+ throw result.error;
521
+ }
522
+ return result.resolvedPath;
523
+ }
524
+
525
+ /**
526
+ * Check if a path contains dangerous patterns without resolving.
527
+ * Useful for quick pre-validation before expensive operations.
528
+ *
529
+ * @param {string} inputPath - The path to check
530
+ * @returns {{ safe: boolean, reason?: string }}
531
+ */
532
+ function hasUnsafePathPatterns(inputPath) {
533
+ if (!inputPath || typeof inputPath !== 'string') {
534
+ return { safe: false, reason: 'invalid_input' };
535
+ }
536
+
537
+ // Check for null bytes (can bypass security in some systems)
538
+ if (inputPath.includes('\0')) {
539
+ return { safe: false, reason: 'null_byte' };
540
+ }
541
+
542
+ // Check for obvious traversal patterns
543
+ if (inputPath.includes('..')) {
544
+ return { safe: false, reason: 'dot_dot_sequence' };
545
+ }
546
+
547
+ // Check for absolute paths on Unix when expecting relative
548
+ if (inputPath.startsWith('/') && !path.isAbsolute(inputPath)) {
549
+ return { safe: false, reason: 'unexpected_absolute' };
550
+ }
551
+
552
+ // Check for Windows-style absolute paths
553
+ if (/^[a-zA-Z]:/.test(inputPath)) {
554
+ return { safe: false, reason: 'windows_absolute' };
555
+ }
556
+
557
+ return { safe: true };
558
+ }
559
+
560
+ /**
561
+ * Sanitize a filename by removing dangerous characters.
562
+ * Does NOT validate the full path - use with validatePath().
563
+ *
564
+ * @param {string} filename - The filename to sanitize
565
+ * @param {Object} options - Sanitization options
566
+ * @param {string} [options.replacement='_'] - Character to replace with
567
+ * @param {number} [options.maxLength=255] - Maximum filename length
568
+ * @returns {string} Sanitized filename
569
+ */
570
+ function sanitizeFilename(filename, options = {}) {
571
+ const { replacement = '_', maxLength = 255 } = options;
572
+
573
+ if (!filename || typeof filename !== 'string') {
574
+ return '';
575
+ }
576
+
577
+ // Remove or replace dangerous characters
578
+ let sanitized = filename
579
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Control chars and reserved
580
+ .replace(/\.{2,}/g, replacement) // Multiple dots
581
+ .replace(/^\.+/, replacement) // Leading dots
582
+ .replace(/^-+/, replacement); // Leading dashes (prevent flag injection)
583
+
584
+ // Truncate if too long
585
+ if (sanitized.length > maxLength) {
586
+ const ext = path.extname(sanitized);
587
+ const base = path.basename(sanitized, ext);
588
+ sanitized = base.slice(0, maxLength - ext.length) + ext;
589
+ }
590
+
591
+ return sanitized;
592
+ }
593
+
323
594
  module.exports = {
595
+ // Patterns and basic validators
324
596
  PATTERNS,
325
597
  isValidBranchName,
326
598
  isValidStoryId,
@@ -334,4 +606,11 @@ module.exports = {
334
606
  parseIntBounded,
335
607
  isValidOption,
336
608
  validateArgs,
609
+
610
+ // Path traversal protection
611
+ PathValidationError,
612
+ validatePath,
613
+ validatePathSync,
614
+ hasUnsafePathPatterns,
615
+ sanitizeFilename,
337
616
  };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Centralized YAML parsing utilities with security guarantees.
3
+ *
4
+ * Security Note (js-yaml v4.x):
5
+ * - yaml.load() is SAFE by default in v4+ (unlike v3.x)
6
+ * - Dangerous JavaScript types (functions, regexps) are NOT supported
7
+ * - Only JSON-compatible types are parsed
8
+ * - See: https://github.com/nodeca/js-yaml/blob/master/migrate_v3_to_v4.md
9
+ *
10
+ * This wrapper provides:
11
+ * - Explicit security documentation
12
+ * - Input validation
13
+ * - Consistent error handling
14
+ * - Future-proofing for library changes
15
+ */
16
+
17
+ const yaml = require('js-yaml');
18
+
19
+ /**
20
+ * Safely parse YAML content. Uses js-yaml's DEFAULT_SCHEMA which is
21
+ * safe by default in v4+ (does not execute arbitrary JavaScript).
22
+ *
23
+ * @param {string} content - YAML string to parse
24
+ * @param {Object} options - Optional yaml.load options
25
+ * @returns {any} Parsed YAML content (object, array, or primitive)
26
+ * @throws {yaml.YAMLException} If YAML is malformed
27
+ */
28
+ function safeLoad(content, options = {}) {
29
+ if (typeof content !== 'string') {
30
+ throw new TypeError('YAML content must be a string');
31
+ }
32
+
33
+ // In js-yaml v4+, load() uses DEFAULT_SCHEMA which is safe.
34
+ // Explicitly pass schema to make the security guarantee clear
35
+ // and protect against future library changes.
36
+ return yaml.load(content, {
37
+ schema: yaml.DEFAULT_SCHEMA,
38
+ ...options,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Safely parse all YAML documents in a multi-document file.
44
+ *
45
+ * @param {string} content - YAML string containing one or more documents
46
+ * @param {Object} options - Optional yaml.loadAll options
47
+ * @returns {any[]} Array of parsed YAML documents
48
+ * @throws {yaml.YAMLException} If YAML is malformed
49
+ */
50
+ function safeLoadAll(content, options = {}) {
51
+ if (typeof content !== 'string') {
52
+ throw new TypeError('YAML content must be a string');
53
+ }
54
+
55
+ const documents = [];
56
+ yaml.loadAll(content, doc => documents.push(doc), { schema: yaml.DEFAULT_SCHEMA, ...options });
57
+ return documents;
58
+ }
59
+
60
+ /**
61
+ * Safely serialize data to YAML string.
62
+ *
63
+ * @param {any} data - Data to serialize
64
+ * @param {Object} options - Optional yaml.dump options
65
+ * @returns {string} YAML string representation
66
+ */
67
+ function safeDump(data, options = {}) {
68
+ return yaml.dump(data, {
69
+ schema: yaml.DEFAULT_SCHEMA,
70
+ ...options,
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Test if js-yaml is configured securely (no JavaScript type support).
76
+ * This function is used in security tests to verify the library version
77
+ * and configuration.
78
+ *
79
+ * @returns {boolean} True if the configuration is secure
80
+ */
81
+ function isSecureConfiguration() {
82
+ // Attempt to parse YAML with JavaScript function syntax
83
+ // This should NOT execute any code in v4+
84
+ const maliciousYaml = `
85
+ test: !!js/function 'function() { return "pwned"; }'
86
+ regex: !!js/regexp /test/i
87
+ undef: !!js/undefined ''
88
+ `;
89
+
90
+ try {
91
+ const result = safeLoad(maliciousYaml);
92
+ // If we get here without executing code, and the values are
93
+ // either strings or undefined (not actual functions), we're safe
94
+ if (typeof result.test === 'function') {
95
+ return false; // Unsafe: function was instantiated
96
+ }
97
+ if (result.regex instanceof RegExp) {
98
+ return false; // Unsafe: regex was instantiated
99
+ }
100
+ // Values should be undefined or error out due to unknown tags
101
+ return true;
102
+ } catch (e) {
103
+ // YAMLException for unknown tags is the expected safe behavior
104
+ if (e.name === 'YAMLException') {
105
+ return true;
106
+ }
107
+ throw e;
108
+ }
109
+ }
110
+
111
+ module.exports = {
112
+ safeLoad,
113
+ safeLoadAll,
114
+ safeDump,
115
+ isSecureConfiguration,
116
+ // Re-export yaml module for edge cases that need direct access
117
+ yaml,
118
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.88.0",
3
+ "version": "2.89.1",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -18,6 +18,7 @@ const { execSync, spawnSync } = require('child_process');
18
18
  // Shared utilities
19
19
  const { c, box } = require('../lib/colors');
20
20
  const { getProjectRoot } = require('../lib/paths');
21
+ const { readJSONCached, readFileCached } = require('../lib/file-cache');
21
22
 
22
23
  // Session manager path (relative to script location)
23
24
  const SESSION_MANAGER_PATH = path.join(__dirname, 'session-manager.js');
@@ -47,44 +48,29 @@ try {
47
48
  }
48
49
 
49
50
  /**
50
- * PERFORMANCE OPTIMIZATION: Load all project files once into cache
51
- * This eliminates duplicate file reads across multiple functions.
52
- * Estimated savings: 40-80ms
51
+ * PERFORMANCE OPTIMIZATION: Load all project files using LRU cache
52
+ * Uses file-cache module for automatic caching with 30s TTL.
53
+ * Files are cached across script invocations within TTL window.
54
+ * Estimated savings: 60-120ms on cache hits
53
55
  */
54
56
  function loadProjectFiles(rootDir) {
55
- const cache = {
56
- status: null,
57
- metadata: null,
58
- settings: null,
59
- sessionState: null,
60
- configYaml: null,
61
- cliPackage: null,
62
- };
63
-
64
57
  const paths = {
65
- status: 'docs/09-agents/status.json',
66
- metadata: 'docs/00-meta/agileflow-metadata.json',
67
- settings: '.claude/settings.json',
68
- sessionState: 'docs/09-agents/session-state.json',
69
- configYaml: '.agileflow/config.yaml',
70
- cliPackage: 'packages/cli/package.json',
58
+ status: path.join(rootDir, 'docs', '09-agents', 'status.json'),
59
+ metadata: path.join(rootDir, 'docs', '00-meta', 'agileflow-metadata.json'),
60
+ settings: path.join(rootDir, '.claude', 'settings.json'),
61
+ sessionState: path.join(rootDir, 'docs', '09-agents', 'session-state.json'),
62
+ configYaml: path.join(rootDir, '.agileflow', 'config.yaml'),
63
+ cliPackage: path.join(rootDir, 'packages', 'cli', 'package.json'),
71
64
  };
72
65
 
73
- for (const [key, relPath] of Object.entries(paths)) {
74
- const fullPath = path.join(rootDir, relPath);
75
- try {
76
- if (!fs.existsSync(fullPath)) continue;
77
- if (key === 'configYaml') {
78
- cache[key] = fs.readFileSync(fullPath, 'utf8');
79
- } else {
80
- cache[key] = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
81
- }
82
- } catch (e) {
83
- // Silently ignore - file not available or invalid
84
- }
85
- }
86
-
87
- return cache;
66
+ return {
67
+ status: readJSONCached(paths.status),
68
+ metadata: readJSONCached(paths.metadata),
69
+ settings: readJSONCached(paths.settings),
70
+ sessionState: readJSONCached(paths.sessionState),
71
+ configYaml: readFileCached(paths.configYaml),
72
+ cliPackage: readJSONCached(paths.cliPackage),
73
+ };
88
74
  }
89
75
 
90
76
  /**
@@ -288,7 +274,7 @@ function runArchival(rootDir, cache = null) {
288
274
  }
289
275
 
290
276
  function clearActiveCommands(rootDir, cache = null) {
291
- const result = { ran: false, cleared: 0, commandNames: [] };
277
+ const result = { ran: false, cleared: 0, commandNames: [], preserved: false };
292
278
 
293
279
  try {
294
280
  const sessionStatePath = path.join(rootDir, 'docs/09-agents/session-state.json');
@@ -305,6 +291,31 @@ function clearActiveCommands(rootDir, cache = null) {
305
291
  result.ran = true;
306
292
  }
307
293
 
294
+ // Check if PreCompact just ran (within last 30 seconds)
295
+ // If so, preserve active_commands instead of clearing them (post-compact session start)
296
+ if (state.last_precompact_at) {
297
+ const precompactTime = new Date(state.last_precompact_at).getTime();
298
+ const now = Date.now();
299
+ const secondsSincePrecompact = (now - precompactTime) / 1000;
300
+
301
+ if (secondsSincePrecompact < 30) {
302
+ // This is a post-compact session start - preserve active commands
303
+ result.preserved = true;
304
+ // Capture command names for display (but don't clear)
305
+ if (state.active_commands && state.active_commands.length > 0) {
306
+ for (const cmd of state.active_commands) {
307
+ if (cmd.name) result.commandNames.push(cmd.name);
308
+ }
309
+ }
310
+ // Clear the precompact timestamp so next true session start will clear
311
+ delete state.last_precompact_at;
312
+ fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
313
+ return result;
314
+ }
315
+ // Precompact was too long ago - clear as normal
316
+ delete state.last_precompact_at;
317
+ }
318
+
308
319
  // Handle new array format (active_commands)
309
320
  if (state.active_commands && state.active_commands.length > 0) {
310
321
  result.cleared = state.active_commands.length;
@@ -1075,10 +1086,18 @@ function formatTable(
1075
1086
  }
1076
1087
 
1077
1088
  // Session cleanup
1078
- const sessionStatus = session.cleared > 0 ? `cleared ${session.cleared} command(s)` : `clean`;
1079
- lines.push(
1080
- row('Session state', sessionStatus, c.lavender, session.cleared > 0 ? c.mintGreen : c.dim)
1081
- );
1089
+ let sessionStatus, sessionColor;
1090
+ if (session.preserved) {
1091
+ sessionStatus = `preserved ${session.commandNames.length} command(s)`;
1092
+ sessionColor = c.mintGreen;
1093
+ } else if (session.cleared > 0) {
1094
+ sessionStatus = `cleared ${session.cleared} command(s)`;
1095
+ sessionColor = c.mintGreen;
1096
+ } else {
1097
+ sessionStatus = `clean`;
1098
+ sessionColor = c.dim;
1099
+ }
1100
+ lines.push(row('Session state', sessionStatus, c.lavender, sessionColor));
1082
1101
 
1083
1102
  // PreCompact status with version check
1084
1103
  if (precompact.configured && precompact.scriptExists) {
@@ -1283,12 +1302,14 @@ async function main() {
1283
1302
  if (parallelSessions.cleaned > 0 && parallelSessions.cleanedSessions) {
1284
1303
  console.log('');
1285
1304
  console.log(`${c.amber}📋 Cleaned ${parallelSessions.cleaned} inactive session(s):${c.reset}`);
1286
- parallelSessions.cleanedSessions.forEach((sess) => {
1305
+ parallelSessions.cleanedSessions.forEach(sess => {
1287
1306
  const name = sess.nickname ? `${sess.id} "${sess.nickname}"` : `Session ${sess.id}`;
1288
1307
  const reason = sess.reason === 'pid_dead' ? 'process ended' : sess.reason;
1289
1308
  console.log(` ${c.dim}└─ ${name} (${reason}, PID ${sess.pid})${c.reset}`);
1290
1309
  });
1291
- console.log(` ${c.slate}Sessions are cleaned when their Claude Code process is no longer running.${c.reset}`);
1310
+ console.log(
1311
+ ` ${c.slate}Sessions are cleaned when their Claude Code process is no longer running.${c.reset}`
1312
+ );
1292
1313
  }
1293
1314
 
1294
1315
  // Story claiming: cleanup stale claims and show warnings
@@ -46,11 +46,14 @@ function saveSessionState(rootDir, state) {
46
46
  async function resolveGlob(pattern, rootDir) {
47
47
  // Use bash globbing for pattern expansion
48
48
  try {
49
- const result = execSync(`bash -c 'shopt -s nullglob; for f in ${pattern}; do echo "$f"; done'`, {
50
- cwd: rootDir,
51
- encoding: 'utf8',
52
- timeout: 10000,
53
- });
49
+ const result = execSync(
50
+ `bash -c 'shopt -s nullglob; for f in ${pattern}; do echo "$f"; done'`,
51
+ {
52
+ cwd: rootDir,
53
+ encoding: 'utf8',
54
+ timeout: 10000,
55
+ }
56
+ );
54
57
  const files = result
55
58
  .split('\n')
56
59
  .filter(f => f.trim())
@@ -218,7 +221,9 @@ function handleBatchLoop(rootDir) {
218
221
  );
219
222
  console.log('');
220
223
  console.log(`${c.green}Pattern: ${loop.pattern}${c.reset}`);
221
- console.log(`${c.dim}${summary.completed} items completed in ${iteration - 1} iterations${c.reset}`);
224
+ console.log(
225
+ `${c.dim}${summary.completed} items completed in ${iteration - 1} iterations${c.reset}`
226
+ );
222
227
  return;
223
228
  }
224
229
 
@@ -287,7 +292,9 @@ function handleBatchLoop(rootDir) {
287
292
  console.log(`${c.cyan}━━━ Next Item ━━━${c.reset}`);
288
293
  console.log(`${c.bold}${nextItem}${c.reset}`);
289
294
  console.log('');
290
- console.log(`${c.dim}Progress: ${summary.completed}/${summary.total} items complete${c.reset}`);
295
+ console.log(
296
+ `${c.dim}Progress: ${summary.completed}/${summary.total} items complete${c.reset}`
297
+ );
291
298
  console.log('');
292
299
  console.log(`${c.brand}▶ Implement "${loop.action}" for this file${c.reset}`);
293
300
  console.log(`${c.dim} Run tests when ready. Loop will validate and continue.${c.reset}`);
@@ -310,7 +317,9 @@ function handleBatchLoop(rootDir) {
310
317
  console.log('');
311
318
  console.log(`${c.green}Pattern: ${loop.pattern}${c.reset}`);
312
319
  console.log(`${c.green}Action: ${loop.action}${c.reset}`);
313
- console.log(`${c.dim}${summary.completed} items completed in ${iteration} iterations${c.reset}`);
320
+ console.log(
321
+ `${c.dim}${summary.completed} items completed in ${iteration} iterations${c.reset}`
322
+ );
314
323
  }
315
324
  } else {
316
325
  // Tests failed - continue iterating
@@ -353,9 +362,7 @@ async function handleInit(args, rootDir) {
353
362
  }
354
363
 
355
364
  const pattern = patternArg.split('=').slice(1).join('=').replace(/"/g, '');
356
- const action = actionArg
357
- ? actionArg.split('=').slice(1).join('=').replace(/"/g, '')
358
- : 'process';
365
+ const action = actionArg ? actionArg.split('=').slice(1).join('=').replace(/"/g, '') : 'process';
359
366
  const maxIterations = parseIntBounded(maxArg ? maxArg.split('=')[1] : null, 50, 1, 200);
360
367
 
361
368
  // Resolve glob pattern
@@ -432,7 +439,9 @@ function handleStatus(rootDir) {
432
439
  console.log(` Pattern: ${loop.pattern}`);
433
440
  console.log(` Action: ${loop.action}`);
434
441
  console.log(` Current Item: ${loop.current_item || 'none'}`);
435
- console.log(` Progress: ${summary.completed}/${summary.total} (${summary.in_progress} in progress)`);
442
+ console.log(
443
+ ` Progress: ${summary.completed}/${summary.total} (${summary.in_progress} in progress)`
444
+ );
436
445
  console.log(` Iteration: ${loop.iteration || 0}/${loop.max_iterations || 50}`);
437
446
  }
438
447
 
@@ -20,6 +20,7 @@ const path = require('path');
20
20
  const { execSync } = require('child_process');
21
21
  const { c: C, box } = require('../lib/colors');
22
22
  const { isValidCommandName } = require('../lib/validate');
23
+ const { readJSONCached, readFileCached } = require('../lib/file-cache');
23
24
 
24
25
  // Claude Code's Bash tool truncates around 30K chars, but ANSI codes and
25
26
  // box-drawing characters (╭╮╰╯─│) are multi-byte UTF-8, so we need buffer.
@@ -131,11 +132,9 @@ function safeRead(filePath) {
131
132
  }
132
133
 
133
134
  function safeReadJSON(filePath) {
134
- try {
135
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
136
- } catch {
137
- return null;
138
- }
135
+ // Use cached read for common JSON files
136
+ const absPath = path.resolve(filePath);
137
+ return readJSONCached(absPath);
139
138
  }
140
139
 
141
140
  function safeLs(dirPath) {
@@ -462,10 +461,10 @@ function generateFullContent() {
462
461
  'loop-mode': 'Autonomous epic execution (MODE=loop)',
463
462
  'multi-session': 'Multi-session coordination detected',
464
463
  'visual-e2e': 'Visual screenshot verification (VISUAL=true)',
465
- 'delegation': 'Expert spawning patterns (load when spawning)',
466
- 'stuck': 'Research prompt guidance (load after 2 failures)',
464
+ delegation: 'Expert spawning patterns (load when spawning)',
465
+ stuck: 'Research prompt guidance (load after 2 failures)',
467
466
  'plan-mode': 'Planning workflow details (load when entering plan mode)',
468
- 'tools': 'Tool usage guidance (load when needed)',
467
+ tools: 'Tool usage guidance (load when needed)',
469
468
  };
470
469
 
471
470
  content += `\n${C.dim}Section meanings:${C.reset}\n`;