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
@@ -32,149 +32,11 @@ import type {
32
32
  RealCodebaseContext,
33
33
  } from '../types'
34
34
  import { getErrorMessage, isNotFoundError } from '../types/fs'
35
+ import domainClassifier, { type ProjectContext } from './domain-classifier'
35
36
  import { parseFrontmatter } from './template-loader'
36
37
 
37
38
  const execAsync = promisify(execCallback)
38
39
 
39
- // =============================================================================
40
- // Domain Detection Keywords
41
- // =============================================================================
42
-
43
- /**
44
- * Keywords that indicate a domain is involved in the task
45
- * These are hints, not absolute rules - context matters
46
- */
47
- const DOMAIN_KEYWORDS: Record<string, string[]> = {
48
- database: [
49
- 'database',
50
- 'db',
51
- 'sql',
52
- 'query',
53
- 'table',
54
- 'schema',
55
- 'migration',
56
- 'postgres',
57
- 'mysql',
58
- 'sqlite',
59
- 'mongo',
60
- 'redis',
61
- 'prisma',
62
- 'drizzle',
63
- 'orm',
64
- 'model',
65
- 'entity',
66
- 'repository',
67
- 'data layer',
68
- 'persist',
69
- ],
70
- backend: [
71
- 'api',
72
- 'endpoint',
73
- 'route',
74
- 'server',
75
- 'controller',
76
- 'service',
77
- 'middleware',
78
- 'auth',
79
- 'authentication',
80
- 'authorization',
81
- 'jwt',
82
- 'oauth',
83
- 'rest',
84
- 'graphql',
85
- 'trpc',
86
- 'express',
87
- 'fastify',
88
- 'hono',
89
- 'nest',
90
- 'validation',
91
- 'business logic',
92
- ],
93
- frontend: [
94
- 'ui',
95
- 'component',
96
- 'page',
97
- 'form',
98
- 'button',
99
- 'input',
100
- 'modal',
101
- 'dialog',
102
- 'react',
103
- 'vue',
104
- 'svelte',
105
- 'angular',
106
- 'next',
107
- 'nuxt',
108
- 'solid',
109
- 'css',
110
- 'style',
111
- 'tailwind',
112
- 'layout',
113
- 'responsive',
114
- 'animation',
115
- 'hook',
116
- 'state',
117
- 'context',
118
- 'redux',
119
- 'zustand',
120
- 'jotai',
121
- ],
122
- testing: [
123
- 'test',
124
- 'spec',
125
- 'unit',
126
- 'integration',
127
- 'e2e',
128
- 'jest',
129
- 'vitest',
130
- 'playwright',
131
- 'cypress',
132
- 'mocha',
133
- 'chai',
134
- 'mock',
135
- 'stub',
136
- 'fixture',
137
- 'coverage',
138
- 'assertion',
139
- ],
140
- devops: [
141
- 'docker',
142
- 'kubernetes',
143
- 'k8s',
144
- 'ci',
145
- 'cd',
146
- 'pipeline',
147
- 'deploy',
148
- 'github actions',
149
- 'vercel',
150
- 'aws',
151
- 'gcp',
152
- 'azure',
153
- 'terraform',
154
- 'nginx',
155
- 'caddy',
156
- 'env',
157
- 'environment',
158
- 'config',
159
- 'secret',
160
- ],
161
- uxui: [
162
- 'design',
163
- 'ux',
164
- 'user experience',
165
- 'accessibility',
166
- 'a11y',
167
- 'color',
168
- 'typography',
169
- 'spacing',
170
- 'prototype',
171
- 'wireframe',
172
- 'figma',
173
- 'user flow',
174
- 'interaction',
175
- ],
176
- }
177
-
178
40
  /**
179
41
  * Domain dependency order - earlier domains should complete first
180
42
  */
@@ -353,84 +215,66 @@ export class OrchestratorExecutor {
353
215
  }
354
216
 
355
217
  /**
356
- * Detect which domains are relevant for this task
218
+ * Detect which domains are relevant for this task.
357
219
  *
358
- * Uses keyword matching + project context to determine domains.
359
- * More intelligent than simple string matching - considers:
360
- * - Task description keywords
361
- * - Project technology stack
362
- * - Available agents
220
+ * Uses LLM-based classification with fallback chain (PRJ-299):
221
+ * cache confirmed patterns LLM heuristic
363
222
  */
364
223
  async detectDomains(
365
224
  taskDescription: string,
366
225
  projectId: string,
367
226
  repoAnalysis: { ecosystem: string; technologies?: string[] } | null
368
227
  ): Promise<{ domains: string[]; primary: string }> {
369
- const taskLower = taskDescription.toLowerCase()
370
- const detectedDomains: Map<string, number> = new Map()
371
-
372
- // Score each domain based on keyword matches
373
- for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
374
- let score = 0
375
- for (const keyword of keywords) {
376
- if (taskLower.includes(keyword.toLowerCase())) {
377
- // Weight multi-word matches higher
378
- score += keyword.includes(' ') ? 3 : 1
379
- }
380
- }
381
- if (score > 0) {
382
- detectedDomains.set(domain, score)
228
+ const globalPath = pathManager.getGlobalProjectPath(projectId)
229
+ const availableAgents = await this.getAvailableAgentNames(globalPath)
230
+
231
+ // Load state.json for project domain info
232
+ let projectDomains = {
233
+ hasFrontend: false,
234
+ hasBackend: true,
235
+ hasDatabase: false,
236
+ hasTesting: false,
237
+ hasDocker: false,
238
+ }
239
+ try {
240
+ const statePath = path.join(globalPath, 'storage', 'state.json')
241
+ const stateContent = await fs.readFile(statePath, 'utf-8')
242
+ const state = JSON.parse(stateContent)
243
+ if (state.domains) {
244
+ projectDomains = state.domains
383
245
  }
246
+ } catch {
247
+ // Use defaults
384
248
  }
385
249
 
386
- // Boost scores for domains that match project technologies
387
- if (repoAnalysis?.technologies) {
388
- const techStr = repoAnalysis.technologies.join(' ').toLowerCase()
389
-
390
- // If project has React/Vue/etc, boost frontend
391
- if (/react|vue|svelte|angular|next|nuxt/.test(techStr)) {
392
- const current = detectedDomains.get('frontend') || 0
393
- if (current > 0) detectedDomains.set('frontend', current + 2)
394
- }
250
+ const context: ProjectContext = {
251
+ domains: projectDomains,
252
+ agents: availableAgents,
253
+ stack: repoAnalysis ? { language: repoAnalysis.ecosystem } : undefined,
254
+ }
395
255
 
396
- // If project has Express/Fastify/etc, boost backend
397
- if (/express|fastify|hono|nest|koa/.test(techStr)) {
398
- const current = detectedDomains.get('backend') || 0
399
- if (current > 0) detectedDomains.set('backend', current + 2)
400
- }
256
+ const { classification } = await domainClassifier.classify(
257
+ taskDescription,
258
+ projectId,
259
+ globalPath,
260
+ context
261
+ )
401
262
 
402
- // If project has Prisma/Drizzle/etc, boost database
403
- if (/prisma|drizzle|mongoose|typeorm|sequelize/.test(techStr)) {
404
- const current = detectedDomains.get('database') || 0
405
- if (current > 0) detectedDomains.set('database', current + 2)
406
- }
407
- }
263
+ const domains = [classification.primaryDomain, ...classification.secondaryDomains]
408
264
 
409
- // Get available agents to filter domains
410
- const globalPath = pathManager.getGlobalProjectPath(projectId)
411
- const availableAgents = await this.getAvailableAgentNames(globalPath)
265
+ // Filter to domains that have corresponding agents
266
+ const validDomains = domains.filter((domain) =>
267
+ availableAgents.some(
268
+ (agent) =>
269
+ agent === domain || agent.includes(domain) || domain.includes(agent.replace('.md', ''))
270
+ )
271
+ )
412
272
 
413
- // Only include domains that have corresponding agents
414
- const validDomains = Array.from(detectedDomains.entries())
415
- .filter(([domain]) => {
416
- // Check if agent exists for this domain
417
- return availableAgents.some(
418
- (agent) =>
419
- agent === domain || agent.includes(domain) || domain.includes(agent.replace('.md', ''))
420
- )
421
- })
422
- .sort((a, b) => b[1] - a[1]) // Sort by score descending
423
- .map(([domain]) => domain)
424
-
425
- // If no domains detected, default to 'general'
426
273
  if (validDomains.length === 0) {
427
274
  return { domains: ['general'], primary: 'general' }
428
275
  }
429
276
 
430
- // Primary is the highest scoring domain
431
- const primary = validDomains[0]
432
-
433
- return { domains: validDomains, primary }
277
+ return { domains: validDomains, primary: validDomains[0] }
434
278
  }
435
279
 
436
280
  /**
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs/promises'
13
13
  import path from 'node:path'
14
14
  import { outcomeAnalyzer } from '../outcomes'
15
+ import type { CommandContextEntry } from '../schemas/command-context'
15
16
  import { queueStorage, stateStorage } from '../storage'
16
17
  import type {
17
18
  LearnedPatterns,
@@ -28,6 +29,7 @@ import type {
28
29
  import { getErrorMessage, isNotFoundError } from '../types/fs'
29
30
  import { fileExists } from '../utils/fs-helpers'
30
31
  import { PACKAGE_ROOT } from '../utils/version'
32
+ import { loadCommandContextConfig, resolveCommandContextFull } from './command-context'
31
33
  import {
32
34
  DEFAULT_BUDGETS,
33
35
  filterSkillsByDomains,
@@ -144,18 +146,14 @@ class PromptBuilder {
144
146
 
145
147
  /**
146
148
  * Get additional modules needed for SMART commands (PRJ-94)
147
- * Returns array of module names that should be injected
149
+ * Now config-driven via command-context.config.json (PRJ-298)
148
150
  */
149
- getModulesForCommand(commandName: string): string[] {
150
- const smartCommands: Record<string, string[]> = {
151
- task: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
152
- ship: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
153
- bug: ['CLAUDE-intelligence.md'],
154
- done: ['CLAUDE-storage.md'],
155
- work: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
156
- spec: ['CLAUDE-intelligence.md'],
157
- }
158
- return smartCommands[commandName] || []
151
+ getModulesForCommand(_commandName: string, commandContext?: CommandContextEntry): string[] {
152
+ if (commandContext) {
153
+ return commandContext.modules
154
+ }
155
+ // Fallback if called without config (shouldn't happen after PRJ-298)
156
+ return []
159
157
  }
160
158
 
161
159
  /**
@@ -411,21 +409,20 @@ class PromptBuilder {
411
409
  // Store context for use in helper methods
412
410
  this._currentContext = context
413
411
 
414
- // Agent assignment (CONDITIONAL - only for code-modifying commands)
412
+ // PRJ-298: Config-driven command context (replaces 4 hardcoded lists)
415
413
  const commandName = template.frontmatter?.name?.replace('p:', '') || ''
416
- const agentCommands = [
417
- 'now',
418
- 'build',
419
- 'feature',
420
- 'design',
421
- 'fix',
422
- 'bug',
423
- 'test',
424
- 'work',
425
- 'cleanup',
426
- 'spec',
427
- ]
428
- const needsAgent = agentCommands.includes(commandName)
414
+ let commandContext: CommandContextEntry
415
+ try {
416
+ const config = await loadCommandContextConfig()
417
+ const resolved = resolveCommandContextFull(config, commandName, template)
418
+ commandContext = resolved.entry
419
+ } catch {
420
+ // Fallback: sensible defaults if config fails to load
421
+ commandContext = { agents: true, patterns: true, checklist: false, modules: [] }
422
+ }
423
+
424
+ // Agent assignment (config-driven)
425
+ const needsAgent = commandContext.agents
429
426
 
430
427
  if (agent && needsAgent) {
431
428
  parts.push(`# AGENT: ${agent.name}\n`)
@@ -591,21 +588,8 @@ class PromptBuilder {
591
588
  )
592
589
  }
593
590
 
594
- // OPTIMIZED: Only include patterns for code-modifying commands
595
- const codeCommands = [
596
- 'now',
597
- 'build',
598
- 'feature',
599
- 'design',
600
- 'cleanup',
601
- 'fix',
602
- 'bug',
603
- 'test',
604
- 'init',
605
- 'spec',
606
- 'work',
607
- ]
608
- const needsPatterns = codeCommands.includes(commandName)
591
+ // OPTIMIZED: Only include patterns for code-modifying commands (config-driven, PRJ-298)
592
+ const needsPatterns = commandContext.patterns
609
593
 
610
594
  // Include code patterns analysis for code-modifying commands
611
595
  const codePatternsContent = state?.codePatterns || ''
@@ -636,8 +620,8 @@ class PromptBuilder {
636
620
  // CRITICAL: Compressed rules
637
621
  parts.push(this.buildCriticalRules())
638
622
 
639
- // PRJ-94: Inject additional modules for SMART commands
640
- const additionalModules = this.getModulesForCommand(commandName)
623
+ // PRJ-94/PRJ-298: Inject additional modules for SMART commands (config-driven)
624
+ const additionalModules = this.getModulesForCommand(commandName, commandContext)
641
625
  if (additionalModules.length > 0) {
642
626
  for (const moduleName of additionalModules) {
643
627
  const moduleContent = await this.loadModule(moduleName)
@@ -698,19 +682,8 @@ class PromptBuilder {
698
682
  )
699
683
  }
700
684
 
701
- // P4.1: Quality Checklists
702
- const checklistCommands = [
703
- 'now',
704
- 'build',
705
- 'feature',
706
- 'design',
707
- 'fix',
708
- 'bug',
709
- 'cleanup',
710
- 'spec',
711
- 'work',
712
- ]
713
- if (checklistCommands.includes(commandName)) {
685
+ // P4.1: Quality Checklists (config-driven, PRJ-298)
686
+ if (commandContext.checklist) {
714
687
  const routing = await this.loadChecklistRouting()
715
688
  const checklists = await this.loadChecklists()
716
689
 
@@ -725,6 +698,16 @@ class PromptBuilder {
725
698
  }
726
699
  }
727
700
 
701
+ // PRJ-264: Output schema injection for structured responses
702
+ const schemaType = this.getSchemaTypeForCommand(commandName)
703
+ if (schemaType) {
704
+ const { renderSchemaForPrompt } = await import('../schemas/llm-output')
705
+ const schemaBlock = renderSchemaForPrompt(schemaType)
706
+ if (schemaBlock) {
707
+ parts.push(`\n${schemaBlock}\n`)
708
+ }
709
+ }
710
+
728
711
  // Simple execution directive
729
712
  parts.push('\nEXECUTE: Follow flow. Use tools. Decide.\n')
730
713
 
@@ -802,6 +785,18 @@ class PromptBuilder {
802
785
  return result || null
803
786
  }
804
787
 
788
+ /**
789
+ * Map command names to their expected output schema type.
790
+ * Returns null for commands that don't need structured output.
791
+ */
792
+ private getSchemaTypeForCommand(commandName: string): string | null {
793
+ const schemaMap: Record<string, string> = {
794
+ task: 'subtaskBreakdown',
795
+ bug: 'classification',
796
+ }
797
+ return schemaMap[commandName] ?? null
798
+ }
799
+
805
800
  /**
806
801
  * Build critical anti-hallucination rules section
807
802
  */
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Response Validator
3
+ *
4
+ * Validates LLM responses against Zod schemas.
5
+ * Provides structured error handling with re-prompt support.
6
+ *
7
+ * Flow:
8
+ * 1. Parse raw text as JSON
9
+ * 2. Validate against Zod schema
10
+ * 3. On success: return typed data
11
+ * 4. On failure: return validation errors for re-prompt or fallback
12
+ *
13
+ * @see PRJ-264
14
+ */
15
+
16
+ import type { z } from 'zod'
17
+
18
+ // =============================================================================
19
+ // Types
20
+ // =============================================================================
21
+
22
+ export interface ValidationSuccess<T> {
23
+ success: true
24
+ data: T
25
+ }
26
+
27
+ export interface ValidationFailure {
28
+ success: false
29
+ error: string
30
+ /** Raw parsed JSON (may be partial) */
31
+ rawParsed: unknown
32
+ /** Zod validation issues */
33
+ issues: string[]
34
+ }
35
+
36
+ export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
37
+
38
+ // =============================================================================
39
+ // Core Validation
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Validate a raw LLM response string against a Zod schema.
44
+ *
45
+ * Handles:
46
+ * - JSON parse errors (LLM returned non-JSON)
47
+ * - Markdown-wrapped JSON (```json ... ```)
48
+ * - Schema validation errors (wrong fields, types)
49
+ */
50
+ export function validateLLMResponse<T>(raw: string, schema: z.ZodType<T>): ValidationResult<T> {
51
+ // Strip markdown code fences if present
52
+ let jsonText = raw.trim()
53
+ const fenceMatch = jsonText.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/)
54
+ if (fenceMatch) {
55
+ jsonText = fenceMatch[1].trim()
56
+ }
57
+
58
+ // Attempt JSON parse
59
+ let parsed: unknown
60
+ try {
61
+ parsed = JSON.parse(jsonText)
62
+ } catch {
63
+ return {
64
+ success: false,
65
+ error: 'Response is not valid JSON',
66
+ rawParsed: null,
67
+ issues: [`JSON parse error: expected JSON, got: ${jsonText.slice(0, 100)}...`],
68
+ }
69
+ }
70
+
71
+ // Validate against schema
72
+ const result = schema.safeParse(parsed)
73
+ if (result.success) {
74
+ return { success: true, data: result.data }
75
+ }
76
+
77
+ // Extract readable error messages
78
+ const issues = result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
79
+
80
+ return {
81
+ success: false,
82
+ error: `Schema validation failed: ${issues.join('; ')}`,
83
+ rawParsed: parsed,
84
+ issues,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build a re-prompt message when validation fails.
90
+ * Includes the original error so the LLM can fix its response.
91
+ */
92
+ export function buildReprompt(failure: ValidationFailure, schemaExample: string): string {
93
+ return `Your previous response was not valid. Errors:
94
+ ${failure.issues.map((i) => `- ${i}`).join('\n')}
95
+
96
+ Return ONLY valid JSON matching this exact format (no markdown, no explanation):
97
+ ${schemaExample}`
98
+ }