prjct-cli 1.13.0 → 1.14.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.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Velocity Engine (PRJ-296)
3
+ *
4
+ * Sprint-based velocity calculation with:
5
+ * - Sprint aggregation from outcomes data
6
+ * - Trend detection (improving/stable/declining)
7
+ * - Estimation accuracy tracking
8
+ * - Over/under estimation pattern detection
9
+ * - Completion projections
10
+ */
11
+
12
+ import {
13
+ type CompletionProjection,
14
+ DEFAULT_VELOCITY_CONFIG,
15
+ type EstimationPattern,
16
+ type SprintVelocity,
17
+ type VelocityConfig,
18
+ type VelocityMetrics,
19
+ type VelocityTrend,
20
+ } from '../schemas/velocity'
21
+ import type { Outcome } from '../types'
22
+
23
+ // =============================================================================
24
+ // Types
25
+ // =============================================================================
26
+
27
+ /** Day-of-week index (0=Sunday, 1=Monday, ..., 6=Saturday) */
28
+ const DAY_INDEX: Record<string, number> = {
29
+ sunday: 0,
30
+ monday: 1,
31
+ tuesday: 2,
32
+ wednesday: 3,
33
+ thursday: 4,
34
+ friday: 5,
35
+ saturday: 6,
36
+ }
37
+
38
+ // =============================================================================
39
+ // Sprint Boundary Calculation
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Get the sprint start date for a given date based on config.
44
+ * Sprints align to calendar boundaries (e.g., every Monday for 7-day sprints).
45
+ */
46
+ export function getSprintStart(date: Date, config: VelocityConfig): Date {
47
+ const resolved = resolveConfig(config)
48
+ const startDayIdx = DAY_INDEX[resolved.startDay]
49
+ const d = new Date(date)
50
+ d.setHours(0, 0, 0, 0)
51
+
52
+ // Roll back to the most recent start day
53
+ const currentDay = d.getDay()
54
+ const diff = (currentDay - startDayIdx + 7) % 7
55
+ d.setDate(d.getDate() - diff)
56
+
57
+ return d
58
+ }
59
+
60
+ /**
61
+ * Get sprint end date from sprint start.
62
+ */
63
+ export function getSprintEnd(sprintStart: Date, config: VelocityConfig): Date {
64
+ const resolved = resolveConfig(config)
65
+ const end = new Date(sprintStart)
66
+ end.setDate(end.getDate() + resolved.sprintLengthDays - 1)
67
+ end.setHours(23, 59, 59, 999)
68
+ return end
69
+ }
70
+
71
+ /**
72
+ * Assign a sprint number to a date.
73
+ * Sprint 1 is the earliest sprint in the data set.
74
+ */
75
+ function getSprintNumber(date: Date, earliestDate: Date, config: VelocityConfig): number {
76
+ const resolved = resolveConfig(config)
77
+ const sprintStart = getSprintStart(date, config)
78
+ const firstSprintStart = getSprintStart(earliestDate, config)
79
+
80
+ const diffMs = sprintStart.getTime() - firstSprintStart.getTime()
81
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
82
+
83
+ return Math.floor(diffDays / resolved.sprintLengthDays) + 1
84
+ }
85
+
86
+ // =============================================================================
87
+ // Core Engine
88
+ // =============================================================================
89
+
90
+ /**
91
+ * Calculate velocity metrics from outcome data.
92
+ */
93
+ export function calculateVelocity(
94
+ outcomes: Outcome[],
95
+ config: VelocityConfig = DEFAULT_VELOCITY_CONFIG
96
+ ): VelocityMetrics {
97
+ const resolved = resolveConfig(config)
98
+
99
+ if (outcomes.length === 0) {
100
+ return {
101
+ sprints: [],
102
+ averageVelocity: 0,
103
+ velocityTrend: 'stable',
104
+ estimationAccuracy: 0,
105
+ overEstimated: [],
106
+ underEstimated: [],
107
+ lastUpdated: new Date().toISOString(),
108
+ }
109
+ }
110
+
111
+ // Parse outcomes into sprint buckets
112
+ const sprintBuckets = bucketBySprint(outcomes, config)
113
+ const sprints = buildSprintVelocities(sprintBuckets, resolved.accuracyTolerance)
114
+
115
+ // Use last N sprints for rolling metrics
116
+ const windowSprints = sprints.slice(-resolved.windowSize)
117
+
118
+ const averageVelocity = calculateAverageVelocity(windowSprints)
119
+ const velocityTrend = detectTrend(windowSprints)
120
+ const estimationAccuracy = calculateOverallAccuracy(outcomes, resolved.accuracyTolerance)
121
+
122
+ // Detect estimation patterns
123
+ const { overEstimated, underEstimated } = detectEstimationPatterns(outcomes)
124
+
125
+ return {
126
+ sprints,
127
+ averageVelocity,
128
+ velocityTrend,
129
+ estimationAccuracy,
130
+ overEstimated,
131
+ underEstimated,
132
+ lastUpdated: new Date().toISOString(),
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Project completion date given remaining points and current velocity.
138
+ */
139
+ export function projectCompletion(
140
+ totalPoints: number,
141
+ averageVelocity: number,
142
+ config: VelocityConfig = DEFAULT_VELOCITY_CONFIG
143
+ ): CompletionProjection {
144
+ const resolved = resolveConfig(config)
145
+
146
+ if (averageVelocity <= 0) {
147
+ return {
148
+ totalPoints,
149
+ sprints: 0,
150
+ estimatedDate: '',
151
+ }
152
+ }
153
+
154
+ const sprints = Math.ceil(totalPoints / averageVelocity)
155
+ const daysRemaining = sprints * resolved.sprintLengthDays
156
+ const estimatedDate = new Date()
157
+ estimatedDate.setDate(estimatedDate.getDate() + daysRemaining)
158
+
159
+ return {
160
+ totalPoints,
161
+ sprints,
162
+ estimatedDate: estimatedDate.toISOString(),
163
+ }
164
+ }
165
+
166
+ // =============================================================================
167
+ // Sprint Bucketing
168
+ // =============================================================================
169
+
170
+ interface SprintBucket {
171
+ sprintNumber: number
172
+ startDate: Date
173
+ endDate: Date
174
+ outcomes: Outcome[]
175
+ }
176
+
177
+ function bucketBySprint(outcomes: Outcome[], config: VelocityConfig): Map<number, SprintBucket> {
178
+ const buckets = new Map<number, SprintBucket>()
179
+
180
+ // Find earliest outcome date
181
+ const dates = outcomes.map((o) => new Date(o.completedAt))
182
+ const earliest = new Date(Math.min(...dates.map((d) => d.getTime())))
183
+
184
+ for (const outcome of outcomes) {
185
+ const completedDate = new Date(outcome.completedAt)
186
+ const sprintNum = getSprintNumber(completedDate, earliest, config)
187
+
188
+ if (!buckets.has(sprintNum)) {
189
+ const start = getSprintStart(completedDate, config)
190
+ const end = getSprintEnd(start, config)
191
+ buckets.set(sprintNum, {
192
+ sprintNumber: sprintNum,
193
+ startDate: start,
194
+ endDate: end,
195
+ outcomes: [],
196
+ })
197
+ }
198
+
199
+ buckets.get(sprintNum)!.outcomes.push(outcome)
200
+ }
201
+
202
+ return buckets
203
+ }
204
+
205
+ function buildSprintVelocities(
206
+ buckets: Map<number, SprintBucket>,
207
+ accuracyTolerance: number
208
+ ): SprintVelocity[] {
209
+ const sprints: SprintVelocity[] = []
210
+
211
+ for (const [, bucket] of buckets) {
212
+ const points = bucket.outcomes.reduce((sum, o) => {
213
+ return sum + derivePoints(o)
214
+ }, 0)
215
+
216
+ const variances = bucket.outcomes.filter((o) => o.variance).map((o) => parseVariancePercent(o))
217
+
218
+ const avgVariance =
219
+ variances.length > 0 ? Math.round(variances.reduce((a, b) => a + b, 0) / variances.length) : 0
220
+
221
+ const accurateCount = variances.filter((v) => Math.abs(v) <= accuracyTolerance).length
222
+ const estimationAccuracy =
223
+ variances.length > 0 ? Math.round((accurateCount / variances.length) * 100) : 0
224
+
225
+ sprints.push({
226
+ sprintNumber: bucket.sprintNumber,
227
+ startDate: bucket.startDate.toISOString(),
228
+ endDate: bucket.endDate.toISOString(),
229
+ pointsCompleted: points,
230
+ tasksCompleted: bucket.outcomes.length,
231
+ avgVariance,
232
+ estimationAccuracy,
233
+ })
234
+ }
235
+
236
+ // Sort by sprint number
237
+ return sprints.sort((a, b) => a.sprintNumber - b.sprintNumber)
238
+ }
239
+
240
+ // =============================================================================
241
+ // Trend Detection
242
+ // =============================================================================
243
+
244
+ /**
245
+ * Detect velocity trend using simple linear regression on points per sprint.
246
+ * Requires at least 3 sprints for meaningful trend detection.
247
+ */
248
+ export function detectTrend(sprints: SprintVelocity[]): VelocityTrend {
249
+ if (sprints.length < 3) return 'stable'
250
+
251
+ const points = sprints.map((s) => s.pointsCompleted)
252
+ const n = points.length
253
+
254
+ // Simple linear regression slope
255
+ let sumX = 0
256
+ let sumY = 0
257
+ let sumXY = 0
258
+ let sumX2 = 0
259
+ for (let i = 0; i < n; i++) {
260
+ sumX += i
261
+ sumY += points[i]
262
+ sumXY += i * points[i]
263
+ sumX2 += i * i
264
+ }
265
+
266
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
267
+ const avgVelocity = sumY / n
268
+
269
+ // Normalize slope as percentage of average velocity
270
+ if (avgVelocity === 0) return 'stable'
271
+
272
+ const normalizedSlope = slope / avgVelocity
273
+
274
+ // Thresholds: >10% per sprint = improving, <-10% = declining
275
+ if (normalizedSlope > 0.1) return 'improving'
276
+ if (normalizedSlope < -0.1) return 'declining'
277
+ return 'stable'
278
+ }
279
+
280
+ // =============================================================================
281
+ // Estimation Accuracy
282
+ // =============================================================================
283
+
284
+ function calculateOverallAccuracy(outcomes: Outcome[], tolerance: number): number {
285
+ const withEstimates = outcomes.filter((o) => o.variance)
286
+ if (withEstimates.length === 0) return 0
287
+
288
+ const accurate = withEstimates.filter((o) => {
289
+ const variancePct = parseVariancePercent(o)
290
+ return Math.abs(variancePct) <= tolerance
291
+ })
292
+
293
+ return Math.round((accurate.length / withEstimates.length) * 100)
294
+ }
295
+
296
+ function calculateAverageVelocity(sprints: SprintVelocity[]): number {
297
+ if (sprints.length === 0) return 0
298
+ const total = sprints.reduce((sum, s) => sum + s.pointsCompleted, 0)
299
+ return Math.round((total / sprints.length) * 10) / 10
300
+ }
301
+
302
+ // =============================================================================
303
+ // Estimation Pattern Detection
304
+ // =============================================================================
305
+
306
+ function detectEstimationPatterns(outcomes: Outcome[]): {
307
+ overEstimated: EstimationPattern[]
308
+ underEstimated: EstimationPattern[]
309
+ } {
310
+ // Group by tags/categories
311
+ const byCategory = new Map<string, { variances: number[]; count: number }>()
312
+
313
+ for (const outcome of outcomes) {
314
+ if (!outcome.variance) continue
315
+ const variancePct = parseVariancePercent(outcome)
316
+
317
+ // Use tags as categories, fall back to 'uncategorized'
318
+ const categories = outcome.tags && outcome.tags.length > 0 ? outcome.tags : ['uncategorized']
319
+
320
+ for (const category of categories) {
321
+ if (!byCategory.has(category)) {
322
+ byCategory.set(category, { variances: [], count: 0 })
323
+ }
324
+ const entry = byCategory.get(category)!
325
+ entry.variances.push(variancePct)
326
+ entry.count++
327
+ }
328
+ }
329
+
330
+ const overEstimated: EstimationPattern[] = []
331
+ const underEstimated: EstimationPattern[] = []
332
+
333
+ for (const [category, data] of byCategory) {
334
+ if (data.count < 2) continue // Need at least 2 data points
335
+
336
+ const avg = Math.round(data.variances.reduce((a, b) => a + b, 0) / data.variances.length)
337
+
338
+ if (avg > 10) {
339
+ // Actual took longer than estimated → under-estimated
340
+ underEstimated.push({ category, avgVariance: avg, taskCount: data.count })
341
+ } else if (avg < -10) {
342
+ // Actual was faster than estimated → over-estimated
343
+ overEstimated.push({ category, avgVariance: Math.abs(avg), taskCount: data.count })
344
+ }
345
+ }
346
+
347
+ // Sort by severity
348
+ overEstimated.sort((a, b) => b.avgVariance - a.avgVariance)
349
+ underEstimated.sort((a, b) => b.avgVariance - a.avgVariance)
350
+
351
+ return { overEstimated, underEstimated }
352
+ }
353
+
354
+ // =============================================================================
355
+ // Helpers
356
+ // =============================================================================
357
+
358
+ /**
359
+ * Parse variance from an outcome into a percentage.
360
+ * Handles both string format ("+30m") and the presence of estimatedDuration.
361
+ */
362
+ function parseVariancePercent(outcome: Outcome): number {
363
+ if (!outcome.variance) return 0
364
+
365
+ const estimated = parseDurationMinutes(outcome.estimatedDuration)
366
+ const actual = parseDurationMinutes(outcome.actualDuration)
367
+
368
+ if (estimated <= 0) return 0
369
+
370
+ return Math.round(((actual - estimated) / estimated) * 100)
371
+ }
372
+
373
+ /**
374
+ * Parse duration string to minutes.
375
+ * Supports: "2h", "30m", "1h 30m", "2h30m", "90m", "45s" (→ 1m)
376
+ */
377
+ export function parseDurationMinutes(duration: string): number {
378
+ let minutes = 0
379
+
380
+ const hourMatch = duration.match(/(\d+)h/)
381
+ if (hourMatch) {
382
+ minutes += Number.parseInt(hourMatch[1], 10) * 60
383
+ }
384
+
385
+ const minMatch = duration.match(/(\d+)m/)
386
+ if (minMatch) {
387
+ minutes += Number.parseInt(minMatch[1], 10)
388
+ }
389
+
390
+ const secMatch = duration.match(/(\d+)s/)
391
+ if (secMatch && minutes === 0) {
392
+ // Only count seconds if no hours/minutes (round up to 1 min)
393
+ minutes = 1
394
+ }
395
+
396
+ return minutes
397
+ }
398
+
399
+ /**
400
+ * Format velocity for LLM context injection.
401
+ */
402
+ export function formatVelocityContext(metrics: VelocityMetrics): string {
403
+ if (metrics.sprints.length === 0) {
404
+ return 'No velocity data available yet.'
405
+ }
406
+
407
+ const lines: string[] = []
408
+ lines.push(
409
+ `Project velocity: ${metrics.averageVelocity} pts/sprint (trend: ${metrics.velocityTrend})`
410
+ )
411
+ lines.push(`Estimation accuracy: ${metrics.estimationAccuracy}%`)
412
+
413
+ for (const pattern of metrics.underEstimated) {
414
+ lines.push(
415
+ `⚠ "${pattern.category}" tasks historically take ${pattern.avgVariance}% longer than estimated`
416
+ )
417
+ }
418
+
419
+ for (const pattern of metrics.overEstimated) {
420
+ lines.push(
421
+ `"${pattern.category}" tasks typically finish ${pattern.avgVariance}% faster than estimated`
422
+ )
423
+ }
424
+
425
+ return lines.join('\n')
426
+ }
427
+
428
+ /** Fibonacci points with typical minutes for points derivation */
429
+ const FIBONACCI_MINUTES: Array<{ points: number; typical: number }> = [
430
+ { points: 1, typical: 10 },
431
+ { points: 2, typical: 20 },
432
+ { points: 3, typical: 45 },
433
+ { points: 5, typical: 90 },
434
+ { points: 8, typical: 180 },
435
+ { points: 13, typical: 360 },
436
+ { points: 21, typical: 720 },
437
+ ]
438
+
439
+ /**
440
+ * Derive story points from an outcome's estimated duration.
441
+ * Maps to nearest Fibonacci point using the standard points-to-minutes table.
442
+ */
443
+ function derivePoints(outcome: Outcome): number {
444
+ if (!outcome.estimatedDuration) return 0
445
+
446
+ const minutes = parseDurationMinutes(outcome.estimatedDuration)
447
+ if (minutes <= 0) return 0
448
+
449
+ let closest = FIBONACCI_MINUTES[0]
450
+ let smallestDiff = Number.POSITIVE_INFINITY
451
+
452
+ for (const entry of FIBONACCI_MINUTES) {
453
+ const diff = Math.abs(entry.typical - minutes)
454
+ if (diff < smallestDiff) {
455
+ smallestDiff = diff
456
+ closest = entry
457
+ }
458
+ }
459
+
460
+ return closest.points
461
+ }
462
+
463
+ function resolveConfig(config: VelocityConfig): Required<VelocityConfig> {
464
+ return {
465
+ sprintLengthDays: config.sprintLengthDays ?? 7,
466
+ startDay: config.startDay ?? 'monday',
467
+ windowSize: config.windowSize ?? 6,
468
+ accuracyTolerance: config.accuracyTolerance ?? 20,
469
+ }
470
+ }
package/core/index.ts CHANGED
@@ -156,6 +156,7 @@ async function main(): Promise<void> {
156
156
  }),
157
157
  help: (p) => commands.help(p || ''),
158
158
  perf: (p) => commands.perf(p || '7'),
159
+ velocity: (p) => commands.velocity(p || '0'),
159
160
  // Maintenance
160
161
  recover: () => commands.recover(),
161
162
  undo: () => commands.undo(),
@@ -49,3 +49,5 @@ export {
49
49
  export * from './shipped'
50
50
  // State (current task + queue)
51
51
  export * from './state'
52
+ // Velocity (sprint-based tracking)
53
+ export * from './velocity'
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Velocity Schema (PRJ-296)
3
+ *
4
+ * Defines the structure for velocity tracking:
5
+ * - Sprint-based velocity with configurable sprint length
6
+ * - Estimation accuracy trends
7
+ * - Over/under estimation patterns
8
+ * - Completion projections
9
+ *
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import { z } from 'zod'
14
+
15
+ // =============================================================================
16
+ // Zod Schemas - Source of Truth
17
+ // =============================================================================
18
+
19
+ export const VelocityTrendSchema = z.enum(['improving', 'stable', 'declining'])
20
+
21
+ export const SprintVelocitySchema = z.object({
22
+ sprintNumber: z.number(),
23
+ startDate: z.string(), // ISO8601
24
+ endDate: z.string(), // ISO8601
25
+ pointsCompleted: z.number(),
26
+ tasksCompleted: z.number(),
27
+ avgVariance: z.number(), // Average estimation variance (%)
28
+ estimationAccuracy: z.number(), // % of tasks within tolerance
29
+ })
30
+
31
+ export const EstimationPatternSchema = z.object({
32
+ category: z.string(), // task type or domain
33
+ avgVariance: z.number(), // positive = over, negative = under
34
+ taskCount: z.number(),
35
+ })
36
+
37
+ export const CompletionProjectionSchema = z.object({
38
+ totalPoints: z.number(),
39
+ sprints: z.number(),
40
+ estimatedDate: z.string(), // ISO8601
41
+ })
42
+
43
+ export const VelocityMetricsSchema = z.object({
44
+ sprints: z.array(SprintVelocitySchema),
45
+ averageVelocity: z.number(),
46
+ velocityTrend: VelocityTrendSchema,
47
+ estimationAccuracy: z.number(), // 0-100%
48
+ overEstimated: z.array(EstimationPatternSchema),
49
+ underEstimated: z.array(EstimationPatternSchema),
50
+ lastUpdated: z.string(), // ISO8601
51
+ })
52
+
53
+ export const VelocityConfigSchema = z.object({
54
+ sprintLengthDays: z.number().min(1).max(90).default(7),
55
+ startDay: z
56
+ .enum(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'])
57
+ .default('monday'),
58
+ windowSize: z.number().min(1).max(52).default(6), // number of sprints for rolling average
59
+ accuracyTolerance: z.number().min(0).max(100).default(20), // ±% for "accurate" estimate
60
+ })
61
+
62
+ // =============================================================================
63
+ // Inferred Types
64
+ // =============================================================================
65
+
66
+ export type VelocityTrend = z.infer<typeof VelocityTrendSchema>
67
+ export type SprintVelocity = z.infer<typeof SprintVelocitySchema>
68
+ export type EstimationPattern = z.infer<typeof EstimationPatternSchema>
69
+ export type CompletionProjection = z.infer<typeof CompletionProjectionSchema>
70
+ export type VelocityMetrics = z.infer<typeof VelocityMetricsSchema>
71
+ export type VelocityConfig = z.input<typeof VelocityConfigSchema>
72
+
73
+ // =============================================================================
74
+ // Defaults
75
+ // =============================================================================
76
+
77
+ export const DEFAULT_VELOCITY_CONFIG: VelocityConfig = {
78
+ sprintLengthDays: 7,
79
+ startDay: 'monday',
80
+ windowSize: 6,
81
+ accuracyTolerance: 20,
82
+ }
83
+
84
+ export const DEFAULT_VELOCITY_METRICS: VelocityMetrics = {
85
+ sprints: [],
86
+ averageVelocity: 0,
87
+ velocityTrend: 'stable',
88
+ estimationAccuracy: 0,
89
+ overEstimated: [],
90
+ underEstimated: [],
91
+ lastUpdated: '',
92
+ }
93
+
94
+ // =============================================================================
95
+ // Validation Helpers
96
+ // =============================================================================
97
+
98
+ export const parseVelocityMetrics = (data: unknown): VelocityMetrics =>
99
+ VelocityMetricsSchema.parse(data)
100
+
101
+ export const safeParseVelocityMetrics = (data: unknown) => VelocityMetricsSchema.safeParse(data)
102
+
103
+ export const parseVelocityConfig = (data: unknown) => VelocityConfigSchema.parse(data)
@@ -79,8 +79,9 @@ export { metricsStorage } from './metrics-storage'
79
79
  export { queueStorage } from './queue-storage'
80
80
  export { shippedStorage } from './shipped-storage'
81
81
  export { stateStorage } from './state-storage'
82
-
83
82
  // ========== GRANULAR STORAGE (Legacy) ==========
84
83
  export { getStorage } from './storage'
85
84
  // ========== AGGREGATE STORAGE (Recommended) ==========
86
85
  export { StorageManager } from './storage-manager'
86
+ export type { VelocityStoreData } from './velocity-storage'
87
+ export { velocityStorage } from './velocity-storage'