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/CHANGELOG.md +10 -0
- package/lib/file-cache.js +359 -0
- package/lib/progress.js +333 -0
- package/lib/validate.js +280 -1
- package/lib/yaml-utils.js +118 -0
- package/package.json +1 -1
- package/scripts/agileflow-welcome.js +61 -40
- package/scripts/batch-pmap-loop.js +21 -12
- package/scripts/obtain-context.js +7 -8
- package/scripts/precompact-context.sh +14 -0
- package/scripts/session-manager.js +16 -10
- package/scripts/test-session-boundary.js +2 -2
- package/tools/cli/commands/config.js +1 -5
- package/tools/cli/commands/setup.js +1 -5
- package/tools/cli/installers/core/installer.js +32 -2
- package/tools/cli/installers/ide/_base-ide.js +133 -19
- package/tools/cli/installers/ide/claude-code.js +14 -51
- package/tools/cli/installers/ide/cursor.js +4 -39
- package/tools/cli/installers/ide/windsurf.js +6 -39
- package/tools/cli/lib/content-injector.js +37 -0
- package/tools/cli/lib/ide-errors.js +233 -0
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
|
@@ -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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
66
|
-
metadata: 'docs
|
|
67
|
-
settings: '.claude
|
|
68
|
-
sessionState: 'docs
|
|
69
|
-
configYaml: '.agileflow
|
|
70
|
-
cliPackage: 'packages
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
467
|
+
tools: 'Tool usage guidance (load when needed)',
|
|
469
468
|
};
|
|
470
469
|
|
|
471
470
|
content += `\n${C.dim}Section meanings:${C.reset}\n`;
|