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.
- package/CHANGELOG.md +49 -0
- package/core/__tests__/domain/velocity.test.ts +623 -0
- package/core/agentic/orchestrator-executor.ts +25 -2
- package/core/agentic/prompt-builder.ts +7 -0
- package/core/commands/command-data.ts +17 -0
- package/core/commands/commands.ts +12 -0
- package/core/commands/register.ts +6 -0
- package/core/commands/velocity.ts +149 -0
- package/core/domain/velocity.ts +470 -0
- package/core/index.ts +1 -0
- package/core/schemas/index.ts +2 -0
- package/core/schemas/velocity.ts +103 -0
- package/core/storage/index.ts +2 -1
- package/core/storage/velocity-storage.ts +149 -0
- package/core/types/agentic.ts +2 -0
- package/dist/bin/prjct.mjs +921 -278
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.14.0] - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- add sprint-based velocity calculation with trend detection (PRJ-296) (#156)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.14.0] - 2026-02-09
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
- **Velocity Dashboard**: New `prjct velocity` command with sprint-by-sprint breakdown, trend detection, and estimation accuracy (PRJ-296)
|
|
14
|
+
- **Estimation Patterns**: Automatic detection of over/under estimation patterns by task category
|
|
15
|
+
- **Completion Projections**: Given remaining backlog points, projects estimated sprints and completion date
|
|
16
|
+
- **Velocity Context Injection**: Historical velocity data automatically injected into LLM task prompts for better estimation
|
|
17
|
+
|
|
18
|
+
### Implementation Details
|
|
19
|
+
|
|
20
|
+
**PRJ-296 — Sprint-Based Velocity Calculation**
|
|
21
|
+
New velocity subsystem that aggregates completed task data (from outcomes.jsonl) into sprint periods, calculates rolling velocity metrics, detects trends, and identifies estimation patterns.
|
|
22
|
+
|
|
23
|
+
Key changes:
|
|
24
|
+
- `core/schemas/velocity.ts` — Zod schemas: SprintVelocity, VelocityMetrics, VelocityConfig, EstimationPattern, CompletionProjection
|
|
25
|
+
- `core/domain/velocity.ts` — Velocity engine: sprint bucketing, linear regression trend detection, accuracy tracking, pattern detection, duration parsing, LLM context formatting
|
|
26
|
+
- `core/storage/velocity-storage.ts` — Write-through storage extending StorageManager with markdown generation
|
|
27
|
+
- `core/commands/velocity.ts` — Dashboard command with chalk-formatted output + registration
|
|
28
|
+
- `core/types/agentic.ts` — Extended `OrchestratorContext` with `velocityContext` field
|
|
29
|
+
- `core/agentic/orchestrator-executor.ts` — Loads velocity context in parallel via `Promise.all`
|
|
30
|
+
- `core/agentic/prompt-builder.ts` — Injects velocity into Section 6 (task context)
|
|
31
|
+
- `core/__tests__/domain/velocity.test.ts` — 35 new tests
|
|
32
|
+
|
|
33
|
+
### Learnings
|
|
34
|
+
- Derive story points from estimated duration via Fibonacci mapping when outcomes lack explicit point data
|
|
35
|
+
- Linear regression slope normalized by average velocity works well for trend detection (>10% = improving, <-10% = declining)
|
|
36
|
+
- Parallel loading pattern in orchestrator-executor (`Promise.all`) ensures zero-latency context enrichment
|
|
37
|
+
|
|
38
|
+
### Test Plan
|
|
39
|
+
|
|
40
|
+
#### For QA
|
|
41
|
+
1. Run `prjct velocity` on a project with outcomes data — verify sprint-by-sprint breakdown with points, tasks, accuracy
|
|
42
|
+
2. Run `prjct velocity` with no outcomes — verify graceful "No velocity data yet" message
|
|
43
|
+
3. Run `prjct velocity 89` — verify completion projection (sprints remaining + date)
|
|
44
|
+
4. Run `bun test core/__tests__/domain/velocity.test.ts` — 35 tests pass
|
|
45
|
+
5. Run `bun test` — all 805 tests pass
|
|
46
|
+
|
|
47
|
+
#### For Users
|
|
48
|
+
- **What changed:** New `prjct velocity` command shows sprint velocity, estimation accuracy trends, and completion projections. Velocity data is automatically injected into task prompts for better LLM estimation.
|
|
49
|
+
- **How to use:** Run `prjct velocity` after completing tasks with estimates. Add backlog points: `prjct velocity 89`
|
|
50
|
+
- **Breaking changes:** None
|
|
51
|
+
|
|
3
52
|
## [1.13.0] - 2026-02-09
|
|
4
53
|
|
|
5
54
|
### Features
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Velocity Engine Tests (PRJ-296)
|
|
3
|
+
*
|
|
4
|
+
* Tests for sprint aggregation, trend detection, estimation accuracy,
|
|
5
|
+
* pattern detection, projections, and graceful degradation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
import {
|
|
10
|
+
calculateVelocity,
|
|
11
|
+
detectTrend,
|
|
12
|
+
formatVelocityContext,
|
|
13
|
+
getSprintEnd,
|
|
14
|
+
getSprintStart,
|
|
15
|
+
parseDurationMinutes,
|
|
16
|
+
projectCompletion,
|
|
17
|
+
} from '../../domain/velocity'
|
|
18
|
+
import type { VelocityConfig } from '../../schemas/velocity'
|
|
19
|
+
import type { Outcome } from '../../types'
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Test Helpers
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
function makeOutcome(overrides: Partial<Outcome> = {}): Outcome {
|
|
26
|
+
return {
|
|
27
|
+
id: 'test-id',
|
|
28
|
+
sessionId: 'sess_test',
|
|
29
|
+
command: 'done',
|
|
30
|
+
task: 'Test task',
|
|
31
|
+
startedAt: '2026-01-20T10:00:00.000Z',
|
|
32
|
+
completedAt: '2026-01-20T11:30:00.000Z',
|
|
33
|
+
estimatedDuration: '1h 30m',
|
|
34
|
+
actualDuration: '1h 30m',
|
|
35
|
+
variance: '+0m',
|
|
36
|
+
completedAsPlanned: true,
|
|
37
|
+
qualityScore: 4,
|
|
38
|
+
tags: ['backend'],
|
|
39
|
+
...overrides,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG: VelocityConfig = {
|
|
44
|
+
sprintLengthDays: 7,
|
|
45
|
+
startDay: 'monday',
|
|
46
|
+
windowSize: 6,
|
|
47
|
+
accuracyTolerance: 20,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Sprint Boundary Tests
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
describe('Sprint Boundaries', () => {
|
|
55
|
+
it('should align sprint start to configured start day', () => {
|
|
56
|
+
// Wednesday Jan 22, 2026 → should roll back to Monday Jan 20
|
|
57
|
+
const date = new Date('2026-01-22T12:00:00Z')
|
|
58
|
+
const start = getSprintStart(date, DEFAULT_CONFIG)
|
|
59
|
+
expect(start.getDay()).toBe(1) // Monday
|
|
60
|
+
expect(start.getDate()).toBe(19) // Jan 19 (Monday)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should keep date if it IS the start day', () => {
|
|
64
|
+
// Monday Jan 20, 2026
|
|
65
|
+
const date = new Date('2026-01-19T12:00:00Z')
|
|
66
|
+
const start = getSprintStart(date, DEFAULT_CONFIG)
|
|
67
|
+
expect(start.getDay()).toBe(1) // Monday
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should calculate sprint end correctly', () => {
|
|
71
|
+
const start = new Date('2026-01-19T00:00:00Z')
|
|
72
|
+
const end = getSprintEnd(start, DEFAULT_CONFIG)
|
|
73
|
+
expect(end.getDate()).toBe(25) // 7 days later - 1
|
|
74
|
+
expect(end.getHours()).toBe(23)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle different start days', () => {
|
|
78
|
+
const config: VelocityConfig = { ...DEFAULT_CONFIG, startDay: 'friday' }
|
|
79
|
+
// Monday Jan 20 → should roll back to Friday Jan 17
|
|
80
|
+
const date = new Date('2026-01-20T12:00:00Z')
|
|
81
|
+
const start = getSprintStart(date, config)
|
|
82
|
+
expect(start.getDay()).toBe(5) // Friday
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Empty Data (Graceful Degradation)
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
describe('Empty Data', () => {
|
|
91
|
+
it('should return zero metrics for empty outcomes', () => {
|
|
92
|
+
const metrics = calculateVelocity([], DEFAULT_CONFIG)
|
|
93
|
+
expect(metrics.sprints).toEqual([])
|
|
94
|
+
expect(metrics.averageVelocity).toBe(0)
|
|
95
|
+
expect(metrics.velocityTrend).toBe('stable')
|
|
96
|
+
expect(metrics.estimationAccuracy).toBe(0)
|
|
97
|
+
expect(metrics.overEstimated).toEqual([])
|
|
98
|
+
expect(metrics.underEstimated).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should format empty velocity context', () => {
|
|
102
|
+
const metrics = calculateVelocity([], DEFAULT_CONFIG)
|
|
103
|
+
const context = formatVelocityContext(metrics)
|
|
104
|
+
expect(context).toBe('No velocity data available yet.')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should handle projection with zero velocity', () => {
|
|
108
|
+
const projection = projectCompletion(100, 0, DEFAULT_CONFIG)
|
|
109
|
+
expect(projection.sprints).toBe(0)
|
|
110
|
+
expect(projection.estimatedDate).toBe('')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Sprint Aggregation
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
describe('Sprint Aggregation', () => {
|
|
119
|
+
it('should group outcomes into sprints', () => {
|
|
120
|
+
const outcomes = [
|
|
121
|
+
// Sprint 1 (week of Jan 19)
|
|
122
|
+
makeOutcome({
|
|
123
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
124
|
+
estimatedDuration: '1h 30m',
|
|
125
|
+
actualDuration: '1h 30m',
|
|
126
|
+
}),
|
|
127
|
+
makeOutcome({
|
|
128
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
129
|
+
estimatedDuration: '30m',
|
|
130
|
+
actualDuration: '30m',
|
|
131
|
+
}),
|
|
132
|
+
// Sprint 2 (week of Jan 26)
|
|
133
|
+
makeOutcome({
|
|
134
|
+
completedAt: '2026-01-27T10:00:00.000Z',
|
|
135
|
+
estimatedDuration: '2h',
|
|
136
|
+
actualDuration: '2h',
|
|
137
|
+
}),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
141
|
+
expect(metrics.sprints.length).toBe(2)
|
|
142
|
+
expect(metrics.sprints[0].tasksCompleted).toBe(2)
|
|
143
|
+
expect(metrics.sprints[1].tasksCompleted).toBe(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should calculate points from estimated duration', () => {
|
|
147
|
+
const outcomes = [
|
|
148
|
+
// 90m estimated → 5 points (fibonacci mapping)
|
|
149
|
+
makeOutcome({
|
|
150
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
151
|
+
estimatedDuration: '1h 30m',
|
|
152
|
+
actualDuration: '1h 30m',
|
|
153
|
+
}),
|
|
154
|
+
// 45m estimated → 3 points
|
|
155
|
+
makeOutcome({
|
|
156
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
157
|
+
estimatedDuration: '45m',
|
|
158
|
+
actualDuration: '45m',
|
|
159
|
+
}),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
163
|
+
// Both in same sprint: 5 + 3 = 8 points
|
|
164
|
+
expect(metrics.sprints[0].pointsCompleted).toBe(8)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should calculate average velocity over window', () => {
|
|
168
|
+
// Create outcomes across 3 sprints
|
|
169
|
+
const outcomes = [
|
|
170
|
+
makeOutcome({
|
|
171
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
172
|
+
estimatedDuration: '1h 30m',
|
|
173
|
+
}),
|
|
174
|
+
makeOutcome({
|
|
175
|
+
completedAt: '2026-01-27T10:00:00.000Z',
|
|
176
|
+
estimatedDuration: '3h',
|
|
177
|
+
}),
|
|
178
|
+
makeOutcome({
|
|
179
|
+
completedAt: '2026-02-03T10:00:00.000Z',
|
|
180
|
+
estimatedDuration: '45m',
|
|
181
|
+
}),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
185
|
+
expect(metrics.sprints.length).toBe(3)
|
|
186
|
+
expect(metrics.averageVelocity).toBeGreaterThan(0)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Trend Detection
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
describe('Trend Detection', () => {
|
|
195
|
+
it('should detect stable trend with consistent velocity', () => {
|
|
196
|
+
const sprints = [
|
|
197
|
+
{
|
|
198
|
+
sprintNumber: 1,
|
|
199
|
+
startDate: '',
|
|
200
|
+
endDate: '',
|
|
201
|
+
pointsCompleted: 10,
|
|
202
|
+
tasksCompleted: 3,
|
|
203
|
+
avgVariance: 0,
|
|
204
|
+
estimationAccuracy: 80,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
sprintNumber: 2,
|
|
208
|
+
startDate: '',
|
|
209
|
+
endDate: '',
|
|
210
|
+
pointsCompleted: 11,
|
|
211
|
+
tasksCompleted: 3,
|
|
212
|
+
avgVariance: 0,
|
|
213
|
+
estimationAccuracy: 80,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
sprintNumber: 3,
|
|
217
|
+
startDate: '',
|
|
218
|
+
endDate: '',
|
|
219
|
+
pointsCompleted: 10,
|
|
220
|
+
tasksCompleted: 3,
|
|
221
|
+
avgVariance: 0,
|
|
222
|
+
estimationAccuracy: 80,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
sprintNumber: 4,
|
|
226
|
+
startDate: '',
|
|
227
|
+
endDate: '',
|
|
228
|
+
pointsCompleted: 11,
|
|
229
|
+
tasksCompleted: 3,
|
|
230
|
+
avgVariance: 0,
|
|
231
|
+
estimationAccuracy: 80,
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
expect(detectTrend(sprints)).toBe('stable')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should detect improving trend', () => {
|
|
238
|
+
const sprints = [
|
|
239
|
+
{
|
|
240
|
+
sprintNumber: 1,
|
|
241
|
+
startDate: '',
|
|
242
|
+
endDate: '',
|
|
243
|
+
pointsCompleted: 5,
|
|
244
|
+
tasksCompleted: 2,
|
|
245
|
+
avgVariance: 0,
|
|
246
|
+
estimationAccuracy: 80,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
sprintNumber: 2,
|
|
250
|
+
startDate: '',
|
|
251
|
+
endDate: '',
|
|
252
|
+
pointsCompleted: 10,
|
|
253
|
+
tasksCompleted: 3,
|
|
254
|
+
avgVariance: 0,
|
|
255
|
+
estimationAccuracy: 80,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
sprintNumber: 3,
|
|
259
|
+
startDate: '',
|
|
260
|
+
endDate: '',
|
|
261
|
+
pointsCompleted: 15,
|
|
262
|
+
tasksCompleted: 4,
|
|
263
|
+
avgVariance: 0,
|
|
264
|
+
estimationAccuracy: 80,
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
sprintNumber: 4,
|
|
268
|
+
startDate: '',
|
|
269
|
+
endDate: '',
|
|
270
|
+
pointsCompleted: 20,
|
|
271
|
+
tasksCompleted: 5,
|
|
272
|
+
avgVariance: 0,
|
|
273
|
+
estimationAccuracy: 80,
|
|
274
|
+
},
|
|
275
|
+
]
|
|
276
|
+
expect(detectTrend(sprints)).toBe('improving')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should detect declining trend', () => {
|
|
280
|
+
const sprints = [
|
|
281
|
+
{
|
|
282
|
+
sprintNumber: 1,
|
|
283
|
+
startDate: '',
|
|
284
|
+
endDate: '',
|
|
285
|
+
pointsCompleted: 20,
|
|
286
|
+
tasksCompleted: 5,
|
|
287
|
+
avgVariance: 0,
|
|
288
|
+
estimationAccuracy: 80,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
sprintNumber: 2,
|
|
292
|
+
startDate: '',
|
|
293
|
+
endDate: '',
|
|
294
|
+
pointsCompleted: 15,
|
|
295
|
+
tasksCompleted: 4,
|
|
296
|
+
avgVariance: 0,
|
|
297
|
+
estimationAccuracy: 80,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
sprintNumber: 3,
|
|
301
|
+
startDate: '',
|
|
302
|
+
endDate: '',
|
|
303
|
+
pointsCompleted: 10,
|
|
304
|
+
tasksCompleted: 3,
|
|
305
|
+
avgVariance: 0,
|
|
306
|
+
estimationAccuracy: 80,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
sprintNumber: 4,
|
|
310
|
+
startDate: '',
|
|
311
|
+
endDate: '',
|
|
312
|
+
pointsCompleted: 5,
|
|
313
|
+
tasksCompleted: 2,
|
|
314
|
+
avgVariance: 0,
|
|
315
|
+
estimationAccuracy: 80,
|
|
316
|
+
},
|
|
317
|
+
]
|
|
318
|
+
expect(detectTrend(sprints)).toBe('declining')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should return stable with fewer than 3 sprints', () => {
|
|
322
|
+
const sprints = [
|
|
323
|
+
{
|
|
324
|
+
sprintNumber: 1,
|
|
325
|
+
startDate: '',
|
|
326
|
+
endDate: '',
|
|
327
|
+
pointsCompleted: 5,
|
|
328
|
+
tasksCompleted: 2,
|
|
329
|
+
avgVariance: 0,
|
|
330
|
+
estimationAccuracy: 80,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
sprintNumber: 2,
|
|
334
|
+
startDate: '',
|
|
335
|
+
endDate: '',
|
|
336
|
+
pointsCompleted: 20,
|
|
337
|
+
tasksCompleted: 5,
|
|
338
|
+
avgVariance: 0,
|
|
339
|
+
estimationAccuracy: 80,
|
|
340
|
+
},
|
|
341
|
+
]
|
|
342
|
+
expect(detectTrend(sprints)).toBe('stable')
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// Estimation Accuracy
|
|
348
|
+
// =============================================================================
|
|
349
|
+
|
|
350
|
+
describe('Estimation Accuracy', () => {
|
|
351
|
+
it('should calculate 100% accuracy when all estimates are exact', () => {
|
|
352
|
+
const outcomes = [
|
|
353
|
+
makeOutcome({
|
|
354
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
355
|
+
estimatedDuration: '1h',
|
|
356
|
+
actualDuration: '1h',
|
|
357
|
+
variance: '+0m',
|
|
358
|
+
}),
|
|
359
|
+
makeOutcome({
|
|
360
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
361
|
+
estimatedDuration: '30m',
|
|
362
|
+
actualDuration: '30m',
|
|
363
|
+
variance: '+0m',
|
|
364
|
+
}),
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
368
|
+
expect(metrics.estimationAccuracy).toBe(100)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('should count tasks within tolerance as accurate', () => {
|
|
372
|
+
// 10% variance is within 20% tolerance
|
|
373
|
+
const outcomes = [
|
|
374
|
+
makeOutcome({
|
|
375
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
376
|
+
estimatedDuration: '1h',
|
|
377
|
+
actualDuration: '1h 6m', // +10%
|
|
378
|
+
variance: '+6m',
|
|
379
|
+
}),
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
383
|
+
expect(metrics.estimationAccuracy).toBe(100) // Within 20% tolerance
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('should flag tasks outside tolerance as inaccurate', () => {
|
|
387
|
+
// 50% over is outside 20% tolerance
|
|
388
|
+
const outcomes = [
|
|
389
|
+
makeOutcome({
|
|
390
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
391
|
+
estimatedDuration: '1h',
|
|
392
|
+
actualDuration: '1h 30m', // +50%
|
|
393
|
+
variance: '+30m',
|
|
394
|
+
}),
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
398
|
+
expect(metrics.estimationAccuracy).toBe(0) // Outside 20% tolerance
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// =============================================================================
|
|
403
|
+
// Estimation Pattern Detection
|
|
404
|
+
// =============================================================================
|
|
405
|
+
|
|
406
|
+
describe('Estimation Patterns', () => {
|
|
407
|
+
it('should detect under-estimation patterns by category', () => {
|
|
408
|
+
// Backend tasks consistently take longer
|
|
409
|
+
const outcomes = [
|
|
410
|
+
makeOutcome({
|
|
411
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
412
|
+
estimatedDuration: '1h',
|
|
413
|
+
actualDuration: '1h 30m',
|
|
414
|
+
tags: ['backend'],
|
|
415
|
+
}),
|
|
416
|
+
makeOutcome({
|
|
417
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
418
|
+
estimatedDuration: '2h',
|
|
419
|
+
actualDuration: '3h',
|
|
420
|
+
tags: ['backend'],
|
|
421
|
+
}),
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
425
|
+
expect(metrics.underEstimated.length).toBeGreaterThan(0)
|
|
426
|
+
expect(metrics.underEstimated[0].category).toBe('backend')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('should detect over-estimation patterns', () => {
|
|
430
|
+
// Frontend tasks consistently finish faster
|
|
431
|
+
const outcomes = [
|
|
432
|
+
makeOutcome({
|
|
433
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
434
|
+
estimatedDuration: '2h',
|
|
435
|
+
actualDuration: '1h',
|
|
436
|
+
tags: ['frontend'],
|
|
437
|
+
}),
|
|
438
|
+
makeOutcome({
|
|
439
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
440
|
+
estimatedDuration: '3h',
|
|
441
|
+
actualDuration: '1h 30m',
|
|
442
|
+
tags: ['frontend'],
|
|
443
|
+
}),
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
447
|
+
expect(metrics.overEstimated.length).toBeGreaterThan(0)
|
|
448
|
+
expect(metrics.overEstimated[0].category).toBe('frontend')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('should not report patterns with fewer than 2 data points', () => {
|
|
452
|
+
const outcomes = [
|
|
453
|
+
makeOutcome({
|
|
454
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
455
|
+
estimatedDuration: '1h',
|
|
456
|
+
actualDuration: '2h',
|
|
457
|
+
tags: ['rare-category'],
|
|
458
|
+
}),
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
462
|
+
const found = metrics.underEstimated.find((p) => p.category === 'rare-category')
|
|
463
|
+
expect(found).toBeUndefined()
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// =============================================================================
|
|
468
|
+
// Completion Projection
|
|
469
|
+
// =============================================================================
|
|
470
|
+
|
|
471
|
+
describe('Completion Projection', () => {
|
|
472
|
+
it('should project sprints based on velocity', () => {
|
|
473
|
+
const projection = projectCompletion(100, 25, DEFAULT_CONFIG)
|
|
474
|
+
expect(projection.sprints).toBe(4) // 100 / 25 = 4
|
|
475
|
+
expect(projection.totalPoints).toBe(100)
|
|
476
|
+
expect(projection.estimatedDate).toBeTruthy()
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('should round up to next sprint', () => {
|
|
480
|
+
const projection = projectCompletion(30, 25, DEFAULT_CONFIG)
|
|
481
|
+
expect(projection.sprints).toBe(2) // ceil(30/25) = 2
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('should handle zero velocity gracefully', () => {
|
|
485
|
+
const projection = projectCompletion(100, 0, DEFAULT_CONFIG)
|
|
486
|
+
expect(projection.sprints).toBe(0)
|
|
487
|
+
expect(projection.estimatedDate).toBe('')
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('should calculate estimated date from sprint count', () => {
|
|
491
|
+
const projection = projectCompletion(50, 25, DEFAULT_CONFIG)
|
|
492
|
+
expect(projection.sprints).toBe(2) // 2 sprints × 7 days = 14 days from now
|
|
493
|
+
const date = new Date(projection.estimatedDate)
|
|
494
|
+
const now = new Date()
|
|
495
|
+
const diffDays = Math.round((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
496
|
+
expect(diffDays).toBeGreaterThanOrEqual(13) // ~14 days, allow for rounding
|
|
497
|
+
expect(diffDays).toBeLessThanOrEqual(15)
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// =============================================================================
|
|
502
|
+
// Duration Parsing
|
|
503
|
+
// =============================================================================
|
|
504
|
+
|
|
505
|
+
describe('Duration Parsing', () => {
|
|
506
|
+
it('should parse hours', () => {
|
|
507
|
+
expect(parseDurationMinutes('2h')).toBe(120)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('should parse minutes', () => {
|
|
511
|
+
expect(parseDurationMinutes('45m')).toBe(45)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('should parse hours and minutes', () => {
|
|
515
|
+
expect(parseDurationMinutes('1h 30m')).toBe(90)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('should parse compact format', () => {
|
|
519
|
+
expect(parseDurationMinutes('2h30m')).toBe(150)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('should parse seconds as 1 minute minimum', () => {
|
|
523
|
+
expect(parseDurationMinutes('45s')).toBe(1)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('should return 0 for empty string', () => {
|
|
527
|
+
expect(parseDurationMinutes('')).toBe(0)
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// =============================================================================
|
|
532
|
+
// Velocity Context Formatting
|
|
533
|
+
// =============================================================================
|
|
534
|
+
|
|
535
|
+
describe('Velocity Context Formatting', () => {
|
|
536
|
+
it('should format velocity summary for LLM injection', () => {
|
|
537
|
+
const outcomes = [
|
|
538
|
+
makeOutcome({
|
|
539
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
540
|
+
estimatedDuration: '1h 30m',
|
|
541
|
+
actualDuration: '1h 30m',
|
|
542
|
+
}),
|
|
543
|
+
makeOutcome({
|
|
544
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
545
|
+
estimatedDuration: '45m',
|
|
546
|
+
actualDuration: '45m',
|
|
547
|
+
}),
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
551
|
+
const context = formatVelocityContext(metrics)
|
|
552
|
+
|
|
553
|
+
expect(context).toContain('pts/sprint')
|
|
554
|
+
expect(context).toContain('Estimation accuracy')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('should include under-estimation warnings', () => {
|
|
558
|
+
const outcomes = [
|
|
559
|
+
makeOutcome({
|
|
560
|
+
completedAt: '2026-01-20T10:00:00.000Z',
|
|
561
|
+
estimatedDuration: '1h',
|
|
562
|
+
actualDuration: '2h',
|
|
563
|
+
tags: ['backend'],
|
|
564
|
+
}),
|
|
565
|
+
makeOutcome({
|
|
566
|
+
completedAt: '2026-01-21T10:00:00.000Z',
|
|
567
|
+
estimatedDuration: '1h',
|
|
568
|
+
actualDuration: '2h',
|
|
569
|
+
tags: ['backend'],
|
|
570
|
+
}),
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
const metrics = calculateVelocity(outcomes, DEFAULT_CONFIG)
|
|
574
|
+
const context = formatVelocityContext(metrics)
|
|
575
|
+
|
|
576
|
+
expect(context).toContain('backend')
|
|
577
|
+
expect(context).toContain('longer than estimated')
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
// =============================================================================
|
|
582
|
+
// Configuration
|
|
583
|
+
// =============================================================================
|
|
584
|
+
|
|
585
|
+
describe('Configuration', () => {
|
|
586
|
+
it('should respect custom sprint length', () => {
|
|
587
|
+
const config: VelocityConfig = { ...DEFAULT_CONFIG, sprintLengthDays: 14 }
|
|
588
|
+
const outcomes = [
|
|
589
|
+
makeOutcome({ completedAt: '2026-01-20T10:00:00.000Z' }),
|
|
590
|
+
makeOutcome({ completedAt: '2026-01-27T10:00:00.000Z' }), // 7 days later, same 14-day sprint
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
const metrics = calculateVelocity(outcomes, config)
|
|
594
|
+
// Both outcomes should be in the same sprint (14-day window)
|
|
595
|
+
expect(metrics.sprints.length).toBe(1)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('should respect custom window size', () => {
|
|
599
|
+
const config: VelocityConfig = { ...DEFAULT_CONFIG, windowSize: 2 }
|
|
600
|
+
// Create outcomes across 4 sprints
|
|
601
|
+
const outcomes = [
|
|
602
|
+
makeOutcome({ completedAt: '2026-01-20T10:00:00.000Z', estimatedDuration: '1h' }),
|
|
603
|
+
makeOutcome({ completedAt: '2026-01-27T10:00:00.000Z', estimatedDuration: '2h' }),
|
|
604
|
+
makeOutcome({ completedAt: '2026-02-03T10:00:00.000Z', estimatedDuration: '3h' }),
|
|
605
|
+
makeOutcome({ completedAt: '2026-02-10T10:00:00.000Z', estimatedDuration: '4h' }),
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
const metrics = calculateVelocity(outcomes, config)
|
|
609
|
+
// All 4 sprints exist
|
|
610
|
+
expect(metrics.sprints.length).toBe(4)
|
|
611
|
+
// But average is only from last 2 sprints
|
|
612
|
+
// Sprint 3: ~3 points (45m typical = 3), Sprint 4: ~5 points (90m typical = 5)
|
|
613
|
+
expect(metrics.averageVelocity).toBeGreaterThan(0)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('should use defaults when config values are missing', () => {
|
|
617
|
+
const config: VelocityConfig = {} // All defaults
|
|
618
|
+
const outcomes = [makeOutcome({ completedAt: '2026-01-20T10:00:00.000Z' })]
|
|
619
|
+
|
|
620
|
+
const metrics = calculateVelocity(outcomes, config)
|
|
621
|
+
expect(metrics.sprints.length).toBe(1)
|
|
622
|
+
})
|
|
623
|
+
})
|