agileflow 2.90.7 → 2.92.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/README.md +178 -0
  4. package/lib/codebase-indexer.js +818 -0
  5. package/lib/colors.js +190 -12
  6. package/lib/consent.js +232 -0
  7. package/lib/correlation.js +277 -0
  8. package/lib/error-codes.js +46 -0
  9. package/lib/errors.js +48 -6
  10. package/lib/file-cache.js +182 -0
  11. package/lib/format-error.js +156 -0
  12. package/lib/path-resolver.js +155 -7
  13. package/lib/paths.js +212 -20
  14. package/lib/placeholder-registry.js +205 -0
  15. package/lib/registry-di.js +358 -0
  16. package/lib/result-schema.js +363 -0
  17. package/lib/result.js +210 -0
  18. package/lib/session-registry.js +13 -0
  19. package/lib/session-state-machine.js +465 -0
  20. package/lib/validate-commands.js +308 -0
  21. package/lib/validate-names.js +3 -3
  22. package/lib/validate.js +116 -52
  23. package/package.json +4 -1
  24. package/scripts/af +34 -0
  25. package/scripts/agent-loop.js +63 -9
  26. package/scripts/agileflow-configure.js +2 -2
  27. package/scripts/agileflow-welcome.js +435 -23
  28. package/scripts/archive-completed-stories.sh +57 -11
  29. package/scripts/claude-tmux.sh +102 -0
  30. package/scripts/damage-control-bash.js +3 -70
  31. package/scripts/damage-control-edit.js +3 -20
  32. package/scripts/damage-control-write.js +3 -20
  33. package/scripts/dependency-check.js +310 -0
  34. package/scripts/get-env.js +11 -4
  35. package/scripts/lib/configure-detect.js +23 -1
  36. package/scripts/lib/configure-features.js +43 -2
  37. package/scripts/lib/context-formatter.js +771 -0
  38. package/scripts/lib/context-loader.js +699 -0
  39. package/scripts/lib/damage-control-utils.js +107 -0
  40. package/scripts/lib/json-utils.sh +162 -0
  41. package/scripts/lib/state-migrator.js +353 -0
  42. package/scripts/lib/story-state-machine.js +437 -0
  43. package/scripts/obtain-context.js +118 -1048
  44. package/scripts/pre-push-check.sh +46 -0
  45. package/scripts/precompact-context.sh +36 -11
  46. package/scripts/query-codebase.js +538 -0
  47. package/scripts/ralph-loop.js +5 -5
  48. package/scripts/session-manager.js +220 -42
  49. package/scripts/spawn-parallel.js +651 -0
  50. package/scripts/tui/blessed/data/watcher.js +180 -0
  51. package/scripts/tui/blessed/index.js +244 -0
  52. package/scripts/tui/blessed/panels/output.js +101 -0
  53. package/scripts/tui/blessed/panels/sessions.js +150 -0
  54. package/scripts/tui/blessed/panels/trace.js +97 -0
  55. package/scripts/tui/blessed/ui/help.js +77 -0
  56. package/scripts/tui/blessed/ui/screen.js +52 -0
  57. package/scripts/tui/blessed/ui/statusbar.js +47 -0
  58. package/scripts/tui/blessed/ui/tabbar.js +99 -0
  59. package/scripts/tui/index.js +38 -30
  60. package/scripts/validators/README.md +143 -0
  61. package/scripts/validators/component-validator.js +239 -0
  62. package/scripts/validators/json-schema-validator.js +186 -0
  63. package/scripts/validators/markdown-validator.js +152 -0
  64. package/scripts/validators/migration-validator.js +129 -0
  65. package/scripts/validators/security-validator.js +380 -0
  66. package/scripts/validators/story-format-validator.js +197 -0
  67. package/scripts/validators/test-result-validator.js +114 -0
  68. package/scripts/validators/workflow-validator.js +247 -0
  69. package/src/core/agents/accessibility.md +6 -0
  70. package/src/core/agents/adr-writer.md +6 -0
  71. package/src/core/agents/analytics.md +6 -0
  72. package/src/core/agents/api.md +6 -0
  73. package/src/core/agents/ci.md +6 -0
  74. package/src/core/agents/codebase-query.md +261 -0
  75. package/src/core/agents/compliance.md +6 -0
  76. package/src/core/agents/configuration-damage-control.md +6 -0
  77. package/src/core/agents/configuration-visual-e2e.md +6 -0
  78. package/src/core/agents/database.md +10 -0
  79. package/src/core/agents/datamigration.md +6 -0
  80. package/src/core/agents/design.md +6 -0
  81. package/src/core/agents/devops.md +6 -0
  82. package/src/core/agents/documentation.md +6 -0
  83. package/src/core/agents/epic-planner.md +6 -0
  84. package/src/core/agents/integrations.md +6 -0
  85. package/src/core/agents/mentor.md +6 -0
  86. package/src/core/agents/mobile.md +6 -0
  87. package/src/core/agents/monitoring.md +6 -0
  88. package/src/core/agents/multi-expert.md +6 -0
  89. package/src/core/agents/performance.md +6 -0
  90. package/src/core/agents/product.md +6 -0
  91. package/src/core/agents/qa.md +6 -0
  92. package/src/core/agents/readme-updater.md +6 -0
  93. package/src/core/agents/refactor.md +6 -0
  94. package/src/core/agents/research.md +6 -0
  95. package/src/core/agents/security.md +6 -0
  96. package/src/core/agents/testing.md +10 -0
  97. package/src/core/agents/ui.md +6 -0
  98. package/src/core/commands/adr.md +114 -0
  99. package/src/core/commands/agent.md +120 -0
  100. package/src/core/commands/assign.md +145 -0
  101. package/src/core/commands/audit.md +401 -0
  102. package/src/core/commands/babysit.md +32 -5
  103. package/src/core/commands/board.md +1 -0
  104. package/src/core/commands/changelog.md +118 -0
  105. package/src/core/commands/configure.md +42 -6
  106. package/src/core/commands/diagnose.md +114 -0
  107. package/src/core/commands/epic.md +205 -1
  108. package/src/core/commands/handoff.md +128 -0
  109. package/src/core/commands/help.md +76 -0
  110. package/src/core/commands/metrics.md +1 -0
  111. package/src/core/commands/pr.md +96 -0
  112. package/src/core/commands/research/analyze.md +1 -0
  113. package/src/core/commands/research/ask.md +2 -0
  114. package/src/core/commands/research/import.md +1 -0
  115. package/src/core/commands/research/list.md +2 -0
  116. package/src/core/commands/research/synthesize.md +584 -0
  117. package/src/core/commands/research/view.md +2 -0
  118. package/src/core/commands/roadmap/analyze.md +400 -0
  119. package/src/core/commands/session/new.md +113 -6
  120. package/src/core/commands/session/spawn.md +197 -0
  121. package/src/core/commands/sprint.md +22 -0
  122. package/src/core/commands/status.md +200 -1
  123. package/src/core/commands/story/list.md +9 -9
  124. package/src/core/commands/story/view.md +1 -0
  125. package/src/core/commands/story.md +143 -4
  126. package/src/core/experts/codebase-query/expertise.yaml +190 -0
  127. package/src/core/experts/codebase-query/question.md +73 -0
  128. package/src/core/experts/codebase-query/self-improve.md +105 -0
  129. package/src/core/templates/agileflow-metadata.json +55 -2
  130. package/src/core/templates/plan-template.md +125 -0
  131. package/src/core/templates/story-lifecycle.md +213 -0
  132. package/src/core/templates/story-template.md +4 -0
  133. package/src/core/templates/tdd-test-template.js +241 -0
  134. package/tools/cli/commands/setup.js +86 -0
  135. package/tools/cli/installers/core/installer.js +94 -0
  136. package/tools/cli/installers/ide/_base-ide.js +20 -11
  137. package/tools/cli/installers/ide/codex.js +29 -47
  138. package/tools/cli/lib/config-manager.js +17 -2
  139. package/tools/cli/lib/content-transformer.js +271 -0
  140. package/tools/cli/lib/error-handler.js +14 -22
  141. package/tools/cli/lib/ide-error-factory.js +421 -0
  142. package/tools/cli/lib/ide-health-monitor.js +364 -0
  143. package/tools/cli/lib/ide-registry.js +114 -1
  144. package/tools/cli/lib/ui.js +14 -25
@@ -0,0 +1,277 @@
1
+ /**
2
+ * AgileFlow CLI - Correlation ID Management
3
+ *
4
+ * Generates and propagates trace IDs and session IDs for event correlation.
5
+ * Enables filtering and tracing events across complex workflows.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // Global context for correlation IDs
13
+ let currentContext = {
14
+ traceId: null,
15
+ sessionId: null,
16
+ spanId: null,
17
+ parentSpanId: null,
18
+ };
19
+
20
+ /**
21
+ * Generate a unique trace ID (16 hex chars, like OpenTelemetry standard)
22
+ * @returns {string} Unique trace ID
23
+ */
24
+ function generateTraceId() {
25
+ return crypto.randomBytes(8).toString('hex');
26
+ }
27
+
28
+ /**
29
+ * Generate a span ID (8 hex chars)
30
+ * @returns {string} Unique span ID
31
+ */
32
+ function generateSpanId() {
33
+ return crypto.randomBytes(4).toString('hex');
34
+ }
35
+
36
+ /**
37
+ * Generate a session ID based on timestamp and random component
38
+ * Format: session_YYYYMMDD_HHMMSS_XXXX
39
+ * @returns {string} Session ID
40
+ */
41
+ function generateSessionId() {
42
+ const now = new Date();
43
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
44
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '');
45
+ const random = crypto.randomBytes(2).toString('hex');
46
+ return `session_${date}_${time}_${random}`;
47
+ }
48
+
49
+ /**
50
+ * Get current correlation context
51
+ * @returns {{ traceId: string | null, sessionId: string | null, spanId: string | null }}
52
+ */
53
+ function getContext() {
54
+ return { ...currentContext };
55
+ }
56
+
57
+ /**
58
+ * Set correlation context
59
+ * @param {Object} context
60
+ * @param {string} [context.traceId] - Trace ID
61
+ * @param {string} [context.sessionId] - Session ID
62
+ * @param {string} [context.spanId] - Span ID
63
+ * @param {string} [context.parentSpanId] - Parent span ID
64
+ */
65
+ function setContext(context) {
66
+ if (context.traceId !== undefined) currentContext.traceId = context.traceId;
67
+ if (context.sessionId !== undefined) currentContext.sessionId = context.sessionId;
68
+ if (context.spanId !== undefined) currentContext.spanId = context.spanId;
69
+ if (context.parentSpanId !== undefined) currentContext.parentSpanId = context.parentSpanId;
70
+ }
71
+
72
+ /**
73
+ * Start a new trace (generates new trace_id)
74
+ * @param {Object} [options]
75
+ * @param {string} [options.sessionId] - Existing session ID to use
76
+ * @returns {{ traceId: string, sessionId: string, spanId: string }}
77
+ */
78
+ function startTrace(options = {}) {
79
+ const traceId = generateTraceId();
80
+ const sessionId = options.sessionId || currentContext.sessionId || generateSessionId();
81
+ const spanId = generateSpanId();
82
+
83
+ setContext({
84
+ traceId,
85
+ sessionId,
86
+ spanId,
87
+ parentSpanId: null,
88
+ });
89
+
90
+ return { traceId, sessionId, spanId };
91
+ }
92
+
93
+ /**
94
+ * Start a new span within current trace
95
+ * @returns {{ traceId: string, sessionId: string, spanId: string, parentSpanId: string | null }}
96
+ */
97
+ function startSpan() {
98
+ const parentSpanId = currentContext.spanId;
99
+ const spanId = generateSpanId();
100
+
101
+ setContext({
102
+ spanId,
103
+ parentSpanId,
104
+ });
105
+
106
+ return {
107
+ traceId: currentContext.traceId,
108
+ sessionId: currentContext.sessionId,
109
+ spanId,
110
+ parentSpanId,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Inject correlation IDs into an event object
116
+ * @param {Object} event - Event to inject into
117
+ * @returns {Object} Event with correlation IDs
118
+ */
119
+ function injectCorrelation(event) {
120
+ const ctx = getContext();
121
+ return {
122
+ ...event,
123
+ ...(ctx.traceId && { trace_id: ctx.traceId }),
124
+ ...(ctx.sessionId && { session_id: ctx.sessionId }),
125
+ ...(ctx.spanId && { span_id: ctx.spanId }),
126
+ ...(ctx.parentSpanId && { parent_span_id: ctx.parentSpanId }),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Clear correlation context (for testing or new sessions)
132
+ */
133
+ function clearContext() {
134
+ currentContext = {
135
+ traceId: null,
136
+ sessionId: null,
137
+ spanId: null,
138
+ parentSpanId: null,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Load session ID from session-state.json if available
144
+ * @param {string} projectDir - Project directory
145
+ * @returns {string | null} Session ID or null
146
+ */
147
+ function loadSessionId(projectDir) {
148
+ try {
149
+ const sessionStatePath = path.join(projectDir, 'docs', '09-agents', 'session-state.json');
150
+ if (fs.existsSync(sessionStatePath)) {
151
+ const data = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
152
+ return data.session_id || null;
153
+ }
154
+ } catch {
155
+ // Ignore errors
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Save session ID to session-state.json
162
+ * @param {string} projectDir - Project directory
163
+ * @param {string} sessionId - Session ID to save
164
+ */
165
+ function saveSessionId(projectDir, sessionId) {
166
+ try {
167
+ const sessionStatePath = path.join(projectDir, 'docs', '09-agents', 'session-state.json');
168
+ let data = {};
169
+
170
+ if (fs.existsSync(sessionStatePath)) {
171
+ data = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
172
+ }
173
+
174
+ data.session_id = sessionId;
175
+ data.updated_at = new Date().toISOString();
176
+
177
+ const dir = path.dirname(sessionStatePath);
178
+ if (!fs.existsSync(dir)) {
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ }
181
+
182
+ fs.writeFileSync(sessionStatePath, JSON.stringify(data, null, 2) + '\n');
183
+ } catch {
184
+ // Ignore errors - session ID is not critical
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Initialize correlation context for a project
190
+ * Loads existing session ID or creates new one
191
+ * @param {string} projectDir - Project directory
192
+ * @param {Object} [options]
193
+ * @param {boolean} [options.newTrace=true] - Start new trace
194
+ * @returns {{ traceId: string, sessionId: string }}
195
+ */
196
+ function initializeForProject(projectDir, options = {}) {
197
+ const { newTrace = true } = options;
198
+
199
+ // Load or create session ID
200
+ let sessionId = loadSessionId(projectDir);
201
+ if (!sessionId) {
202
+ sessionId = generateSessionId();
203
+ saveSessionId(projectDir, sessionId);
204
+ }
205
+
206
+ setContext({ sessionId });
207
+
208
+ if (newTrace) {
209
+ const trace = startTrace({ sessionId });
210
+ return { traceId: trace.traceId, sessionId };
211
+ }
212
+
213
+ return {
214
+ traceId: currentContext.traceId || generateTraceId(),
215
+ sessionId,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Extract correlation IDs from an event
221
+ * @param {Object} event - Event object
222
+ * @returns {{ traceId: string | null, sessionId: string | null, spanId: string | null }}
223
+ */
224
+ function extractCorrelation(event) {
225
+ return {
226
+ traceId: event.trace_id || null,
227
+ sessionId: event.session_id || null,
228
+ spanId: event.span_id || null,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Filter events by trace ID
234
+ * @param {Array} events - Array of events
235
+ * @param {string} traceId - Trace ID to filter by
236
+ * @returns {Array} Filtered events
237
+ */
238
+ function filterByTraceId(events, traceId) {
239
+ return events.filter(e => e.trace_id === traceId);
240
+ }
241
+
242
+ /**
243
+ * Filter events by session ID
244
+ * @param {Array} events - Array of events
245
+ * @param {string} sessionId - Session ID to filter by
246
+ * @returns {Array} Filtered events
247
+ */
248
+ function filterBySessionId(events, sessionId) {
249
+ return events.filter(e => e.session_id === sessionId);
250
+ }
251
+
252
+ module.exports = {
253
+ // ID generation
254
+ generateTraceId,
255
+ generateSpanId,
256
+ generateSessionId,
257
+
258
+ // Context management
259
+ getContext,
260
+ setContext,
261
+ clearContext,
262
+ startTrace,
263
+ startSpan,
264
+
265
+ // Event injection/extraction
266
+ injectCorrelation,
267
+ extractCorrelation,
268
+
269
+ // Project integration
270
+ initializeForProject,
271
+ loadSessionId,
272
+ saveSessionId,
273
+
274
+ // Filtering
275
+ filterByTraceId,
276
+ filterBySessionId,
277
+ };
@@ -524,6 +524,48 @@ function formatError(error, options = {}) {
524
524
  return lines.join('\n');
525
525
  }
526
526
 
527
+ /**
528
+ * Create an error result with standardized metadata
529
+ * This factory produces Result objects compatible with result-schema.js
530
+ * @param {string} errorCode - Error code (e.g., 'ENOENT', 'ECONFIG')
531
+ * @param {string} [message] - Optional custom error message
532
+ * @param {Object} [options] - Additional options
533
+ * @param {Object} [options.context] - Additional context about the error
534
+ * @param {Error} [options.cause] - Original error that caused this failure
535
+ * @returns {{ ok: false, error: string, errorCode: string, severity: string, category: string, recoverable: boolean, suggestedFix: string, autoFix: string|null, context?: Object, cause?: Error }}
536
+ */
537
+ function createErrorResult(errorCode, message, options = {}) {
538
+ const codeData = ErrorCodes[errorCode] || ErrorCodes.EUNKNOWN;
539
+
540
+ return {
541
+ ok: false,
542
+ error: message || codeData.message,
543
+ errorCode: codeData.code,
544
+ severity: codeData.severity,
545
+ category: codeData.category,
546
+ recoverable: codeData.recoverable,
547
+ suggestedFix: codeData.suggestedFix,
548
+ autoFix: codeData.autoFix || null,
549
+ context: options.context,
550
+ cause: options.cause,
551
+ };
552
+ }
553
+
554
+ /**
555
+ * Create a success result
556
+ * @template T
557
+ * @param {T} data - The success data
558
+ * @param {Object} [meta] - Optional metadata
559
+ * @returns {{ ok: true, data: T }}
560
+ */
561
+ function createSuccessResult(data, meta = {}) {
562
+ return {
563
+ ok: true,
564
+ data,
565
+ ...meta,
566
+ };
567
+ }
568
+
527
569
  module.exports = {
528
570
  // Enums
529
571
  Severity,
@@ -541,4 +583,8 @@ module.exports = {
541
583
  getSuggestedFix,
542
584
  getAutoFix,
543
585
  formatError,
586
+
587
+ // Result factories (for result-schema.js compatibility)
588
+ createErrorResult,
589
+ createSuccessResult,
544
590
  };
package/lib/errors.js CHANGED
@@ -236,10 +236,13 @@ function getCharName(char) {
236
236
  * @param {string} filePath - Absolute path to JSON file
237
237
  * @param {object} options - Optional settings
238
238
  * @param {*} options.defaultValue - Value to return if file doesn't exist (makes missing file not an error)
239
- * @returns {{ ok: boolean, data?: any, error?: string }}
239
+ * @param {boolean} options.hidePathInError - Hide file path in error messages (security: default false)
240
+ * @param {string} options.context - Context for error messages (alternative to exposing path)
241
+ * @returns {{ ok: boolean, data?: any, error?: string, errorCode?: string }}
240
242
  */
241
243
  function safeReadJSON(filePath, options = {}) {
242
- const { defaultValue } = options;
244
+ const { defaultValue, hidePathInError = false, context } = options;
245
+ const pathDisplay = hidePathInError ? context || 'configuration file' : filePath;
243
246
 
244
247
  try {
245
248
  if (!fs.existsSync(filePath)) {
@@ -247,19 +250,58 @@ function safeReadJSON(filePath, options = {}) {
247
250
  debugLog('safeReadJSON', { filePath, status: 'missing, using default' });
248
251
  return { ok: true, data: defaultValue };
249
252
  }
250
- const error = `File not found: ${filePath}`;
253
+ const error = `File not found: ${pathDisplay}`;
251
254
  debugLog('safeReadJSON', { filePath, error });
252
- return { ok: false, error };
255
+ return { ok: false, error, errorCode: 'ENOENT' };
253
256
  }
254
257
 
255
258
  const content = fs.readFileSync(filePath, 'utf8');
259
+
260
+ // Handle empty files gracefully
261
+ if (!content.trim()) {
262
+ if (defaultValue !== undefined) {
263
+ debugLog('safeReadJSON', { filePath, status: 'empty, using default' });
264
+ return { ok: true, data: defaultValue };
265
+ }
266
+ const error = `File is empty: ${pathDisplay}`;
267
+ debugLog('safeReadJSON', { filePath, error: 'empty file' });
268
+ return { ok: false, error, errorCode: 'EPARSE' };
269
+ }
270
+
256
271
  const data = JSON.parse(content);
257
272
  debugLog('safeReadJSON', { filePath, status: 'success' });
258
273
  return { ok: true, data };
259
274
  } catch (err) {
260
- const error = `Failed to read JSON from ${filePath}: ${err.message}`;
275
+ // Detect specific JSON parse errors
276
+ let errorCode = 'EPARSE';
277
+ let errorMessage = `Failed to parse JSON`;
278
+
279
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
280
+ errorCode = 'EACCES';
281
+ errorMessage = `Permission denied reading file`;
282
+ } else if (err.message && err.message.includes('Unexpected')) {
283
+ // JSON syntax error - don't expose full content
284
+ errorMessage = `Invalid JSON syntax`;
285
+ if (err.message.includes('position')) {
286
+ // Extract position info but not content
287
+ const posMatch = err.message.match(/position (\d+)/);
288
+ if (posMatch) {
289
+ errorMessage = `Invalid JSON syntax near position ${posMatch[1]}`;
290
+ }
291
+ }
292
+ } else if (err.message && err.message.includes('token')) {
293
+ errorMessage = `Invalid JSON: unexpected token`;
294
+ }
295
+
296
+ // Add context if not hiding path
297
+ if (!hidePathInError) {
298
+ errorMessage = `${errorMessage} in ${pathDisplay}`;
299
+ } else if (context) {
300
+ errorMessage = `${errorMessage} in ${context}`;
301
+ }
302
+
261
303
  debugLog('safeReadJSON', { filePath, error: err.message });
262
- return { ok: false, error };
304
+ return { ok: false, error: errorMessage, errorCode };
263
305
  }
264
306
  }
265
307
 
package/lib/file-cache.js CHANGED
@@ -336,6 +336,180 @@ function readProjectFiles(rootDir, options = {}) {
336
336
  };
337
337
  }
338
338
 
339
+ // =============================================================================
340
+ // Command Caching (for git and other shell commands)
341
+ // =============================================================================
342
+
343
+ const { execSync } = require('child_process');
344
+
345
+ // Separate cache for command output with shorter TTL
346
+ const commandCache = new LRUCache({
347
+ maxSize: 50,
348
+ ttlMs: 30000, // 30 seconds default
349
+ });
350
+
351
+ /**
352
+ * Execute and cache a shell command
353
+ * @param {string} command - Shell command to execute
354
+ * @param {Object} [options]
355
+ * @param {string} [options.cwd] - Working directory
356
+ * @param {boolean} [options.force=false] - Skip cache and force execution
357
+ * @param {number} [options.ttlMs] - Custom TTL for this command
358
+ * @param {string} [options.cacheKey] - Custom cache key (default: auto-generated)
359
+ * @returns {{ ok: boolean, data?: string, error?: string, cached?: boolean }}
360
+ */
361
+ function execCached(command, options = {}) {
362
+ const { cwd = process.cwd(), force = false, ttlMs, cacheKey } = options;
363
+ const key = cacheKey || `cmd:${command}:${cwd}`;
364
+
365
+ // Check cache first (unless force)
366
+ if (!force) {
367
+ const cached = commandCache.get(key);
368
+ if (cached !== undefined) {
369
+ return { ok: true, data: cached, cached: true };
370
+ }
371
+ }
372
+
373
+ // Execute command
374
+ try {
375
+ const output = execSync(command, {
376
+ cwd,
377
+ encoding: 'utf8',
378
+ stdio: ['pipe', 'pipe', 'pipe'],
379
+ timeout: 10000, // 10 second timeout
380
+ }).trim();
381
+
382
+ // Cache the result
383
+ commandCache.set(key, output, ttlMs);
384
+
385
+ return { ok: true, data: output, cached: false };
386
+ } catch (error) {
387
+ // Cache errors briefly to avoid repeated failures
388
+ const errMsg = error.message || 'Command failed';
389
+ return { ok: false, error: errMsg, cached: false };
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Execute and cache a git command
395
+ * Helper with git-specific cache key format
396
+ * @param {string} gitCommand - Git subcommand (e.g., 'status --short')
397
+ * @param {Object} [options]
398
+ * @param {string} [options.cwd] - Working directory
399
+ * @param {boolean} [options.force=false] - Skip cache
400
+ * @param {number} [options.ttlMs=30000] - TTL (default 30s)
401
+ * @returns {{ ok: boolean, data?: string, error?: string, cached?: boolean }}
402
+ */
403
+ function gitCached(gitCommand, options = {}) {
404
+ const { cwd = process.cwd(), ttlMs = 30000, force = false } = options;
405
+ const command = `git ${gitCommand}`;
406
+ const cacheKey = `git:${gitCommand}:${cwd}`;
407
+
408
+ return execCached(command, {
409
+ cwd,
410
+ force,
411
+ ttlMs,
412
+ cacheKey,
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Common git commands with caching
418
+ */
419
+ const gitCommands = {
420
+ /**
421
+ * Get current branch name (cached)
422
+ * @param {string} [cwd] - Working directory
423
+ * @param {Object} [options]
424
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
425
+ */
426
+ branch(cwd, options = {}) {
427
+ return gitCached('branch --show-current', { cwd, ...options });
428
+ },
429
+
430
+ /**
431
+ * Get short status (cached)
432
+ * @param {string} [cwd] - Working directory
433
+ * @param {Object} [options]
434
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
435
+ */
436
+ status(cwd, options = {}) {
437
+ return gitCached('status --short', { cwd, ...options });
438
+ },
439
+
440
+ /**
441
+ * Get recent commits (cached)
442
+ * @param {string} [cwd] - Working directory
443
+ * @param {Object} [options]
444
+ * @param {number} [options.count=5] - Number of commits
445
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
446
+ */
447
+ log(cwd, options = {}) {
448
+ const { count = 5, ...rest } = options;
449
+ return gitCached(`log -${count} --oneline`, { cwd, ...rest });
450
+ },
451
+
452
+ /**
453
+ * Get diff summary (cached with shorter TTL)
454
+ * @param {string} [cwd] - Working directory
455
+ * @param {Object} [options]
456
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
457
+ */
458
+ diff(cwd, options = {}) {
459
+ return gitCached('diff --stat', { cwd, ttlMs: 15000, ...options });
460
+ },
461
+
462
+ /**
463
+ * Get last commit short hash (cached)
464
+ * @param {string} [cwd] - Working directory
465
+ * @param {Object} [options]
466
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
467
+ */
468
+ commitHash(cwd, options = {}) {
469
+ return gitCached('log -1 --format="%h"', { cwd, ...options });
470
+ },
471
+
472
+ /**
473
+ * Get last commit message (cached)
474
+ * @param {string} [cwd] - Working directory
475
+ * @param {Object} [options]
476
+ * @returns {{ ok: boolean, data?: string, cached?: boolean }}
477
+ */
478
+ commitMessage(cwd, options = {}) {
479
+ return gitCached('log -1 --format="%s"', { cwd, ...options });
480
+ },
481
+ };
482
+
483
+ /**
484
+ * Invalidate all git caches for a directory
485
+ * Call this after git operations that modify state
486
+ * @param {string} [cwd] - Working directory
487
+ */
488
+ function invalidateGitCache(cwd = process.cwd()) {
489
+ const prefix = `git:`;
490
+ const suffix = `:${cwd}`;
491
+ for (const key of commandCache.cache.keys()) {
492
+ if (key.startsWith(prefix) && key.endsWith(suffix)) {
493
+ commandCache.delete(key);
494
+ }
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Get command cache statistics
500
+ * @returns {Object} Cache stats
501
+ */
502
+ function getCommandCacheStats() {
503
+ return commandCache.getStats();
504
+ }
505
+
506
+ /**
507
+ * Clear command cache
508
+ */
509
+ function clearCommandCache() {
510
+ commandCache.clear();
511
+ }
512
+
339
513
  module.exports = {
340
514
  // Core LRU Cache class (for custom usage)
341
515
  LRUCache,
@@ -356,4 +530,12 @@ module.exports = {
356
530
  readMetadata,
357
531
  readRegistry,
358
532
  readProjectFiles,
533
+
534
+ // Command caching
535
+ execCached,
536
+ gitCached,
537
+ gitCommands,
538
+ invalidateGitCache,
539
+ getCommandCacheStats,
540
+ clearCommandCache,
359
541
  };