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,272 @@
1
+ /**
2
+ * Model Schema Tests
3
+ *
4
+ * Tests for:
5
+ * - Provider model validation
6
+ * - Semver comparison
7
+ * - Minimum CLI version enforcement
8
+ * - Model mismatch detection
9
+ * - Default model resolution
10
+ * - Backwards compatibility (missing model field)
11
+ *
12
+ * @see PRJ-265
13
+ */
14
+
15
+ import { describe, expect, it } from 'bun:test'
16
+ import {
17
+ ClaudeProvider,
18
+ CursorProvider,
19
+ GeminiProvider,
20
+ validateCliVersion,
21
+ } from '../../infrastructure/ai-provider'
22
+ import {
23
+ checkModelMismatch,
24
+ compareSemver,
25
+ getDefaultModel,
26
+ getSupportedModels,
27
+ isValidModelForProvider,
28
+ type ModelMetadata,
29
+ meetsMinVersion,
30
+ } from '../../schemas/model'
31
+
32
+ // =============================================================================
33
+ // Provider Model Configuration
34
+ // =============================================================================
35
+
36
+ describe('Provider model configuration', () => {
37
+ it('should have model fields on Claude provider', () => {
38
+ expect(ClaudeProvider.defaultModel).toBe('sonnet')
39
+ expect(ClaudeProvider.supportedModels).toEqual(['opus', 'sonnet', 'haiku'])
40
+ expect(ClaudeProvider.minCliVersion).toBe('1.0.0')
41
+ })
42
+
43
+ it('should have model fields on Gemini provider', () => {
44
+ expect(GeminiProvider.defaultModel).toBe('2.5-flash')
45
+ expect(GeminiProvider.supportedModels).toEqual(['2.5-pro', '2.5-flash', '2.0-flash'])
46
+ expect(GeminiProvider.minCliVersion).toBe('1.0.0')
47
+ })
48
+
49
+ it('should have null model fields on multi-model IDEs', () => {
50
+ expect(CursorProvider.defaultModel).toBeNull()
51
+ expect(CursorProvider.supportedModels).toEqual([])
52
+ expect(CursorProvider.minCliVersion).toBeNull()
53
+ })
54
+ })
55
+
56
+ // =============================================================================
57
+ // Model Validation
58
+ // =============================================================================
59
+
60
+ describe('isValidModelForProvider', () => {
61
+ it('should accept valid Claude models', () => {
62
+ expect(isValidModelForProvider('claude', 'opus')).toBe(true)
63
+ expect(isValidModelForProvider('claude', 'sonnet')).toBe(true)
64
+ expect(isValidModelForProvider('claude', 'haiku')).toBe(true)
65
+ })
66
+
67
+ it('should reject invalid Claude models', () => {
68
+ expect(isValidModelForProvider('claude', 'gpt-4')).toBe(false)
69
+ expect(isValidModelForProvider('claude', 'unknown')).toBe(false)
70
+ })
71
+
72
+ it('should accept valid Gemini models', () => {
73
+ expect(isValidModelForProvider('gemini', '2.5-pro')).toBe(true)
74
+ expect(isValidModelForProvider('gemini', '2.5-flash')).toBe(true)
75
+ expect(isValidModelForProvider('gemini', '2.0-flash')).toBe(true)
76
+ })
77
+
78
+ it('should reject invalid Gemini models', () => {
79
+ expect(isValidModelForProvider('gemini', 'sonnet')).toBe(false)
80
+ })
81
+
82
+ it('should accept any model for multi-model IDEs (empty supportedModels)', () => {
83
+ expect(isValidModelForProvider('cursor', 'gpt-4')).toBe(true)
84
+ expect(isValidModelForProvider('cursor', 'anything')).toBe(true)
85
+ expect(isValidModelForProvider('windsurf', 'claude-sonnet')).toBe(true)
86
+ })
87
+
88
+ it('should accept any model for unknown providers', () => {
89
+ expect(isValidModelForProvider('future-provider', 'some-model')).toBe(true)
90
+ })
91
+ })
92
+
93
+ // =============================================================================
94
+ // Default Model Resolution
95
+ // =============================================================================
96
+
97
+ describe('getDefaultModel', () => {
98
+ it('should return sonnet for Claude', () => {
99
+ expect(getDefaultModel('claude')).toBe('sonnet')
100
+ })
101
+
102
+ it('should return 2.5-flash for Gemini', () => {
103
+ expect(getDefaultModel('gemini')).toBe('2.5-flash')
104
+ })
105
+
106
+ it('should return null for providers without defaults', () => {
107
+ expect(getDefaultModel('cursor')).toBeNull()
108
+ expect(getDefaultModel('unknown')).toBeNull()
109
+ })
110
+ })
111
+
112
+ // =============================================================================
113
+ // Supported Models
114
+ // =============================================================================
115
+
116
+ describe('getSupportedModels', () => {
117
+ it('should return Claude models', () => {
118
+ expect(getSupportedModels('claude')).toEqual(['opus', 'sonnet', 'haiku'])
119
+ })
120
+
121
+ it('should return empty for multi-model IDEs', () => {
122
+ expect(getSupportedModels('cursor')).toEqual([])
123
+ })
124
+
125
+ it('should return empty for unknown providers', () => {
126
+ expect(getSupportedModels('unknown')).toEqual([])
127
+ })
128
+ })
129
+
130
+ // =============================================================================
131
+ // Semver Comparison
132
+ // =============================================================================
133
+
134
+ describe('compareSemver', () => {
135
+ it('should return 0 for equal versions', () => {
136
+ expect(compareSemver('1.0.0', '1.0.0')).toBe(0)
137
+ expect(compareSemver('2.3.4', '2.3.4')).toBe(0)
138
+ })
139
+
140
+ it('should return -1 when a < b', () => {
141
+ expect(compareSemver('1.0.0', '2.0.0')).toBe(-1)
142
+ expect(compareSemver('1.0.0', '1.1.0')).toBe(-1)
143
+ expect(compareSemver('1.0.0', '1.0.1')).toBe(-1)
144
+ })
145
+
146
+ it('should return 1 when a > b', () => {
147
+ expect(compareSemver('2.0.0', '1.0.0')).toBe(1)
148
+ expect(compareSemver('1.1.0', '1.0.0')).toBe(1)
149
+ expect(compareSemver('1.0.1', '1.0.0')).toBe(1)
150
+ })
151
+
152
+ it('should handle missing patch versions', () => {
153
+ expect(compareSemver('1.0', '1.0.0')).toBe(0)
154
+ })
155
+ })
156
+
157
+ // =============================================================================
158
+ // Version Validation
159
+ // =============================================================================
160
+
161
+ describe('meetsMinVersion', () => {
162
+ it('should pass when version meets minimum', () => {
163
+ expect(meetsMinVersion('claude', '1.0.0')).toBe(true)
164
+ expect(meetsMinVersion('claude', '2.5.0')).toBe(true)
165
+ })
166
+
167
+ it('should fail when version is below minimum', () => {
168
+ expect(meetsMinVersion('claude', '0.9.0')).toBe(false)
169
+ expect(meetsMinVersion('claude', '0.0.1')).toBe(false)
170
+ })
171
+
172
+ it('should pass for providers without minimum', () => {
173
+ expect(meetsMinVersion('cursor', '0.1.0')).toBe(true)
174
+ expect(meetsMinVersion('unknown', '0.0.0')).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe('validateCliVersion', () => {
179
+ it('should return null for valid versions', () => {
180
+ expect(validateCliVersion('claude', '1.0.0')).toBeNull()
181
+ expect(validateCliVersion('claude', '2.0.0')).toBeNull()
182
+ })
183
+
184
+ it('should return warning for versions below minimum', () => {
185
+ const warning = validateCliVersion('claude', '0.5.0')
186
+ expect(warning).toContain('below minimum')
187
+ expect(warning).toContain('Claude Code')
188
+ })
189
+
190
+ it('should return null for undefined version', () => {
191
+ expect(validateCliVersion('claude', undefined)).toBeNull()
192
+ })
193
+
194
+ it('should return null for providers without minCliVersion', () => {
195
+ expect(validateCliVersion('cursor', '0.1.0')).toBeNull()
196
+ })
197
+ })
198
+
199
+ // =============================================================================
200
+ // Model Mismatch Detection
201
+ // =============================================================================
202
+
203
+ describe('checkModelMismatch', () => {
204
+ const claudeOpus: ModelMetadata = {
205
+ provider: 'claude',
206
+ model: 'opus',
207
+ cliVersion: '1.5.0',
208
+ recordedAt: '2026-02-07T00:00:00.000Z',
209
+ }
210
+
211
+ const claudeSonnet: ModelMetadata = {
212
+ provider: 'claude',
213
+ model: 'sonnet',
214
+ cliVersion: '1.5.0',
215
+ recordedAt: '2026-02-07T00:00:00.000Z',
216
+ }
217
+
218
+ const geminiPro: ModelMetadata = {
219
+ provider: 'gemini',
220
+ model: '2.5-pro',
221
+ cliVersion: '1.0.0',
222
+ recordedAt: '2026-02-07T00:00:00.000Z',
223
+ }
224
+
225
+ it('should return null when models match', () => {
226
+ expect(checkModelMismatch(claudeOpus, claudeOpus)).toBeNull()
227
+ })
228
+
229
+ it('should warn when models differ within same provider', () => {
230
+ const warning = checkModelMismatch(claudeOpus, claudeSonnet)
231
+ expect(warning).toContain('mismatch')
232
+ expect(warning).toContain('opus')
233
+ expect(warning).toContain('sonnet')
234
+ })
235
+
236
+ it('should warn when providers differ', () => {
237
+ const warning = checkModelMismatch(claudeOpus, geminiPro)
238
+ expect(warning).toContain('mismatch')
239
+ expect(warning).toContain('claude')
240
+ expect(warning).toContain('gemini')
241
+ })
242
+
243
+ it('should return null when either metadata is undefined', () => {
244
+ expect(checkModelMismatch(undefined, claudeOpus)).toBeNull()
245
+ expect(checkModelMismatch(claudeOpus, undefined)).toBeNull()
246
+ expect(checkModelMismatch(undefined, undefined)).toBeNull()
247
+ })
248
+ })
249
+
250
+ // =============================================================================
251
+ // Backwards Compatibility
252
+ // =============================================================================
253
+
254
+ describe('backwards compatibility', () => {
255
+ it('should handle configs without model field gracefully', () => {
256
+ // Simulates loading an old config without model fields
257
+ const oldProviderConfig = {
258
+ name: 'claude' as const,
259
+ displayName: 'Claude Code',
260
+ cliCommand: 'claude',
261
+ }
262
+ // getDefaultModel should work even if provider config has no model field
263
+ expect(getDefaultModel(oldProviderConfig.name)).toBe('sonnet')
264
+ })
265
+
266
+ it('should resolve default model when preferredModel is not set', () => {
267
+ const preferredModel: string | undefined = undefined
268
+ const provider = 'claude'
269
+ const resolved = preferredModel ?? getDefaultModel(provider)
270
+ expect(resolved).toBe('sonnet')
271
+ })
272
+ })
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Command Classifier
3
+ *
4
+ * Classifies unknown commands by analyzing template metadata.
5
+ * When a command isn't in command-context.config.json, this module
6
+ * determines what context sections it needs based on template content.
7
+ *
8
+ * Classification chain:
9
+ * 1. Config lookup (instant) — handled by command-context.ts
10
+ * 2. Template heuristic (instant) — this module
11
+ * 3. Wildcard fallback — handled by command-context.ts
12
+ *
13
+ * @see PRJ-298
14
+ */
15
+
16
+ import type { CommandContextEntry } from '../schemas/command-context'
17
+ import type { Template } from '../types'
18
+
19
+ // Keywords that indicate code-modifying commands
20
+ const CODE_KEYWORDS = [
21
+ 'build',
22
+ 'create',
23
+ 'add',
24
+ 'implement',
25
+ 'fix',
26
+ 'refactor',
27
+ 'update',
28
+ 'modify',
29
+ 'change',
30
+ 'write',
31
+ 'generate',
32
+ 'scaffold',
33
+ 'migrate',
34
+ 'optimize',
35
+ 'improve',
36
+ 'enhance',
37
+ 'redesign',
38
+ 'rewrite',
39
+ ]
40
+
41
+ // Keywords that indicate read-only / info commands
42
+ const INFO_KEYWORDS = [
43
+ 'list',
44
+ 'show',
45
+ 'get',
46
+ 'status',
47
+ 'info',
48
+ 'check',
49
+ 'view',
50
+ 'display',
51
+ 'describe',
52
+ 'explain',
53
+ 'analyze',
54
+ 'report',
55
+ 'dashboard',
56
+ ]
57
+
58
+ // Keywords that indicate quality/verification commands
59
+ const QUALITY_KEYWORDS = [
60
+ 'test',
61
+ 'verify',
62
+ 'validate',
63
+ 'review',
64
+ 'audit',
65
+ 'check',
66
+ 'lint',
67
+ 'ship',
68
+ 'deploy',
69
+ 'release',
70
+ 'complete',
71
+ 'done',
72
+ 'finish',
73
+ ]
74
+
75
+ // Tools that indicate code modification
76
+ const CODE_TOOLS = ['Write', 'Edit', 'Bash']
77
+
78
+ /**
79
+ * Count keyword matches using word boundaries to avoid substring false positives.
80
+ */
81
+ function countMatches(text: string, keywords: string[]): number {
82
+ return keywords.filter((k) => new RegExp(`\\b${k}\\b`).test(text)).length
83
+ }
84
+
85
+ /**
86
+ * Classify a command based on its template metadata.
87
+ * Analyzes the command name, description, allowed tools, and content
88
+ * to determine what context sections are relevant.
89
+ *
90
+ * Priority: code-modifying > quality/verification > info/read-only > default
91
+ */
92
+ export function classifyCommand(commandName: string, template: Template): CommandContextEntry {
93
+ const description = (template.frontmatter?.description || '').toLowerCase()
94
+ const content = template.content.toLowerCase()
95
+ const allowedTools = template.frontmatter?.['allowed-tools'] || []
96
+ const combined = `${commandName} ${description} ${content}`
97
+
98
+ const codeScore = countMatches(combined, CODE_KEYWORDS)
99
+ const infoScore = countMatches(combined, INFO_KEYWORDS)
100
+ const qualityScore = countMatches(combined, QUALITY_KEYWORDS)
101
+ const hasCodeTools = allowedTools.some((t: string) => CODE_TOOLS.includes(t))
102
+
103
+ // Code-modifying command: needs agents + patterns + checklists
104
+ if (hasCodeTools && codeScore > 0) {
105
+ return {
106
+ agents: true,
107
+ patterns: true,
108
+ checklist: qualityScore > 0,
109
+ modules: [],
110
+ }
111
+ }
112
+
113
+ // Quality/verification command: needs patterns + checklists
114
+ // Quality takes priority over info when quality score is higher
115
+ if (qualityScore > 0 && qualityScore >= infoScore) {
116
+ return {
117
+ agents: false,
118
+ patterns: true,
119
+ checklist: true,
120
+ modules: [],
121
+ }
122
+ }
123
+
124
+ // Info/read-only command: needs nothing
125
+ if (infoScore > 0 && codeScore === 0) {
126
+ return {
127
+ agents: false,
128
+ patterns: false,
129
+ checklist: false,
130
+ modules: [],
131
+ }
132
+ }
133
+
134
+ // Default for unknown: agents + patterns (sensible default)
135
+ return {
136
+ agents: true,
137
+ patterns: true,
138
+ checklist: false,
139
+ modules: [],
140
+ }
141
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Command Context Resolver
3
+ *
4
+ * Replaces 4 hardcoded command lists in prompt-builder with config-driven lookups.
5
+ * Falls back to LLM classification for unknown commands (Phase 2).
6
+ * Auto-learns after repeated classifications (Phase 3).
7
+ *
8
+ * @see PRJ-298
9
+ */
10
+
11
+ import fs from 'node:fs/promises'
12
+ import path from 'node:path'
13
+ import {
14
+ type CommandContextConfig,
15
+ CommandContextConfigSchema,
16
+ type CommandContextEntry,
17
+ } from '../schemas/command-context'
18
+ import type { Template } from '../types'
19
+ import { PACKAGE_ROOT } from '../utils/version'
20
+ import { classifyCommand } from './command-classifier'
21
+
22
+ const CONFIG_PATH = path.join(PACKAGE_ROOT, 'core/config/command-context.config.json')
23
+
24
+ let cachedConfig: CommandContextConfig | null = null
25
+
26
+ /**
27
+ * Load and validate the command context config.
28
+ * Cached after first load for the process lifetime.
29
+ */
30
+ export async function loadCommandContextConfig(): Promise<CommandContextConfig> {
31
+ if (cachedConfig) return cachedConfig
32
+
33
+ const raw = await fs.readFile(CONFIG_PATH, 'utf-8')
34
+ const parsed = JSON.parse(raw)
35
+ cachedConfig = CommandContextConfigSchema.parse(parsed)
36
+ return cachedConfig
37
+ }
38
+
39
+ /**
40
+ * Get context config for a command.
41
+ * Returns the command's config if explicitly defined, otherwise falls back to wildcard '*'.
42
+ */
43
+ export function resolveCommandContext(
44
+ config: CommandContextConfig,
45
+ commandName: string
46
+ ): CommandContextEntry {
47
+ return config.commands[commandName] ?? config.commands['*']
48
+ }
49
+
50
+ // =============================================================================
51
+ // LLM Classification Cache (Phase 2)
52
+ // =============================================================================
53
+
54
+ const classificationCache = new Map<string, CommandContextEntry>()
55
+
56
+ /**
57
+ * Get a cached LLM classification result for a command.
58
+ */
59
+ export function getCachedClassification(commandName: string): CommandContextEntry | undefined {
60
+ return classificationCache.get(commandName)
61
+ }
62
+
63
+ /**
64
+ * Cache an LLM classification result for a command.
65
+ */
66
+ export function cacheClassification(commandName: string, entry: CommandContextEntry): void {
67
+ classificationCache.set(commandName, entry)
68
+ }
69
+
70
+ // =============================================================================
71
+ // Auto-Learn Tracking (Phase 3)
72
+ // =============================================================================
73
+
74
+ const classificationHistory = new Map<string, { entry: CommandContextEntry; count: number }>()
75
+
76
+ const AUTO_LEARN_THRESHOLD = 3
77
+
78
+ /**
79
+ * Track a classification result. Returns true if the threshold is reached
80
+ * and the classification should be persisted to config.
81
+ */
82
+ export function trackClassification(commandName: string, entry: CommandContextEntry): boolean {
83
+ const key = commandName
84
+ const existing = classificationHistory.get(key)
85
+
86
+ if (existing && isSameEntry(existing.entry, entry)) {
87
+ existing.count++
88
+ return existing.count >= AUTO_LEARN_THRESHOLD
89
+ }
90
+
91
+ classificationHistory.set(key, { entry, count: 1 })
92
+ return false
93
+ }
94
+
95
+ /**
96
+ * Persist a learned classification to the config file.
97
+ */
98
+ export async function persistClassification(
99
+ commandName: string,
100
+ entry: CommandContextEntry
101
+ ): Promise<void> {
102
+ const raw = await fs.readFile(CONFIG_PATH, 'utf-8')
103
+ const config = JSON.parse(raw) as CommandContextConfig
104
+
105
+ config.commands[commandName] = entry
106
+ await fs.writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
107
+
108
+ // Invalidate cache so next load picks up the change
109
+ cachedConfig = null
110
+ }
111
+
112
+ function isSameEntry(a: CommandContextEntry, b: CommandContextEntry): boolean {
113
+ return (
114
+ a.agents === b.agents &&
115
+ a.patterns === b.patterns &&
116
+ a.checklist === b.checklist &&
117
+ a.modules.length === b.modules.length &&
118
+ a.modules.every((m, i) => m === b.modules[i])
119
+ )
120
+ }
121
+
122
+ // =============================================================================
123
+ // Convenience: resolve with all fallbacks
124
+ // =============================================================================
125
+
126
+ /**
127
+ * Resolve command context with full fallback chain:
128
+ * 1. Config lookup (instant)
129
+ * 2. Classification cache (instant)
130
+ * 3. Template heuristic classification (instant) — caches result + tracks for auto-learn
131
+ * 4. Wildcard default (instant)
132
+ *
133
+ * Returns { entry, source } where source indicates how it was resolved.
134
+ */
135
+ export function resolveCommandContextFull(
136
+ config: CommandContextConfig,
137
+ commandName: string,
138
+ template?: Template
139
+ ): { entry: CommandContextEntry; source: 'config' | 'classified' | 'cache' | 'wildcard' } {
140
+ // 1. Explicit config match
141
+ if (commandName in config.commands && commandName !== '*') {
142
+ return { entry: config.commands[commandName], source: 'config' }
143
+ }
144
+
145
+ // 2. Classification cache
146
+ const cached = getCachedClassification(commandName)
147
+ if (cached) {
148
+ return { entry: cached, source: 'cache' }
149
+ }
150
+
151
+ // 3. Template heuristic classification
152
+ if (template) {
153
+ const classified = classifyCommand(commandName, template)
154
+ cacheClassification(commandName, classified)
155
+
156
+ // Track for auto-learn (Phase 3)
157
+ const shouldPersist = trackClassification(commandName, classified)
158
+ if (shouldPersist) {
159
+ // Fire-and-forget persist — don't block prompt building
160
+ persistClassification(commandName, classified).catch(() => {})
161
+ }
162
+
163
+ return { entry: classified, source: 'classified' }
164
+ }
165
+
166
+ // 4. Wildcard default
167
+ return { entry: config.commands['*'], source: 'wildcard' }
168
+ }