prjct-cli 0.44.1 → 0.45.3

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 (207) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/bin/prjct.ts +131 -10
  3. package/core/__tests__/agentic/memory-system.test.ts +39 -26
  4. package/core/__tests__/agentic/plan-mode.test.ts +64 -46
  5. package/core/__tests__/agentic/prompt-builder.test.ts +14 -14
  6. package/core/__tests__/services/project-index.test.ts +353 -0
  7. package/core/__tests__/types/fs.test.ts +3 -3
  8. package/core/__tests__/utils/date-helper.test.ts +10 -10
  9. package/core/__tests__/utils/output.test.ts +9 -6
  10. package/core/__tests__/utils/project-commands.test.ts +5 -6
  11. package/core/agentic/agent-router.ts +9 -10
  12. package/core/agentic/chain-of-thought.ts +16 -4
  13. package/core/agentic/command-executor.ts +66 -40
  14. package/core/agentic/context-builder.ts +8 -5
  15. package/core/agentic/ground-truth.ts +15 -9
  16. package/core/agentic/index.ts +145 -152
  17. package/core/agentic/loop-detector.ts +40 -11
  18. package/core/agentic/memory-system.ts +98 -35
  19. package/core/agentic/orchestrator-executor.ts +135 -71
  20. package/core/agentic/plan-mode.ts +46 -16
  21. package/core/agentic/prompt-builder.ts +108 -42
  22. package/core/agentic/services.ts +10 -9
  23. package/core/agentic/skill-loader.ts +9 -15
  24. package/core/agentic/smart-context.ts +129 -79
  25. package/core/agentic/template-executor.ts +13 -12
  26. package/core/agentic/template-loader.ts +7 -4
  27. package/core/agentic/tool-registry.ts +16 -13
  28. package/core/agents/index.ts +1 -1
  29. package/core/agents/performance.ts +10 -27
  30. package/core/ai-tools/formatters.ts +8 -6
  31. package/core/ai-tools/generator.ts +4 -4
  32. package/core/ai-tools/index.ts +1 -1
  33. package/core/ai-tools/registry.ts +21 -11
  34. package/core/bus/bus.ts +23 -16
  35. package/core/bus/index.ts +2 -2
  36. package/core/cli/linear.ts +3 -5
  37. package/core/cli/start.ts +28 -25
  38. package/core/commands/analysis.ts +287 -29
  39. package/core/commands/analytics.ts +52 -44
  40. package/core/commands/base.ts +15 -13
  41. package/core/commands/cleanup.ts +6 -13
  42. package/core/commands/command-data.ts +49 -8
  43. package/core/commands/commands.ts +60 -23
  44. package/core/commands/context.ts +4 -4
  45. package/core/commands/design.ts +3 -10
  46. package/core/commands/index.ts +5 -8
  47. package/core/commands/maintenance.ts +7 -4
  48. package/core/commands/planning.ts +179 -56
  49. package/core/commands/register.ts +14 -9
  50. package/core/commands/registry.ts +15 -14
  51. package/core/commands/setup.ts +26 -14
  52. package/core/commands/shipping.ts +11 -16
  53. package/core/commands/snapshots.ts +16 -32
  54. package/core/commands/uninstall.ts +541 -0
  55. package/core/commands/workflow.ts +24 -28
  56. package/core/constants/index.ts +10 -22
  57. package/core/context/generator.ts +82 -33
  58. package/core/context-tools/files-tool.ts +583 -0
  59. package/core/context-tools/imports-tool.ts +403 -0
  60. package/core/context-tools/index.ts +433 -0
  61. package/core/context-tools/recent-tool.ts +307 -0
  62. package/core/context-tools/signatures-tool.ts +501 -0
  63. package/core/context-tools/summary-tool.ts +307 -0
  64. package/core/context-tools/token-counter.ts +284 -0
  65. package/core/context-tools/types.ts +253 -0
  66. package/core/domain/agent-generator.ts +7 -5
  67. package/core/domain/agent-loader.ts +2 -2
  68. package/core/domain/analyzer.ts +19 -16
  69. package/core/domain/architecture-generator.ts +6 -3
  70. package/core/domain/context-estimator.ts +3 -4
  71. package/core/domain/snapshot-manager.ts +25 -22
  72. package/core/domain/task-stack.ts +24 -14
  73. package/core/errors.ts +1 -1
  74. package/core/events/events.ts +2 -4
  75. package/core/events/index.ts +1 -2
  76. package/core/index.ts +28 -12
  77. package/core/infrastructure/agent-detector.ts +3 -3
  78. package/core/infrastructure/ai-provider.ts +23 -20
  79. package/core/infrastructure/author-detector.ts +16 -10
  80. package/core/infrastructure/capability-installer.ts +2 -2
  81. package/core/infrastructure/claude-agent.ts +6 -6
  82. package/core/infrastructure/command-installer.ts +22 -17
  83. package/core/infrastructure/config-manager.ts +18 -14
  84. package/core/infrastructure/editors-config.ts +8 -4
  85. package/core/infrastructure/path-manager.ts +8 -6
  86. package/core/infrastructure/permission-manager.ts +20 -17
  87. package/core/infrastructure/setup.ts +42 -38
  88. package/core/infrastructure/update-checker.ts +5 -5
  89. package/core/integrations/issue-tracker/enricher.ts +8 -19
  90. package/core/integrations/issue-tracker/index.ts +2 -2
  91. package/core/integrations/issue-tracker/manager.ts +15 -15
  92. package/core/integrations/issue-tracker/types.ts +5 -22
  93. package/core/integrations/jira/client.ts +67 -59
  94. package/core/integrations/jira/index.ts +11 -14
  95. package/core/integrations/jira/mcp-adapter.ts +5 -10
  96. package/core/integrations/jira/service.ts +10 -10
  97. package/core/integrations/linear/client.ts +27 -18
  98. package/core/integrations/linear/index.ts +9 -12
  99. package/core/integrations/linear/service.ts +11 -11
  100. package/core/integrations/linear/sync.ts +8 -8
  101. package/core/outcomes/analyzer.ts +5 -18
  102. package/core/outcomes/index.ts +2 -2
  103. package/core/outcomes/recorder.ts +3 -3
  104. package/core/plugin/builtin/webhook.ts +19 -15
  105. package/core/plugin/hooks.ts +29 -21
  106. package/core/plugin/index.ts +7 -7
  107. package/core/plugin/loader.ts +19 -19
  108. package/core/plugin/registry.ts +12 -23
  109. package/core/schemas/agents.ts +1 -1
  110. package/core/schemas/analysis.ts +1 -1
  111. package/core/schemas/enriched-task.ts +62 -49
  112. package/core/schemas/ideas.ts +13 -13
  113. package/core/schemas/index.ts +17 -27
  114. package/core/schemas/issues.ts +40 -25
  115. package/core/schemas/metrics.ts +143 -0
  116. package/core/schemas/outcomes.ts +70 -62
  117. package/core/schemas/permissions.ts +15 -12
  118. package/core/schemas/prd.ts +27 -14
  119. package/core/schemas/project.ts +3 -3
  120. package/core/schemas/roadmap.ts +47 -34
  121. package/core/schemas/schemas.ts +3 -4
  122. package/core/schemas/shipped.ts +3 -3
  123. package/core/schemas/state.ts +43 -29
  124. package/core/server/index.ts +5 -6
  125. package/core/server/routes-extended.ts +68 -72
  126. package/core/server/routes.ts +3 -3
  127. package/core/server/server.ts +31 -26
  128. package/core/services/agent-generator.ts +237 -0
  129. package/core/services/agent-service.ts +2 -2
  130. package/core/services/breakdown-service.ts +2 -4
  131. package/core/services/context-generator.ts +299 -0
  132. package/core/services/context-selector.ts +420 -0
  133. package/core/services/doctor-service.ts +426 -0
  134. package/core/services/file-categorizer.ts +448 -0
  135. package/core/services/file-scorer.ts +270 -0
  136. package/core/services/git-analyzer.ts +267 -0
  137. package/core/services/index.ts +27 -10
  138. package/core/services/memory-service.ts +3 -4
  139. package/core/services/project-index.ts +911 -0
  140. package/core/services/project-service.ts +4 -4
  141. package/core/services/skill-installer.ts +14 -17
  142. package/core/services/skill-lock.ts +3 -3
  143. package/core/services/skill-service.ts +12 -6
  144. package/core/services/stack-detector.ts +245 -0
  145. package/core/services/sync-service.ts +170 -329
  146. package/core/services/watch-service.ts +294 -0
  147. package/core/session/compaction.ts +23 -31
  148. package/core/session/index.ts +11 -5
  149. package/core/session/log-migration.ts +3 -3
  150. package/core/session/metrics.ts +19 -14
  151. package/core/session/session-log-manager.ts +12 -17
  152. package/core/session/task-session-manager.ts +25 -25
  153. package/core/session/utils.ts +1 -1
  154. package/core/storage/ideas-storage.ts +41 -57
  155. package/core/storage/index-storage.ts +514 -0
  156. package/core/storage/index.ts +41 -13
  157. package/core/storage/metrics-storage.ts +320 -0
  158. package/core/storage/queue-storage.ts +35 -45
  159. package/core/storage/shipped-storage.ts +17 -20
  160. package/core/storage/state-storage.ts +50 -30
  161. package/core/storage/storage-manager.ts +6 -6
  162. package/core/storage/storage.ts +18 -15
  163. package/core/sync/auth-config.ts +3 -3
  164. package/core/sync/index.ts +13 -19
  165. package/core/sync/oauth-handler.ts +3 -3
  166. package/core/sync/sync-client.ts +4 -9
  167. package/core/sync/sync-manager.ts +12 -14
  168. package/core/types/commands.ts +42 -7
  169. package/core/types/index.ts +284 -302
  170. package/core/types/integrations.ts +3 -3
  171. package/core/types/storage.ts +49 -0
  172. package/core/types/utils.ts +3 -3
  173. package/core/utils/agent-stream.ts +3 -1
  174. package/core/utils/animations.ts +14 -11
  175. package/core/utils/branding.ts +7 -7
  176. package/core/utils/cache.ts +1 -3
  177. package/core/utils/collection-filters.ts +3 -15
  178. package/core/utils/date-helper.ts +2 -7
  179. package/core/utils/file-helper.ts +13 -8
  180. package/core/utils/jsonl-helper.ts +13 -10
  181. package/core/utils/keychain.ts +4 -8
  182. package/core/utils/logger.ts +1 -1
  183. package/core/utils/next-steps.ts +3 -3
  184. package/core/utils/output.ts +58 -11
  185. package/core/utils/project-commands.ts +6 -6
  186. package/core/utils/project-credentials.ts +5 -12
  187. package/core/utils/runtime.ts +2 -2
  188. package/core/utils/session-helper.ts +3 -4
  189. package/core/utils/version.ts +3 -3
  190. package/core/wizard/index.ts +13 -0
  191. package/core/wizard/onboarding.ts +633 -0
  192. package/core/workflow/state-machine.ts +7 -7
  193. package/dist/bin/prjct.mjs +18907 -13189
  194. package/dist/core/infrastructure/command-installer.js +96 -111
  195. package/dist/core/infrastructure/editors-config.js +6 -6
  196. package/dist/core/infrastructure/setup.js +256 -257
  197. package/dist/core/utils/version.js +9 -9
  198. package/package.json +11 -12
  199. package/scripts/build.js +3 -3
  200. package/scripts/postinstall.js +2 -2
  201. package/templates/mcp-config.json +6 -1
  202. package/templates/permissions/permissive.jsonc +1 -1
  203. package/templates/permissions/strict.jsonc +5 -9
  204. package/templates/global/docs/agents.md +0 -88
  205. package/templates/global/docs/architecture.md +0 -103
  206. package/templates/global/docs/commands.md +0 -96
  207. package/templates/global/docs/validation.md +0 -95
@@ -0,0 +1,911 @@
1
+ /**
2
+ * ProjectIndex - Persistent Project Scanner with Scoring
3
+ *
4
+ * Features:
5
+ * - Full scan: Analyzes entire project, caches results
6
+ * - Incremental update: Only re-scans changed files
7
+ * - Relevance scoring: Prioritizes important files
8
+ * - Pattern detection: Identifies project architecture
9
+ *
10
+ * Usage:
11
+ * - sync-service calls fullScan() on first sync
12
+ * - Subsequent syncs use incrementalUpdate() or cached index
13
+ * - watch-service uses incrementalUpdate() for changed files
14
+ *
15
+ * Storage location: ~/.prjct-cli/projects/{projectId}/index/
16
+ */
17
+
18
+ import { exec } from 'node:child_process'
19
+ import fs from 'node:fs/promises'
20
+ import path from 'node:path'
21
+ import { promisify } from 'node:util'
22
+ import {
23
+ type ConfigFileEntry,
24
+ type DetectedPattern,
25
+ type DetectedStack,
26
+ type DirectoryEntry,
27
+ getDefaultIndex,
28
+ INDEX_VERSION,
29
+ indexStorage,
30
+ type LanguageStats,
31
+ type ProjectIndex,
32
+ type ScoredFile,
33
+ } from '../storage/index-storage'
34
+ import { getTimestamp } from '../utils/date-helper'
35
+ import { type FileStats, fileScorer, RELEVANCE_THRESHOLD, type ScoringContext } from './file-scorer'
36
+
37
+ const execAsync = promisify(exec)
38
+
39
+ // ============================================================================
40
+ // TYPES
41
+ // ============================================================================
42
+
43
+ export interface IndexOptions {
44
+ forceFullScan?: boolean // Force full scan even if index exists
45
+ maxFiles?: number // Limit number of files to scan (for large repos)
46
+ excludePatterns?: string[] // Additional patterns to exclude
47
+ }
48
+
49
+ export interface ScanResult {
50
+ index: ProjectIndex
51
+ fromCache: boolean
52
+ changedFiles: number
53
+ scanDuration: number
54
+ }
55
+
56
+ export interface RelevantContext {
57
+ files: ScoredFile[]
58
+ estimatedTokens: number
59
+ originalTokens: number
60
+ compressionRate: number
61
+ }
62
+
63
+ // ============================================================================
64
+ // CONSTANTS
65
+ // ============================================================================
66
+
67
+ // Source file extensions to scan
68
+ const SOURCE_EXTENSIONS = new Set([
69
+ '.ts',
70
+ '.tsx',
71
+ '.js',
72
+ '.jsx',
73
+ '.mjs',
74
+ '.cjs', // JavaScript/TypeScript
75
+ '.py',
76
+ '.pyw', // Python
77
+ '.go', // Go
78
+ '.rs', // Rust
79
+ '.java',
80
+ '.kt',
81
+ '.scala', // JVM
82
+ '.c',
83
+ '.cpp',
84
+ '.h',
85
+ '.hpp', // C/C++
86
+ '.rb', // Ruby
87
+ '.php', // PHP
88
+ '.swift', // Swift
89
+ '.cs', // C#
90
+ '.vue',
91
+ '.svelte', // Frontend frameworks
92
+ ])
93
+
94
+ // Config file names to track
95
+ const CONFIG_FILES = new Set([
96
+ 'package.json',
97
+ 'tsconfig.json',
98
+ 'vite.config.ts',
99
+ 'vite.config.js',
100
+ 'next.config.js',
101
+ 'next.config.mjs',
102
+ 'next.config.ts',
103
+ 'webpack.config.js',
104
+ 'rollup.config.js',
105
+ 'esbuild.config.js',
106
+ 'jest.config.js',
107
+ 'jest.config.ts',
108
+ 'vitest.config.ts',
109
+ 'vitest.config.js',
110
+ 'tailwind.config.js',
111
+ 'tailwind.config.ts',
112
+ 'postcss.config.js',
113
+ '.eslintrc',
114
+ '.eslintrc.js',
115
+ '.eslintrc.json',
116
+ '.prettierrc',
117
+ '.prettierrc.js',
118
+ '.prettierrc.json',
119
+ 'Cargo.toml',
120
+ 'go.mod',
121
+ 'pyproject.toml',
122
+ 'requirements.txt',
123
+ 'setup.py',
124
+ 'Dockerfile',
125
+ 'docker-compose.yml',
126
+ 'docker-compose.yaml',
127
+ '.env',
128
+ '.env.local',
129
+ '.env.development',
130
+ '.env.production',
131
+ ])
132
+
133
+ // Directories to ignore
134
+ const IGNORE_DIRS = new Set([
135
+ 'node_modules',
136
+ '.git',
137
+ '.next',
138
+ '.nuxt',
139
+ 'dist',
140
+ 'build',
141
+ 'out',
142
+ 'coverage',
143
+ '.turbo',
144
+ '.cache',
145
+ '.parcel-cache',
146
+ '__pycache__',
147
+ '.pytest_cache',
148
+ 'target', // Rust
149
+ 'vendor', // Go/PHP
150
+ '.venv',
151
+ 'venv', // Python
152
+ 'eggs',
153
+ '*.egg-info',
154
+ ])
155
+
156
+ // Directory type detection patterns
157
+ const DIR_TYPE_PATTERNS: { type: DirectoryEntry['type']; patterns: RegExp[] }[] = [
158
+ { type: 'test', patterns: [/^tests?$/i, /^__tests__$/i, /^spec$/i, /^e2e$/i] },
159
+ {
160
+ type: 'source',
161
+ patterns: [
162
+ /^src$/i,
163
+ /^lib$/i,
164
+ /^core$/i,
165
+ /^app$/i,
166
+ /^pages$/i,
167
+ /^components$/i,
168
+ /^services$/i,
169
+ /^utils$/i,
170
+ ],
171
+ },
172
+ { type: 'config', patterns: [/^config$/i, /^\.config$/i, /^settings$/i] },
173
+ { type: 'build', patterns: [/^dist$/i, /^build$/i, /^out$/i, /^\.next$/i] },
174
+ { type: 'vendor', patterns: [/^node_modules$/i, /^vendor$/i, /^packages$/i] },
175
+ { type: 'docs', patterns: [/^docs?$/i, /^documentation$/i] },
176
+ ]
177
+
178
+ // Pattern detection rules
179
+ const PATTERN_DETECTORS: {
180
+ name: string
181
+ detect: (index: ProjectIndex) => number
182
+ evidence: (index: ProjectIndex) => string[]
183
+ }[] = [
184
+ {
185
+ name: 'monorepo',
186
+ detect: (idx) => {
187
+ const hasWorkspaces = idx.configFiles.some(
188
+ (cf) => cf.type === 'package.json' && cf.parsed?.workspaces
189
+ )
190
+ const hasPackages = idx.directories.some((d) => d.path === 'packages' || d.path === 'apps')
191
+ return hasWorkspaces ? 0.9 : hasPackages ? 0.7 : 0
192
+ },
193
+ evidence: (idx) => {
194
+ const ev: string[] = []
195
+ if (idx.directories.some((d) => d.path === 'packages')) ev.push('packages/')
196
+ if (idx.directories.some((d) => d.path === 'apps')) ev.push('apps/')
197
+ return ev
198
+ },
199
+ },
200
+ {
201
+ name: 'api-first',
202
+ detect: (idx) => {
203
+ const hasApiDir = idx.directories.some(
204
+ (d) => d.path.includes('api') || d.path.includes('routes')
205
+ )
206
+ const hasOpenApi = idx.configFiles.some(
207
+ (cf) => cf.path.includes('openapi') || cf.path.includes('swagger')
208
+ )
209
+ return hasOpenApi ? 0.9 : hasApiDir ? 0.6 : 0
210
+ },
211
+ evidence: (idx) =>
212
+ idx.directories
213
+ .filter((d) => d.path.includes('api') || d.path.includes('routes'))
214
+ .map((d) => `${d.path}/`),
215
+ },
216
+ {
217
+ name: 'component-based',
218
+ detect: (idx) => {
219
+ const hasComponents = idx.directories.some((d) => d.path.includes('components'))
220
+ const hasReact = idx.detectedStack.frameworks.includes('React')
221
+ const hasVue = idx.detectedStack.frameworks.includes('Vue')
222
+ return hasComponents && (hasReact || hasVue) ? 0.8 : hasComponents ? 0.5 : 0
223
+ },
224
+ evidence: (idx) =>
225
+ idx.directories.filter((d) => d.path.includes('components')).map((d) => `${d.path}/`),
226
+ },
227
+ {
228
+ name: 'serverless',
229
+ detect: (idx) => {
230
+ const hasServerless = idx.configFiles.some(
231
+ (cf) =>
232
+ cf.path.includes('serverless') ||
233
+ cf.path.includes('netlify') ||
234
+ cf.path.includes('vercel')
235
+ )
236
+ const hasLambda = idx.directories.some(
237
+ (d) => d.path.includes('functions') || d.path.includes('lambda')
238
+ )
239
+ return hasServerless ? 0.9 : hasLambda ? 0.6 : 0
240
+ },
241
+ evidence: (idx) =>
242
+ idx.configFiles
243
+ .filter((cf) => cf.path.includes('serverless') || cf.path.includes('vercel'))
244
+ .map((cf) => cf.path),
245
+ },
246
+ ]
247
+
248
+ // ============================================================================
249
+ // PROJECT INDEXER CLASS
250
+ // ============================================================================
251
+
252
+ export class ProjectIndexer {
253
+ private projectPath: string
254
+ private projectId: string
255
+
256
+ constructor(projectPath: string, projectId: string) {
257
+ this.projectPath = projectPath
258
+ this.projectId = projectId
259
+ }
260
+
261
+ // ==========================================================================
262
+ // MAIN METHODS
263
+ // ==========================================================================
264
+
265
+ /**
266
+ * Perform a full project scan
267
+ * Creates fresh index from scratch
268
+ */
269
+ async fullScan(options: IndexOptions = {}): Promise<ScanResult> {
270
+ const startTime = Date.now()
271
+
272
+ // Create fresh index
273
+ const index = getDefaultIndex(this.projectPath)
274
+
275
+ // Scan all files
276
+ const allFiles = await this.scanAllFiles(options)
277
+ const filesArray = Array.from(allFiles.values())
278
+
279
+ // Build language stats
280
+ index.languages = this.buildLanguageStats(filesArray)
281
+
282
+ // Find and parse config files
283
+ index.configFiles = await this.findConfigFiles()
284
+
285
+ // Analyze directory structure
286
+ index.directories = await this.analyzeDirectories()
287
+
288
+ // Detect stack
289
+ index.detectedStack = await this.detectStack(index.configFiles)
290
+
291
+ // Calculate scores
292
+ const context = this.buildScoringContext(allFiles)
293
+ const scores = fileScorer.getRelevantFiles(context, RELEVANCE_THRESHOLD)
294
+
295
+ index.relevantFiles = scores.map((s) => ({
296
+ path: s.path,
297
+ score: s.score,
298
+ size: allFiles.get(s.path)?.size || 0,
299
+ mtime: allFiles.get(s.path)?.mtime.toISOString() || '',
300
+ }))
301
+
302
+ // Detect patterns
303
+ index.patterns = this.detectPatterns(index)
304
+
305
+ // Set metrics
306
+ index.totalFiles = allFiles.size
307
+ index.totalSize = filesArray.reduce((sum, f) => sum + f.size, 0)
308
+ index.totalLines = filesArray.reduce((sum, f) => sum + (f.lines || 0), 0)
309
+ index.scanDuration = Date.now() - startTime
310
+
311
+ // Set timestamps
312
+ const now = getTimestamp()
313
+ index.lastFullScan = now
314
+ index.lastIncrementalUpdate = now
315
+
316
+ // Persist
317
+ await indexStorage.writeIndex(this.projectId, index)
318
+ await this.saveChecksums(allFiles)
319
+ await indexStorage.writeScores(this.projectId, index.relevantFiles)
320
+
321
+ return {
322
+ index,
323
+ fromCache: false,
324
+ changedFiles: allFiles.size,
325
+ scanDuration: index.scanDuration,
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Incremental update - only re-scan changed files
331
+ */
332
+ async incrementalUpdate(changedPaths?: string[]): Promise<ScanResult> {
333
+ const startTime = Date.now()
334
+
335
+ // Load existing index
336
+ const index = await indexStorage.readIndex(this.projectId)
337
+ if (!index) {
338
+ // No index exists, do full scan
339
+ return this.fullScan()
340
+ }
341
+
342
+ // If specific paths provided, use those; otherwise detect changes
343
+ let filesToUpdate: string[]
344
+ if (changedPaths && changedPaths.length > 0) {
345
+ filesToUpdate = changedPaths
346
+ } else {
347
+ const changes = await this.detectFileChanges()
348
+ filesToUpdate = [...changes.added, ...changes.modified]
349
+
350
+ // Remove deleted files from index
351
+ if (changes.deleted.length > 0) {
352
+ index.relevantFiles = index.relevantFiles.filter((f) => !changes.deleted.includes(f.path))
353
+ }
354
+ }
355
+
356
+ // If no changes, return cached
357
+ if (filesToUpdate.length === 0) {
358
+ return {
359
+ index,
360
+ fromCache: true,
361
+ changedFiles: 0,
362
+ scanDuration: Date.now() - startTime,
363
+ }
364
+ }
365
+
366
+ // Scan only changed files
367
+ const updatedFiles = await this.scanFiles(filesToUpdate)
368
+
369
+ // Rebuild scoring context with updated files
370
+ const existingFiles = await this.loadExistingFileStats(index)
371
+ for (const [path, stats] of updatedFiles) {
372
+ existingFiles.set(path, stats)
373
+ }
374
+
375
+ const context = this.buildScoringContext(existingFiles)
376
+ const scores = fileScorer.getRelevantFiles(context, RELEVANCE_THRESHOLD)
377
+
378
+ index.relevantFiles = scores.map((s) => ({
379
+ path: s.path,
380
+ score: s.score,
381
+ size: existingFiles.get(s.path)?.size || 0,
382
+ mtime: existingFiles.get(s.path)?.mtime.toISOString() || '',
383
+ }))
384
+
385
+ // Update timestamps
386
+ index.lastIncrementalUpdate = getTimestamp()
387
+ index.scanDuration = Date.now() - startTime
388
+
389
+ // Persist
390
+ await indexStorage.writeIndex(this.projectId, index)
391
+ await indexStorage.writeScores(this.projectId, index.relevantFiles)
392
+
393
+ return {
394
+ index,
395
+ fromCache: false,
396
+ changedFiles: filesToUpdate.length,
397
+ scanDuration: index.scanDuration,
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Load index from cache if valid, otherwise full scan
403
+ */
404
+ async loadOrScan(options: IndexOptions = {}): Promise<ScanResult> {
405
+ if (options.forceFullScan) {
406
+ return this.fullScan(options)
407
+ }
408
+
409
+ const index = await indexStorage.readIndex(this.projectId)
410
+ if (index?.lastFullScan) {
411
+ // Check if index is fresh enough (< 24 hours old)
412
+ const age = await indexStorage.getIndexAge(this.projectId)
413
+ if (age < 24) {
414
+ return {
415
+ index,
416
+ fromCache: true,
417
+ changedFiles: 0,
418
+ scanDuration: 0,
419
+ }
420
+ }
421
+ }
422
+
423
+ return this.fullScan(options)
424
+ }
425
+
426
+ /**
427
+ * Get relevant context for LLM with token estimation
428
+ */
429
+ async getRelevantContext(maxTokens: number = 50000): Promise<RelevantContext> {
430
+ const index = await indexStorage.readIndex(this.projectId)
431
+ if (!index) {
432
+ return {
433
+ files: [],
434
+ estimatedTokens: 0,
435
+ originalTokens: 0,
436
+ compressionRate: 0,
437
+ }
438
+ }
439
+
440
+ const CHARS_PER_TOKEN = 4
441
+ let estimatedTokens = 0
442
+ const selectedFiles: ScoredFile[] = []
443
+
444
+ // Select files by score until we hit token limit
445
+ for (const file of index.relevantFiles) {
446
+ const fileTokens = Math.ceil(file.size / CHARS_PER_TOKEN)
447
+ if (estimatedTokens + fileTokens > maxTokens) {
448
+ break
449
+ }
450
+ selectedFiles.push(file)
451
+ estimatedTokens += fileTokens
452
+ }
453
+
454
+ // Original tokens = total project size
455
+ const originalTokens = Math.ceil(index.totalSize / CHARS_PER_TOKEN)
456
+ const compressionRate =
457
+ originalTokens > 0 ? (originalTokens - estimatedTokens) / originalTokens : 0
458
+
459
+ return {
460
+ files: selectedFiles,
461
+ estimatedTokens,
462
+ originalTokens,
463
+ compressionRate,
464
+ }
465
+ }
466
+
467
+ // ==========================================================================
468
+ // SCANNING METHODS
469
+ // ==========================================================================
470
+
471
+ /**
472
+ * Scan all source files in the project
473
+ */
474
+ private async scanAllFiles(options: IndexOptions = {}): Promise<Map<string, FileStats>> {
475
+ const files = new Map<string, FileStats>()
476
+ const maxFiles = options.maxFiles || 10000
477
+
478
+ // Use find command for speed
479
+ try {
480
+ const excludeDirs = Array.from(IGNORE_DIRS)
481
+ .map((d) => `-not -path "*/${d}/*"`)
482
+ .join(' ')
483
+ const extensions = Array.from(SOURCE_EXTENSIONS)
484
+ .map((e) => `-name "*${e}"`)
485
+ .join(' -o ')
486
+
487
+ const { stdout } = await execAsync(
488
+ `find . -type f \\( ${extensions} \\) ${excludeDirs} | head -n ${maxFiles}`,
489
+ { cwd: this.projectPath, maxBuffer: 10 * 1024 * 1024 }
490
+ )
491
+
492
+ const paths = stdout.trim().split('\n').filter(Boolean)
493
+
494
+ // Process files in parallel batches
495
+ const batchSize = 100
496
+ for (let i = 0; i < paths.length; i += batchSize) {
497
+ const batch = paths.slice(i, i + batchSize)
498
+ const results = await Promise.all(
499
+ batch.map((p) => this.getFileStats(p.replace(/^\.\//, '')))
500
+ )
501
+ for (const stats of results) {
502
+ if (stats) {
503
+ files.set(stats.path, stats)
504
+ }
505
+ }
506
+ }
507
+ } catch {
508
+ // Fallback to recursive directory walk
509
+ await this.walkDirectory('.', files, maxFiles)
510
+ }
511
+
512
+ return files
513
+ }
514
+
515
+ /**
516
+ * Scan specific files
517
+ */
518
+ private async scanFiles(paths: string[]): Promise<Map<string, FileStats>> {
519
+ const files = new Map<string, FileStats>()
520
+
521
+ const results = await Promise.all(paths.map((p) => this.getFileStats(p)))
522
+
523
+ for (const stats of results) {
524
+ if (stats) {
525
+ files.set(stats.path, stats)
526
+ }
527
+ }
528
+
529
+ return files
530
+ }
531
+
532
+ /**
533
+ * Get stats for a single file
534
+ */
535
+ private async getFileStats(relativePath: string): Promise<FileStats | null> {
536
+ const fullPath = path.join(this.projectPath, relativePath)
537
+
538
+ try {
539
+ const stat = await fs.stat(fullPath)
540
+ const content = await fs.readFile(fullPath, 'utf-8')
541
+ const lines = content.split('\n').length
542
+
543
+ return {
544
+ path: relativePath,
545
+ size: stat.size,
546
+ mtime: stat.mtime,
547
+ lines,
548
+ }
549
+ } catch {
550
+ return null
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Recursive directory walk (fallback)
556
+ */
557
+ private async walkDirectory(
558
+ dir: string,
559
+ files: Map<string, FileStats>,
560
+ maxFiles: number
561
+ ): Promise<void> {
562
+ if (files.size >= maxFiles) return
563
+
564
+ const fullDir = path.join(this.projectPath, dir)
565
+
566
+ try {
567
+ const entries = await fs.readdir(fullDir, { withFileTypes: true })
568
+
569
+ for (const entry of entries) {
570
+ if (files.size >= maxFiles) break
571
+
572
+ const relativePath = path.join(dir, entry.name).replace(/^\.\//, '')
573
+
574
+ if (entry.isDirectory()) {
575
+ if (!IGNORE_DIRS.has(entry.name)) {
576
+ await this.walkDirectory(relativePath, files, maxFiles)
577
+ }
578
+ } else if (entry.isFile()) {
579
+ const ext = path.extname(entry.name)
580
+ if (SOURCE_EXTENSIONS.has(ext)) {
581
+ const stats = await this.getFileStats(relativePath)
582
+ if (stats) {
583
+ files.set(relativePath, stats)
584
+ }
585
+ }
586
+ }
587
+ }
588
+ } catch {
589
+ // Directory may not be accessible
590
+ }
591
+ }
592
+
593
+ // ==========================================================================
594
+ // CONFIG & DIRECTORY ANALYSIS
595
+ // ==========================================================================
596
+
597
+ /**
598
+ * Find and parse config files
599
+ */
600
+ private async findConfigFiles(): Promise<ConfigFileEntry[]> {
601
+ const configs: ConfigFileEntry[] = []
602
+
603
+ for (const configName of CONFIG_FILES) {
604
+ const configPath = path.join(this.projectPath, configName)
605
+
606
+ try {
607
+ await fs.access(configPath)
608
+ const checksum = await indexStorage.calculateChecksum(configPath)
609
+
610
+ const entry: ConfigFileEntry = {
611
+ path: configName,
612
+ type: configName,
613
+ checksum,
614
+ }
615
+
616
+ // Parse JSON config files
617
+ if (configName.endsWith('.json')) {
618
+ try {
619
+ const content = await fs.readFile(configPath, 'utf-8')
620
+ entry.parsed = JSON.parse(content)
621
+ } catch {
622
+ // Invalid JSON
623
+ }
624
+ }
625
+
626
+ configs.push(entry)
627
+ } catch {
628
+ // Config file doesn't exist
629
+ }
630
+ }
631
+
632
+ return configs
633
+ }
634
+
635
+ /**
636
+ * Analyze top-level directory structure
637
+ */
638
+ private async analyzeDirectories(): Promise<DirectoryEntry[]> {
639
+ const directories: DirectoryEntry[] = []
640
+
641
+ try {
642
+ const entries = await fs.readdir(this.projectPath, { withFileTypes: true })
643
+
644
+ for (const entry of entries) {
645
+ if (!entry.isDirectory()) continue
646
+ if (IGNORE_DIRS.has(entry.name)) continue
647
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue
648
+
649
+ const dirPath = entry.name
650
+ const type = this.classifyDirectory(dirPath)
651
+ const fileCount = await this.countFilesInDir(dirPath)
652
+
653
+ directories.push({
654
+ path: dirPath,
655
+ type,
656
+ fileCount,
657
+ })
658
+ }
659
+ } catch {
660
+ // Project path may not be accessible
661
+ }
662
+
663
+ return directories
664
+ }
665
+
666
+ /**
667
+ * Classify directory type
668
+ */
669
+ private classifyDirectory(dirName: string): DirectoryEntry['type'] {
670
+ for (const { type, patterns } of DIR_TYPE_PATTERNS) {
671
+ if (patterns.some((p) => p.test(dirName))) {
672
+ return type
673
+ }
674
+ }
675
+ return 'unknown'
676
+ }
677
+
678
+ /**
679
+ * Count files in a directory
680
+ */
681
+ private async countFilesInDir(relativePath: string): Promise<number> {
682
+ const fullPath = path.join(this.projectPath, relativePath)
683
+
684
+ try {
685
+ const { stdout } = await execAsync(`find . -type f | wc -l`, { cwd: fullPath })
686
+ return parseInt(stdout.trim(), 10) || 0
687
+ } catch {
688
+ return 0
689
+ }
690
+ }
691
+
692
+ // ==========================================================================
693
+ // STACK DETECTION
694
+ // ==========================================================================
695
+
696
+ /**
697
+ * Detect technology stack from config files
698
+ */
699
+ private async detectStack(configFiles: ConfigFileEntry[]): Promise<DetectedStack> {
700
+ const stack: DetectedStack = {
701
+ ecosystem: 'unknown',
702
+ frameworks: [],
703
+ hasTests: false,
704
+ hasDocker: false,
705
+ hasCi: false,
706
+ buildTool: null,
707
+ }
708
+
709
+ // Find package.json for JS/TS projects
710
+ const packageJson = configFiles.find((cf) => cf.type === 'package.json')
711
+ if (packageJson?.parsed) {
712
+ stack.ecosystem = 'JavaScript'
713
+
714
+ const deps = {
715
+ ...(((packageJson.parsed as Record<string, unknown>).dependencies as Record<
716
+ string,
717
+ string
718
+ >) || {}),
719
+ ...(((packageJson.parsed as Record<string, unknown>).devDependencies as Record<
720
+ string,
721
+ string
722
+ >) || {}),
723
+ }
724
+
725
+ // Detect frameworks
726
+ if (deps.react) stack.frameworks.push('React')
727
+ if (deps.next) stack.frameworks.push('Next.js')
728
+ if (deps.vue) stack.frameworks.push('Vue')
729
+ if (deps.nuxt) stack.frameworks.push('Nuxt')
730
+ if (deps.svelte) stack.frameworks.push('Svelte')
731
+ if (deps['@angular/core']) stack.frameworks.push('Angular')
732
+ if (deps.express) stack.frameworks.push('Express')
733
+ if (deps.fastify) stack.frameworks.push('Fastify')
734
+ if (deps.hono) stack.frameworks.push('Hono')
735
+ if (deps['@nestjs/core']) stack.frameworks.push('NestJS')
736
+
737
+ // Detect testing
738
+ if (deps.jest || deps.vitest || deps.mocha) stack.hasTests = true
739
+
740
+ // Detect build tool
741
+ if (deps.vite) stack.buildTool = 'vite'
742
+ else if (deps.webpack) stack.buildTool = 'webpack'
743
+ else if (deps.esbuild) stack.buildTool = 'esbuild'
744
+ else if (deps.rollup) stack.buildTool = 'rollup'
745
+ }
746
+
747
+ // Other ecosystems
748
+ if (configFiles.some((cf) => cf.type === 'Cargo.toml')) {
749
+ stack.ecosystem = 'Rust'
750
+ }
751
+ if (configFiles.some((cf) => cf.type === 'go.mod')) {
752
+ stack.ecosystem = 'Go'
753
+ }
754
+ if (configFiles.some((cf) => cf.type === 'pyproject.toml' || cf.type === 'requirements.txt')) {
755
+ stack.ecosystem = 'Python'
756
+ }
757
+
758
+ // Docker & CI
759
+ stack.hasDocker = configFiles.some(
760
+ (cf) => cf.type === 'Dockerfile' || cf.type.includes('docker-compose')
761
+ )
762
+
763
+ // Check for CI configs
764
+ try {
765
+ await fs.access(path.join(this.projectPath, '.github', 'workflows'))
766
+ stack.hasCi = true
767
+ } catch {
768
+ // No GitHub Actions
769
+ }
770
+
771
+ return stack
772
+ }
773
+
774
+ // ==========================================================================
775
+ // PATTERN DETECTION
776
+ // ==========================================================================
777
+
778
+ /**
779
+ * Detect architectural patterns
780
+ */
781
+ private detectPatterns(index: ProjectIndex): DetectedPattern[] {
782
+ const patterns: DetectedPattern[] = []
783
+
784
+ for (const detector of PATTERN_DETECTORS) {
785
+ const confidence = detector.detect(index)
786
+ if (confidence > 0.3) {
787
+ patterns.push({
788
+ name: detector.name,
789
+ confidence,
790
+ evidence: detector.evidence(index),
791
+ })
792
+ }
793
+ }
794
+
795
+ return patterns.sort((a, b) => b.confidence - a.confidence)
796
+ }
797
+
798
+ // ==========================================================================
799
+ // HELPER METHODS
800
+ // ==========================================================================
801
+
802
+ /**
803
+ * Build language statistics
804
+ */
805
+ private buildLanguageStats(files: FileStats[]): Record<string, LanguageStats> {
806
+ const stats: Record<string, LanguageStats> = {}
807
+
808
+ for (const file of files) {
809
+ const ext = path.extname(file.path)
810
+ if (!ext) continue
811
+
812
+ if (!stats[ext]) {
813
+ stats[ext] = { count: 0, totalLines: 0, totalSize: 0 }
814
+ }
815
+
816
+ stats[ext].count++
817
+ stats[ext].totalLines += file.lines || 0
818
+ stats[ext].totalSize += file.size
819
+ }
820
+
821
+ return stats
822
+ }
823
+
824
+ /**
825
+ * Build scoring context for all files
826
+ */
827
+ private buildScoringContext(files: Map<string, FileStats>): ScoringContext {
828
+ const configFiles = new Set<string>()
829
+ let maxRecentCommits = 0
830
+
831
+ for (const file of files.values()) {
832
+ if (CONFIG_FILES.has(path.basename(file.path))) {
833
+ configFiles.add(file.path)
834
+ }
835
+ if (file.recentCommits && file.recentCommits > maxRecentCommits) {
836
+ maxRecentCommits = file.recentCommits
837
+ }
838
+ }
839
+
840
+ return {
841
+ allFiles: files,
842
+ configFiles,
843
+ maxFileSize: Math.max(...Array.from(files.values()).map((f) => f.size)),
844
+ maxRecentCommits,
845
+ now: new Date(),
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Load existing file stats from index
851
+ */
852
+ private async loadExistingFileStats(index: ProjectIndex): Promise<Map<string, FileStats>> {
853
+ const files = new Map<string, FileStats>()
854
+
855
+ for (const file of index.relevantFiles) {
856
+ files.set(file.path, {
857
+ path: file.path,
858
+ size: file.size,
859
+ mtime: new Date(file.mtime),
860
+ })
861
+ }
862
+
863
+ return files
864
+ }
865
+
866
+ /**
867
+ * Detect changed files using checksums
868
+ */
869
+ private async detectFileChanges(): Promise<{
870
+ added: string[]
871
+ modified: string[]
872
+ deleted: string[]
873
+ }> {
874
+ // Scan current files and calculate checksums
875
+ const currentFiles = new Map<string, string>()
876
+
877
+ const allFiles = await this.scanAllFiles()
878
+ for (const [filePath] of allFiles) {
879
+ const fullPath = path.join(this.projectPath, filePath)
880
+ const checksum = await indexStorage.calculateChecksum(fullPath)
881
+ currentFiles.set(filePath, checksum)
882
+ }
883
+
884
+ return indexStorage.detectChangedFiles(this.projectId, currentFiles)
885
+ }
886
+
887
+ /**
888
+ * Save checksums for all scanned files
889
+ */
890
+ private async saveChecksums(files: Map<string, FileStats>): Promise<void> {
891
+ const checksums: Record<string, string> = {}
892
+
893
+ for (const [filePath] of files) {
894
+ const fullPath = path.join(this.projectPath, filePath)
895
+ checksums[filePath] = await indexStorage.calculateChecksum(fullPath)
896
+ }
897
+
898
+ await indexStorage.writeChecksums(this.projectId, {
899
+ version: INDEX_VERSION,
900
+ lastUpdated: getTimestamp(),
901
+ checksums,
902
+ })
903
+ }
904
+ }
905
+
906
+ // Factory function for convenience
907
+ export function createProjectIndexer(projectPath: string, projectId: string): ProjectIndexer {
908
+ return new ProjectIndexer(projectPath, projectId)
909
+ }
910
+
911
+ export { RELEVANCE_THRESHOLD }