prjct-cli 1.8.0 → 1.10.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 (32) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/core/__tests__/agentic/domain-classifier.test.ts +330 -0
  3. package/core/__tests__/agentic/prompt-assembly.test.ts +298 -0
  4. package/core/__tests__/agentic/prompt-builder.test.ts +2 -2
  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__/agentic/token-budget.test.ts +294 -0
  8. package/core/__tests__/schemas/model.test.ts +272 -0
  9. package/core/agentic/anti-hallucination.ts +124 -0
  10. package/core/agentic/domain-classifier.ts +525 -0
  11. package/core/agentic/environment-block.ts +102 -0
  12. package/core/agentic/index.ts +1 -0
  13. package/core/agentic/injection-validator.ts +16 -0
  14. package/core/agentic/orchestrator-executor.ts +43 -199
  15. package/core/agentic/prompt-builder.ts +352 -158
  16. package/core/agentic/response-validator.ts +98 -0
  17. package/core/agentic/smart-context.ts +60 -144
  18. package/core/agentic/token-budget.ts +226 -0
  19. package/core/infrastructure/ai-provider.ts +35 -0
  20. package/core/schemas/analysis.ts +4 -0
  21. package/core/schemas/classification.ts +91 -0
  22. package/core/schemas/index.ts +6 -0
  23. package/core/schemas/llm-output.ts +170 -0
  24. package/core/schemas/model.ts +153 -0
  25. package/core/schemas/state.ts +3 -0
  26. package/core/services/context-selector.ts +8 -2
  27. package/core/types/config.ts +2 -0
  28. package/core/types/provider.ts +12 -0
  29. package/dist/bin/prjct.mjs +2146 -1347
  30. package/dist/core/infrastructure/command-installer.js +78 -7
  31. package/dist/core/infrastructure/setup.js +78 -7
  32. package/package.json +1 -1
@@ -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
  /**
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Token Budget Coordinator
3
+ *
4
+ * Centrally manages the global token budget across all context-loading components.
5
+ * Ensures the combined prompt stays within the model's context window
6
+ * and reserves space for the output.
7
+ *
8
+ * Budget formula: inputBudget = modelContextWindow * 0.65
9
+ * Priority: state (P1) > injection context (P2) > file content (P3)
10
+ *
11
+ * @see PRJ-266
12
+ */
13
+
14
+ // =============================================================================
15
+ // Model Context Windows
16
+ // =============================================================================
17
+
18
+ /** Context window sizes by model identifier (in tokens) */
19
+ export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
20
+ // Claude models (short names from model.ts)
21
+ opus: 200_000,
22
+ sonnet: 200_000,
23
+ haiku: 200_000,
24
+ // Gemini models
25
+ '2.5-pro': 1_000_000,
26
+ '2.5-flash': 1_000_000,
27
+ '2.0-flash': 1_000_000,
28
+ // Full model IDs (for direct API usage)
29
+ 'claude-opus-4.5': 200_000,
30
+ 'claude-sonnet-4.5': 200_000,
31
+ 'claude-haiku-4.5': 200_000,
32
+ 'claude-opus-4-6': 200_000,
33
+ // Default fallback
34
+ default: 200_000,
35
+ }
36
+
37
+ /** Ratio of context window reserved for input (rest for output) */
38
+ export const INPUT_RATIO = 0.65
39
+
40
+ // =============================================================================
41
+ // Budget Categories
42
+ // =============================================================================
43
+
44
+ /** Budget category identifiers ordered by priority */
45
+ export type BudgetCategory = 'state' | 'injection' | 'files'
46
+
47
+ /** Budget allocation result for each category */
48
+ export interface BudgetAllocation {
49
+ state: number
50
+ injection: number
51
+ files: number
52
+ inputBudget: number
53
+ outputReserve: number
54
+ contextWindow: number
55
+ }
56
+
57
+ /** Usage tracking per category */
58
+ export interface BudgetUsage {
59
+ category: BudgetCategory
60
+ allocated: number
61
+ used: number
62
+ remaining: number
63
+ }
64
+
65
+ // =============================================================================
66
+ // Default Allocation Ratios (within input budget)
67
+ // =============================================================================
68
+
69
+ interface AllocationConfig {
70
+ ratio: number
71
+ minimum: number
72
+ }
73
+
74
+ const ALLOCATION_CONFIG: Record<BudgetCategory, AllocationConfig> = {
75
+ /** P1: State — current task, queue, patterns (highest priority) */
76
+ state: { ratio: 0.02, minimum: 1_500 },
77
+ /** P2: Injection — agents, skills, modules, checklists */
78
+ injection: { ratio: 0.08, minimum: 8_000 },
79
+ /** P3: Files — codebase file content (lowest priority, gets remainder) */
80
+ files: { ratio: 0.9, minimum: 20_000 },
81
+ }
82
+
83
+ /** Priority order for budget distribution */
84
+ const PRIORITY_ORDER: BudgetCategory[] = ['state', 'injection', 'files']
85
+
86
+ // =============================================================================
87
+ // TokenBudgetCoordinator
88
+ // =============================================================================
89
+
90
+ export class TokenBudgetCoordinator {
91
+ private readonly _contextWindow: number
92
+ private readonly _inputBudget: number
93
+ private readonly _outputReserve: number
94
+ private readonly _allocations: Map<BudgetCategory, number> = new Map()
95
+ private readonly _used: Map<BudgetCategory, number> = new Map()
96
+
97
+ constructor(model?: string) {
98
+ this._contextWindow = getContextWindow(model)
99
+ this._inputBudget = Math.floor(this._contextWindow * INPUT_RATIO)
100
+ this._outputReserve = this._contextWindow - this._inputBudget
101
+ this.distributeBudget()
102
+ }
103
+
104
+ /** Distribute input budget across categories by priority */
105
+ private distributeBudget(): void {
106
+ let remaining = this._inputBudget
107
+
108
+ for (const category of PRIORITY_ORDER) {
109
+ const config = ALLOCATION_CONFIG[category]
110
+
111
+ if (category === 'files') {
112
+ // Lowest priority gets whatever remains
113
+ this._allocations.set(category, Math.max(remaining, 0))
114
+ } else {
115
+ const allocation = Math.max(config.minimum, Math.floor(this._inputBudget * config.ratio))
116
+ const granted = Math.min(allocation, remaining)
117
+ this._allocations.set(category, granted)
118
+ remaining -= granted
119
+ }
120
+
121
+ this._used.set(category, 0)
122
+ }
123
+ }
124
+
125
+ /** Get the full budget allocation */
126
+ getAllocation(): BudgetAllocation {
127
+ return {
128
+ state: this._allocations.get('state') ?? 0,
129
+ injection: this._allocations.get('injection') ?? 0,
130
+ files: this._allocations.get('files') ?? 0,
131
+ inputBudget: this._inputBudget,
132
+ outputReserve: this._outputReserve,
133
+ contextWindow: this._contextWindow,
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Request tokens from a category.
139
+ * Returns actual tokens granted (may be less than requested if budget is exhausted).
140
+ */
141
+ request(category: BudgetCategory, requestedTokens: number): number {
142
+ const allocated = this._allocations.get(category) ?? 0
143
+ const currentUsed = this._used.get(category) ?? 0
144
+ const available = Math.max(0, allocated - currentUsed)
145
+ const granted = Math.min(requestedTokens, available)
146
+ this._used.set(category, currentUsed + granted)
147
+ return granted
148
+ }
149
+
150
+ /** Record token usage for a category */
151
+ record(category: BudgetCategory, tokensUsed: number): void {
152
+ const current = this._used.get(category) ?? 0
153
+ this._used.set(category, current + tokensUsed)
154
+ }
155
+
156
+ /** Get usage details for a category */
157
+ getUsage(category: BudgetCategory): BudgetUsage {
158
+ const allocated = this._allocations.get(category) ?? 0
159
+ const used = this._used.get(category) ?? 0
160
+ return {
161
+ category,
162
+ allocated,
163
+ used,
164
+ remaining: Math.max(0, allocated - used),
165
+ }
166
+ }
167
+
168
+ /** Get allocation for a specific category */
169
+ getAllocationFor(category: BudgetCategory): number {
170
+ return this._allocations.get(category) ?? 0
171
+ }
172
+
173
+ /** Get total remaining input budget across all categories */
174
+ get totalRemaining(): number {
175
+ let totalUsed = 0
176
+ for (const v of this._used.values()) {
177
+ totalUsed += v
178
+ }
179
+ return Math.max(0, this._inputBudget - totalUsed)
180
+ }
181
+
182
+ /** Check if total usage exceeds input budget */
183
+ get isOverBudget(): boolean {
184
+ let totalUsed = 0
185
+ for (const v of this._used.values()) {
186
+ totalUsed += v
187
+ }
188
+ return totalUsed > this._inputBudget
189
+ }
190
+
191
+ /** Context window size */
192
+ get contextWindow(): number {
193
+ return this._contextWindow
194
+ }
195
+
196
+ /** Total input budget */
197
+ get inputBudget(): number {
198
+ return this._inputBudget
199
+ }
200
+
201
+ /** Output token reserve */
202
+ get outputReserve(): number {
203
+ return this._outputReserve
204
+ }
205
+ }
206
+
207
+ // =============================================================================
208
+ // Helpers
209
+ // =============================================================================
210
+
211
+ /** Get context window size for a model identifier */
212
+ export function getContextWindow(model?: string): number {
213
+ if (!model) return MODEL_CONTEXT_WINDOWS.default
214
+ return MODEL_CONTEXT_WINDOWS[model] ?? MODEL_CONTEXT_WINDOWS.default
215
+ }
216
+
217
+ /** Calculate input budget for a model */
218
+ export function calculateInputBudget(model?: string): number {
219
+ return Math.floor(getContextWindow(model) * INPUT_RATIO)
220
+ }
221
+
222
+ /** Calculate output reserve for a model */
223
+ export function calculateOutputReserve(model?: string): number {
224
+ const contextWindow = getContextWindow(model)
225
+ return contextWindow - Math.floor(contextWindow * INPUT_RATIO)
226
+ }
@@ -21,6 +21,7 @@ import { exec } from 'node:child_process'
21
21
  import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import { promisify } from 'node:util'
24
+ import { compareSemver } from '../schemas/model'
24
25
  import { fileExists } from '../utils/fs-helpers'
25
26
  import { readProviderCache, writeProviderCache } from '../utils/provider-cache'
26
27
 
@@ -58,6 +59,9 @@ export const ClaudeProvider: AIProviderConfig = {
58
59
  ignoreFile: '.claudeignore',
59
60
  websiteUrl: 'https://www.anthropic.com/claude',
60
61
  docsUrl: 'https://docs.anthropic.com/claude-code',
62
+ defaultModel: 'sonnet',
63
+ supportedModels: ['opus', 'sonnet', 'haiku'],
64
+ minCliVersion: '1.0.0',
61
65
  }
62
66
 
63
67
  /**
@@ -77,6 +81,9 @@ export const GeminiProvider: AIProviderConfig = {
77
81
  ignoreFile: '.geminiignore',
78
82
  websiteUrl: 'https://geminicli.com',
79
83
  docsUrl: 'https://geminicli.com/docs',
84
+ defaultModel: '2.5-flash',
85
+ supportedModels: ['2.5-pro', '2.5-flash', '2.0-flash'],
86
+ minCliVersion: '1.0.0',
80
87
  }
81
88
 
82
89
  /**
@@ -100,6 +107,9 @@ export const AntigravityProvider: AIProviderConfig = {
100
107
  ignoreFile: '.agentignore', // Assumed
101
108
  websiteUrl: 'https://gemini.google.com/app/antigravity',
102
109
  docsUrl: 'https://gemini.google.com/app/antigravity',
110
+ defaultModel: null, // Platform-managed
111
+ supportedModels: [],
112
+ minCliVersion: null,
103
113
  }
104
114
 
105
115
  /**
@@ -129,6 +139,9 @@ export const CursorProvider: AIProviderConfig = {
129
139
  isProjectLevel: true, // Config is project-level only
130
140
  websiteUrl: 'https://cursor.com',
131
141
  docsUrl: 'https://cursor.com/docs',
142
+ defaultModel: null, // Multi-model IDE, user selects
143
+ supportedModels: [],
144
+ minCliVersion: null,
132
145
  }
133
146
 
134
147
  /**
@@ -159,6 +172,9 @@ export const WindsurfProvider: AIProviderConfig = {
159
172
  isProjectLevel: true, // Config is project-level only
160
173
  websiteUrl: 'https://windsurf.com',
161
174
  docsUrl: 'https://docs.windsurf.com',
175
+ defaultModel: null, // Multi-model IDE, user selects
176
+ supportedModels: [],
177
+ minCliVersion: null,
162
178
  }
163
179
 
164
180
  /**
@@ -221,14 +237,33 @@ export async function detectProvider(provider: AIProviderName): Promise<Provider
221
237
  }
222
238
 
223
239
  const version = await getCliVersion(config.cliCommand)
240
+ const versionWarning = validateCliVersion(provider, version || undefined)
224
241
 
225
242
  return {
226
243
  installed: true,
227
244
  version: version || undefined,
228
245
  path: cliPath,
246
+ versionWarning: versionWarning || undefined,
229
247
  }
230
248
  }
231
249
 
250
+ /**
251
+ * Validate that a detected CLI version meets the provider's minimum requirement.
252
+ * Returns a warning message if the version is below minimum, or null if OK.
253
+ */
254
+ export function validateCliVersion(
255
+ provider: AIProviderName,
256
+ version: string | undefined
257
+ ): string | null {
258
+ const config = Providers[provider]
259
+ if (!config.minCliVersion || !version) return null
260
+
261
+ if (compareSemver(version, config.minCliVersion) < 0) {
262
+ return `⚠️ ${config.displayName} v${version} is below minimum v${config.minCliVersion}. Some features may not work correctly.`
263
+ }
264
+ return null
265
+ }
266
+
232
267
  /**
233
268
  * Detect all available CLI-based providers
234
269
  * Results are cached to disk with a 10-minute TTL to avoid redundant shell spawns.
@@ -4,6 +4,8 @@
4
4
  * Defines the structure for analysis.json - repository analysis.
5
5
  */
6
6
 
7
+ import type { ModelMetadata } from './model'
8
+
7
9
  export interface CodePattern {
8
10
  name: string
9
11
  description: string
@@ -28,6 +30,8 @@ export interface AnalysisSchema {
28
30
  patterns: CodePattern[]
29
31
  antiPatterns: AntiPattern[]
30
32
  analyzedAt: string // ISO8601
33
+ /** Which AI model was used for this analysis (PRJ-265) */
34
+ modelMetadata?: ModelMetadata
31
35
  }
32
36
 
33
37
  export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {