agileflow 2.87.0 → 2.89.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.89.0] - 2026-01-14
11
+
12
+ ### Added
13
+ - Security hardening, LRU file caching, and comprehensive test coverage
14
+
15
+ ## [2.88.0] - 2026-01-13
16
+
17
+ ### Fixed
18
+ - Security and code quality improvements from EP-0012 ideation
19
+
10
20
  ## [2.87.0] - 2026-01-13
11
21
 
12
22
  ### Added
@@ -0,0 +1,358 @@
1
+ /**
2
+ * File Cache - LRU Cache for frequently read JSON files
3
+ *
4
+ * Optimizes performance by caching frequently accessed JSON files
5
+ * with configurable TTL (Time To Live).
6
+ *
7
+ * Features:
8
+ * - LRU (Least Recently Used) eviction when max size reached
9
+ * - TTL-based automatic expiration
10
+ * - Separate caches for different data types
11
+ * - Thread-safe for single-process Node.js usage
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ /**
18
+ * LRU Cache implementation with TTL support
19
+ */
20
+ class LRUCache {
21
+ /**
22
+ * Create a new LRU Cache
23
+ * @param {Object} options
24
+ * @param {number} [options.maxSize=100] - Maximum number of entries
25
+ * @param {number} [options.ttlMs=30000] - Time to live in milliseconds (default 30s)
26
+ */
27
+ constructor(options = {}) {
28
+ this.maxSize = options.maxSize || 100;
29
+ this.ttlMs = options.ttlMs || 30000;
30
+ this.cache = new Map();
31
+ this.stats = {
32
+ hits: 0,
33
+ misses: 0,
34
+ evictions: 0,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Get a value from cache
40
+ * @param {string} key - Cache key
41
+ * @returns {*} Cached value or undefined if not found/expired
42
+ */
43
+ get(key) {
44
+ const entry = this.cache.get(key);
45
+
46
+ if (!entry) {
47
+ this.stats.misses++;
48
+ return undefined;
49
+ }
50
+
51
+ // Check if expired
52
+ if (Date.now() > entry.expiresAt) {
53
+ this.cache.delete(key);
54
+ this.stats.misses++;
55
+ return undefined;
56
+ }
57
+
58
+ // Move to end (most recently used)
59
+ this.cache.delete(key);
60
+ this.cache.set(key, entry);
61
+
62
+ this.stats.hits++;
63
+ return entry.value;
64
+ }
65
+
66
+ /**
67
+ * Set a value in cache
68
+ * @param {string} key - Cache key
69
+ * @param {*} value - Value to cache
70
+ * @param {number} [ttlMs] - Optional custom TTL for this entry
71
+ */
72
+ set(key, value, ttlMs = this.ttlMs) {
73
+ // Remove existing entry if present
74
+ if (this.cache.has(key)) {
75
+ this.cache.delete(key);
76
+ }
77
+
78
+ // Evict oldest entries if at capacity
79
+ while (this.cache.size >= this.maxSize) {
80
+ const oldestKey = this.cache.keys().next().value;
81
+ this.cache.delete(oldestKey);
82
+ this.stats.evictions++;
83
+ }
84
+
85
+ // Add new entry
86
+ this.cache.set(key, {
87
+ value,
88
+ expiresAt: Date.now() + ttlMs,
89
+ cachedAt: Date.now(),
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Check if key exists and is not expired
95
+ * @param {string} key - Cache key
96
+ * @returns {boolean}
97
+ */
98
+ has(key) {
99
+ const entry = this.cache.get(key);
100
+ if (!entry) return false;
101
+ if (Date.now() > entry.expiresAt) {
102
+ this.cache.delete(key);
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Remove a key from cache
110
+ * @param {string} key - Cache key
111
+ * @returns {boolean} True if key was removed
112
+ */
113
+ delete(key) {
114
+ return this.cache.delete(key);
115
+ }
116
+
117
+ /**
118
+ * Clear all entries
119
+ */
120
+ clear() {
121
+ this.cache.clear();
122
+ }
123
+
124
+ /**
125
+ * Get cache statistics
126
+ * @returns {Object} Stats object
127
+ */
128
+ getStats() {
129
+ return {
130
+ ...this.stats,
131
+ size: this.cache.size,
132
+ maxSize: this.maxSize,
133
+ hitRate: this.stats.hits + this.stats.misses > 0
134
+ ? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(1) + '%'
135
+ : '0%',
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Get number of entries in cache
141
+ * @returns {number}
142
+ */
143
+ get size() {
144
+ return this.cache.size;
145
+ }
146
+ }
147
+
148
+ // =============================================================================
149
+ // File Cache Singleton
150
+ // =============================================================================
151
+
152
+ // Global cache instance (persists across requires in same process)
153
+ const fileCache = new LRUCache({
154
+ maxSize: 50,
155
+ ttlMs: 30000, // 30 seconds
156
+ });
157
+
158
+ /**
159
+ * Read and cache a JSON file
160
+ * @param {string} filePath - Absolute path to JSON file
161
+ * @param {Object} [options]
162
+ * @param {boolean} [options.force=false] - Skip cache and force read
163
+ * @param {number} [options.ttlMs] - Custom TTL for this file
164
+ * @returns {Object|null} Parsed JSON or null if error
165
+ */
166
+ function readJSONCached(filePath, options = {}) {
167
+ const { force = false, ttlMs } = options;
168
+ const cacheKey = `json:${filePath}`;
169
+
170
+ // Check cache first (unless force reload)
171
+ if (!force) {
172
+ const cached = fileCache.get(cacheKey);
173
+ if (cached !== undefined) {
174
+ return cached;
175
+ }
176
+ }
177
+
178
+ // Read from disk
179
+ try {
180
+ if (!fs.existsSync(filePath)) {
181
+ return null;
182
+ }
183
+ const content = fs.readFileSync(filePath, 'utf8');
184
+ const data = JSON.parse(content);
185
+
186
+ // Cache the result
187
+ fileCache.set(cacheKey, data, ttlMs);
188
+
189
+ return data;
190
+ } catch (error) {
191
+ // Cache null to avoid repeated failed reads
192
+ fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Read and cache a text file
199
+ * @param {string} filePath - Absolute path to file
200
+ * @param {Object} [options]
201
+ * @param {boolean} [options.force=false] - Skip cache and force read
202
+ * @param {number} [options.ttlMs] - Custom TTL for this file
203
+ * @returns {string|null} File content or null if error
204
+ */
205
+ function readFileCached(filePath, options = {}) {
206
+ const { force = false, ttlMs } = options;
207
+ const cacheKey = `file:${filePath}`;
208
+
209
+ // Check cache first (unless force reload)
210
+ if (!force) {
211
+ const cached = fileCache.get(cacheKey);
212
+ if (cached !== undefined) {
213
+ return cached;
214
+ }
215
+ }
216
+
217
+ // Read from disk
218
+ try {
219
+ if (!fs.existsSync(filePath)) {
220
+ return null;
221
+ }
222
+ const content = fs.readFileSync(filePath, 'utf8');
223
+
224
+ // Cache the result
225
+ fileCache.set(cacheKey, content, ttlMs);
226
+
227
+ return content;
228
+ } catch (error) {
229
+ // Cache null to avoid repeated failed reads
230
+ fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Invalidate cache for a specific file
237
+ * Call this after writing to a cached file
238
+ * @param {string} filePath - Absolute path to file
239
+ */
240
+ function invalidate(filePath) {
241
+ fileCache.delete(`json:${filePath}`);
242
+ fileCache.delete(`file:${filePath}`);
243
+ }
244
+
245
+ /**
246
+ * Invalidate cache for all files in a directory
247
+ * @param {string} dirPath - Directory path
248
+ */
249
+ function invalidateDir(dirPath) {
250
+ const normalizedDir = path.normalize(dirPath);
251
+ for (const key of fileCache.cache.keys()) {
252
+ const keyPath = key.replace(/^(json|file):/, '');
253
+ if (keyPath.startsWith(normalizedDir)) {
254
+ fileCache.delete(key);
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Clear entire cache
261
+ */
262
+ function clearCache() {
263
+ fileCache.clear();
264
+ }
265
+
266
+ /**
267
+ * Get cache statistics
268
+ * @returns {Object} Cache stats
269
+ */
270
+ function getCacheStats() {
271
+ return fileCache.getStats();
272
+ }
273
+
274
+ // =============================================================================
275
+ // Convenience Methods for Common Files
276
+ // =============================================================================
277
+
278
+ /**
279
+ * Read status.json with caching
280
+ * @param {string} rootDir - Project root directory
281
+ * @param {Object} [options]
282
+ * @returns {Object|null}
283
+ */
284
+ function readStatus(rootDir, options = {}) {
285
+ const filePath = path.join(rootDir, 'docs', '09-agents', 'status.json');
286
+ return readJSONCached(filePath, options);
287
+ }
288
+
289
+ /**
290
+ * Read session-state.json with caching
291
+ * @param {string} rootDir - Project root directory
292
+ * @param {Object} [options]
293
+ * @returns {Object|null}
294
+ */
295
+ function readSessionState(rootDir, options = {}) {
296
+ const filePath = path.join(rootDir, 'docs', '09-agents', 'session-state.json');
297
+ return readJSONCached(filePath, options);
298
+ }
299
+
300
+ /**
301
+ * Read agileflow-metadata.json with caching
302
+ * @param {string} rootDir - Project root directory
303
+ * @param {Object} [options]
304
+ * @returns {Object|null}
305
+ */
306
+ function readMetadata(rootDir, options = {}) {
307
+ const filePath = path.join(rootDir, 'docs', '00-meta', 'agileflow-metadata.json');
308
+ return readJSONCached(filePath, options);
309
+ }
310
+
311
+ /**
312
+ * Read registry.json with caching
313
+ * @param {string} rootDir - Project root directory
314
+ * @param {Object} [options]
315
+ * @returns {Object|null}
316
+ */
317
+ function readRegistry(rootDir, options = {}) {
318
+ const filePath = path.join(rootDir, '.agileflow', 'sessions', 'registry.json');
319
+ return readJSONCached(filePath, options);
320
+ }
321
+
322
+ /**
323
+ * Batch read multiple common files
324
+ * More efficient than reading each individually
325
+ * @param {string} rootDir - Project root directory
326
+ * @param {Object} [options]
327
+ * @returns {Object} Object with status, sessionState, metadata, registry
328
+ */
329
+ function readProjectFiles(rootDir, options = {}) {
330
+ return {
331
+ status: readStatus(rootDir, options),
332
+ sessionState: readSessionState(rootDir, options),
333
+ metadata: readMetadata(rootDir, options),
334
+ registry: readRegistry(rootDir, options),
335
+ };
336
+ }
337
+
338
+ module.exports = {
339
+ // Core LRU Cache class (for custom usage)
340
+ LRUCache,
341
+
342
+ // File reading with caching
343
+ readJSONCached,
344
+ readFileCached,
345
+
346
+ // Cache management
347
+ invalidate,
348
+ invalidateDir,
349
+ clearCache,
350
+ getCacheStats,
351
+
352
+ // Convenience methods for common files
353
+ readStatus,
354
+ readSessionState,
355
+ readMetadata,
356
+ readRegistry,
357
+ readProjectFiles,
358
+ };
@@ -0,0 +1,331 @@
1
+ /**
2
+ * progress.js - Progress Indicators for Long-Running Operations
3
+ *
4
+ * Provides animated spinners and micro-progress indicators that meet
5
+ * the Doherty Threshold (<400ms feels instant).
6
+ *
7
+ * Features:
8
+ * - Animated braille spinner
9
+ * - Micro-progress with current/total counts
10
+ * - TTY detection (no animation in non-TTY)
11
+ * - Minimal overhead for fast operations
12
+ */
13
+
14
+ const { c } = require('./colors');
15
+
16
+ // Braille spinner characters (smooth animation)
17
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
18
+
19
+ // Doherty Threshold - operations faster than this feel instant
20
+ const DOHERTY_THRESHOLD_MS = 400;
21
+
22
+ /**
23
+ * Progress spinner for long-running operations.
24
+ * Automatically handles TTY detection and cleanup.
25
+ */
26
+ class Spinner {
27
+ /**
28
+ * Create a new spinner
29
+ * @param {string} message - Initial message to display
30
+ * @param {Object} [options={}] - Spinner options
31
+ * @param {number} [options.interval=80] - Animation interval in ms
32
+ * @param {boolean} [options.enabled=true] - Whether spinner is enabled
33
+ */
34
+ constructor(message, options = {}) {
35
+ this.message = message;
36
+ this.interval = options.interval || 80;
37
+ this.enabled = options.enabled !== false && process.stdout.isTTY;
38
+ this.frameIndex = 0;
39
+ this.timer = null;
40
+ this.startTime = null;
41
+ this.current = 0;
42
+ this.total = 0;
43
+ }
44
+
45
+ /**
46
+ * Start the spinner animation
47
+ * @returns {Spinner} this for chaining
48
+ */
49
+ start() {
50
+ if (!this.enabled) {
51
+ // In non-TTY, just print the message once
52
+ console.log(`${c.dim}${this.message}${c.reset}`);
53
+ return this;
54
+ }
55
+
56
+ this.startTime = Date.now();
57
+ this.render();
58
+ this.timer = setInterval(() => {
59
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
60
+ this.render();
61
+ }, this.interval);
62
+
63
+ return this;
64
+ }
65
+
66
+ /**
67
+ * Update the spinner message
68
+ * @param {string} message - New message
69
+ * @returns {Spinner} this for chaining
70
+ */
71
+ update(message) {
72
+ this.message = message;
73
+ if (this.enabled && this.timer) {
74
+ this.render();
75
+ }
76
+ return this;
77
+ }
78
+
79
+ /**
80
+ * Update micro-progress (current/total)
81
+ * @param {number} current - Current item number
82
+ * @param {number} total - Total items
83
+ * @param {string} [action] - Action being performed (e.g., 'Archiving')
84
+ * @returns {Spinner} this for chaining
85
+ */
86
+ progress(current, total, action = null) {
87
+ this.current = current;
88
+ this.total = total;
89
+ if (action) {
90
+ this.message = `${action}... ${current}/${total}`;
91
+ } else {
92
+ this.message = `${this.message.split('...')[0]}... ${current}/${total}`;
93
+ }
94
+ if (this.enabled && this.timer) {
95
+ this.render();
96
+ }
97
+ return this;
98
+ }
99
+
100
+ /**
101
+ * Render the current spinner frame
102
+ * @private
103
+ */
104
+ render() {
105
+ if (!this.enabled) return;
106
+
107
+ const frame = SPINNER_FRAMES[this.frameIndex];
108
+ const line = `${c.cyan}${frame}${c.reset} ${this.message}`;
109
+
110
+ // Clear line and write new content
111
+ process.stdout.clearLine(0);
112
+ process.stdout.cursorTo(0);
113
+ process.stdout.write(line);
114
+ }
115
+
116
+ /**
117
+ * Stop the spinner with a success message
118
+ * @param {string} [message] - Success message (defaults to original message)
119
+ * @returns {Spinner} this for chaining
120
+ */
121
+ succeed(message = null) {
122
+ return this.stop('✓', message || this.message, c.green);
123
+ }
124
+
125
+ /**
126
+ * Stop the spinner with a failure message
127
+ * @param {string} [message] - Failure message (defaults to original message)
128
+ * @returns {Spinner} this for chaining
129
+ */
130
+ fail(message = null) {
131
+ return this.stop('✗', message || this.message, c.red);
132
+ }
133
+
134
+ /**
135
+ * Stop the spinner with a warning message
136
+ * @param {string} [message] - Warning message (defaults to original message)
137
+ * @returns {Spinner} this for chaining
138
+ */
139
+ warn(message = null) {
140
+ return this.stop('⚠', message || this.message, c.yellow);
141
+ }
142
+
143
+ /**
144
+ * Stop the spinner with an info message
145
+ * @param {string} [message] - Info message (defaults to original message)
146
+ * @returns {Spinner} this for chaining
147
+ */
148
+ info(message = null) {
149
+ return this.stop('ℹ', message || this.message, c.blue);
150
+ }
151
+
152
+ /**
153
+ * Stop the spinner with a custom symbol
154
+ * @param {string} symbol - Symbol to display
155
+ * @param {string} message - Final message
156
+ * @param {string} [color=''] - ANSI color code
157
+ * @returns {Spinner} this for chaining
158
+ */
159
+ stop(symbol, message, color = '') {
160
+ if (this.timer) {
161
+ clearInterval(this.timer);
162
+ this.timer = null;
163
+ }
164
+
165
+ if (this.enabled) {
166
+ process.stdout.clearLine(0);
167
+ process.stdout.cursorTo(0);
168
+ }
169
+
170
+ const elapsed = this.startTime ? Date.now() - this.startTime : 0;
171
+ const suffix = elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
172
+
173
+ console.log(`${color}${symbol}${c.reset} ${message}${suffix}`);
174
+ return this;
175
+ }
176
+
177
+ /**
178
+ * Check if operation was fast (under Doherty Threshold)
179
+ * @returns {boolean}
180
+ */
181
+ wasFast() {
182
+ return this.startTime && (Date.now() - this.startTime) < DOHERTY_THRESHOLD_MS;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Format duration for display
188
+ * @param {number} ms - Duration in milliseconds
189
+ * @returns {string} Formatted duration
190
+ */
191
+ function formatDuration(ms) {
192
+ if (ms < 1000) {
193
+ return `${ms}ms`;
194
+ }
195
+ const seconds = (ms / 1000).toFixed(1);
196
+ return `${seconds}s`;
197
+ }
198
+
199
+ /**
200
+ * Create and start a new spinner
201
+ * @param {string} message - Message to display
202
+ * @param {Object} [options={}] - Spinner options
203
+ * @returns {Spinner} Started spinner instance
204
+ */
205
+ function createSpinner(message, options = {}) {
206
+ return new Spinner(message, options).start();
207
+ }
208
+
209
+ /**
210
+ * Run an async operation with a spinner
211
+ * @param {string} message - Message to display
212
+ * @param {Function} fn - Async function to execute
213
+ * @param {Object} [options={}] - Spinner options
214
+ * @returns {Promise<any>} Result of the async function
215
+ */
216
+ async function withSpinner(message, fn, options = {}) {
217
+ const spinner = createSpinner(message, options);
218
+ try {
219
+ const result = await fn(spinner);
220
+ spinner.succeed();
221
+ return result;
222
+ } catch (error) {
223
+ spinner.fail(error.message);
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Progress bar for operations with known total
230
+ */
231
+ class ProgressBar {
232
+ /**
233
+ * Create a new progress bar
234
+ * @param {string} label - Label to display
235
+ * @param {number} total - Total items
236
+ * @param {Object} [options={}] - Bar options
237
+ * @param {number} [options.width=30] - Bar width in characters
238
+ */
239
+ constructor(label, total, options = {}) {
240
+ this.label = label;
241
+ this.total = total;
242
+ this.current = 0;
243
+ this.width = options.width || 30;
244
+ this.enabled = process.stdout.isTTY;
245
+ this.startTime = Date.now();
246
+ }
247
+
248
+ /**
249
+ * Update progress
250
+ * @param {number} current - Current item number
251
+ * @param {string} [item] - Current item being processed
252
+ * @returns {ProgressBar} this for chaining
253
+ */
254
+ update(current, item = null) {
255
+ this.current = current;
256
+
257
+ if (!this.enabled) {
258
+ // Non-TTY: print every 10% or on completion
259
+ const percent = Math.floor((current / this.total) * 100);
260
+ if (percent % 10 === 0 || current === this.total) {
261
+ console.log(`${this.label}: ${percent}% (${current}/${this.total})`);
262
+ }
263
+ return this;
264
+ }
265
+
266
+ const percent = this.total > 0 ? (current / this.total) : 0;
267
+ const filled = Math.round(this.width * percent);
268
+ const empty = this.width - filled;
269
+
270
+ const bar = `${c.green}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
271
+ const percentStr = `${Math.round(percent * 100)}%`.padStart(4);
272
+ const countStr = `${current}/${this.total}`;
273
+ const itemStr = item ? ` ${c.dim}${item}${c.reset}` : '';
274
+
275
+ process.stdout.clearLine(0);
276
+ process.stdout.cursorTo(0);
277
+ process.stdout.write(`${this.label} ${bar} ${percentStr} (${countStr})${itemStr}`);
278
+
279
+ return this;
280
+ }
281
+
282
+ /**
283
+ * Increment progress by 1
284
+ * @param {string} [item] - Current item being processed
285
+ * @returns {ProgressBar} this for chaining
286
+ */
287
+ increment(item = null) {
288
+ return this.update(this.current + 1, item);
289
+ }
290
+
291
+ /**
292
+ * Complete the progress bar
293
+ * @param {string} [message] - Completion message
294
+ * @returns {ProgressBar} this for chaining
295
+ */
296
+ complete(message = null) {
297
+ if (this.enabled) {
298
+ process.stdout.clearLine(0);
299
+ process.stdout.cursorTo(0);
300
+ }
301
+
302
+ const elapsed = Date.now() - this.startTime;
303
+ const suffix = elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
304
+ const msg = message || `${this.label} complete`;
305
+
306
+ console.log(`${c.green}✓${c.reset} ${msg} (${this.total} items)${suffix}`);
307
+ return this;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Create a progress bar
313
+ * @param {string} label - Label to display
314
+ * @param {number} total - Total items
315
+ * @param {Object} [options={}] - Bar options
316
+ * @returns {ProgressBar}
317
+ */
318
+ function createProgressBar(label, total, options = {}) {
319
+ return new ProgressBar(label, total, options);
320
+ }
321
+
322
+ module.exports = {
323
+ Spinner,
324
+ ProgressBar,
325
+ createSpinner,
326
+ createProgressBar,
327
+ withSpinner,
328
+ formatDuration,
329
+ DOHERTY_THRESHOLD_MS,
330
+ SPINNER_FRAMES,
331
+ };