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.
- package/CHANGELOG.md +205 -1
- package/bin/prjct.ts +14 -0
- package/core/__tests__/agentic/command-context.test.ts +281 -0
- package/core/__tests__/agentic/domain-classifier.test.ts +330 -0
- package/core/__tests__/agentic/response-validator.test.ts +263 -0
- package/core/__tests__/agentic/smart-context.test.ts +3 -3
- package/core/__tests__/domain/fibonacci.test.ts +113 -0
- package/core/__tests__/infrastructure/performance-tracker.test.ts +328 -0
- package/core/__tests__/schemas/model.test.ts +272 -0
- package/core/agentic/command-classifier.ts +141 -0
- package/core/agentic/command-context.ts +168 -0
- package/core/agentic/domain-classifier.ts +525 -0
- package/core/agentic/index.ts +1 -0
- package/core/agentic/orchestrator-executor.ts +43 -199
- package/core/agentic/prompt-builder.ts +50 -55
- package/core/agentic/response-validator.ts +98 -0
- package/core/agentic/smart-context.ts +60 -144
- package/core/commands/command-data.ts +17 -0
- package/core/commands/commands.ts +9 -0
- package/core/commands/performance.ts +114 -0
- package/core/commands/register.ts +6 -0
- package/core/commands/workflow.ts +87 -4
- package/core/config/command-context.config.json +66 -0
- package/core/domain/fibonacci.ts +128 -0
- package/core/index.ts +25 -1
- package/core/infrastructure/ai-provider.ts +35 -0
- package/core/infrastructure/performance-tracker.ts +326 -0
- package/core/schemas/analysis.ts +4 -0
- package/core/schemas/classification.ts +91 -0
- package/core/schemas/command-context.ts +29 -0
- package/core/schemas/index.ts +6 -0
- package/core/schemas/llm-output.ts +170 -0
- package/core/schemas/model.ts +153 -0
- package/core/schemas/performance.ts +128 -0
- package/core/schemas/state.ts +9 -0
- package/core/storage/state-storage.ts +21 -0
- package/core/types/config.ts +2 -0
- package/core/types/provider.ts +12 -0
- package/dist/bin/prjct.mjs +3184 -1945
- package/dist/core/infrastructure/command-installer.js +78 -7
- package/dist/core/infrastructure/setup.js +78 -7
- 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
|
+
}
|