prjct-cli 1.8.0 → 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.
@@ -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
  /**
@@ -698,6 +698,16 @@ class PromptBuilder {
698
698
  }
699
699
  }
700
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
+
701
711
  // Simple execution directive
702
712
  parts.push('\nEXECUTE: Follow flow. Use tools. Decide.\n')
703
713
 
@@ -775,6 +785,18 @@ class PromptBuilder {
775
785
  return result || null
776
786
  }
777
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
+
778
800
  /**
779
801
  * Build critical anti-hallucination rules section
780
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
+ }
@@ -4,8 +4,10 @@
4
4
  * Intelligently filters context based on task type.
5
5
  * Reduces prompt size by 40-70% while maintaining relevance.
6
6
  *
7
+ * Uses LLM-based domain classification (PRJ-299) instead of keyword matching.
8
+ *
7
9
  * @module agentic/smart-context
8
- * @version 1.0
10
+ * @version 2.0
9
11
  */
10
12
 
11
13
  import { agentPerformanceTracker } from '../agents'
@@ -19,6 +21,7 @@ import type {
19
21
  StackInfo,
20
22
  TaskType,
21
23
  } from '../types'
24
+ import domainClassifier, { classifyWithHeuristic, type ProjectContext } from './domain-classifier'
22
25
 
23
26
  // Re-export types for convenience
24
27
  export type {
@@ -35,163 +38,76 @@ export type {
35
38
  // Type alias exported for backward compatibility (used by external consumers)
36
39
  export type ProjectState = SmartContextProjectState
37
40
 
41
+ // Map ClassificationDomain → ContextDomain
42
+ function toContextDomain(domain: string): ContextDomain {
43
+ const mapping: Record<string, ContextDomain> = {
44
+ frontend: 'frontend',
45
+ backend: 'backend',
46
+ database: 'backend', // database maps to backend context domain
47
+ devops: 'devops',
48
+ testing: 'testing',
49
+ docs: 'docs',
50
+ uxui: 'frontend', // uxui maps to frontend context domain
51
+ general: 'general',
52
+ }
53
+ return mapping[domain] || 'general'
54
+ }
55
+
38
56
  /**
39
57
  * SmartContext - Intelligent context filtering.
40
58
  */
41
59
  class SmartContext {
42
60
  /**
43
61
  * Detect the domain of a task from its description.
62
+ *
63
+ * Synchronous version using the improved heuristic (word-boundary matching).
64
+ * For full LLM-based classification, use classifyDomain().
44
65
  */
45
66
  detectDomain(taskDescription: string): DomainAnalysis {
46
- const lower = taskDescription.toLowerCase()
47
-
48
- // Frontend indicators
49
- const frontendKeywords = [
50
- 'ui',
51
- 'component',
52
- 'react',
53
- 'vue',
54
- 'angular',
55
- 'css',
56
- 'style',
57
- 'button',
58
- 'form',
59
- 'modal',
60
- 'layout',
61
- 'responsive',
62
- 'animation',
63
- 'dom',
64
- 'html',
65
- 'frontend',
66
- 'fe',
67
- 'client',
68
- 'browser',
69
- 'jsx',
70
- 'tsx',
71
- ]
72
-
73
- // Backend indicators
74
- const backendKeywords = [
75
- 'api',
76
- 'server',
77
- 'database',
78
- 'db',
79
- 'endpoint',
80
- 'route',
81
- 'handler',
82
- 'controller',
83
- 'service',
84
- 'repository',
85
- 'model',
86
- 'query',
87
- 'backend',
88
- 'be',
89
- 'rest',
90
- 'graphql',
91
- 'prisma',
92
- 'sql',
93
- 'redis',
94
- 'auth',
95
- ]
96
-
97
- // DevOps indicators
98
- const devopsKeywords = [
99
- 'deploy',
100
- 'docker',
101
- 'kubernetes',
102
- 'k8s',
103
- 'ci',
104
- 'cd',
105
- 'pipeline',
106
- 'terraform',
107
- 'ansible',
108
- 'aws',
109
- 'gcp',
110
- 'azure',
111
- 'config',
112
- 'nginx',
113
- 'devops',
114
- 'infrastructure',
115
- 'monitoring',
116
- 'logging',
117
- 'build',
118
- ]
119
-
120
- // Docs indicators
121
- const docsKeywords = [
122
- 'document',
123
- 'docs',
124
- 'readme',
125
- 'changelog',
126
- 'comment',
127
- 'jsdoc',
128
- 'tutorial',
129
- 'guide',
130
- 'explain',
131
- 'describe',
132
- 'markdown',
133
- ]
134
-
135
- // Testing indicators
136
- const testingKeywords = [
137
- 'test',
138
- 'spec',
139
- // JS/TS
140
- 'bun',
141
- 'bun test',
142
- 'jest',
143
- 'mocha',
144
- 'cypress',
145
- 'playwright',
146
- // Python
147
- 'pytest',
148
- 'unittest',
149
- // Go
150
- 'go test',
151
- // Rust
152
- 'cargo test',
153
- // .NET
154
- 'dotnet test',
155
- // Java
156
- 'mvn test',
157
- 'gradle test',
158
- 'gradlew test',
159
- 'e2e',
160
- 'unit',
161
- 'integration',
162
- 'coverage',
163
- 'mock',
164
- 'fixture',
165
- ]
166
-
167
- // Count matches
168
- const scores: Record<ContextDomain, number> = {
169
- frontend: frontendKeywords.filter((k) => lower.includes(k)).length,
170
- backend: backendKeywords.filter((k) => lower.includes(k)).length,
171
- devops: devopsKeywords.filter((k) => lower.includes(k)).length,
172
- docs: docsKeywords.filter((k) => lower.includes(k)).length,
173
- testing: testingKeywords.filter((k) => lower.includes(k)).length,
174
- general: 0,
67
+ // Default context when no project info is available
68
+ const defaultContext: ProjectContext = {
69
+ domains: {
70
+ hasFrontend: true,
71
+ hasBackend: true,
72
+ hasDatabase: true,
73
+ hasTesting: true,
74
+ hasDocker: true,
75
+ },
76
+ agents: [],
175
77
  }
176
78
 
177
- // Find primary and secondary domains
178
- const sorted = Object.entries(scores)
179
- .filter(([_, score]) => score > 0)
180
- .sort((a, b) => b[1] - a[1])
79
+ const result = classifyWithHeuristic(taskDescription, defaultContext)
181
80
 
182
- if (sorted.length === 0) {
183
- return { primary: 'general', secondary: [], confidence: 0.5 }
81
+ return {
82
+ primary: toContextDomain(result.primaryDomain),
83
+ secondary: result.secondaryDomains.map(toContextDomain),
84
+ confidence: result.confidence,
184
85
  }
86
+ }
185
87
 
186
- const primary = sorted[0][0] as ContextDomain
187
- const primaryScore = sorted[0][1]
188
- const secondary = sorted.slice(1, 3).map(([domain]) => domain as ContextDomain)
189
-
190
- // Calculate confidence based on score gap
191
- const totalScore = sorted.reduce((sum, [_, score]) => sum + score, 0)
192
- const confidence = totalScore > 0 ? Math.min(0.95, primaryScore / totalScore + 0.3) : 0.5
88
+ /**
89
+ * Classify domain using the full fallback chain (cache → history → LLM → heuristic).
90
+ * Async version that leverages project context and LLM classification.
91
+ */
92
+ async classifyDomain(
93
+ taskDescription: string,
94
+ projectId: string,
95
+ globalPath: string,
96
+ context: ProjectContext
97
+ ): Promise<DomainAnalysis & { source: string }> {
98
+ const { classification, source } = await domainClassifier.classify(
99
+ taskDescription,
100
+ projectId,
101
+ globalPath,
102
+ context
103
+ )
193
104
 
194
- return { primary, secondary, confidence }
105
+ return {
106
+ primary: toContextDomain(classification.primaryDomain),
107
+ secondary: classification.secondaryDomains.map(toContextDomain),
108
+ confidence: classification.confidence,
109
+ source,
110
+ }
195
111
  }
196
112
 
197
113
  /**