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 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.1] - 2026-01-14
11
+
12
+ ### Fixed
13
+ - Fix active_commands preservation after conversation compact
14
+
15
+ ## [2.89.0] - 2026-01-14
16
+
17
+ ### Added
18
+ - Security hardening, LRU file caching, and comprehensive test coverage
19
+
10
20
  ## [2.88.0] - 2026-01-13
11
21
 
12
22
  ### Fixed
@@ -0,0 +1,359 @@
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:
134
+ this.stats.hits + this.stats.misses > 0
135
+ ? ((this.stats.hits / (this.stats.hits + this.stats.misses)) * 100).toFixed(1) + '%'
136
+ : '0%',
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Get number of entries in cache
142
+ * @returns {number}
143
+ */
144
+ get size() {
145
+ return this.cache.size;
146
+ }
147
+ }
148
+
149
+ // =============================================================================
150
+ // File Cache Singleton
151
+ // =============================================================================
152
+
153
+ // Global cache instance (persists across requires in same process)
154
+ const fileCache = new LRUCache({
155
+ maxSize: 50,
156
+ ttlMs: 30000, // 30 seconds
157
+ });
158
+
159
+ /**
160
+ * Read and cache a JSON file
161
+ * @param {string} filePath - Absolute path to JSON file
162
+ * @param {Object} [options]
163
+ * @param {boolean} [options.force=false] - Skip cache and force read
164
+ * @param {number} [options.ttlMs] - Custom TTL for this file
165
+ * @returns {Object|null} Parsed JSON or null if error
166
+ */
167
+ function readJSONCached(filePath, options = {}) {
168
+ const { force = false, ttlMs } = options;
169
+ const cacheKey = `json:${filePath}`;
170
+
171
+ // Check cache first (unless force reload)
172
+ if (!force) {
173
+ const cached = fileCache.get(cacheKey);
174
+ if (cached !== undefined) {
175
+ return cached;
176
+ }
177
+ }
178
+
179
+ // Read from disk
180
+ try {
181
+ if (!fs.existsSync(filePath)) {
182
+ return null;
183
+ }
184
+ const content = fs.readFileSync(filePath, 'utf8');
185
+ const data = JSON.parse(content);
186
+
187
+ // Cache the result
188
+ fileCache.set(cacheKey, data, ttlMs);
189
+
190
+ return data;
191
+ } catch (error) {
192
+ // Cache null to avoid repeated failed reads
193
+ fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Read and cache a text file
200
+ * @param {string} filePath - Absolute path to file
201
+ * @param {Object} [options]
202
+ * @param {boolean} [options.force=false] - Skip cache and force read
203
+ * @param {number} [options.ttlMs] - Custom TTL for this file
204
+ * @returns {string|null} File content or null if error
205
+ */
206
+ function readFileCached(filePath, options = {}) {
207
+ const { force = false, ttlMs } = options;
208
+ const cacheKey = `file:${filePath}`;
209
+
210
+ // Check cache first (unless force reload)
211
+ if (!force) {
212
+ const cached = fileCache.get(cacheKey);
213
+ if (cached !== undefined) {
214
+ return cached;
215
+ }
216
+ }
217
+
218
+ // Read from disk
219
+ try {
220
+ if (!fs.existsSync(filePath)) {
221
+ return null;
222
+ }
223
+ const content = fs.readFileSync(filePath, 'utf8');
224
+
225
+ // Cache the result
226
+ fileCache.set(cacheKey, content, ttlMs);
227
+
228
+ return content;
229
+ } catch (error) {
230
+ // Cache null to avoid repeated failed reads
231
+ fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Invalidate cache for a specific file
238
+ * Call this after writing to a cached file
239
+ * @param {string} filePath - Absolute path to file
240
+ */
241
+ function invalidate(filePath) {
242
+ fileCache.delete(`json:${filePath}`);
243
+ fileCache.delete(`file:${filePath}`);
244
+ }
245
+
246
+ /**
247
+ * Invalidate cache for all files in a directory
248
+ * @param {string} dirPath - Directory path
249
+ */
250
+ function invalidateDir(dirPath) {
251
+ const normalizedDir = path.normalize(dirPath);
252
+ for (const key of fileCache.cache.keys()) {
253
+ const keyPath = key.replace(/^(json|file):/, '');
254
+ if (keyPath.startsWith(normalizedDir)) {
255
+ fileCache.delete(key);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Clear entire cache
262
+ */
263
+ function clearCache() {
264
+ fileCache.clear();
265
+ }
266
+
267
+ /**
268
+ * Get cache statistics
269
+ * @returns {Object} Cache stats
270
+ */
271
+ function getCacheStats() {
272
+ return fileCache.getStats();
273
+ }
274
+
275
+ // =============================================================================
276
+ // Convenience Methods for Common Files
277
+ // =============================================================================
278
+
279
+ /**
280
+ * Read status.json with caching
281
+ * @param {string} rootDir - Project root directory
282
+ * @param {Object} [options]
283
+ * @returns {Object|null}
284
+ */
285
+ function readStatus(rootDir, options = {}) {
286
+ const filePath = path.join(rootDir, 'docs', '09-agents', 'status.json');
287
+ return readJSONCached(filePath, options);
288
+ }
289
+
290
+ /**
291
+ * Read session-state.json with caching
292
+ * @param {string} rootDir - Project root directory
293
+ * @param {Object} [options]
294
+ * @returns {Object|null}
295
+ */
296
+ function readSessionState(rootDir, options = {}) {
297
+ const filePath = path.join(rootDir, 'docs', '09-agents', 'session-state.json');
298
+ return readJSONCached(filePath, options);
299
+ }
300
+
301
+ /**
302
+ * Read agileflow-metadata.json with caching
303
+ * @param {string} rootDir - Project root directory
304
+ * @param {Object} [options]
305
+ * @returns {Object|null}
306
+ */
307
+ function readMetadata(rootDir, options = {}) {
308
+ const filePath = path.join(rootDir, 'docs', '00-meta', 'agileflow-metadata.json');
309
+ return readJSONCached(filePath, options);
310
+ }
311
+
312
+ /**
313
+ * Read registry.json with caching
314
+ * @param {string} rootDir - Project root directory
315
+ * @param {Object} [options]
316
+ * @returns {Object|null}
317
+ */
318
+ function readRegistry(rootDir, options = {}) {
319
+ const filePath = path.join(rootDir, '.agileflow', 'sessions', 'registry.json');
320
+ return readJSONCached(filePath, options);
321
+ }
322
+
323
+ /**
324
+ * Batch read multiple common files
325
+ * More efficient than reading each individually
326
+ * @param {string} rootDir - Project root directory
327
+ * @param {Object} [options]
328
+ * @returns {Object} Object with status, sessionState, metadata, registry
329
+ */
330
+ function readProjectFiles(rootDir, options = {}) {
331
+ return {
332
+ status: readStatus(rootDir, options),
333
+ sessionState: readSessionState(rootDir, options),
334
+ metadata: readMetadata(rootDir, options),
335
+ registry: readRegistry(rootDir, options),
336
+ };
337
+ }
338
+
339
+ module.exports = {
340
+ // Core LRU Cache class (for custom usage)
341
+ LRUCache,
342
+
343
+ // File reading with caching
344
+ readJSONCached,
345
+ readFileCached,
346
+
347
+ // Cache management
348
+ invalidate,
349
+ invalidateDir,
350
+ clearCache,
351
+ getCacheStats,
352
+
353
+ // Convenience methods for common files
354
+ readStatus,
355
+ readSessionState,
356
+ readMetadata,
357
+ readRegistry,
358
+ readProjectFiles,
359
+ };
@@ -0,0 +1,333 @@
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 =
172
+ elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
173
+
174
+ console.log(`${color}${symbol}${c.reset} ${message}${suffix}`);
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Check if operation was fast (under Doherty Threshold)
180
+ * @returns {boolean}
181
+ */
182
+ wasFast() {
183
+ return this.startTime && Date.now() - this.startTime < DOHERTY_THRESHOLD_MS;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Format duration for display
189
+ * @param {number} ms - Duration in milliseconds
190
+ * @returns {string} Formatted duration
191
+ */
192
+ function formatDuration(ms) {
193
+ if (ms < 1000) {
194
+ return `${ms}ms`;
195
+ }
196
+ const seconds = (ms / 1000).toFixed(1);
197
+ return `${seconds}s`;
198
+ }
199
+
200
+ /**
201
+ * Create and start a new spinner
202
+ * @param {string} message - Message to display
203
+ * @param {Object} [options={}] - Spinner options
204
+ * @returns {Spinner} Started spinner instance
205
+ */
206
+ function createSpinner(message, options = {}) {
207
+ return new Spinner(message, options).start();
208
+ }
209
+
210
+ /**
211
+ * Run an async operation with a spinner
212
+ * @param {string} message - Message to display
213
+ * @param {Function} fn - Async function to execute
214
+ * @param {Object} [options={}] - Spinner options
215
+ * @returns {Promise<any>} Result of the async function
216
+ */
217
+ async function withSpinner(message, fn, options = {}) {
218
+ const spinner = createSpinner(message, options);
219
+ try {
220
+ const result = await fn(spinner);
221
+ spinner.succeed();
222
+ return result;
223
+ } catch (error) {
224
+ spinner.fail(error.message);
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Progress bar for operations with known total
231
+ */
232
+ class ProgressBar {
233
+ /**
234
+ * Create a new progress bar
235
+ * @param {string} label - Label to display
236
+ * @param {number} total - Total items
237
+ * @param {Object} [options={}] - Bar options
238
+ * @param {number} [options.width=30] - Bar width in characters
239
+ */
240
+ constructor(label, total, options = {}) {
241
+ this.label = label;
242
+ this.total = total;
243
+ this.current = 0;
244
+ this.width = options.width || 30;
245
+ this.enabled = process.stdout.isTTY;
246
+ this.startTime = Date.now();
247
+ }
248
+
249
+ /**
250
+ * Update progress
251
+ * @param {number} current - Current item number
252
+ * @param {string} [item] - Current item being processed
253
+ * @returns {ProgressBar} this for chaining
254
+ */
255
+ update(current, item = null) {
256
+ this.current = current;
257
+
258
+ if (!this.enabled) {
259
+ // Non-TTY: print every 10% or on completion
260
+ const percent = Math.floor((current / this.total) * 100);
261
+ if (percent % 10 === 0 || current === this.total) {
262
+ console.log(`${this.label}: ${percent}% (${current}/${this.total})`);
263
+ }
264
+ return this;
265
+ }
266
+
267
+ const percent = this.total > 0 ? current / this.total : 0;
268
+ const filled = Math.round(this.width * percent);
269
+ const empty = this.width - filled;
270
+
271
+ const bar = `${c.green}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
272
+ const percentStr = `${Math.round(percent * 100)}%`.padStart(4);
273
+ const countStr = `${current}/${this.total}`;
274
+ const itemStr = item ? ` ${c.dim}${item}${c.reset}` : '';
275
+
276
+ process.stdout.clearLine(0);
277
+ process.stdout.cursorTo(0);
278
+ process.stdout.write(`${this.label} ${bar} ${percentStr} (${countStr})${itemStr}`);
279
+
280
+ return this;
281
+ }
282
+
283
+ /**
284
+ * Increment progress by 1
285
+ * @param {string} [item] - Current item being processed
286
+ * @returns {ProgressBar} this for chaining
287
+ */
288
+ increment(item = null) {
289
+ return this.update(this.current + 1, item);
290
+ }
291
+
292
+ /**
293
+ * Complete the progress bar
294
+ * @param {string} [message] - Completion message
295
+ * @returns {ProgressBar} this for chaining
296
+ */
297
+ complete(message = null) {
298
+ if (this.enabled) {
299
+ process.stdout.clearLine(0);
300
+ process.stdout.cursorTo(0);
301
+ }
302
+
303
+ const elapsed = Date.now() - this.startTime;
304
+ const suffix =
305
+ elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
306
+ const msg = message || `${this.label} complete`;
307
+
308
+ console.log(`${c.green}✓${c.reset} ${msg} (${this.total} items)${suffix}`);
309
+ return this;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Create a progress bar
315
+ * @param {string} label - Label to display
316
+ * @param {number} total - Total items
317
+ * @param {Object} [options={}] - Bar options
318
+ * @returns {ProgressBar}
319
+ */
320
+ function createProgressBar(label, total, options = {}) {
321
+ return new ProgressBar(label, total, options);
322
+ }
323
+
324
+ module.exports = {
325
+ Spinner,
326
+ ProgressBar,
327
+ createSpinner,
328
+ createProgressBar,
329
+ withSpinner,
330
+ formatDuration,
331
+ DOHERTY_THRESHOLD_MS,
332
+ SPINNER_FRAMES,
333
+ };