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,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
|
package/core/schemas/analysis.ts
CHANGED
|
@@ -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'> = {
|