prjct-cli 1.7.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +205 -1
  2. package/bin/prjct.ts +14 -0
  3. package/core/__tests__/agentic/command-context.test.ts +281 -0
  4. package/core/__tests__/agentic/domain-classifier.test.ts +330 -0
  5. package/core/__tests__/agentic/response-validator.test.ts +263 -0
  6. package/core/__tests__/agentic/smart-context.test.ts +3 -3
  7. package/core/__tests__/domain/fibonacci.test.ts +113 -0
  8. package/core/__tests__/infrastructure/performance-tracker.test.ts +328 -0
  9. package/core/__tests__/schemas/model.test.ts +272 -0
  10. package/core/agentic/command-classifier.ts +141 -0
  11. package/core/agentic/command-context.ts +168 -0
  12. package/core/agentic/domain-classifier.ts +525 -0
  13. package/core/agentic/index.ts +1 -0
  14. package/core/agentic/orchestrator-executor.ts +43 -199
  15. package/core/agentic/prompt-builder.ts +50 -55
  16. package/core/agentic/response-validator.ts +98 -0
  17. package/core/agentic/smart-context.ts +60 -144
  18. package/core/commands/command-data.ts +17 -0
  19. package/core/commands/commands.ts +9 -0
  20. package/core/commands/performance.ts +114 -0
  21. package/core/commands/register.ts +6 -0
  22. package/core/commands/workflow.ts +87 -4
  23. package/core/config/command-context.config.json +66 -0
  24. package/core/domain/fibonacci.ts +128 -0
  25. package/core/index.ts +25 -1
  26. package/core/infrastructure/ai-provider.ts +35 -0
  27. package/core/infrastructure/performance-tracker.ts +326 -0
  28. package/core/schemas/analysis.ts +4 -0
  29. package/core/schemas/classification.ts +91 -0
  30. package/core/schemas/command-context.ts +29 -0
  31. package/core/schemas/index.ts +6 -0
  32. package/core/schemas/llm-output.ts +170 -0
  33. package/core/schemas/model.ts +153 -0
  34. package/core/schemas/performance.ts +128 -0
  35. package/core/schemas/state.ts +9 -0
  36. package/core/storage/state-storage.ts +21 -0
  37. package/core/types/config.ts +2 -0
  38. package/core/types/provider.ts +12 -0
  39. package/dist/bin/prjct.mjs +3184 -1945
  40. package/dist/core/infrastructure/command-installer.js +78 -7
  41. package/dist/core/infrastructure/setup.js +78 -7
  42. package/package.json +1 -1
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Domain Classifier
3
+ *
4
+ * LLM-based task domain classification with caching and fallback chain.
5
+ * Replaces hardcoded keyword substring matching that caused false positives
6
+ * (e.g., "author" matching "auth" → backend).
7
+ *
8
+ * Fallback chain:
9
+ * 1. Cache lookup (file-based, 1hr TTL)
10
+ * 2. Confirmed patterns (from successful task completions)
11
+ * 3. LLM call (Claude haiku, ~200 tokens)
12
+ * 4. Improved heuristic (word-boundary matching, no substring traps)
13
+ *
14
+ * @see PRJ-299
15
+ */
16
+
17
+ import { createHash } from 'node:crypto'
18
+ import fs from 'node:fs/promises'
19
+ import path from 'node:path'
20
+ import {
21
+ type ClassificationCache,
22
+ type ClassificationDomain,
23
+ DEFAULT_CLASSIFICATION_CACHE,
24
+ GENERAL_CLASSIFICATION,
25
+ type TaskClassification,
26
+ TaskClassificationSchema,
27
+ } from '../schemas/classification'
28
+ import { renderSchemaForPrompt } from '../schemas/llm-output'
29
+ import { getErrorMessage, isNotFoundError } from '../types/fs'
30
+
31
+ const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
32
+
33
+ // =============================================================================
34
+ // Project Context (passed to classifier)
35
+ // =============================================================================
36
+
37
+ export interface ProjectContext {
38
+ /** Domains detected during sync */
39
+ domains: {
40
+ hasFrontend: boolean
41
+ hasBackend: boolean
42
+ hasDatabase: boolean
43
+ hasTesting: boolean
44
+ hasDocker: boolean
45
+ }
46
+ /** Available agent names (without .md extension) */
47
+ agents: string[]
48
+ /** Project stack info */
49
+ stack?: { language?: string; framework?: string }
50
+ }
51
+
52
+ // =============================================================================
53
+ // Hashing
54
+ // =============================================================================
55
+
56
+ function hashDescription(description: string): string {
57
+ return createHash('sha256').update(description.toLowerCase().trim()).digest('hex').slice(0, 16)
58
+ }
59
+
60
+ // =============================================================================
61
+ // Cache Layer
62
+ // =============================================================================
63
+
64
+ async function loadCache(globalPath: string): Promise<ClassificationCache> {
65
+ try {
66
+ const cachePath = path.join(globalPath, 'storage', 'classification-cache.json')
67
+ const content = await fs.readFile(cachePath, 'utf-8')
68
+ return JSON.parse(content)
69
+ } catch (error) {
70
+ if (isNotFoundError(error)) return DEFAULT_CLASSIFICATION_CACHE
71
+ console.warn('[classifier] Failed to load cache:', getErrorMessage(error))
72
+ return DEFAULT_CLASSIFICATION_CACHE
73
+ }
74
+ }
75
+
76
+ async function saveCache(globalPath: string, cache: ClassificationCache): Promise<void> {
77
+ try {
78
+ const cachePath = path.join(globalPath, 'storage', 'classification-cache.json')
79
+ await fs.writeFile(cachePath, JSON.stringify(cache, null, 2))
80
+ } catch (error) {
81
+ console.warn('[classifier] Failed to save cache:', getErrorMessage(error))
82
+ }
83
+ }
84
+
85
+ function lookupCache(
86
+ cache: ClassificationCache,
87
+ hash: string,
88
+ projectId: string
89
+ ): TaskClassification | null {
90
+ const entry = cache.entries[hash]
91
+ if (!entry) return null
92
+ if (entry.projectId !== projectId) return null
93
+
94
+ // Check TTL
95
+ const age = Date.now() - new Date(entry.classifiedAt).getTime()
96
+ if (age > CACHE_TTL_MS) return null
97
+
98
+ return entry.classification
99
+ }
100
+
101
+ // =============================================================================
102
+ // Confirmed Patterns (from successful task completions)
103
+ // =============================================================================
104
+
105
+ function lookupPatterns(cache: ClassificationCache, hash: string): TaskClassification | null {
106
+ const pattern = cache.confirmedPatterns.find((p) => p.descriptionHash === hash)
107
+ return pattern?.classification ?? null
108
+ }
109
+
110
+ // =============================================================================
111
+ // LLM Classification (Claude Haiku via raw API)
112
+ // =============================================================================
113
+
114
+ async function classifyWithLLM(
115
+ description: string,
116
+ context: ProjectContext
117
+ ): Promise<TaskClassification | null> {
118
+ const apiKey = process.env.ANTHROPIC_API_KEY
119
+ if (!apiKey) return null
120
+
121
+ const availableDomains = buildAvailableDomains(context)
122
+ const schemaBlock = renderSchemaForPrompt('classification') || ''
123
+
124
+ const prompt = `Classify this software engineering task into a domain.
125
+
126
+ Task: "${description}"
127
+
128
+ Available domains in this project: ${availableDomains.join(', ')}
129
+ Available agents: ${context.agents.join(', ') || 'none'}
130
+ Stack: ${context.stack?.language || 'unknown'} / ${context.stack?.framework || 'unknown'}
131
+
132
+ ${schemaBlock}`
133
+
134
+ try {
135
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'x-api-key': apiKey,
140
+ 'anthropic-version': '2023-06-01',
141
+ },
142
+ body: JSON.stringify({
143
+ model: 'claude-haiku-4-5-20251001',
144
+ max_tokens: 200,
145
+ messages: [{ role: 'user', content: prompt }],
146
+ }),
147
+ })
148
+
149
+ if (!response.ok) return null
150
+
151
+ const data = (await response.json()) as {
152
+ content: Array<{ type: string; text?: string }>
153
+ }
154
+ const text = data.content?.[0]?.text
155
+ if (!text) return null
156
+
157
+ const parsed = JSON.parse(text)
158
+
159
+ // Validate with Zod schema (PRJ-264)
160
+ const result = TaskClassificationSchema.safeParse(parsed)
161
+ if (result.success) {
162
+ return result.data
163
+ }
164
+
165
+ // Re-prompt: coerce what we can from partial response
166
+ return {
167
+ primaryDomain: availableDomains.includes(parsed.primaryDomain)
168
+ ? parsed.primaryDomain
169
+ : 'general',
170
+ secondaryDomains: (parsed.secondaryDomains || []).filter((d: string) =>
171
+ availableDomains.includes(d as ClassificationDomain)
172
+ ),
173
+ confidence: Math.min(1, Math.max(0, parsed.confidence || 0.5)),
174
+ filePatterns: Array.isArray(parsed.filePatterns) ? parsed.filePatterns : [],
175
+ relevantAgents: Array.isArray(parsed.relevantAgents) ? parsed.relevantAgents : [],
176
+ }
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+
182
+ // =============================================================================
183
+ // Improved Heuristic (word-boundary matching, no substring traps)
184
+ // =============================================================================
185
+
186
+ /** Word-boundary keyword map — avoids substring false positives */
187
+ const DOMAIN_PATTERNS: Record<ClassificationDomain, RegExp[]> = {
188
+ frontend: [
189
+ /\bui\b/i,
190
+ /\bcomponents?\b/i,
191
+ /\breact\b/i,
192
+ /\bvue\b/i,
193
+ /\bangular\b/i,
194
+ /\bsvelte\b/i,
195
+ /\bnext\.?js\b/i,
196
+ /\bnuxt\b/i,
197
+ /\bcss\b/i,
198
+ /\bscss\b/i,
199
+ /\bstyles?\b/i,
200
+ /\bbuttons?\b/i,
201
+ /\bforms?\b/i,
202
+ /\bmodals?\b/i,
203
+ /\blayout\b/i,
204
+ /\bresponsive\b/i,
205
+ /\banimation\b/i,
206
+ /\bdom\b/i,
207
+ /\bhtml\b/i,
208
+ /\bfrontend\b/i,
209
+ /\bclient[- ]side\b/i,
210
+ /\bbrowser\b/i,
211
+ /\bjsx\b/i,
212
+ /\btsx\b/i,
213
+ /\bhooks?\b/i,
214
+ /\bredux\b/i,
215
+ /\bzustand\b/i,
216
+ /\btailwind\b/i,
217
+ /\bdashboard\b/i,
218
+ /\bpage\b/i,
219
+ /\bnavigation\b/i,
220
+ /\bsidebar\b/i,
221
+ /\bheader\b/i,
222
+ /\bfooter\b/i,
223
+ /\bwidget\b/i,
224
+ /\btooltip\b/i,
225
+ /\bdropdown\b/i,
226
+ /\bcarousel\b/i,
227
+ /\bprofile\s+page\b/i,
228
+ /\bdisplay\b/i,
229
+ ],
230
+ backend: [
231
+ /\bapi\b/i,
232
+ /\bendpoints?\b/i,
233
+ /\bserver\b/i,
234
+ /\broutes?\b/i,
235
+ /\bhandlers?\b/i,
236
+ /\bcontrollers?\b/i,
237
+ /\bservices?\b/i,
238
+ /\bmiddleware\b/i,
239
+ /\bauth\b/i,
240
+ /\bauthentication\b/i,
241
+ /\bauthorization\b/i,
242
+ /\bjwt\b/i,
243
+ /\boauth\b/i,
244
+ /\brest\b/i,
245
+ /\bgraphql\b/i,
246
+ /\btrpc\b/i,
247
+ /\bexpress\b/i,
248
+ /\bfastify\b/i,
249
+ /\bhono\b/i,
250
+ /\bnest\.?js\b/i,
251
+ /\bvalidation\b/i,
252
+ /\bbusiness\s+logic\b/i,
253
+ /\bcron\b/i,
254
+ /\bwebhook\b/i,
255
+ /\bworker\b/i,
256
+ /\bqueue\b/i,
257
+ /\bcache\b/i,
258
+ ],
259
+ database: [
260
+ /\bdatabase\b/i,
261
+ /\bdb\b/i,
262
+ /\bsql\b/i,
263
+ /\bquery\b/i,
264
+ /\btables?\b/i,
265
+ /\bschema\b/i,
266
+ /\bmigrations?\b/i,
267
+ /\bpostgres\b/i,
268
+ /\bmysql\b/i,
269
+ /\bsqlite\b/i,
270
+ /\bmongo\b/i,
271
+ /\bredis\b/i,
272
+ /\bprisma\b/i,
273
+ /\bdrizzle\b/i,
274
+ /\borm\b/i,
275
+ /\bentity\b/i,
276
+ /\brepository\b/i,
277
+ /\bdata\s+layer\b/i,
278
+ /\bpersist\b/i,
279
+ /\bindex(?:es|ing)?\b/i,
280
+ /\bconnection\s+pool\b/i,
281
+ ],
282
+ devops: [
283
+ /\bdocker\b/i,
284
+ /\bkubernetes\b/i,
285
+ /\bk8s\b/i,
286
+ /\bci\b/i,
287
+ /\bcd\b/i,
288
+ /\bpipeline\b/i,
289
+ /\bdeploy\b/i,
290
+ /\bgithub\s+actions\b/i,
291
+ /\bvercel\b/i,
292
+ /\baws\b/i,
293
+ /\bgcp\b/i,
294
+ /\bazure\b/i,
295
+ /\bterraform\b/i,
296
+ /\bnginx\b/i,
297
+ /\bcaddy\b/i,
298
+ /\binfrastructure\b/i,
299
+ /\bmonitoring\b/i,
300
+ /\blogging\b/i,
301
+ /\bcontainer\b/i,
302
+ /\bhelm\b/i,
303
+ ],
304
+ testing: [
305
+ /\btests?\b/i,
306
+ /\bspec\b/i,
307
+ /\bunit\s+tests?\b/i,
308
+ /\bintegration\s+tests?\b/i,
309
+ /\be2e\b/i,
310
+ /\bjest\b/i,
311
+ /\bvitest\b/i,
312
+ /\bplaywright\b/i,
313
+ /\bcypress\b/i,
314
+ /\bmocha\b/i,
315
+ /\bmocks?\b/i,
316
+ /\bstubs?\b/i,
317
+ /\bfixtures?\b/i,
318
+ /\bcoverage\b/i,
319
+ /\bassertions?\b/i,
320
+ ],
321
+ docs: [
322
+ /\bdocument(?:ation)?\b/i,
323
+ /\bdocs\b/i,
324
+ /\breadme\b/i,
325
+ /\bchangelog\b/i,
326
+ /\bjsdoc\b/i,
327
+ /\btutorial\b/i,
328
+ /\bguide\b/i,
329
+ /\bmarkdown\b/i,
330
+ ],
331
+ uxui: [
332
+ /\bdesign\b/i,
333
+ /\bux\b/i,
334
+ /\buser\s+experience\b/i,
335
+ /\baccessibility\b/i,
336
+ /\ba11y\b/i,
337
+ /\bwcag\b/i,
338
+ /\bfigma\b/i,
339
+ /\bprototype\b/i,
340
+ /\bwireframe\b/i,
341
+ /\busability\b/i,
342
+ ],
343
+ general: [],
344
+ }
345
+
346
+ /**
347
+ * Improved heuristic classifier using word-boundary regex.
348
+ * Avoids substring traps like "author" matching "auth".
349
+ */
350
+ function classifyWithHeuristic(description: string, context: ProjectContext): TaskClassification {
351
+ const availableDomains = buildAvailableDomains(context)
352
+ const scores = new Map<ClassificationDomain, number>()
353
+
354
+ for (const [domain, patterns] of Object.entries(DOMAIN_PATTERNS)) {
355
+ if (domain === 'general') continue
356
+ // Only score domains that exist in this project
357
+ if (!availableDomains.includes(domain as ClassificationDomain)) continue
358
+
359
+ let score = 0
360
+ for (const pattern of patterns) {
361
+ const matches = description.match(new RegExp(pattern, 'gi'))
362
+ if (matches) {
363
+ // Multi-word patterns score higher
364
+ score += pattern.source.includes('\\s') ? 3 : 1
365
+ }
366
+ }
367
+ if (score > 0) {
368
+ scores.set(domain as ClassificationDomain, score)
369
+ }
370
+ }
371
+
372
+ if (scores.size === 0) {
373
+ return GENERAL_CLASSIFICATION
374
+ }
375
+
376
+ const sorted = Array.from(scores.entries()).sort((a, b) => b[1] - a[1])
377
+ const primary = sorted[0][0]
378
+ const primaryScore = sorted[0][1]
379
+ const secondary = sorted.slice(1, 3).map(([d]) => d)
380
+
381
+ const totalScore = sorted.reduce((sum, [, s]) => sum + s, 0)
382
+ const confidence = Math.min(0.85, primaryScore / totalScore + 0.2)
383
+
384
+ // Map domain to file patterns
385
+ const filePatterns = getFilePatterns(primary)
386
+
387
+ // Map domain to agent names
388
+ const relevantAgents = context.agents.filter(
389
+ (a) => a === primary || a.includes(primary) || primary.includes(a.replace('.md', ''))
390
+ )
391
+
392
+ return {
393
+ primaryDomain: primary,
394
+ secondaryDomains: secondary,
395
+ confidence,
396
+ filePatterns,
397
+ relevantAgents,
398
+ }
399
+ }
400
+
401
+ // =============================================================================
402
+ // Helpers
403
+ // =============================================================================
404
+
405
+ function buildAvailableDomains(context: ProjectContext): ClassificationDomain[] {
406
+ const domains: ClassificationDomain[] = []
407
+ if (context.domains.hasFrontend) domains.push('frontend')
408
+ if (context.domains.hasBackend) domains.push('backend')
409
+ if (context.domains.hasDatabase) domains.push('database')
410
+ if (context.domains.hasTesting) domains.push('testing')
411
+ if (context.domains.hasDocker) domains.push('devops')
412
+ // Always include these as possibilities
413
+ domains.push('docs', 'uxui', 'general')
414
+ return domains
415
+ }
416
+
417
+ function getFilePatterns(domain: ClassificationDomain): string[] {
418
+ const patterns: Record<ClassificationDomain, string[]> = {
419
+ frontend: ['src/components/**', 'src/pages/**', 'src/hooks/**', '**/*.tsx', '**/*.jsx'],
420
+ backend: ['src/api/**', 'src/routes/**', 'src/services/**', 'src/handlers/**'],
421
+ database: ['src/models/**', 'src/schemas/**', '**/*.sql', 'prisma/**'],
422
+ devops: ['.github/**', 'docker/**', 'deploy/**', 'infra/**', '**/*.yml', '**/*.yaml'],
423
+ testing: ['**/*.test.*', '**/*.spec.*', 'tests/**', '__tests__/**', 'e2e/**'],
424
+ docs: ['docs/**', '**/*.md', '**/*.mdx'],
425
+ uxui: ['src/components/**', 'src/styles/**', '**/*.css'],
426
+ general: ['**/*.ts', '**/*.js'],
427
+ }
428
+ return patterns[domain] || patterns.general
429
+ }
430
+
431
+ // =============================================================================
432
+ // Main Classifier
433
+ // =============================================================================
434
+
435
+ export class DomainClassifier {
436
+ /**
437
+ * Classify a task description into a domain.
438
+ *
439
+ * Fallback chain:
440
+ * 1. Cache lookup (1hr TTL)
441
+ * 2. Confirmed patterns (from completed tasks)
442
+ * 3. LLM call (Claude haiku)
443
+ * 4. Improved heuristic (word-boundary matching)
444
+ */
445
+ async classify(
446
+ description: string,
447
+ projectId: string,
448
+ globalPath: string,
449
+ context: ProjectContext
450
+ ): Promise<{
451
+ classification: TaskClassification
452
+ source: 'cache' | 'history' | 'llm' | 'heuristic'
453
+ }> {
454
+ const hash = hashDescription(description)
455
+ const cache = await loadCache(globalPath)
456
+
457
+ // 1. Cache lookup
458
+ const cached = lookupCache(cache, hash, projectId)
459
+ if (cached) {
460
+ return { classification: cached, source: 'cache' }
461
+ }
462
+
463
+ // 2. Confirmed patterns
464
+ const pattern = lookupPatterns(cache, hash)
465
+ if (pattern) {
466
+ return { classification: pattern, source: 'history' }
467
+ }
468
+
469
+ // 3. LLM call
470
+ const llmResult = await classifyWithLLM(description, context)
471
+ if (llmResult) {
472
+ // Cache the LLM result
473
+ cache.entries[hash] = {
474
+ classification: llmResult,
475
+ classifiedAt: new Date().toISOString(),
476
+ source: 'llm',
477
+ descriptionHash: hash,
478
+ projectId,
479
+ }
480
+ await saveCache(globalPath, cache)
481
+ return { classification: llmResult, source: 'llm' }
482
+ }
483
+
484
+ // 4. Heuristic fallback
485
+ const heuristicResult = classifyWithHeuristic(description, context)
486
+ // Cache heuristic results too
487
+ cache.entries[hash] = {
488
+ classification: heuristicResult,
489
+ classifiedAt: new Date().toISOString(),
490
+ source: 'heuristic',
491
+ descriptionHash: hash,
492
+ projectId,
493
+ }
494
+ await saveCache(globalPath, cache)
495
+ return { classification: heuristicResult, source: 'heuristic' }
496
+ }
497
+
498
+ /**
499
+ * Persist a classification as a confirmed pattern after successful task completion.
500
+ */
501
+ async confirmClassification(
502
+ description: string,
503
+ classification: TaskClassification,
504
+ globalPath: string
505
+ ): Promise<void> {
506
+ const hash = hashDescription(description)
507
+ const cache = await loadCache(globalPath)
508
+
509
+ // Check if already confirmed
510
+ if (cache.confirmedPatterns.some((p) => p.descriptionHash === hash)) return
511
+
512
+ cache.confirmedPatterns.push({
513
+ descriptionHash: hash,
514
+ classification,
515
+ confirmedAt: new Date().toISOString(),
516
+ taskDescription: description,
517
+ })
518
+ await saveCache(globalPath, cache)
519
+ }
520
+ }
521
+
522
+ // Singleton
523
+ const domainClassifier = new DomainClassifier()
524
+ export default domainClassifier
525
+ export { hashDescription, classifyWithHeuristic, DOMAIN_PATTERNS }
@@ -123,6 +123,7 @@ export {
123
123
  // ============ Context ============
124
124
  // Context building and prompt generation
125
125
  export { default as contextBuilder } from './context-builder'
126
+ export { DomainClassifier, default as domainClassifier } from './domain-classifier'
126
127
  export {
127
128
  default as groundTruth,
128
129
  escapeRegex,