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,128 @@
1
+ /**
2
+ * Fibonacci Estimation Module
3
+ *
4
+ * Provides Fibonacci-based story point estimation with
5
+ * points-to-time conversion and historical suggestion.
6
+ */
7
+
8
+ import outcomeRecorder from '../outcomes/recorder'
9
+
10
+ // =============================================================================
11
+ // Constants
12
+ // =============================================================================
13
+
14
+ /** Valid Fibonacci story points */
15
+ export const FIBONACCI_POINTS = [1, 2, 3, 5, 8, 13, 21] as const
16
+ export type FibonacciPoint = (typeof FIBONACCI_POINTS)[number]
17
+
18
+ /** Default points-to-minutes mapping */
19
+ const DEFAULT_MINUTES_MAP: Record<FibonacciPoint, { min: number; max: number; typical: number }> = {
20
+ 1: { min: 5, max: 15, typical: 10 },
21
+ 2: { min: 15, max: 30, typical: 20 },
22
+ 3: { min: 30, max: 60, typical: 45 },
23
+ 5: { min: 60, max: 120, typical: 90 },
24
+ 8: { min: 120, max: 240, typical: 180 },
25
+ 13: { min: 240, max: 480, typical: 360 },
26
+ 21: { min: 480, max: 960, typical: 720 },
27
+ }
28
+
29
+ // =============================================================================
30
+ // Validation
31
+ // =============================================================================
32
+
33
+ /** Check if a number is a valid Fibonacci point */
34
+ export const isValidPoint = (n: number): n is FibonacciPoint =>
35
+ FIBONACCI_POINTS.includes(n as FibonacciPoint)
36
+
37
+ // =============================================================================
38
+ // Points-to-Time Conversion
39
+ // =============================================================================
40
+
41
+ /** Get the time range for a given point value */
42
+ export const pointsToMinutes = (
43
+ points: FibonacciPoint
44
+ ): { min: number; max: number; typical: number } => {
45
+ return DEFAULT_MINUTES_MAP[points]
46
+ }
47
+
48
+ /** Format a minute count as a human-readable duration */
49
+ export const formatMinutes = (minutes: number): string => {
50
+ if (minutes < 60) return `${minutes}m`
51
+ const hours = Math.floor(minutes / 60)
52
+ const mins = minutes % 60
53
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
54
+ }
55
+
56
+ /** Get a human-readable time range for a point value */
57
+ export const pointsToTimeRange = (points: FibonacciPoint): string => {
58
+ const range = pointsToMinutes(points)
59
+ return `${formatMinutes(range.min)}–${formatMinutes(range.max)}`
60
+ }
61
+
62
+ // =============================================================================
63
+ // Historical Suggestion
64
+ // =============================================================================
65
+
66
+ /**
67
+ * Suggest a point estimate based on historical outcomes for similar task types.
68
+ * Returns null if not enough data (< 3 outcomes).
69
+ */
70
+ export const suggestFromHistory = async (
71
+ projectId: string,
72
+ taskType: string
73
+ ): Promise<{ points: FibonacciPoint; basedOn: number } | null> => {
74
+ const outcomes = await outcomeRecorder.getAll(projectId)
75
+
76
+ // Filter by task type tag
77
+ const relevant = outcomes.filter((o) => o.tags?.includes(taskType))
78
+
79
+ if (relevant.length < 3) return null
80
+
81
+ // Calculate average actual duration in minutes
82
+ const totalMinutes = relevant.reduce((sum, o) => {
83
+ return sum + parseDuration(o.actualDuration)
84
+ }, 0)
85
+ const avgMinutes = totalMinutes / relevant.length
86
+
87
+ // Find closest Fibonacci point by typical time
88
+ const closest = findClosestPoint(avgMinutes)
89
+
90
+ return { points: closest, basedOn: relevant.length }
91
+ }
92
+
93
+ // =============================================================================
94
+ // Helpers
95
+ // =============================================================================
96
+
97
+ /** Find the Fibonacci point whose typical time is closest to the given minutes */
98
+ export const findClosestPoint = (minutes: number): FibonacciPoint => {
99
+ let closest: FibonacciPoint = 1
100
+ let smallestDiff = Number.POSITIVE_INFINITY
101
+
102
+ for (const point of FIBONACCI_POINTS) {
103
+ const diff = Math.abs(DEFAULT_MINUTES_MAP[point].typical - minutes)
104
+ if (diff < smallestDiff) {
105
+ smallestDiff = diff
106
+ closest = point
107
+ }
108
+ }
109
+
110
+ return closest
111
+ }
112
+
113
+ /** Parse a duration string like "2h 30m" to minutes */
114
+ const parseDuration = (duration: string): number => {
115
+ let minutes = 0
116
+
117
+ const hourMatch = duration.match(/(\d+)h/)
118
+ if (hourMatch) {
119
+ minutes += Number.parseInt(hourMatch[1], 10) * 60
120
+ }
121
+
122
+ const minMatch = duration.match(/(\d+)m/)
123
+ if (minMatch) {
124
+ minutes += Number.parseInt(minMatch[1], 10)
125
+ }
126
+
127
+ return minutes
128
+ }
package/core/index.ts CHANGED
@@ -13,6 +13,7 @@ import chalk from 'chalk'
13
13
  import type { CommandMeta } from './commands/registry'
14
14
  import { detectAllProviders, detectAntigravity } from './infrastructure/ai-provider'
15
15
  import configManager from './infrastructure/config-manager'
16
+ import performanceTracker from './infrastructure/performance-tracker'
16
17
  import { sessionTracker } from './services/session-tracker'
17
18
  import { getErrorMessage, getErrorStack } from './types/fs'
18
19
  import { getError } from './utils/error-messages'
@@ -154,6 +155,7 @@ async function main(): Promise<void> {
154
155
  json: options.json === true,
155
156
  }),
156
157
  help: (p) => commands.help(p || ''),
158
+ perf: (p) => commands.perf(p || '7'),
157
159
  // Maintenance
158
160
  recover: () => commands.recover(),
159
161
  undo: () => commands.undo(),
@@ -181,7 +183,7 @@ async function main(): Promise<void> {
181
183
  }
182
184
  }
183
185
 
184
- // 7. Track command in session
186
+ // 7. Track command in session + performance metrics
185
187
  if (projectId) {
186
188
  const durationMs = Date.now() - commandStartTime
187
189
  try {
@@ -189,6 +191,28 @@ async function main(): Promise<void> {
189
191
  } catch {
190
192
  // Non-critical
191
193
  }
194
+
195
+ // Performance tracking (non-critical)
196
+ try {
197
+ // Record command duration
198
+ await performanceTracker.recordTiming(projectId, 'command_duration', durationMs, {
199
+ command: commandName,
200
+ })
201
+
202
+ // Record startup time (from bin/prjct.ts marker if available)
203
+ const perfStartNs = (globalThis as Record<string, unknown>).__perfStartNs as
204
+ | bigint
205
+ | undefined
206
+ if (perfStartNs) {
207
+ const startupMs = Number(process.hrtime.bigint() - perfStartNs) / 1_000_000
208
+ await performanceTracker.recordTiming(projectId, 'startup_time', startupMs)
209
+ }
210
+
211
+ // Record memory snapshot
212
+ await performanceTracker.recordMemory(projectId, { command: commandName })
213
+ } catch {
214
+ // Performance tracking is non-critical
215
+ }
192
216
  }
193
217
 
194
218
  // 8. Display result
@@ -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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * PerformanceTracker - Measures CLI performance metrics
3
+ *
4
+ * Instruments startup time, memory usage, context correctness,
5
+ * subtask handoff rate, and command durations.
6
+ *
7
+ * Storage: ~/.prjct-cli/projects/{projectId}/storage/performance.jsonl
8
+ * Rotation: 5MB (via jsonl-helper)
9
+ *
10
+ * @see PRJ-297
11
+ */
12
+
13
+ import fs from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import type {
16
+ ContextCorrectness,
17
+ MemorySnapshot,
18
+ MetricName,
19
+ PerformanceEntry,
20
+ PerformanceMetric,
21
+ PerformanceReport,
22
+ SubtaskHandoff,
23
+ } from '../schemas/performance'
24
+ import { getTimestamp } from '../utils/date-helper'
25
+ import { appendJsonLineWithRotation, filterJsonLines } from '../utils/jsonl-helper'
26
+ import pathManager from './path-manager'
27
+
28
+ // =============================================================================
29
+ // CONSTANTS
30
+ // =============================================================================
31
+
32
+ const PERF_FILENAME = 'performance.jsonl'
33
+ const ROTATION_SIZE_MB = 5
34
+
35
+ // =============================================================================
36
+ // PERFORMANCE TRACKER
37
+ // =============================================================================
38
+
39
+ class PerformanceTracker {
40
+ private marks: Map<string, bigint> = new Map()
41
+
42
+ /**
43
+ * Get the performance.jsonl path for a project
44
+ */
45
+ private getPath(projectId: string): string {
46
+ return pathManager.getStoragePath(projectId, PERF_FILENAME)
47
+ }
48
+
49
+ /**
50
+ * Ensure the storage directory exists
51
+ */
52
+ private async ensureDir(projectId: string): Promise<void> {
53
+ const filePath = this.getPath(projectId)
54
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
55
+ }
56
+
57
+ // ===========================================================================
58
+ // Timing
59
+ // ===========================================================================
60
+
61
+ /**
62
+ * Mark the start of a timing measurement.
63
+ * Uses process.hrtime.bigint() for nanosecond precision.
64
+ */
65
+ markStart(label: string): void {
66
+ this.marks.set(label, process.hrtime.bigint())
67
+ }
68
+
69
+ /**
70
+ * Mark the end of a timing measurement and return duration in ms.
71
+ * Returns null if no matching start mark exists.
72
+ */
73
+ markEnd(label: string): number | null {
74
+ const start = this.marks.get(label)
75
+ if (start === undefined) return null
76
+
77
+ const end = process.hrtime.bigint()
78
+ this.marks.delete(label)
79
+ return Number(end - start) / 1_000_000 // ns → ms
80
+ }
81
+
82
+ /**
83
+ * Record a timing metric to storage
84
+ */
85
+ async recordTiming(
86
+ projectId: string,
87
+ metric: MetricName,
88
+ durationMs: number,
89
+ context?: Record<string, unknown>
90
+ ): Promise<void> {
91
+ await this.ensureDir(projectId)
92
+
93
+ const entry: PerformanceMetric = {
94
+ timestamp: getTimestamp(),
95
+ metric,
96
+ value: Math.round(durationMs * 100) / 100, // 2 decimal places
97
+ unit: 'ms',
98
+ context,
99
+ }
100
+
101
+ await appendJsonLineWithRotation(this.getPath(projectId), entry, ROTATION_SIZE_MB)
102
+ }
103
+
104
+ // ===========================================================================
105
+ // Memory
106
+ // ===========================================================================
107
+
108
+ /**
109
+ * Take a memory snapshot using process.memoryUsage()
110
+ */
111
+ snapshotMemory(): MemorySnapshot {
112
+ const mem = process.memoryUsage()
113
+ return {
114
+ heapUsed: mem.heapUsed,
115
+ heapTotal: mem.heapTotal,
116
+ rss: mem.rss,
117
+ external: mem.external,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Record a memory snapshot to storage
123
+ */
124
+ async recordMemory(
125
+ projectId: string,
126
+ context?: Record<string, unknown>
127
+ ): Promise<MemorySnapshot> {
128
+ await this.ensureDir(projectId)
129
+
130
+ const snapshot = this.snapshotMemory()
131
+ const filePath = this.getPath(projectId)
132
+ const ts = getTimestamp()
133
+
134
+ const entries: PerformanceMetric[] = [
135
+ { timestamp: ts, metric: 'heap_used', value: snapshot.heapUsed, unit: 'bytes', context },
136
+ { timestamp: ts, metric: 'heap_total', value: snapshot.heapTotal, unit: 'bytes', context },
137
+ { timestamp: ts, metric: 'rss', value: snapshot.rss, unit: 'bytes', context },
138
+ {
139
+ timestamp: ts,
140
+ metric: 'external_memory',
141
+ value: snapshot.external,
142
+ unit: 'bytes',
143
+ context,
144
+ },
145
+ ]
146
+
147
+ for (const entry of entries) {
148
+ await appendJsonLineWithRotation(filePath, entry, ROTATION_SIZE_MB)
149
+ }
150
+
151
+ return snapshot
152
+ }
153
+
154
+ // ===========================================================================
155
+ // Context Correctness
156
+ // ===========================================================================
157
+
158
+ /**
159
+ * Record whether a task received sync context
160
+ */
161
+ async recordContextCorrectness(
162
+ projectId: string,
163
+ data: Omit<ContextCorrectness, 'timestamp' | 'metric'>
164
+ ): Promise<void> {
165
+ await this.ensureDir(projectId)
166
+
167
+ const entry: ContextCorrectness = {
168
+ timestamp: getTimestamp(),
169
+ metric: 'context_correctness',
170
+ ...data,
171
+ }
172
+
173
+ await appendJsonLineWithRotation(this.getPath(projectId), entry, ROTATION_SIZE_MB)
174
+ }
175
+
176
+ // ===========================================================================
177
+ // Subtask Handoff
178
+ // ===========================================================================
179
+
180
+ /**
181
+ * Record whether a subtask's output field was populated on completion
182
+ */
183
+ async recordSubtaskHandoff(
184
+ projectId: string,
185
+ data: Omit<SubtaskHandoff, 'timestamp' | 'metric'>
186
+ ): Promise<void> {
187
+ await this.ensureDir(projectId)
188
+
189
+ const entry: SubtaskHandoff = {
190
+ timestamp: getTimestamp(),
191
+ metric: 'subtask_handoff',
192
+ ...data,
193
+ }
194
+
195
+ await appendJsonLineWithRotation(this.getPath(projectId), entry, ROTATION_SIZE_MB)
196
+ }
197
+
198
+ // ===========================================================================
199
+ // Report Generation
200
+ // ===========================================================================
201
+
202
+ /**
203
+ * Read all metrics for a project within a date range
204
+ */
205
+ async getMetrics(projectId: string, sinceDate?: Date): Promise<PerformanceEntry[]> {
206
+ const filePath = this.getPath(projectId)
207
+
208
+ if (!sinceDate) {
209
+ // Default: last 7 days
210
+ sinceDate = new Date()
211
+ sinceDate.setDate(sinceDate.getDate() - 7)
212
+ }
213
+
214
+ const sinceIso = sinceDate.toISOString()
215
+
216
+ return filterJsonLines<PerformanceEntry>(filePath, (entry) => {
217
+ return entry.timestamp >= sinceIso
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Generate a performance report for a project
223
+ */
224
+ async getReport(projectId: string, days: number = 7): Promise<PerformanceReport> {
225
+ const sinceDate = new Date()
226
+ sinceDate.setDate(sinceDate.getDate() - days)
227
+
228
+ const entries = await this.getMetrics(projectId, sinceDate)
229
+ const report: PerformanceReport = {
230
+ period: `${days}d`,
231
+ }
232
+
233
+ // Startup time
234
+ const startupEntries = entries.filter(
235
+ (e): e is PerformanceMetric => 'metric' in e && e.metric === 'startup_time'
236
+ )
237
+ if (startupEntries.length > 0) {
238
+ const values = startupEntries.map((e) => e.value)
239
+ report.startup = {
240
+ avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
241
+ min: Math.min(...values),
242
+ max: Math.max(...values),
243
+ count: values.length,
244
+ unit: 'ms',
245
+ }
246
+ }
247
+
248
+ // Memory
249
+ const heapEntries = entries.filter(
250
+ (e): e is PerformanceMetric => 'metric' in e && e.metric === 'heap_used'
251
+ )
252
+ const rssEntries = entries.filter(
253
+ (e): e is PerformanceMetric => 'metric' in e && e.metric === 'rss'
254
+ )
255
+ if (heapEntries.length > 0) {
256
+ const toMB = (bytes: number) => Math.round((bytes / (1024 * 1024)) * 10) / 10
257
+ const heapValues = heapEntries.map((e) => e.value)
258
+ const rssValues = rssEntries.map((e) => e.value)
259
+ report.memory = {
260
+ avgHeapMB: toMB(heapValues.reduce((a, b) => a + b, 0) / heapValues.length),
261
+ peakHeapMB: toMB(Math.max(...heapValues)),
262
+ avgRssMB:
263
+ rssValues.length > 0 ? toMB(rssValues.reduce((a, b) => a + b, 0) / rssValues.length) : 0,
264
+ }
265
+ }
266
+
267
+ // Context correctness
268
+ const contextEntries = entries.filter(
269
+ (e): e is ContextCorrectness => 'metric' in e && e.metric === 'context_correctness'
270
+ )
271
+ if (contextEntries.length > 0) {
272
+ const received = contextEntries.filter((e) => e.receivedSync).length
273
+ report.contextCorrectness = {
274
+ total: contextEntries.length,
275
+ receivedSync: received,
276
+ rate: Math.round((received / contextEntries.length) * 100),
277
+ }
278
+ }
279
+
280
+ // Subtask handoff
281
+ const handoffEntries = entries.filter(
282
+ (e): e is SubtaskHandoff => 'metric' in e && e.metric === 'subtask_handoff'
283
+ )
284
+ if (handoffEntries.length > 0) {
285
+ const populated = handoffEntries.filter((e) => e.outputPopulated).length
286
+ report.subtaskHandoff = {
287
+ total: handoffEntries.length,
288
+ outputPopulated: populated,
289
+ rate: Math.round((populated / handoffEntries.length) * 100),
290
+ }
291
+ }
292
+
293
+ // Command durations
294
+ const cmdEntries = entries.filter(
295
+ (e): e is PerformanceMetric => 'metric' in e && e.metric === 'command_duration'
296
+ )
297
+ if (cmdEntries.length > 0) {
298
+ const byCommand: Record<string, number[]> = {}
299
+ for (const e of cmdEntries) {
300
+ const cmd = (e.context?.command as string) || 'unknown'
301
+ if (!byCommand[cmd]) byCommand[cmd] = []
302
+ byCommand[cmd].push(e.value)
303
+ }
304
+
305
+ report.commandDurations = {}
306
+ for (const [cmd, values] of Object.entries(byCommand)) {
307
+ report.commandDurations[cmd] = {
308
+ avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
309
+ min: Math.min(...values),
310
+ max: Math.max(...values),
311
+ count: values.length,
312
+ unit: 'ms',
313
+ }
314
+ }
315
+ }
316
+
317
+ return report
318
+ }
319
+ }
320
+
321
+ // =============================================================================
322
+ // EXPORTS
323
+ // =============================================================================
324
+
325
+ export const performanceTracker = new PerformanceTracker()
326
+ export default performanceTracker
@@ -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'> = {