prjct-cli 0.9.2 → 0.10.2

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 (53) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/core/__tests__/agentic/memory-system.test.js +263 -0
  3. package/core/__tests__/agentic/plan-mode.test.js +336 -0
  4. package/core/agentic/agent-router.js +253 -186
  5. package/core/agentic/chain-of-thought.js +578 -0
  6. package/core/agentic/command-executor.js +299 -17
  7. package/core/agentic/context-builder.js +208 -8
  8. package/core/agentic/context-filter.js +83 -83
  9. package/core/agentic/ground-truth.js +591 -0
  10. package/core/agentic/loop-detector.js +406 -0
  11. package/core/agentic/memory-system.js +850 -0
  12. package/core/agentic/parallel-tools.js +366 -0
  13. package/core/agentic/plan-mode.js +572 -0
  14. package/core/agentic/prompt-builder.js +127 -2
  15. package/core/agentic/response-templates.js +290 -0
  16. package/core/agentic/semantic-compression.js +517 -0
  17. package/core/agentic/think-blocks.js +657 -0
  18. package/core/agentic/tool-registry.js +32 -0
  19. package/core/agentic/validation-rules.js +380 -0
  20. package/core/command-registry.js +48 -0
  21. package/core/commands.js +128 -60
  22. package/core/context-sync.js +183 -0
  23. package/core/domain/agent-generator.js +77 -46
  24. package/core/domain/agent-loader.js +183 -0
  25. package/core/domain/agent-matcher.js +217 -0
  26. package/core/domain/agent-validator.js +217 -0
  27. package/core/domain/context-estimator.js +175 -0
  28. package/core/domain/product-standards.js +92 -0
  29. package/core/domain/smart-cache.js +157 -0
  30. package/core/domain/task-analyzer.js +353 -0
  31. package/core/domain/tech-detector.js +365 -0
  32. package/package.json +8 -16
  33. package/templates/commands/done.md +7 -0
  34. package/templates/commands/feature.md +8 -0
  35. package/templates/commands/ship.md +8 -0
  36. package/templates/commands/spec.md +128 -0
  37. package/templates/global/CLAUDE.md +17 -0
  38. package/core/__tests__/agentic/agent-router.test.js +0 -398
  39. package/core/__tests__/agentic/command-executor.test.js +0 -223
  40. package/core/__tests__/agentic/context-builder.test.js +0 -160
  41. package/core/__tests__/agentic/context-filter.test.js +0 -494
  42. package/core/__tests__/agentic/prompt-builder.test.js +0 -212
  43. package/core/__tests__/agentic/template-loader.test.js +0 -164
  44. package/core/__tests__/agentic/tool-registry.test.js +0 -243
  45. package/core/__tests__/domain/agent-generator.test.js +0 -296
  46. package/core/__tests__/domain/analyzer.test.js +0 -324
  47. package/core/__tests__/infrastructure/author-detector.test.js +0 -103
  48. package/core/__tests__/infrastructure/config-manager.test.js +0 -454
  49. package/core/__tests__/infrastructure/path-manager.test.js +0 -412
  50. package/core/__tests__/setup.test.js +0 -15
  51. package/core/__tests__/utils/date-helper.test.js +0 -169
  52. package/core/__tests__/utils/file-helper.test.js +0 -258
  53. package/core/__tests__/utils/jsonl-helper.test.js +0 -387
@@ -2,6 +2,17 @@
2
2
  * Command Executor
3
3
  * WITH MANDATORY AGENT ASSIGNMENT
4
4
  * Every task MUST use a specialized agent
5
+ *
6
+ * OPTIMIZATION (P0.2): Explicit Validation
7
+ * - Pre-flight checks before execution
8
+ * - Specific error messages, never generic failures
9
+ * - Actionable suggestions in every error
10
+ *
11
+ * P3.4: Plan Mode + Approval Flow
12
+ * - Separates planning from execution
13
+ * - Requires approval for destructive commands
14
+ *
15
+ * Source: Claude Code, Devin, Augment Code patterns
5
16
  */
6
17
 
7
18
  const templateLoader = require('./template-loader')
@@ -10,68 +21,242 @@ const promptBuilder = require('./prompt-builder')
10
21
  const toolRegistry = require('./tool-registry')
11
22
  const MandatoryAgentRouter = require('./agent-router')
12
23
  const ContextFilter = require('./context-filter')
24
+ const ContextEstimator = require('../domain/context-estimator')
25
+ const { validate, formatError } = require('./validation-rules')
26
+ const loopDetector = require('./loop-detector')
27
+ const chainOfThought = require('./chain-of-thought')
28
+ const semanticCompression = require('./semantic-compression')
29
+ const responseTemplates = require('./response-templates')
30
+ const memorySystem = require('./memory-system')
31
+ const groundTruth = require('./ground-truth')
32
+ const thinkBlocks = require('./think-blocks')
33
+ const parallelTools = require('./parallel-tools')
34
+ const planMode = require('./plan-mode')
35
+ // P3.5, P3.6, P3.7: DELEGATED TO CLAUDE CODE
36
+ // - semantic-search → Claude Code has Grep/Glob with semantic understanding
37
+ // - code-intelligence → Claude Code has native LSP integration
38
+ // - browser-preview → Claude Code can use Bash directly
13
39
 
14
40
  class CommandExecutor {
15
41
  constructor() {
16
42
  this.agentRouter = new MandatoryAgentRouter()
17
43
  this.contextFilter = new ContextFilter()
44
+ this.contextEstimator = null
18
45
  }
19
46
 
20
47
  /**
21
48
  * Execute command with MANDATORY agent assignment
22
49
  */
23
50
  async execute(commandName, params, projectPath) {
51
+ // Context for loop detection
52
+ const loopContext = params.task || params.description || ''
53
+
54
+ // Check if we're in a loop BEFORE attempting
55
+ if (loopDetector.shouldEscalate(commandName, loopContext)) {
56
+ const escalation = loopDetector.getEscalationInfo(commandName, loopContext)
57
+ return {
58
+ success: false,
59
+ error: escalation.message,
60
+ escalation,
61
+ isLoopDetected: true,
62
+ suggestion: escalation.suggestion
63
+ }
64
+ }
65
+
24
66
  try {
25
67
  // 1. Load template
26
68
  const template = await templateLoader.load(commandName)
27
69
 
28
- // 2. Build FULL context (before filtering)
29
- const fullContext = await contextBuilder.build(projectPath, params)
70
+ // 2. Build METADATA context only (lazy loading - no file reads yet)
71
+ const metadataContext = await contextBuilder.build(projectPath, params)
72
+
73
+ // 2.5. VALIDATE: Pre-flight checks with specific errors
74
+ const validation = await validate(commandName, metadataContext)
75
+ if (!validation.valid) {
76
+ return {
77
+ success: false,
78
+ error: formatError(validation),
79
+ validation,
80
+ isValidationError: true
81
+ }
82
+ }
30
83
 
31
- // 3. Check if command requires agent
32
- const requiresAgent = template.metadata?.['required-agent'] ||
33
- this.isTaskCommand(commandName)
84
+ // 2.55. P3.4 PLAN MODE: Check if command requires planning
85
+ const requiresPlanning = planMode.requiresPlanning(commandName)
86
+ const isDestructive = planMode.isDestructive(commandName)
87
+ const isInPlanningMode = planMode.isInPlanningMode(metadataContext.projectId)
88
+
89
+ // Start planning mode if required and not already in it
90
+ let activePlan = null
91
+ if (requiresPlanning && !isInPlanningMode && !params.skipPlanning) {
92
+ activePlan = planMode.startPlanning(metadataContext.projectId, commandName, params)
93
+ } else if (isInPlanningMode) {
94
+ activePlan = planMode.getActivePlan(metadataContext.projectId)
95
+ }
34
96
 
35
- let context = fullContext
97
+ // 2.6. GROUND TRUTH: Verify actual state before critical operations
98
+ let groundTruthResult = null
99
+ if (groundTruth.requiresVerification(commandName)) {
100
+ const preState = await contextBuilder.loadStateForCommand(metadataContext, commandName)
101
+ groundTruthResult = await groundTruth.verify(commandName, metadataContext, preState)
102
+
103
+ // Log warnings but don't block (user can override)
104
+ if (!groundTruthResult.verified && groundTruthResult.warnings.length > 0) {
105
+ console.log(groundTruth.formatWarnings(groundTruthResult))
106
+ }
107
+ }
108
+
109
+ // 2.7. THINK BLOCKS (P3.1): Dynamic reasoning based on triggers
110
+ // ANTI-HALLUCINATION FIX: Load state BEFORE using it (was undefined)
111
+ let thinkBlock = null
112
+ const preThinkState = groundTruthResult?.actual || await contextBuilder.loadStateForCommand(metadataContext, commandName)
113
+ const thinkTrigger = thinkBlocks.detectTrigger(commandName, metadataContext, preThinkState)
114
+ if (thinkTrigger) {
115
+ thinkBlock = await thinkBlocks.generate(thinkTrigger, commandName, metadataContext, preThinkState)
116
+
117
+ // Log think block if in debug mode
118
+ if (process.env.PRJCT_DEBUG === 'true') {
119
+ console.log(thinkBlocks.format(thinkBlock, true))
120
+ }
121
+ }
122
+
123
+ // 2.8. CHAIN OF THOUGHT: Reasoning for critical commands
124
+ let reasoning = null
125
+ if (chainOfThought.requiresReasoning(commandName)) {
126
+ // Load state for reasoning
127
+ const reasoningState = await contextBuilder.loadStateForCommand(metadataContext, commandName)
128
+ reasoning = await chainOfThought.reason(commandName, metadataContext, reasoningState)
129
+
130
+ // If reasoning shows critical issues, warn but continue
131
+ if (reasoning.reasoning && !reasoning.reasoning.allPassed) {
132
+ console.log('⚠️ Chain of Thought detected issues:')
133
+ console.log(chainOfThought.formatPlan(reasoning))
134
+ }
135
+ }
136
+
137
+ // 3. CRITICAL: Force agent assignment for ALL task-related commands
138
+ const requiresAgent = template.metadata?.['required-agent'] !== false &&
139
+ (template.metadata?.['required-agent'] === true ||
140
+ this.isTaskCommand(commandName) ||
141
+ this.shouldUseAgent(commandName))
142
+
143
+ let context = metadataContext
36
144
  let assignedAgent = null
37
145
 
146
+ // MANDATORY: Assign specialized agent for task commands
38
147
  if (requiresAgent) {
39
- // 4. MANDATORY: Assign specialized agent
148
+ // 4. Create task object for analysis
40
149
  const task = {
41
150
  description: params.task || params.description || commandName,
42
151
  type: commandName
43
152
  }
44
153
 
154
+ // 5. LAZY CONTEXT: Analyze task FIRST, then estimate files needed
155
+ // This avoids reading all files before knowing what we need
45
156
  const agentAssignment = await this.agentRouter.executeTask(
46
157
  task,
47
- fullContext,
158
+ metadataContext, // Only metadata, no files yet
48
159
  projectPath
49
160
  )
50
161
 
51
162
  assignedAgent = agentAssignment.agent
163
+ const taskAnalysis = agentAssignment.taskAnalysis
164
+
165
+ // Validate agent was assigned
166
+ if (!assignedAgent || !assignedAgent.name) {
167
+ throw new Error(
168
+ `CRITICAL: Failed to assign agent for command "${commandName}". ` +
169
+ `System requires ALL task commands to use specialized agents.`
170
+ )
171
+ }
172
+
173
+ // 6. PRE-FILTER: Estimate which files are needed BEFORE reading
174
+ if (!this.contextEstimator) {
175
+ this.contextEstimator = new ContextEstimator()
176
+ }
177
+
178
+ const estimatedFiles = await this.contextEstimator.estimateFiles(
179
+ taskAnalysis,
180
+ projectPath
181
+ )
52
182
 
53
- // 5. Filter context for this specific agent (70-90% reduction)
183
+ // 7. Build context ONLY with estimated files (lazy loading)
54
184
  const filtered = await this.contextFilter.filterForAgent(
55
185
  assignedAgent,
56
186
  task,
57
187
  projectPath,
58
- fullContext
188
+ {
189
+ ...metadataContext,
190
+ estimatedFiles, // Pre-filtered file list
191
+ fileCount: estimatedFiles.length
192
+ }
59
193
  )
60
194
 
61
195
  context = {
62
196
  ...filtered,
63
197
  agent: assignedAgent,
64
- originalSize: fullContext.files?.length || 0,
198
+ originalSize: estimatedFiles.length, // Estimated, not actual full size
65
199
  filteredSize: filtered.files?.length || 0,
66
- reduction: filtered.metrics?.reductionPercent || 0
200
+ reduction: filtered.metrics?.reductionPercent || 0,
201
+ lazyLoaded: true // Flag indicating lazy loading was used
67
202
  }
68
203
  }
69
204
 
70
205
  // 6. Load state with filtered context
71
- const state = await contextBuilder.loadState(context)
206
+ const rawState = await contextBuilder.loadState(context)
207
+
208
+ // 6.5. SEMANTIC COMPRESSION: Compress state for reduced token usage
209
+ const compressedState = {}
210
+ for (const [key, content] of Object.entries(rawState)) {
211
+ if (content) {
212
+ const compressed = semanticCompression.compress(content, key)
213
+ compressedState[key] = {
214
+ raw: content,
215
+ summary: compressed.summary,
216
+ compressed
217
+ }
218
+ } else {
219
+ compressedState[key] = { raw: null, summary: 'Empty', compressed: null }
220
+ }
221
+ }
72
222
 
73
- // 7. Build prompt with agent assignment
74
- const prompt = promptBuilder.build(template, context, state, assignedAgent)
223
+ // Use compressed summaries for prompt, keep raw for tool execution
224
+ const state = {
225
+ ...rawState,
226
+ _compressed: compressedState,
227
+ _compressionMetrics: semanticCompression.getMetrics()
228
+ }
229
+
230
+ // 7. MEMORY: Load learned patterns AND relevant memories for this command
231
+ let learnedPatterns = null
232
+ let relevantMemories = null
233
+ if (context.projectId) {
234
+ learnedPatterns = {
235
+ commit_footer: await memorySystem.getSmartDecision(context.projectId, 'commit_footer'),
236
+ branch_naming: await memorySystem.getSmartDecision(context.projectId, 'branch_naming'),
237
+ test_before_ship: await memorySystem.getSmartDecision(context.projectId, 'test_before_ship'),
238
+ preferred_agent: await memorySystem.getSmartDecision(context.projectId, `preferred_agent_${commandName}`)
239
+ }
240
+
241
+ // P3.3: Get relevant memories for context
242
+ relevantMemories = await memorySystem.getRelevantMemories(
243
+ context.projectId,
244
+ { commandName, params },
245
+ 5 // Top 5 relevant memories
246
+ )
247
+ }
248
+
249
+ // 9. Build prompt with agent assignment, learned patterns, think blocks, memories, AND plan mode
250
+ const planInfo = {
251
+ isPlanning: requiresPlanning || isInPlanningMode,
252
+ requiresApproval: isDestructive && !params.approved,
253
+ active: activePlan,
254
+ allowedTools: planMode.getAllowedTools(
255
+ isInPlanningMode,
256
+ template.frontmatter['allowed-tools'] || []
257
+ )
258
+ }
259
+ const prompt = promptBuilder.build(template, context, state, assignedAgent, learnedPatterns, thinkBlock, relevantMemories, planInfo)
75
260
 
76
261
  // 8. Log agent usage
77
262
  if (assignedAgent) {
@@ -79,6 +264,9 @@ class CommandExecutor {
79
264
  console.log(`📉 Context reduced by: ${context.reduction}%`)
80
265
  }
81
266
 
267
+ // Record successful attempt
268
+ loopDetector.recordSuccess(commandName, loopContext)
269
+
82
270
  return {
83
271
  success: true,
84
272
  template,
@@ -86,12 +274,89 @@ class CommandExecutor {
86
274
  state,
87
275
  prompt,
88
276
  assignedAgent,
89
- contextReduction: context.reduction
277
+ contextReduction: context.reduction,
278
+ reasoning, // Chain of thought results
279
+ thinkBlock, // Think blocks (P3.1)
280
+ groundTruth: groundTruthResult, // Ground truth verification (P1.3)
281
+ compressionMetrics: state._compressionMetrics,
282
+ learnedPatterns, // Memory system patterns
283
+ relevantMemories, // P3.3: Semantic memories
284
+ // Response formatter helper
285
+ formatResponse: (data) => responseTemplates.format(commandName, data),
286
+ // Think block formatter helper
287
+ formatThinkBlock: (verbose) => thinkBlocks.format(thinkBlock, verbose),
288
+ // P3.2: Parallel tools helper
289
+ parallel: {
290
+ execute: (toolCalls) => parallelTools.execute(toolCalls),
291
+ readAll: (paths) => parallelTools.readAll(paths),
292
+ canParallelize: (tools) => parallelTools.canParallelize(tools),
293
+ getMetrics: () => parallelTools.getMetrics()
294
+ },
295
+ // P3.3: Memory system helpers
296
+ memory: {
297
+ create: (memory) => memorySystem.createMemory(context.projectId, memory),
298
+ autoRemember: (type, value, ctx) => memorySystem.autoRemember(context.projectId, type, value, ctx),
299
+ search: (query) => memorySystem.searchMemories(context.projectId, query),
300
+ findByTags: (tags) => memorySystem.findByTags(context.projectId, tags),
301
+ getStats: () => memorySystem.getMemoryStats(context.projectId)
302
+ },
303
+ // P3.4: Plan Mode helpers
304
+ plan: {
305
+ active: activePlan,
306
+ isPlanning: requiresPlanning || isInPlanningMode,
307
+ isDestructive,
308
+ requiresApproval: isDestructive && !params.approved,
309
+ // Planning phase methods
310
+ recordInfo: (info) => planMode.recordGatheredInfo(context.projectId, info),
311
+ setAnalysis: (analysis) => planMode.setAnalysis(context.projectId, analysis),
312
+ propose: (plan) => planMode.proposePlan(context.projectId, plan),
313
+ // Approval methods
314
+ approve: (feedback) => planMode.approvePlan(context.projectId, feedback),
315
+ reject: (reason) => planMode.rejectPlan(context.projectId, reason),
316
+ getApprovalPrompt: () => planMode.generateApprovalPrompt(commandName, context),
317
+ // Execution methods
318
+ startExecution: () => planMode.startExecution(context.projectId),
319
+ getNextStep: () => planMode.getNextStep(context.projectId),
320
+ completeStep: (result) => planMode.completeStep(context.projectId, result),
321
+ failStep: (error) => planMode.failStep(context.projectId, error),
322
+ abort: (reason) => planMode.abortPlan(context.projectId, reason),
323
+ // Status
324
+ getStatus: () => planMode.formatStatus(context.projectId),
325
+ getAllowedTools: () => planMode.getAllowedTools(
326
+ isInPlanningMode,
327
+ template.frontmatter['allowed-tools'] || []
328
+ )
329
+ }
330
+ // P3.5, P3.6: DELEGATED TO CLAUDE CODE
331
+ // Use Claude Code's native tools instead:
332
+ // - Grep for semantic search
333
+ // - Glob for file patterns
334
+ // - Native LSP for code intelligence
90
335
  }
91
336
  } catch (error) {
337
+ // Record failed attempt for loop detection
338
+ const attemptInfo = loopDetector.recordAttempt(commandName, loopContext, {
339
+ success: false,
340
+ error: error.message
341
+ })
342
+
343
+ // Check if we should escalate after this failure
344
+ if (attemptInfo.shouldEscalate) {
345
+ const escalation = loopDetector.getEscalationInfo(commandName, loopContext)
346
+ return {
347
+ success: false,
348
+ error: escalation.message,
349
+ escalation,
350
+ isLoopDetected: true,
351
+ suggestion: escalation.suggestion
352
+ }
353
+ }
354
+
92
355
  return {
93
356
  success: false,
94
357
  error: error.message,
358
+ attemptNumber: attemptInfo.attemptNumber,
359
+ isLooping: attemptInfo.isLooping
95
360
  }
96
361
  }
97
362
  }
@@ -100,10 +365,27 @@ class CommandExecutor {
100
365
  * Check if command is task-related
101
366
  */
102
367
  isTaskCommand(commandName) {
103
- const taskCommands = ['work', 'now', 'build', 'feature', 'bug', 'done']
368
+ const taskCommands = [
369
+ 'work', 'now', 'build', 'feature', 'bug', 'done',
370
+ 'task', 'design', 'cleanup', 'fix', 'test'
371
+ ]
104
372
  return taskCommands.includes(commandName)
105
373
  }
106
374
 
375
+ /**
376
+ * Determine if command should use an agent
377
+ * Expanded list of commands that benefit from agent specialization
378
+ */
379
+ shouldUseAgent(commandName) {
380
+ // Commands that should ALWAYS use agents
381
+ const agentCommands = [
382
+ 'work', 'now', 'build', 'feature', 'bug', 'done',
383
+ 'task', 'design', 'cleanup', 'fix', 'test',
384
+ 'sync', 'analyze' // These analyze/modify code, need specialization
385
+ ]
386
+ return agentCommands.includes(commandName)
387
+ }
388
+
107
389
  /**
108
390
  * Execute tool with permission check
109
391
  * @param {string} toolName - Tool name
@@ -2,6 +2,13 @@
2
2
  * Context Builder
3
3
  * Builds project context for Claude to make decisions
4
4
  * NO if/else logic - just data collection
5
+ *
6
+ * OPTIMIZATION (P0.1): Smart Context Caching
7
+ * - Parallel file reads with Promise.all()
8
+ * - Session-based caching to avoid redundant reads
9
+ * - Selective loading based on command needs
10
+ *
11
+ * Source: Windsurf, Cursor patterns
5
12
  */
6
13
 
7
14
  const fs = require('fs').promises
@@ -9,6 +16,28 @@ const pathManager = require('../infrastructure/path-manager')
9
16
  const configManager = require('../infrastructure/config-manager')
10
17
 
11
18
  class ContextBuilder {
19
+ constructor() {
20
+ // Session cache - cleared between commands or after timeout
21
+ this._cache = new Map()
22
+ // ANTI-HALLUCINATION: Reduced from 30s to 5s to prevent stale data
23
+ this._cacheTimeout = 5000 // 5 seconds (was 30s - caused stale context issues)
24
+ this._lastCacheTime = null
25
+ // Track file modification times for additional staleness detection
26
+ this._mtimes = new Map()
27
+ }
28
+
29
+ /**
30
+ * Clear cache if stale or force clear
31
+ * @param {boolean} force - Force clear regardless of timeout
32
+ */
33
+ _clearCacheIfStale(force = false) {
34
+ if (force || !this._lastCacheTime ||
35
+ Date.now() - this._lastCacheTime > this._cacheTimeout) {
36
+ this._cache.clear()
37
+ this._lastCacheTime = Date.now()
38
+ }
39
+ }
40
+
12
41
  /**
13
42
  * Build full project context for Claude
14
43
  * @param {string} projectPath - Local project path
@@ -34,7 +63,9 @@ class ContextBuilder {
34
63
  metrics: pathManager.getFilePath(projectId, 'progress', 'metrics.md'),
35
64
  ideas: pathManager.getFilePath(projectId, 'planning', 'ideas.md'),
36
65
  roadmap: pathManager.getFilePath(projectId, 'planning', 'roadmap.md'),
66
+ specs: pathManager.getFilePath(projectId, 'planning', 'specs'),
37
67
  memory: pathManager.getFilePath(projectId, 'memory', 'context.jsonl'),
68
+ patterns: pathManager.getFilePath(projectId, 'memory', 'patterns.json'),
38
69
  analysis: pathManager.getFilePath(projectId, 'analysis', 'repo-summary.md'),
39
70
  },
40
71
 
@@ -49,25 +80,182 @@ class ContextBuilder {
49
80
  }
50
81
 
51
82
  /**
52
- * Load current project state
83
+ * Load current project state - PARALLEL VERSION
84
+ * Uses Promise.all() for 40-60% faster file I/O
85
+ *
53
86
  * @param {Object} context - Context from build()
87
+ * @param {string[]} onlyKeys - Optional: only load specific keys (selective loading)
54
88
  * @returns {Promise<Object>} Current state
55
89
  */
56
- async loadState(context) {
90
+ async loadState(context, onlyKeys = null) {
91
+ this._clearCacheIfStale()
92
+
57
93
  const state = {}
94
+ const entries = Object.entries(context.paths)
95
+
96
+ // Filter to only requested keys if specified
97
+ const filteredEntries = onlyKeys
98
+ ? entries.filter(([key]) => onlyKeys.includes(key))
99
+ : entries
100
+
101
+ // ANTI-HALLUCINATION: Verify mtime before trusting cache
102
+ // Files can change between commands - stale cache causes hallucinations
103
+ for (const [, filePath] of filteredEntries) {
104
+ if (this._cache.has(filePath)) {
105
+ try {
106
+ const stat = await fs.stat(filePath)
107
+ const cachedMtime = this._mtimes.get(filePath)
108
+ if (!cachedMtime || stat.mtimeMs > cachedMtime) {
109
+ // File changed since cached - invalidate
110
+ this._cache.delete(filePath)
111
+ this._mtimes.delete(filePath)
112
+ }
113
+ } catch {
114
+ // File doesn't exist - invalidate cache
115
+ this._cache.delete(filePath)
116
+ this._mtimes.delete(filePath)
117
+ }
118
+ }
119
+ }
120
+
121
+ // Separate cached vs uncached files
122
+ const uncachedEntries = []
123
+ for (const [key, filePath] of filteredEntries) {
124
+ if (this._cache.has(filePath)) {
125
+ state[key] = this._cache.get(filePath)
126
+ } else {
127
+ uncachedEntries.push([key, filePath])
128
+ }
129
+ }
130
+
131
+ // PARALLEL READ: All uncached files at once
132
+ if (uncachedEntries.length > 0) {
133
+ const readPromises = uncachedEntries.map(async ([key, filePath]) => {
134
+ try {
135
+ const [content, stat] = await Promise.all([
136
+ fs.readFile(filePath, 'utf-8'),
137
+ fs.stat(filePath)
138
+ ])
139
+ return { key, filePath, content, mtime: stat.mtimeMs }
140
+ } catch {
141
+ return { key, filePath, content: null, mtime: null }
142
+ }
143
+ })
58
144
 
59
- // Read all core files
60
- for (const [key, filePath] of Object.entries(context.paths)) {
61
- try {
62
- state[key] = await fs.readFile(filePath, 'utf-8')
63
- } catch {
64
- state[key] = null
145
+ const results = await Promise.all(readPromises)
146
+
147
+ // Populate state and cache (with mtime for anti-hallucination)
148
+ for (const { key, filePath, content, mtime } of results) {
149
+ state[key] = content
150
+ this._cache.set(filePath, content)
151
+ if (mtime) {
152
+ this._mtimes.set(filePath, mtime)
153
+ }
65
154
  }
66
155
  }
67
156
 
68
157
  return state
69
158
  }
70
159
 
160
+ /**
161
+ * Load state for specific command - optimized selective loading
162
+ * Each command only loads what it needs
163
+ *
164
+ * @param {Object} context - Context from build()
165
+ * @param {string} commandName - Command name for selective loading
166
+ * @returns {Promise<Object>} Current state (filtered)
167
+ */
168
+ async loadStateForCommand(context, commandName) {
169
+ // Command-specific file requirements
170
+ // Minimizes context window usage
171
+ const commandFileMap = {
172
+ // Core workflow
173
+ 'now': ['now', 'next'],
174
+ 'done': ['now', 'next', 'metrics'],
175
+ 'next': ['next'],
176
+
177
+ // Progress
178
+ 'ship': ['now', 'shipped', 'metrics'],
179
+ 'recap': ['shipped', 'metrics', 'now'],
180
+ 'progress': ['shipped', 'metrics'],
181
+
182
+ // Planning
183
+ 'idea': ['ideas', 'next'],
184
+ 'feature': ['roadmap', 'next', 'ideas'],
185
+ 'roadmap': ['roadmap'],
186
+ 'spec': ['roadmap', 'next', 'specs'],
187
+
188
+ // Analysis
189
+ 'analyze': ['analysis', 'context'],
190
+ 'sync': ['analysis', 'context', 'now'],
191
+
192
+ // All files (fallback)
193
+ 'default': null // null means load all
194
+ }
195
+
196
+ const requiredFiles = commandFileMap[commandName] || commandFileMap.default
197
+ return this.loadState(context, requiredFiles)
198
+ }
199
+
200
+ /**
201
+ * Batch read multiple files in parallel
202
+ * Utility for custom file sets
203
+ *
204
+ * @param {string[]} filePaths - Array of file paths
205
+ * @returns {Promise<Map<string, string|null>>} Map of path -> content
206
+ */
207
+ async batchRead(filePaths) {
208
+ this._clearCacheIfStale()
209
+
210
+ const results = new Map()
211
+ const uncachedPaths = []
212
+
213
+ // Check cache first
214
+ for (const filePath of filePaths) {
215
+ if (this._cache.has(filePath)) {
216
+ results.set(filePath, this._cache.get(filePath))
217
+ } else {
218
+ uncachedPaths.push(filePath)
219
+ }
220
+ }
221
+
222
+ // Parallel read uncached
223
+ if (uncachedPaths.length > 0) {
224
+ const readPromises = uncachedPaths.map(async (filePath) => {
225
+ try {
226
+ const content = await fs.readFile(filePath, 'utf-8')
227
+ return { filePath, content }
228
+ } catch {
229
+ return { filePath, content: null }
230
+ }
231
+ })
232
+
233
+ const readResults = await Promise.all(readPromises)
234
+
235
+ for (const { filePath, content } of readResults) {
236
+ results.set(filePath, content)
237
+ this._cache.set(filePath, content)
238
+ }
239
+ }
240
+
241
+ return results
242
+ }
243
+
244
+ /**
245
+ * Invalidate cache for specific file (after write)
246
+ * @param {string} filePath - File that was written
247
+ */
248
+ invalidateCache(filePath) {
249
+ this._cache.delete(filePath)
250
+ }
251
+
252
+ /**
253
+ * Force clear entire cache
254
+ */
255
+ clearCache() {
256
+ this._clearCacheIfStale(true)
257
+ }
258
+
71
259
  /**
72
260
  * Check file existence
73
261
  * @param {string} filePath - File path
@@ -81,6 +269,18 @@ class ContextBuilder {
81
269
  return false
82
270
  }
83
271
  }
272
+
273
+ /**
274
+ * Get cache stats (for debugging/metrics)
275
+ * @returns {Object} Cache statistics
276
+ */
277
+ getCacheStats() {
278
+ return {
279
+ size: this._cache.size,
280
+ lastRefresh: this._lastCacheTime,
281
+ timeout: this._cacheTimeout
282
+ }
283
+ }
84
284
  }
85
285
 
86
286
  module.exports = new ContextBuilder()