prjct-cli 0.9.2 → 0.10.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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * TaskAnalyzer - Deep Semantic Task Analysis
3
+ *
4
+ * Analyzes tasks semantically, not just keywords
5
+ * Considers project context, history, and relationships
6
+ *
7
+ * @version 1.0.0
8
+ */
9
+
10
+ const fs = require('fs').promises
11
+ const path = require('path')
12
+ const TechDetector = require('./tech-detector')
13
+ const configManager = require('../infrastructure/config-manager')
14
+ const pathManager = require('../infrastructure/path-manager')
15
+
16
+ class TaskAnalyzer {
17
+ constructor(projectPath) {
18
+ this.projectPath = projectPath
19
+ this.projectId = null
20
+ this.techDetector = null
21
+ this.taskHistory = null
22
+ }
23
+
24
+ /**
25
+ * Initialize analyzer with project context
26
+ */
27
+ async initialize() {
28
+ this.projectId = await configManager.getProjectId(this.projectPath)
29
+ this.techDetector = new TechDetector(this.projectPath)
30
+ await this.loadTaskHistory()
31
+ }
32
+
33
+ /**
34
+ * Deep semantic analysis of a task
35
+ * @param {Object} task - Task object {description, type}
36
+ * @returns {Promise<Object>} Analysis result
37
+ */
38
+ async analyzeTask(task) {
39
+ if (!this.techDetector) {
40
+ await this.initialize()
41
+ }
42
+
43
+ const description = (task.description || '').toLowerCase()
44
+ const type = (task.type || '').toLowerCase()
45
+ const fullText = `${description} ${type}`.trim()
46
+
47
+ // Get project technologies
48
+ const projectTech = await this.techDetector.detectAll()
49
+
50
+ // Multi-domain detection
51
+ const domains = this.detectDomains(fullText, projectTech)
52
+
53
+ // Semantic understanding
54
+ const semantic = this.analyzeSemantics(fullText, projectTech)
55
+
56
+ // Historical patterns
57
+ const historical = await this.analyzeHistory(fullText)
58
+
59
+ // Complexity estimation
60
+ const complexity = this.estimateComplexity(fullText, domains)
61
+
62
+ // Combine all signals
63
+ const primaryDomain = this.selectPrimaryDomain(domains, semantic, historical)
64
+ const confidence = this.calculateConfidence(domains, semantic, historical)
65
+
66
+ return {
67
+ primaryDomain,
68
+ domains, // All detected domains
69
+ confidence,
70
+ semantic,
71
+ historical,
72
+ complexity,
73
+ projectTechnologies: projectTech,
74
+ matchedKeywords: domains[primaryDomain]?.keywords || [],
75
+ reason: this.buildReason(primaryDomain, domains, semantic, historical),
76
+ alternatives: this.getAlternatives(primaryDomain, domains)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Detect multiple domains from task description
82
+ */
83
+ detectDomains(text, projectTech) {
84
+ const domains = {}
85
+
86
+ // Enhanced patterns with project context
87
+ const patterns = {
88
+ frontend: [
89
+ 'component', 'ui', 'user interface', 'frontend', 'client',
90
+ 'style', 'css', 'layout', 'responsive', 'design',
91
+ 'page', 'view', 'template', 'render', 'display',
92
+ 'button', 'form', 'input', 'modal', 'dialog',
93
+ ...(projectTech.frameworks.filter(f => ['react', 'vue', 'angular', 'svelte', 'next', 'nuxt'].includes(f.toLowerCase())).map(f => f.toLowerCase()))
94
+ ],
95
+ backend: [
96
+ 'api', 'server', 'endpoint', 'route', 'middleware',
97
+ 'auth', 'authentication', 'authorization', 'jwt', 'session',
98
+ 'backend', 'service', 'controller', 'handler',
99
+ 'database', 'query', 'model', 'schema',
100
+ ...(projectTech.frameworks.filter(f => ['express', 'fastify', 'django', 'flask', 'rails', 'phoenix'].includes(f.toLowerCase())).map(f => f.toLowerCase()))
101
+ ],
102
+ database: [
103
+ 'database', 'db', 'query', 'migration', 'schema', 'model',
104
+ 'sql', 'data', 'table', 'collection', 'index', 'relation',
105
+ 'postgres', 'mysql', 'mongodb', 'redis'
106
+ ],
107
+ devops: [
108
+ 'deploy', 'deployment', 'docker', 'kubernetes', 'k8s',
109
+ 'ci/cd', 'pipeline', 'build', 'ship', 'release',
110
+ 'production', 'infrastructure', 'container', 'orchestration'
111
+ ],
112
+ qa: [
113
+ 'test', 'testing', 'bug', 'error', 'fix', 'debug', 'issue',
114
+ 'quality', 'coverage', 'unit test', 'integration test',
115
+ 'e2e', 'spec', 'assertion', 'validation'
116
+ ],
117
+ architecture: [
118
+ 'design', 'architecture', 'pattern', 'structure',
119
+ 'refactor', 'refactoring', 'organize', 'plan',
120
+ 'feature', 'system', 'module', 'component design'
121
+ ]
122
+ }
123
+
124
+ // Score each domain
125
+ for (const [domain, keywords] of Object.entries(patterns)) {
126
+ const matches = keywords.filter(keyword => text.includes(keyword))
127
+ if (matches.length > 0) {
128
+ domains[domain] = {
129
+ keywords: matches,
130
+ count: matches.length,
131
+ score: matches.length + (matches.length > 2 ? 1 : 0) // Bonus for multiple matches
132
+ }
133
+ }
134
+ }
135
+
136
+ return domains
137
+ }
138
+
139
+ /**
140
+ * Semantic analysis - understand intent, not just keywords
141
+ */
142
+ analyzeSemantics(text, projectTech) {
143
+ const semantic = {
144
+ intent: null,
145
+ requiresMultipleAgents: false,
146
+ complexity: 'medium'
147
+ }
148
+
149
+ // Detect intent patterns
150
+ if (text.match(/\b(create|add|build|implement|make)\b/)) {
151
+ semantic.intent = 'create'
152
+ } else if (text.match(/\b(fix|repair|debug|resolve)\b/)) {
153
+ semantic.intent = 'fix'
154
+ } else if (text.match(/\b(improve|optimize|enhance|refactor)\b/)) {
155
+ semantic.intent = 'improve'
156
+ } else if (text.match(/\b(test|verify|validate)\b/)) {
157
+ semantic.intent = 'test'
158
+ }
159
+
160
+ // Detect multi-agent requirements
161
+ if (text.match(/\b(api|endpoint).*\b(test|spec)\b/) ||
162
+ text.match(/\b(test|spec).*\b(api|endpoint)\b/)) {
163
+ semantic.requiresMultipleAgents = true
164
+ semantic.agents = ['backend', 'qa']
165
+ }
166
+
167
+ if (text.match(/\b(component|ui).*\b(test|spec)\b/) ||
168
+ text.match(/\b(test|spec).*\b(component|ui)\b/)) {
169
+ semantic.requiresMultipleAgents = true
170
+ semantic.agents = ['frontend', 'qa']
171
+ }
172
+
173
+ return semantic
174
+ }
175
+
176
+ /**
177
+ * Analyze historical patterns
178
+ */
179
+ async analyzeHistory(text) {
180
+ if (!this.taskHistory) {
181
+ return { confidence: 0, patterns: [] }
182
+ }
183
+
184
+ // Find similar tasks in history
185
+ const similar = this.taskHistory.filter(task => {
186
+ const similarity = this.calculateTextSimilarity(text, task.description)
187
+ return similarity > 0.5
188
+ })
189
+
190
+ if (similar.length === 0) {
191
+ return { confidence: 0, patterns: [] }
192
+ }
193
+
194
+ // Find most common domain for similar tasks
195
+ const domainCounts = {}
196
+ similar.forEach(task => {
197
+ if (task.domain) {
198
+ domainCounts[task.domain] = (domainCounts[task.domain] || 0) + 1
199
+ }
200
+ })
201
+
202
+ const mostCommon = Object.entries(domainCounts)
203
+ .sort((a, b) => b[1] - a[1])[0]
204
+
205
+ return {
206
+ confidence: mostCommon ? mostCommon[1] / similar.length : 0,
207
+ patterns: similar.map(t => ({ domain: t.domain, description: t.description })),
208
+ suggestedDomain: mostCommon ? mostCommon[0] : null
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Estimate task complexity
214
+ */
215
+ estimateComplexity(text, domains) {
216
+ let complexity = 'medium'
217
+
218
+ // Complexity indicators
219
+ const simpleIndicators = ['add', 'create', 'simple', 'basic']
220
+ const complexIndicators = ['refactor', 'optimize', 'architecture', 'migration', 'redesign']
221
+
222
+ const simpleCount = simpleIndicators.filter(ind => text.includes(ind)).length
223
+ const complexCount = complexIndicators.filter(ind => text.includes(ind)).length
224
+
225
+ if (complexCount > simpleCount) {
226
+ complexity = 'high'
227
+ } else if (simpleCount > 0 && complexCount === 0) {
228
+ complexity = 'low'
229
+ }
230
+
231
+ // Multiple domains = more complex
232
+ if (Object.keys(domains).length > 1) {
233
+ complexity = 'high'
234
+ }
235
+
236
+ return complexity
237
+ }
238
+
239
+ /**
240
+ * Select primary domain from all signals
241
+ */
242
+ selectPrimaryDomain(domains, semantic, historical) {
243
+ // Priority: historical > semantic > keyword matching
244
+ if (historical.suggestedDomain && historical.confidence > 0.7) {
245
+ return historical.suggestedDomain
246
+ }
247
+
248
+ if (semantic.agents && semantic.agents.length > 0) {
249
+ return semantic.agents[0] // Primary agent for multi-agent tasks
250
+ }
251
+
252
+ // Find domain with highest score
253
+ const sorted = Object.entries(domains)
254
+ .sort((a, b) => b[1].score - a[1].score)
255
+
256
+ return sorted.length > 0 ? sorted[0][0] : 'generalist'
257
+ }
258
+
259
+ /**
260
+ * Calculate confidence in domain selection
261
+ */
262
+ calculateConfidence(domains, semantic, historical) {
263
+ let confidence = 0.5 // Base confidence
264
+
265
+ // Boost from keyword matches
266
+ const primaryDomain = this.selectPrimaryDomain(domains, semantic, historical)
267
+ if (domains[primaryDomain]) {
268
+ confidence += domains[primaryDomain].score * 0.1
269
+ }
270
+
271
+ // Boost from historical patterns
272
+ if (historical.confidence > 0) {
273
+ confidence += historical.confidence * 0.3
274
+ }
275
+
276
+ // Boost from semantic understanding
277
+ if (semantic.intent) {
278
+ confidence += 0.1
279
+ }
280
+
281
+ return Math.min(confidence, 1.0)
282
+ }
283
+
284
+ /**
285
+ * Build human-readable reason
286
+ */
287
+ buildReason(primaryDomain, domains, semantic, historical) {
288
+ const parts = []
289
+
290
+ if (historical.suggestedDomain && historical.confidence > 0.7) {
291
+ parts.push(`Historical pattern: similar tasks used ${primaryDomain}`)
292
+ }
293
+
294
+ if (domains[primaryDomain]) {
295
+ parts.push(`Keywords: ${domains[primaryDomain].keywords.join(', ')}`)
296
+ }
297
+
298
+ if (semantic.intent) {
299
+ parts.push(`Intent: ${semantic.intent}`)
300
+ }
301
+
302
+ return parts.join(' | ') || `Detected ${primaryDomain} domain`
303
+ }
304
+
305
+ /**
306
+ * Get alternative domains
307
+ */
308
+ getAlternatives(primaryDomain, domains) {
309
+ return Object.keys(domains)
310
+ .filter(d => d !== primaryDomain)
311
+ .sort((a, b) => domains[b].score - domains[a].score)
312
+ }
313
+
314
+ /**
315
+ * Load task history from memory
316
+ */
317
+ async loadTaskHistory() {
318
+ try {
319
+ const memoryPath = pathManager.getFilePath(this.projectId, 'memory', 'context.jsonl')
320
+ const content = await fs.readFile(memoryPath, 'utf-8')
321
+ const lines = content.split('\n').filter(Boolean)
322
+
323
+ this.taskHistory = lines
324
+ .map(line => {
325
+ try {
326
+ return JSON.parse(line)
327
+ } catch {
328
+ return null
329
+ }
330
+ })
331
+ .filter(entry => entry && entry.type === 'task_start' && entry.domain)
332
+ .slice(-100) // Last 100 tasks
333
+ } catch {
334
+ this.taskHistory = []
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Calculate text similarity (simple Jaccard)
340
+ */
341
+ calculateTextSimilarity(text1, text2) {
342
+ const words1 = new Set(text1.toLowerCase().split(/\s+/))
343
+ const words2 = new Set(text2.toLowerCase().split(/\s+/))
344
+
345
+ const intersection = new Set([...words1].filter(w => words2.has(w)))
346
+ const union = new Set([...words1, ...words2])
347
+
348
+ return intersection.size / union.size
349
+ }
350
+ }
351
+
352
+ module.exports = TaskAnalyzer
353
+
@@ -0,0 +1,365 @@
1
+ /**
2
+ * TechDetector - Dynamic Technology Detection
3
+ *
4
+ * NO HARDCODING - Detects technologies from actual project files
5
+ * Works with ANY technology stack (Elixir, Phoenix, Svelte, etc.)
6
+ *
7
+ * @version 1.0.0
8
+ */
9
+
10
+ const fs = require('fs').promises
11
+ const path = require('path')
12
+
13
+ class TechDetector {
14
+ constructor(projectPath) {
15
+ this.projectPath = projectPath
16
+ this.cache = null
17
+ }
18
+
19
+ /**
20
+ * Detect all technologies in the project
21
+ * Returns structured data about languages, frameworks, tools
22
+ * @returns {Promise<Object>} - { languages: [], frameworks: [], tools: [], packageManagers: [] }
23
+ */
24
+ async detectAll() {
25
+ if (this.cache) {
26
+ return this.cache
27
+ }
28
+
29
+ const detected = {
30
+ languages: [],
31
+ frameworks: [],
32
+ tools: [],
33
+ packageManagers: [],
34
+ databases: [],
35
+ buildTools: [],
36
+ testFrameworks: []
37
+ }
38
+
39
+ // Detect from package managers (most reliable source)
40
+ await this.detectFromPackageJson(detected)
41
+ await this.detectFromCargoToml(detected)
42
+ await this.detectFromGoMod(detected)
43
+ await this.detectFromRequirements(detected)
44
+ await this.detectFromGemfile(detected)
45
+ await this.detectFromMixExs(detected) // Elixir
46
+ await this.detectFromPomXml(detected) // Maven/Java
47
+ await this.detectFromComposerJson(detected) // PHP
48
+
49
+ // Detect from config files
50
+ await this.detectFromConfigFiles(detected)
51
+
52
+ // Detect from directory structure
53
+ await this.detectFromStructure(detected)
54
+
55
+ // Cache result
56
+ this.cache = detected
57
+ return detected
58
+ }
59
+
60
+ /**
61
+ * Detect from package.json (Node.js/JavaScript/TypeScript)
62
+ */
63
+ async detectFromPackageJson(detected) {
64
+ try {
65
+ const packagePath = path.join(this.projectPath, 'package.json')
66
+ const content = await fs.readFile(packagePath, 'utf-8')
67
+ const pkg = JSON.parse(content)
68
+
69
+ detected.packageManagers.push('npm')
70
+
71
+ // Language
72
+ if (pkg.dependencies?.typescript || pkg.devDependencies?.typescript) {
73
+ detected.languages.push('TypeScript')
74
+ } else {
75
+ detected.languages.push('JavaScript')
76
+ }
77
+
78
+ // Collect all dependencies (no hardcoding - just list them)
79
+ const allDeps = {
80
+ ...(pkg.dependencies || {}),
81
+ ...(pkg.devDependencies || {})
82
+ }
83
+
84
+ // Frameworks and tools are in dependencies - let Claude decide what's important
85
+ // We just collect the names, no assumptions
86
+ const depNames = Object.keys(allDeps)
87
+
88
+ // Common patterns (but not hardcoded - just helpers)
89
+ for (const dep of depNames) {
90
+ // Frontend frameworks
91
+ if (['react', 'vue', 'angular', 'svelte'].includes(dep.toLowerCase())) {
92
+ detected.frameworks.push(dep)
93
+ }
94
+ // Meta-frameworks
95
+ else if (['next', 'nuxt', 'sveltekit', 'remix'].includes(dep.toLowerCase())) {
96
+ detected.frameworks.push(dep)
97
+ }
98
+ // Backend frameworks
99
+ else if (['express', 'fastify', 'koa', 'hapi', 'nest'].includes(dep.toLowerCase())) {
100
+ detected.frameworks.push(dep)
101
+ }
102
+ // Build tools
103
+ else if (['vite', 'webpack', 'rollup', 'esbuild', 'parcel'].includes(dep.toLowerCase())) {
104
+ detected.buildTools.push(dep)
105
+ }
106
+ // Test frameworks
107
+ else if (['jest', 'vitest', 'mocha', 'jasmine', 'cypress', 'playwright'].includes(dep.toLowerCase())) {
108
+ detected.testFrameworks.push(dep)
109
+ }
110
+ // Databases
111
+ else if (['mongoose', 'sequelize', 'prisma', 'typeorm'].includes(dep.toLowerCase())) {
112
+ detected.databases.push(dep)
113
+ }
114
+ }
115
+
116
+ // Also check for yarn/pnpm
117
+ if (await this.fileExists('yarn.lock')) {
118
+ detected.packageManagers.push('yarn')
119
+ }
120
+ if (await this.fileExists('pnpm-lock.yaml')) {
121
+ detected.packageManagers.push('pnpm')
122
+ }
123
+ } catch (error) {
124
+ // File doesn't exist or invalid JSON - skip
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Detect from Cargo.toml (Rust)
130
+ */
131
+ async detectFromCargoToml(detected) {
132
+ try {
133
+ const cargoPath = path.join(this.projectPath, 'Cargo.toml')
134
+ await fs.readFile(cargoPath, 'utf-8')
135
+
136
+ detected.languages.push('Rust')
137
+ detected.packageManagers.push('Cargo')
138
+ } catch (error) {
139
+ // File doesn't exist - skip
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Detect from go.mod (Go)
145
+ */
146
+ async detectFromGoMod(detected) {
147
+ try {
148
+ const goModPath = path.join(this.projectPath, 'go.mod')
149
+ await fs.readFile(goModPath, 'utf-8')
150
+
151
+ detected.languages.push('Go')
152
+ detected.packageManagers.push('Go Modules')
153
+ } catch (error) {
154
+ // File doesn't exist - skip
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Detect from requirements.txt (Python)
160
+ */
161
+ async detectFromRequirements(detected) {
162
+ try {
163
+ const reqPath = path.join(this.projectPath, 'requirements.txt')
164
+ const content = await fs.readFile(reqPath, 'utf-8')
165
+
166
+ detected.languages.push('Python')
167
+ detected.packageManagers.push('pip')
168
+
169
+ // Detect common frameworks
170
+ const lines = content.split('\n').map(l => l.trim().toLowerCase())
171
+ if (lines.some(l => l.includes('django'))) detected.frameworks.push('Django')
172
+ if (lines.some(l => l.includes('flask'))) detected.frameworks.push('Flask')
173
+ if (lines.some(l => l.includes('fastapi'))) detected.frameworks.push('FastAPI')
174
+ } catch (error) {
175
+ // File doesn't exist - skip
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Detect from Gemfile (Ruby)
181
+ */
182
+ async detectFromGemfile(detected) {
183
+ try {
184
+ const gemfilePath = path.join(this.projectPath, 'Gemfile')
185
+ const content = await fs.readFile(gemfilePath, 'utf-8')
186
+
187
+ detected.languages.push('Ruby')
188
+ detected.packageManagers.push('Bundler')
189
+
190
+ if (content.includes('rails')) {
191
+ detected.frameworks.push('Rails')
192
+ }
193
+ } catch (error) {
194
+ // File doesn't exist - skip
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Detect from mix.exs (Elixir)
200
+ */
201
+ async detectFromMixExs(detected) {
202
+ try {
203
+ const mixPath = path.join(this.projectPath, 'mix.exs')
204
+ const content = await fs.readFile(mixPath, 'utf-8')
205
+
206
+ detected.languages.push('Elixir')
207
+ detected.packageManagers.push('Mix')
208
+
209
+ if (content.includes('phoenix')) {
210
+ detected.frameworks.push('Phoenix')
211
+ }
212
+ } catch (error) {
213
+ // File doesn't exist - skip
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Detect from pom.xml (Maven/Java)
219
+ */
220
+ async detectFromPomXml(detected) {
221
+ try {
222
+ const pomPath = path.join(this.projectPath, 'pom.xml')
223
+ await fs.readFile(pomPath, 'utf-8')
224
+
225
+ detected.languages.push('Java')
226
+ detected.packageManagers.push('Maven')
227
+ } catch (error) {
228
+ // File doesn't exist - skip
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Detect from composer.json (PHP)
234
+ */
235
+ async detectFromComposerJson(detected) {
236
+ try {
237
+ const composerPath = path.join(this.projectPath, 'composer.json')
238
+ const content = await fs.readFile(composerPath, 'utf-8')
239
+ const composer = JSON.parse(content)
240
+
241
+ detected.languages.push('PHP')
242
+ detected.packageManagers.push('Composer')
243
+
244
+ const allDeps = {
245
+ ...(composer.require || {}),
246
+ ...(composer['require-dev'] || {})
247
+ }
248
+
249
+ if (Object.keys(allDeps).some(d => d.includes('laravel'))) {
250
+ detected.frameworks.push('Laravel')
251
+ }
252
+ if (Object.keys(allDeps).some(d => d.includes('symfony'))) {
253
+ detected.frameworks.push('Symfony')
254
+ }
255
+ } catch (error) {
256
+ // File doesn't exist - skip
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Detect from config files (Docker, etc.)
262
+ */
263
+ async detectFromConfigFiles(detected) {
264
+ // Docker
265
+ if (await this.fileExists('Dockerfile')) {
266
+ detected.tools.push('Docker')
267
+ }
268
+ if (await this.fileExists('docker-compose.yml') || await this.fileExists('docker-compose.yaml')) {
269
+ detected.tools.push('Docker Compose')
270
+ }
271
+
272
+ // Kubernetes
273
+ if (await this.fileExists('k8s') || await this.fileExists('kubernetes')) {
274
+ detected.tools.push('Kubernetes')
275
+ }
276
+
277
+ // Terraform
278
+ if (await this.fileExists('.tf') || await this.findFiles('*.tf')) {
279
+ detected.tools.push('Terraform')
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Detect from directory structure
285
+ */
286
+ async detectFromStructure(_detected) {
287
+ try {
288
+ const entries = await fs.readdir(this.projectPath, { withFileTypes: true })
289
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
290
+
291
+ // Common patterns (but not assumptions - just hints)
292
+ // Could analyze structure here in the future
293
+ if (dirs.includes('src') && dirs.includes('lib')) {
294
+ // Could be Elixir, but don't assume
295
+ }
296
+ } catch (error) {
297
+ // Can't read directory - skip
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Get a summary string of detected technologies
303
+ * Useful for agent generation
304
+ */
305
+ async getSummary() {
306
+ const tech = await this.detectAll()
307
+ const parts = []
308
+
309
+ if (tech.languages.length > 0) {
310
+ parts.push(`Languages: ${tech.languages.join(', ')}`)
311
+ }
312
+ if (tech.frameworks.length > 0) {
313
+ parts.push(`Frameworks: ${tech.frameworks.join(', ')}`)
314
+ }
315
+ if (tech.tools.length > 0) {
316
+ parts.push(`Tools: ${tech.tools.join(', ')}`)
317
+ }
318
+ if (tech.databases.length > 0) {
319
+ parts.push(`Databases: ${tech.databases.join(', ')}`)
320
+ }
321
+
322
+ return parts.join(' | ')
323
+ }
324
+
325
+ /**
326
+ * Helper: Check if file exists
327
+ */
328
+ async fileExists(filename) {
329
+ try {
330
+ await fs.access(path.join(this.projectPath, filename))
331
+ return true
332
+ } catch {
333
+ return false
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Helper: Find files matching pattern
339
+ */
340
+ async findFiles(pattern) {
341
+ try {
342
+ const { exec } = require('child_process')
343
+ const { promisify } = require('util')
344
+ const execAsync = promisify(exec)
345
+
346
+ const { stdout } = await execAsync(
347
+ `find . -name "${pattern}" -type f ! -path "*/node_modules/*" ! -path "*/.git/*" | head -1`,
348
+ { cwd: this.projectPath }
349
+ )
350
+ return stdout.trim().length > 0
351
+ } catch {
352
+ return false
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Clear cache (useful after project changes)
358
+ */
359
+ clearCache() {
360
+ this.cache = null
361
+ }
362
+ }
363
+
364
+ module.exports = TechDetector
365
+