prjct-cli 0.28.0 → 0.28.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
@@ -1,21 +1,42 @@
1
1
  # Changelog
2
2
 
3
- ## [0.28.0] - 2026-01-10
3
+ ## [0.28.1] - 2026-01-10
4
4
 
5
- ### Feature: Claude Code Native Integration
5
+ ### Feature: @ Agent Mentions
6
6
 
7
- **Skills as First-Class Citizens**: prjct commands are now installed as Claude Code Skills for automatic discovery.
7
+ Direct agent invocation via @ notation in tasks.
8
8
 
9
- - **SessionStart Hook**: Injects fresh project context at session start via `~/.claude/hooks/prjct-session-start.sh`
10
- - **Skills Auto-Install**: 4 skills installed to `~/.claude/skills/` during `npm install`:
11
- - `prjct-task` - Start tasks with classification
12
- - `prjct-sync` - Analyze codebase, generate agents
13
- - `prjct-done` - Complete current subtask
14
- - `prjct-ship` - Ship with PR + version bump
15
- - **Setup Integration**: `core/infrastructure/setup.ts` now installs hooks and skills automatically
16
- - **Simplified CLAUDE.md**: Reduced global template from 221 to ~50 lines - hooks handle fresh context
9
+ **New Capabilities:**
10
+
11
+ 1. **@ Agent Mentions**
12
+ - `p. task @frontend add button` - Loads frontend specialist directly
13
+ - `p. task @frontend @uxui dark mode` - Loads multiple agents
14
+ - Supports all prjct agents: `@frontend`, `@backend`, `@database`, `@uxui`, `@testing`, `@devops`
15
+
16
+ 2. **@ Claude Subagents**
17
+ - `@explore` - Fast codebase search (Task tool with subagent_type=Explore)
18
+ - `@general` - Complex multi-step research
19
+ - `@plan` - Architecture design and planning
20
+ - Example: `p. task @frontend @explore add button like existing ones`
17
21
 
18
- **Upgrade Impact**: Run `npm install -g prjct-cli` to install hooks and skills.
22
+ 3. **Performance Improvements**
23
+ - Template cache: O(1) LRU using ES6 Map insertion order
24
+ - Agent loading: Parallel Promise.all() for faster startup
25
+ - Glob searches: Parallel execution in context estimation
26
+
27
+ 4. **Tool Permissions**
28
+ - Templates can specify `tool-permissions` in frontmatter
29
+ - Supports `allow`, `ask`, and `deny` rules per tool
30
+ - Example: `ship.md` now declares safe vs dangerous git commands
31
+
32
+ **Files Changed:**
33
+ - `core/agentic/command-executor.ts` - Parse @ mentions, load mentioned agents
34
+ - `core/agentic/prompt-builder.ts` - Build subagent instructions, tool permissions
35
+ - `core/agentic/template-loader.ts` - LRU cache optimization, tool-permissions parsing
36
+ - `core/agentic/agent-router.ts` - `loadAgentsByMention()` method
37
+ - `core/domain/agent-loader.ts` - Parallel agent loading
38
+ - `core/domain/context-estimator.ts` - Parallel glob execution
39
+ - `templates/commands/task.md` - @ mention documentation
19
40
 
20
41
  ---
21
42
 
package/CLAUDE.md CHANGED
@@ -145,34 +145,26 @@ Next: [suggested action]
145
145
 
146
146
  ---
147
147
 
148
- ## CLAUDE CODE INTEGRATION (v0.28)
148
+ ## SKILL INTEGRATION (v0.27)
149
149
 
150
- prjct-cli uses Claude Code's native features for robust integration:
150
+ Agents are linked to Claude Code skills from claude-plugins.dev.
151
151
 
152
- ### SessionStart Hook
152
+ ### Agent → Skill Mapping
153
153
 
154
- A hook runs at the start of every Claude Code session to inject fresh context:
155
- - Located at: `~/.claude/hooks/prjct-session-start.sh`
156
- - Automatically reads project state and injects into session
157
- - Bypasses CLAUDE.md caching issues
154
+ | Agent | Skill |
155
+ |-------|-------|
156
+ | `frontend.md` | `frontend-design` |
157
+ | `uxui.md` | `frontend-design` |
158
+ | `backend.md` | `javascript-typescript` |
159
+ | `testing.md` | `developer-kit` |
160
+ | `devops.md` | `developer-kit` |
161
+ | `prjct-planner.md` | `feature-dev` |
162
+ | `prjct-shipper.md` | `code-review` |
158
163
 
159
- ### Skills (Auto-Discovery)
164
+ ### Usage
160
165
 
161
- Skills are auto-discovered by Claude Code when relevant:
166
+ - `p. sync` installs required skills automatically
167
+ - `p. task` invokes skills based on task type
168
+ - Skills config: `{globalPath}/config/skills.json`
162
169
 
163
- | Skill | Trigger |
164
- |-------|---------|
165
- | `prjct-task` | "p. task", starting work, features/bugs |
166
- | `prjct-sync` | "p. sync", analyze codebase |
167
- | `prjct-done` | "p. done", completing work |
168
- | `prjct-ship` | "p. ship", releasing features |
169
-
170
- Skills location: `~/.claude/skills/prjct-*/SKILL.md`
171
-
172
- ### Setup
173
-
174
- All integration is installed automatically via `npm install -g prjct-cli`:
175
- 1. SessionStart hook → `~/.claude/hooks/`
176
- 2. Skills → `~/.claude/skills/`
177
- 3. Commands → `~/.claude/commands/p/`
178
- 4. Settings → `~/.claude/settings.json`
170
+ See `templates/agentic/skill-integration.md` for details.
@@ -104,6 +104,21 @@ class AgentRouter {
104
104
  }
105
105
  }
106
106
 
107
+ /**
108
+ * Load multiple agents by @ mention names
109
+ * @example loadAgentsByMention(["frontend", "uxui"]) -> [Agent, Agent]
110
+ */
111
+ async loadAgentsByMention(mentions: string[]): Promise<Agent[]> {
112
+ const agents: Agent[] = []
113
+ for (const name of mentions) {
114
+ const agent = await this.loadAgent(name)
115
+ if (agent) {
116
+ agents.push(agent)
117
+ }
118
+ }
119
+ return agents
120
+ }
121
+
107
122
  /**
108
123
  * Log agent usage to JSONL file
109
124
  */
@@ -18,6 +18,7 @@ import chainOfThought from './chain-of-thought'
18
18
  import memorySystem from './memory-system'
19
19
  import groundTruth from './ground-truth'
20
20
  import planMode from './plan-mode'
21
+ import AgentRouter from './agent-router'
21
22
 
22
23
  import type {
23
24
  ExecutionResult,
@@ -107,6 +108,18 @@ export class CommandExecutor {
107
108
  }
108
109
 
109
110
  try {
111
+ // 0. Parse @ agent mentions from task input
112
+ const taskInput = (params.task as string) || (params.description as string) || ''
113
+ const { prjctAgents, claudeSubagents, cleanInput } = promptBuilder.parseAgentMentions(taskInput)
114
+
115
+ // Update params with clean input (without @ mentions)
116
+ if (prjctAgents.length > 0 || claudeSubagents.length > 0) {
117
+ if (params.task) params.task = cleanInput
118
+ if (params.description) params.description = cleanInput
119
+ params._mentionedAgents = prjctAgents
120
+ params._claudeSubagents = claudeSubagents
121
+ }
122
+
110
123
  // 1. Load template
111
124
  const template = await templateLoader.load(commandName)
112
125
 
@@ -195,27 +208,62 @@ export class CommandExecutor {
195
208
  )
196
209
  }
197
210
 
198
- // 9. Build prompt - NO agent assignment here, Claude decides via templates
211
+ // 8.5. Load @ mentioned agents if any
212
+ let primaryAgent = null
213
+ if (prjctAgents.length > 0) {
214
+ const agentRouter = new AgentRouter()
215
+ await agentRouter.initialize(projectPath)
216
+ const loadedAgents = await agentRouter.loadAgentsByMention(prjctAgents)
217
+
218
+ if (loadedAgents.length > 0) {
219
+ primaryAgent = {
220
+ name: loadedAgents[0].name,
221
+ content: loadedAgents[0].content,
222
+ role: undefined,
223
+ skills: [],
224
+ }
225
+ console.log(`🤖 Loading agents: ${loadedAgents.map((a) => `@${a.name}`).join(', ')}`)
226
+ }
227
+ }
228
+
229
+ // 9. Build prompt - use mentioned agent or let Claude decide via templates
199
230
  const planInfo = {
200
231
  isPlanning: requiresPlanning || isInPlanningMode,
201
232
  requiresApproval: isDestructive && !params.approved,
202
233
  active: activePlan,
203
234
  allowedTools: planMode.getAllowedTools(isInPlanningMode, template.frontmatter['allowed-tools'] || []),
204
235
  }
205
- // Agent is null - Claude assigns via Task tool using agent-routing.md
206
- const prompt = promptBuilder.build(
236
+
237
+ // Build base prompt
238
+ let prompt = promptBuilder.build(
207
239
  template,
208
240
  context as Parameters<typeof promptBuilder.build>[1],
209
241
  state,
210
- null,
242
+ primaryAgent,
211
243
  learnedPatterns,
212
244
  null,
213
245
  relevantMemories,
214
246
  planInfo
215
247
  )
216
248
 
249
+ // Add Claude subagent instructions if @ mentioned
250
+ if (claudeSubagents.length > 0) {
251
+ const subagentInstructions = promptBuilder.buildSubagentInstructions(claudeSubagents)
252
+ prompt = prompt + subagentInstructions
253
+ console.log(`🔧 Claude subagents requested: ${claudeSubagents.map((s) => `@${s}`).join(', ')}`)
254
+ }
255
+
256
+ // Add tool permissions from template if present
257
+ const toolPermissions = template.frontmatter['tool-permissions'] as Record<string, { allow?: string[]; ask?: string[]; deny?: string[] }> | undefined
258
+ if (toolPermissions) {
259
+ const permissionsSection = promptBuilder.buildToolPermissions(toolPermissions)
260
+ prompt = prompt + permissionsSection
261
+ }
262
+
217
263
  // Log agentic mode
218
- console.log(`🤖 Agentic delegation enabled - Claude will assign agent via Task tool`)
264
+ if (!primaryAgent) {
265
+ console.log(`🤖 Agentic delegation enabled - Claude will assign agent via Task tool`)
266
+ }
219
267
 
220
268
  // Record successful attempt
221
269
  loopDetector.recordSuccess(commandName, loopContext)
@@ -498,6 +498,87 @@ class PromptBuilder {
498
498
  return result || null
499
499
  }
500
500
 
501
+ /**
502
+ * Claude Code subagents that can be invoked via @ notation
503
+ */
504
+ private readonly CLAUDE_SUBAGENTS = ['explore', 'general', 'plan']
505
+
506
+ /**
507
+ * Parse @ agent mentions from input
508
+ * Separates prjct agents from Claude Code subagents
509
+ * @example "@frontend @explore add dark mode" -> { prjctAgents: ["frontend"], claudeSubagents: ["explore"], cleanInput: "add dark mode" }
510
+ */
511
+ parseAgentMentions(input: string): {
512
+ prjctAgents: string[]
513
+ claudeSubagents: string[]
514
+ cleanInput: string
515
+ } {
516
+ if (!input) return { prjctAgents: [], claudeSubagents: [], cleanInput: '' }
517
+
518
+ const mentionPattern = /@(\w+)/g
519
+ const prjctAgents: string[] = []
520
+ const claudeSubagents: string[] = []
521
+ let match
522
+
523
+ while ((match = mentionPattern.exec(input)) !== null) {
524
+ const name = match[1].toLowerCase()
525
+ if (this.CLAUDE_SUBAGENTS.includes(name)) {
526
+ claudeSubagents.push(name)
527
+ } else {
528
+ prjctAgents.push(name)
529
+ }
530
+ }
531
+
532
+ const cleanInput = input.replace(mentionPattern, '').trim()
533
+ return { prjctAgents, claudeSubagents, cleanInput }
534
+ }
535
+
536
+ /**
537
+ * Build Claude subagent instructions for the prompt
538
+ */
539
+ buildSubagentInstructions(claudeSubagents: string[]): string {
540
+ if (!claudeSubagents.length) return ''
541
+
542
+ const instructions = claudeSubagents.map((sub) => {
543
+ switch (sub) {
544
+ case 'explore':
545
+ return '- USE Task tool with subagent_type=Explore for fast codebase exploration'
546
+ case 'general':
547
+ return '- USE Task tool with subagent_type=general-purpose for complex multi-step research'
548
+ case 'plan':
549
+ return '- USE Task tool with subagent_type=Plan for architecture design'
550
+ default:
551
+ return null
552
+ }
553
+ }).filter(Boolean)
554
+
555
+ if (!instructions.length) return ''
556
+
557
+ return `
558
+ ## CLAUDE SUBAGENT INSTRUCTIONS
559
+ ${instructions.join('\n')}
560
+ `
561
+ }
562
+
563
+ /**
564
+ * Build tool permissions section from template frontmatter
565
+ */
566
+ buildToolPermissions(toolPermissions: Record<string, { allow?: string[]; ask?: string[]; deny?: string[] }> | null): string {
567
+ if (!toolPermissions) return ''
568
+
569
+ const parts: string[] = ['\n## TOOL PERMISSIONS\n', 'Check these BEFORE executing:\n']
570
+
571
+ for (const [tool, rules] of Object.entries(toolPermissions)) {
572
+ parts.push(`\n**${tool}:**`)
573
+ if (rules.allow?.length) parts.push(`- ALLOW: ${rules.allow.join(', ')}`)
574
+ if (rules.ask?.length) parts.push(`- ASK USER: ${rules.ask.join(', ')}`)
575
+ if (rules.deny?.length) parts.push(`- NEVER: ${rules.deny.join(', ')}`)
576
+ }
577
+
578
+ parts.push('\n⚠️ DENY = Never execute. ASK = Confirm with user first.\n')
579
+ return parts.join('\n')
580
+ }
581
+
501
582
  /**
502
583
  * Build critical anti-hallucination rules section
503
584
  */
@@ -15,26 +15,92 @@ import type { Frontmatter, ParsedTemplate } from '../types'
15
15
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates', 'commands')
16
16
  const MAX_CACHE_SIZE = 50
17
17
 
18
+ // Use single Map for O(1) LRU operations (ES6 Maps maintain insertion order)
18
19
  const cache = new Map<string, ParsedTemplate>()
19
- const cacheOrder: string[] = []
20
20
 
21
21
  // ============ Cache Helpers ============
22
22
 
23
- function updateLruOrder(key: string): void {
24
- const index = cacheOrder.indexOf(key)
25
- if (index > -1) cacheOrder.splice(index, 1)
26
- cacheOrder.push(key)
27
- }
23
+ function setWithLru(key: string, value: ParsedTemplate): void {
24
+ // Delete first to move to end when re-adding (most recently used)
25
+ cache.delete(key)
26
+ cache.set(key, value)
28
27
 
29
- function evictLru(): void {
30
- while (cache.size >= MAX_CACHE_SIZE && cacheOrder.length > 0) {
31
- const oldest = cacheOrder.shift()
28
+ // Evict oldest (first item) if over limit
29
+ if (cache.size > MAX_CACHE_SIZE) {
30
+ const oldest = cache.keys().next().value
32
31
  if (oldest) cache.delete(oldest)
33
32
  }
34
33
  }
35
34
 
35
+ function getWithLru(key: string): ParsedTemplate | undefined {
36
+ const value = cache.get(key)
37
+ if (value !== undefined) {
38
+ // Move to end (most recently used)
39
+ cache.delete(key)
40
+ cache.set(key, value)
41
+ }
42
+ return value
43
+ }
44
+
36
45
  // ============ Parsing Functions ============
37
46
 
47
+ /**
48
+ * Parse tool-permissions YAML block
49
+ * Handles nested structure like:
50
+ * tool-permissions:
51
+ * bash:
52
+ * allow: ["git *"]
53
+ * deny: ["rm -rf"]
54
+ */
55
+ function parseToolPermissions(lines: string[], startIndex: number): {
56
+ permissions: Record<string, { allow?: string[]; ask?: string[]; deny?: string[] }>
57
+ endIndex: number
58
+ } {
59
+ const permissions: Record<string, { allow?: string[]; ask?: string[]; deny?: string[] }> = {}
60
+ let i = startIndex
61
+ let currentTool: string | null = null
62
+
63
+ while (i < lines.length) {
64
+ const line = lines[i]
65
+ const trimmed = line.trim()
66
+
67
+ // End if we hit a new top-level key (no leading spaces)
68
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t')) {
69
+ break
70
+ }
71
+
72
+ // Tool name (2 spaces indent)
73
+ const toolMatch = line.match(/^ {2}(\w+):$/)
74
+ if (toolMatch) {
75
+ currentTool = toolMatch[1]
76
+ permissions[currentTool] = {}
77
+ i++
78
+ continue
79
+ }
80
+
81
+ // Permission array (4 spaces indent)
82
+ const permMatch = line.match(/^ {4}(allow|ask|deny):\s*\[(.+)\]/)
83
+ if (permMatch && currentTool) {
84
+ const [, permType, arrayContent] = permMatch
85
+ permissions[currentTool][permType as 'allow' | 'ask' | 'deny'] = arrayContent
86
+ .split(',')
87
+ .map((v) => v.trim().replace(/^["']|["']$/g, ''))
88
+ i++
89
+ continue
90
+ }
91
+
92
+ // Skip empty lines within block
93
+ if (trimmed === '') {
94
+ i++
95
+ continue
96
+ }
97
+
98
+ i++
99
+ }
100
+
101
+ return { permissions, endIndex: i }
102
+ }
103
+
38
104
  export function parseFrontmatter(content: string): ParsedTemplate {
39
105
  const frontmatterRegex = /^---\n([\s\S]+?)\n---\n([\s\S]*)$/
40
106
  const match = content.match(frontmatterRegex)
@@ -45,21 +111,35 @@ export function parseFrontmatter(content: string): ParsedTemplate {
45
111
 
46
112
  const [, frontmatterText, mainContent] = match
47
113
  const frontmatter: Frontmatter = {}
114
+ const lines = frontmatterText.split('\n')
48
115
 
49
- frontmatterText.split('\n').forEach((line) => {
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i]
50
118
  const [key, ...valueParts] = line.split(':')
51
- if (key && valueParts.length > 0) {
52
- const value = valueParts.join(':').trim()
53
-
54
- // Parse arrays
55
- if (value.startsWith('[') && value.endsWith(']')) {
56
- frontmatter[key.trim()] = value.slice(1, -1).split(',').map((v) => v.trim())
57
- } else {
58
- // Remove quotes if present
59
- frontmatter[key.trim()] = value.replace(/^["']|["']$/g, '')
60
- }
119
+
120
+ if (!key || line.startsWith(' ') || line.startsWith('\t')) {
121
+ continue
61
122
  }
62
- })
123
+
124
+ const keyTrimmed = key.trim()
125
+ const value = valueParts.join(':').trim()
126
+
127
+ // Handle tool-permissions nested block
128
+ if (keyTrimmed === 'tool-permissions' && value === '') {
129
+ const { permissions, endIndex } = parseToolPermissions(lines, i + 1)
130
+ frontmatter['tool-permissions'] = permissions
131
+ i = endIndex - 1
132
+ continue
133
+ }
134
+
135
+ // Parse arrays
136
+ if (value.startsWith('[') && value.endsWith(']')) {
137
+ frontmatter[keyTrimmed] = value.slice(1, -1).split(',').map((v) => v.trim())
138
+ } else if (value) {
139
+ // Remove quotes if present
140
+ frontmatter[keyTrimmed] = value.replace(/^["']|["']$/g, '')
141
+ }
142
+ }
63
143
 
64
144
  return { frontmatter, content: mainContent.trim() }
65
145
  }
@@ -67,10 +147,10 @@ export function parseFrontmatter(content: string): ParsedTemplate {
67
147
  // ============ Main Functions ============
68
148
 
69
149
  export async function load(commandName: string): Promise<ParsedTemplate> {
70
- // Check cache first
71
- if (cache.has(commandName)) {
72
- updateLruOrder(commandName)
73
- return cache.get(commandName)!
150
+ // Check cache first with LRU update
151
+ const cached = getWithLru(commandName)
152
+ if (cached) {
153
+ return cached
74
154
  }
75
155
 
76
156
  const templatePath = path.join(TEMPLATES_DIR, `${commandName}.md`)
@@ -79,12 +159,8 @@ export async function load(commandName: string): Promise<ParsedTemplate> {
79
159
  const rawContent = await fs.readFile(templatePath, 'utf-8')
80
160
  const parsed = parseFrontmatter(rawContent)
81
161
 
82
- // Evict LRU if needed before adding
83
- evictLru()
84
-
85
- // Cache result
86
- cache.set(commandName, parsed)
87
- cacheOrder.push(commandName)
162
+ // Cache with LRU management
163
+ setWithLru(commandName, parsed)
88
164
 
89
165
  return parsed
90
166
  } catch {
@@ -99,7 +175,6 @@ export async function getAllowedTools(commandName: string): Promise<string[]> {
99
175
 
100
176
  export function clearCache(): void {
101
177
  cache.clear()
102
- cacheOrder.length = 0
103
178
  }
104
179
 
105
180
  // ============ Default Export (backwards compat) ============
@@ -70,23 +70,21 @@ class AgentLoader {
70
70
  }
71
71
 
72
72
  /**
73
- * Load all agents for the project
73
+ * Load all agents for the project (parallel loading for performance)
74
74
  */
75
75
  async loadAllAgents(): Promise<Agent[]> {
76
76
  try {
77
77
  const files = await fs.readdir(this.agentsDir)
78
78
  const agentFiles = files.filter((f) => f.endsWith('.md') && !f.startsWith('.'))
79
79
 
80
- const agents: Agent[] = []
81
- for (const file of agentFiles) {
80
+ // Load all agents in parallel for better performance
81
+ const agentPromises = agentFiles.map((file) => {
82
82
  const agentName = file.replace('.md', '')
83
- const agent = await this.loadAgent(agentName)
84
- if (agent) {
85
- agents.push(agent)
86
- }
87
- }
83
+ return this.loadAgent(agentName)
84
+ })
88
85
 
89
- return agents
86
+ const results = await Promise.all(agentPromises)
87
+ return results.filter((agent): agent is Agent => agent !== null)
90
88
  } catch (error) {
91
89
  if (isNotFoundError(error)) {
92
90
  return [] // Agents directory doesn't exist yet
@@ -137,21 +137,21 @@ class ContextEstimator {
137
137
  globPatterns.push(`*${ext}`)
138
138
  }
139
139
 
140
- // Execute glob searches
141
- for (const pattern of globPatterns) {
142
- try {
143
- const matches = await glob(pattern, {
144
- cwd: projectPath,
145
- ignore: patterns.exclude.map((ex) => `**/${ex}/**`),
146
- nodir: true,
147
- follow: false,
148
- })
149
-
150
- if (Array.isArray(matches)) {
151
- files.push(...matches)
152
- }
153
- } catch {
154
- // Skip invalid patterns
140
+ // Execute glob searches in parallel for better performance
141
+ const ignorePatterns = patterns.exclude.map((ex) => `**/${ex}/**`)
142
+ const globPromises = globPatterns.map((pattern) =>
143
+ glob(pattern, {
144
+ cwd: projectPath,
145
+ ignore: ignorePatterns,
146
+ nodir: true,
147
+ follow: false,
148
+ }).catch(() => [] as string[]) // Return empty array on error
149
+ )
150
+
151
+ const results = await Promise.all(globPromises)
152
+ for (const matches of results) {
153
+ if (Array.isArray(matches)) {
154
+ files.push(...matches)
155
155
  }
156
156
  }
157
157
 
@@ -42,6 +42,10 @@ function parseJsonc<T>(content: string): T {
42
42
  }
43
43
 
44
44
  class ConfigManager {
45
+ // Cache projectId lookups with TTL (30 seconds) to avoid repeated disk reads
46
+ private projectIdCache = new Map<string, { id: string; timestamp: number }>()
47
+ private readonly CACHE_TTL = 30000
48
+
45
49
  /**
46
50
  * Read the project configuration file
47
51
  * Supports both .json and .jsonc formats (with comments)
@@ -228,13 +232,30 @@ class ConfigManager {
228
232
 
229
233
  /**
230
234
  * Get the project ID from config, or generate it if config doesn't exist
235
+ * Uses in-memory cache with TTL to avoid repeated disk reads
231
236
  */
232
237
  async getProjectId(projectPath: string): Promise<string> {
233
- const config = await this.readConfig(projectPath)
234
- if (config && config.projectId) {
235
- return config.projectId
238
+ // Check cache first
239
+ const cached = this.projectIdCache.get(projectPath)
240
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
241
+ return cached.id
236
242
  }
237
- return pathManager.generateProjectId(projectPath)
243
+
244
+ // Read from disk
245
+ const config = await this.readConfig(projectPath)
246
+ const id = config?.projectId || pathManager.generateProjectId(projectPath)
247
+
248
+ // Cache the result
249
+ this.projectIdCache.set(projectPath, { id, timestamp: Date.now() })
250
+
251
+ return id
252
+ }
253
+
254
+ /**
255
+ * Clear the projectId cache (useful for testing or after config changes)
256
+ */
257
+ clearProjectIdCache(): void {
258
+ this.projectIdCache.clear()
238
259
  }
239
260
 
240
261
  /**