@grainulation/harvest 1.0.0 → 1.0.2
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/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +93 -0
- package/README.md +44 -45
- package/bin/harvest.js +135 -60
- package/lib/analyzer.js +33 -26
- package/lib/calibration.js +199 -32
- package/lib/dashboard.js +54 -32
- package/lib/decay.js +224 -18
- package/lib/farmer.js +54 -38
- package/lib/harvest-card.js +475 -0
- package/lib/patterns.js +64 -43
- package/lib/report.js +243 -61
- package/lib/server.js +323 -150
- package/lib/templates.js +47 -32
- package/lib/token-tracker.js +288 -0
- package/lib/tokens.js +317 -0
- package/lib/velocity.js +68 -40
- package/lib/wrapped.js +489 -0
- package/package.json +10 -3
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token cost tracking and attribution.
|
|
5
|
+
*
|
|
6
|
+
* Tracks token consumption from Agent SDK data per sprint, per user, per model.
|
|
7
|
+
* Computes cost-per-verified-claim as the key efficiency metric.
|
|
8
|
+
*
|
|
9
|
+
* Agent SDK exposes: total_cost_usd, input_tokens, output_tokens,
|
|
10
|
+
* cache_creation_input_tokens, cache_read_input_tokens per query() call.
|
|
11
|
+
*
|
|
12
|
+
* Pricing (per MTok, Opus 4.6 defaults):
|
|
13
|
+
* input: $5.00
|
|
14
|
+
* output: $25.00
|
|
15
|
+
* cache_read: $0.50 (90% discount on input)
|
|
16
|
+
* cache_write: $6.25 (1.25x input)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PRICING = {
|
|
20
|
+
"claude-opus-4-6": {
|
|
21
|
+
input: 5.0,
|
|
22
|
+
output: 25.0,
|
|
23
|
+
cacheRead: 0.5,
|
|
24
|
+
cacheWrite: 6.25,
|
|
25
|
+
},
|
|
26
|
+
"claude-sonnet-4-6": {
|
|
27
|
+
input: 3.0,
|
|
28
|
+
output: 15.0,
|
|
29
|
+
cacheRead: 0.3,
|
|
30
|
+
cacheWrite: 3.75,
|
|
31
|
+
},
|
|
32
|
+
"claude-haiku-4-5": {
|
|
33
|
+
input: 0.8,
|
|
34
|
+
output: 4.0,
|
|
35
|
+
cacheRead: 0.08,
|
|
36
|
+
cacheWrite: 1.0,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DEFAULT_MODEL = "claude-opus-4-6";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compute cost from token counts and pricing.
|
|
44
|
+
* @param {object} usage - { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens }
|
|
45
|
+
* @param {string} [model] - Model identifier
|
|
46
|
+
* @param {object} [customPricing] - Override pricing
|
|
47
|
+
* @returns {object} - { totalCostUsd, breakdown }
|
|
48
|
+
*/
|
|
49
|
+
function computeCost(usage, model, customPricing) {
|
|
50
|
+
const pricing =
|
|
51
|
+
(customPricing && customPricing[model]) ||
|
|
52
|
+
DEFAULT_PRICING[model] ||
|
|
53
|
+
DEFAULT_PRICING[DEFAULT_MODEL];
|
|
54
|
+
|
|
55
|
+
const input = ((usage.input_tokens || 0) / 1_000_000) * pricing.input;
|
|
56
|
+
const output = ((usage.output_tokens || 0) / 1_000_000) * pricing.output;
|
|
57
|
+
const cacheRead =
|
|
58
|
+
((usage.cache_read_input_tokens || 0) / 1_000_000) * pricing.cacheRead;
|
|
59
|
+
const cacheWrite =
|
|
60
|
+
((usage.cache_creation_input_tokens || 0) / 1_000_000) * pricing.cacheWrite;
|
|
61
|
+
|
|
62
|
+
const totalCostUsd = round6(input + output + cacheRead + cacheWrite);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
totalCostUsd,
|
|
66
|
+
breakdown: {
|
|
67
|
+
input: round6(input),
|
|
68
|
+
output: round6(output),
|
|
69
|
+
cacheRead: round6(cacheRead),
|
|
70
|
+
cacheWrite: round6(cacheWrite),
|
|
71
|
+
},
|
|
72
|
+
tokens: {
|
|
73
|
+
input: usage.input_tokens || 0,
|
|
74
|
+
output: usage.output_tokens || 0,
|
|
75
|
+
cacheRead: usage.cache_read_input_tokens || 0,
|
|
76
|
+
cacheWrite: usage.cache_creation_input_tokens || 0,
|
|
77
|
+
total:
|
|
78
|
+
(usage.input_tokens || 0) +
|
|
79
|
+
(usage.output_tokens || 0) +
|
|
80
|
+
(usage.cache_read_input_tokens || 0) +
|
|
81
|
+
(usage.cache_creation_input_tokens || 0),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Analyze token economics across sprints.
|
|
88
|
+
*
|
|
89
|
+
* Each sprint may have a `tokenUsage` field (from Agent SDK interception)
|
|
90
|
+
* or a `compilation.tokenUsage` field. Format:
|
|
91
|
+
* { input_tokens, output_tokens, cache_read_input_tokens,
|
|
92
|
+
* cache_creation_input_tokens, model, total_cost_usd }
|
|
93
|
+
*
|
|
94
|
+
* @param {Array} sprints - Sprint objects
|
|
95
|
+
* @returns {object} - Token economics report
|
|
96
|
+
*/
|
|
97
|
+
function analyzeTokens(sprints) {
|
|
98
|
+
const sprintCosts = [];
|
|
99
|
+
let totalCostUsd = 0;
|
|
100
|
+
let totalTokens = 0;
|
|
101
|
+
let totalClaims = 0;
|
|
102
|
+
let totalVerifiedClaims = 0;
|
|
103
|
+
|
|
104
|
+
for (const sprint of sprints) {
|
|
105
|
+
const usage = extractUsage(sprint);
|
|
106
|
+
const claimCount = sprint.claims.length;
|
|
107
|
+
const verifiedCount = countVerifiedClaims(sprint);
|
|
108
|
+
|
|
109
|
+
totalClaims += claimCount;
|
|
110
|
+
totalVerifiedClaims += verifiedCount;
|
|
111
|
+
|
|
112
|
+
if (!usage) {
|
|
113
|
+
sprintCosts.push({
|
|
114
|
+
sprint: sprint.name,
|
|
115
|
+
cost: null,
|
|
116
|
+
tokens: null,
|
|
117
|
+
claimCount,
|
|
118
|
+
verifiedClaims: verifiedCount,
|
|
119
|
+
costPerClaim: null,
|
|
120
|
+
costPerVerifiedClaim: null,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const model = usage.model || DEFAULT_MODEL;
|
|
126
|
+
const cost =
|
|
127
|
+
usage.total_cost_usd != null
|
|
128
|
+
? {
|
|
129
|
+
totalCostUsd: usage.total_cost_usd,
|
|
130
|
+
breakdown: null,
|
|
131
|
+
tokens: tokenSummary(usage),
|
|
132
|
+
}
|
|
133
|
+
: computeCost(usage, model);
|
|
134
|
+
|
|
135
|
+
totalCostUsd += cost.totalCostUsd;
|
|
136
|
+
totalTokens += cost.tokens.total;
|
|
137
|
+
|
|
138
|
+
const costPerClaim =
|
|
139
|
+
claimCount > 0 ? round6(cost.totalCostUsd / claimCount) : null;
|
|
140
|
+
const costPerVerifiedClaim =
|
|
141
|
+
verifiedCount > 0 ? round6(cost.totalCostUsd / verifiedCount) : null;
|
|
142
|
+
|
|
143
|
+
sprintCosts.push({
|
|
144
|
+
sprint: sprint.name,
|
|
145
|
+
model,
|
|
146
|
+
cost: cost.totalCostUsd,
|
|
147
|
+
breakdown: cost.breakdown,
|
|
148
|
+
tokens: cost.tokens,
|
|
149
|
+
claimCount,
|
|
150
|
+
verifiedClaims: verifiedCount,
|
|
151
|
+
costPerClaim,
|
|
152
|
+
costPerVerifiedClaim,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Cache efficiency: ratio of cache reads to total input
|
|
157
|
+
const totalCacheReads = sprintCosts.reduce(
|
|
158
|
+
(a, s) => a + (s.tokens ? s.tokens.cacheRead : 0),
|
|
159
|
+
0,
|
|
160
|
+
);
|
|
161
|
+
const totalInput = sprintCosts.reduce(
|
|
162
|
+
(a, s) =>
|
|
163
|
+
a +
|
|
164
|
+
(s.tokens
|
|
165
|
+
? s.tokens.input + s.tokens.cacheRead + s.tokens.cacheWrite
|
|
166
|
+
: 0),
|
|
167
|
+
0,
|
|
168
|
+
);
|
|
169
|
+
const cacheHitRate =
|
|
170
|
+
totalInput > 0 ? Math.round((totalCacheReads / totalInput) * 100) : null;
|
|
171
|
+
|
|
172
|
+
const sprintsWithCost = sprintCosts.filter((s) => s.cost !== null);
|
|
173
|
+
const avgCostPerSprint =
|
|
174
|
+
sprintsWithCost.length > 0
|
|
175
|
+
? round6(totalCostUsd / sprintsWithCost.length)
|
|
176
|
+
: null;
|
|
177
|
+
const costPerVerifiedClaim =
|
|
178
|
+
totalVerifiedClaims > 0 ? round6(totalCostUsd / totalVerifiedClaims) : null;
|
|
179
|
+
const costPerClaim =
|
|
180
|
+
totalClaims > 0 ? round6(totalCostUsd / totalClaims) : null;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
summary: {
|
|
184
|
+
totalSprints: sprints.length,
|
|
185
|
+
sprintsWithUsageData: sprintsWithCost.length,
|
|
186
|
+
totalCostUsd: round6(totalCostUsd),
|
|
187
|
+
totalTokens,
|
|
188
|
+
totalClaims,
|
|
189
|
+
totalVerifiedClaims,
|
|
190
|
+
costPerClaim,
|
|
191
|
+
costPerVerifiedClaim,
|
|
192
|
+
avgCostPerSprint,
|
|
193
|
+
cacheHitRate,
|
|
194
|
+
},
|
|
195
|
+
perSprint: sprintCosts,
|
|
196
|
+
insight: generateTokenInsight(
|
|
197
|
+
sprintCosts,
|
|
198
|
+
costPerVerifiedClaim,
|
|
199
|
+
cacheHitRate,
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Extract token usage data from a sprint.
|
|
206
|
+
*/
|
|
207
|
+
function extractUsage(sprint) {
|
|
208
|
+
// Direct tokenUsage on sprint
|
|
209
|
+
if (sprint.tokenUsage) return sprint.tokenUsage;
|
|
210
|
+
// From compilation metadata
|
|
211
|
+
if (sprint.compilation && sprint.compilation.tokenUsage)
|
|
212
|
+
return sprint.compilation.tokenUsage;
|
|
213
|
+
// From compilation.meta
|
|
214
|
+
if (
|
|
215
|
+
sprint.compilation &&
|
|
216
|
+
sprint.compilation.meta &&
|
|
217
|
+
sprint.compilation.meta.tokenUsage
|
|
218
|
+
) {
|
|
219
|
+
return sprint.compilation.meta.tokenUsage;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Count claims that survived compilation (verified).
|
|
226
|
+
* A claim is "verified" if it has evidence >= documented, or status is active/resolved.
|
|
227
|
+
*/
|
|
228
|
+
function countVerifiedClaims(sprint) {
|
|
229
|
+
if (sprint.compilation && sprint.compilation.claims) {
|
|
230
|
+
return sprint.compilation.claims.length;
|
|
231
|
+
}
|
|
232
|
+
// Fallback: count claims with evidence better than stated/web
|
|
233
|
+
return sprint.claims.filter((c) => {
|
|
234
|
+
const tier = c.evidence || "stated";
|
|
235
|
+
return tier !== "stated" && tier !== "web";
|
|
236
|
+
}).length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tokenSummary(usage) {
|
|
240
|
+
return {
|
|
241
|
+
input: usage.input_tokens || 0,
|
|
242
|
+
output: usage.output_tokens || 0,
|
|
243
|
+
cacheRead: usage.cache_read_input_tokens || 0,
|
|
244
|
+
cacheWrite: usage.cache_creation_input_tokens || 0,
|
|
245
|
+
total:
|
|
246
|
+
(usage.input_tokens || 0) +
|
|
247
|
+
(usage.output_tokens || 0) +
|
|
248
|
+
(usage.cache_read_input_tokens || 0) +
|
|
249
|
+
(usage.cache_creation_input_tokens || 0),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function generateTokenInsight(sprintCosts, costPerVerified, cacheHitRate) {
|
|
254
|
+
const parts = [];
|
|
255
|
+
const withCost = sprintCosts.filter((s) => s.cost !== null);
|
|
256
|
+
|
|
257
|
+
if (withCost.length === 0) {
|
|
258
|
+
return "No token usage data found. Token tracking requires Agent SDK integration.";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (costPerVerified !== null) {
|
|
262
|
+
if (costPerVerified < 0.01) {
|
|
263
|
+
parts.push(
|
|
264
|
+
`Excellent efficiency: $${costPerVerified.toFixed(4)} per verified claim.`,
|
|
265
|
+
);
|
|
266
|
+
} else if (costPerVerified < 0.05) {
|
|
267
|
+
parts.push(
|
|
268
|
+
`Good efficiency: $${costPerVerified.toFixed(4)} per verified claim.`,
|
|
269
|
+
);
|
|
270
|
+
} else {
|
|
271
|
+
parts.push(
|
|
272
|
+
`Cost per verified claim: $${costPerVerified.toFixed(4)}. Consider improving cache usage or reducing exploratory queries.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (cacheHitRate !== null) {
|
|
278
|
+
if (cacheHitRate > 50) {
|
|
279
|
+
parts.push(
|
|
280
|
+
`Strong cache utilization at ${cacheHitRate}% -- prompt caching is working well.`,
|
|
281
|
+
);
|
|
282
|
+
} else if (cacheHitRate > 20) {
|
|
283
|
+
parts.push(
|
|
284
|
+
`Cache hit rate: ${cacheHitRate}%. Room to improve by reusing system prompts across operations.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Trend: cost per claim improving over time?
|
|
290
|
+
if (withCost.length >= 3) {
|
|
291
|
+
const firstHalf = withCost.slice(0, Math.floor(withCost.length / 2));
|
|
292
|
+
const secondHalf = withCost.slice(Math.floor(withCost.length / 2));
|
|
293
|
+
const avgFirst = avg(firstHalf.map((s) => s.costPerClaim).filter(Boolean));
|
|
294
|
+
const avgSecond = avg(
|
|
295
|
+
secondHalf.map((s) => s.costPerClaim).filter(Boolean),
|
|
296
|
+
);
|
|
297
|
+
if (avgFirst && avgSecond && avgSecond < avgFirst * 0.85) {
|
|
298
|
+
parts.push(
|
|
299
|
+
"Research is getting cheaper: cost per claim is trending down across recent sprints.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return parts.length > 0
|
|
305
|
+
? parts.join(" ")
|
|
306
|
+
: "Token usage tracked across sprints.";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function avg(arr) {
|
|
310
|
+
return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function round6(n) {
|
|
314
|
+
return Math.round(n * 1_000_000) / 1_000_000;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = { analyzeTokens, computeCost, DEFAULT_PRICING };
|
package/lib/velocity.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Sprint timing and phase analysis.
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const PHASE_PREFIXES = {
|
|
14
|
-
cal:
|
|
15
|
-
burn:
|
|
16
|
-
d:
|
|
17
|
-
r:
|
|
18
|
-
p:
|
|
19
|
-
e:
|
|
20
|
-
f:
|
|
21
|
-
x:
|
|
22
|
-
w:
|
|
14
|
+
cal: "calibration",
|
|
15
|
+
burn: "control-burn",
|
|
16
|
+
d: "define",
|
|
17
|
+
r: "research",
|
|
18
|
+
p: "prototype",
|
|
19
|
+
e: "evaluate",
|
|
20
|
+
f: "feedback",
|
|
21
|
+
x: "challenge",
|
|
22
|
+
w: "witness",
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
function measureVelocity(sprints) {
|
|
@@ -31,16 +31,16 @@ function measureVelocity(sprints) {
|
|
|
31
31
|
|
|
32
32
|
// Extract timestamps from claims
|
|
33
33
|
const claimDates = claims
|
|
34
|
-
.map(c => c.created || c.date || c.timestamp)
|
|
34
|
+
.map((c) => c.created || c.date || c.timestamp)
|
|
35
35
|
.filter(Boolean)
|
|
36
|
-
.map(d => new Date(d))
|
|
37
|
-
.filter(d => !isNaN(d.getTime()))
|
|
36
|
+
.map((d) => new Date(d))
|
|
37
|
+
.filter((d) => !isNaN(d.getTime()))
|
|
38
38
|
.sort((a, b) => a - b);
|
|
39
39
|
|
|
40
40
|
// Extract timestamps from git log
|
|
41
41
|
const gitDates = gitLog
|
|
42
|
-
.map(g => new Date(g.date))
|
|
43
|
-
.filter(d => !isNaN(d.getTime()))
|
|
42
|
+
.map((g) => new Date(g.date))
|
|
43
|
+
.filter((d) => !isNaN(d.getTime()))
|
|
44
44
|
.sort((a, b) => a - b);
|
|
45
45
|
|
|
46
46
|
// Use whichever source has data
|
|
@@ -53,15 +53,18 @@ function measureVelocity(sprints) {
|
|
|
53
53
|
claimsPerDay: null,
|
|
54
54
|
phases: extractPhaseTimings(claims),
|
|
55
55
|
stalls: [],
|
|
56
|
-
note:
|
|
56
|
+
note: "Insufficient timestamp data.",
|
|
57
57
|
});
|
|
58
58
|
continue;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const startDate = allDates[0];
|
|
62
62
|
const endDate = allDates[allDates.length - 1];
|
|
63
|
-
const durationDays = Math.max(
|
|
64
|
-
|
|
63
|
+
const durationDays = Math.max(
|
|
64
|
+
1,
|
|
65
|
+
Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)),
|
|
66
|
+
);
|
|
67
|
+
const claimsPerDay = Math.round((claims.length / durationDays) * 10) / 10;
|
|
65
68
|
|
|
66
69
|
// Detect stalls: gaps > 2 days between consecutive activity
|
|
67
70
|
const stalls = [];
|
|
@@ -69,8 +72,8 @@ function measureVelocity(sprints) {
|
|
|
69
72
|
const gap = (allDates[i] - allDates[i - 1]) / (1000 * 60 * 60 * 24);
|
|
70
73
|
if (gap > 2) {
|
|
71
74
|
stalls.push({
|
|
72
|
-
afterDate: allDates[i - 1].toISOString().split(
|
|
73
|
-
beforeDate: allDates[i].toISOString().split(
|
|
75
|
+
afterDate: allDates[i - 1].toISOString().split("T")[0],
|
|
76
|
+
beforeDate: allDates[i].toISOString().split("T")[0],
|
|
74
77
|
gapDays: Math.round(gap * 10) / 10,
|
|
75
78
|
});
|
|
76
79
|
}
|
|
@@ -78,8 +81,8 @@ function measureVelocity(sprints) {
|
|
|
78
81
|
|
|
79
82
|
results.push({
|
|
80
83
|
sprint: sprint.name,
|
|
81
|
-
startDate: startDate.toISOString().split(
|
|
82
|
-
endDate: endDate.toISOString().split(
|
|
84
|
+
startDate: startDate.toISOString().split("T")[0],
|
|
85
|
+
endDate: endDate.toISOString().split("T")[0],
|
|
83
86
|
durationDays,
|
|
84
87
|
totalClaims: claims.length,
|
|
85
88
|
claimsPerDay,
|
|
@@ -90,13 +93,23 @@ function measureVelocity(sprints) {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
// Aggregate stats
|
|
93
|
-
const validResults = results.filter(r => r.durationDays !== null);
|
|
94
|
-
const avgDuration =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
const validResults = results.filter((r) => r.durationDays !== null);
|
|
97
|
+
const avgDuration =
|
|
98
|
+
validResults.length > 0
|
|
99
|
+
? Math.round(
|
|
100
|
+
(validResults.reduce((a, r) => a + r.durationDays, 0) /
|
|
101
|
+
validResults.length) *
|
|
102
|
+
10,
|
|
103
|
+
) / 10
|
|
104
|
+
: null;
|
|
105
|
+
const avgClaimsPerDay =
|
|
106
|
+
validResults.length > 0
|
|
107
|
+
? Math.round(
|
|
108
|
+
(validResults.reduce((a, r) => a + r.claimsPerDay, 0) /
|
|
109
|
+
validResults.length) *
|
|
110
|
+
10,
|
|
111
|
+
) / 10
|
|
112
|
+
: null;
|
|
100
113
|
const totalStalls = results.reduce((a, r) => a + r.stalls.length, 0);
|
|
101
114
|
|
|
102
115
|
return {
|
|
@@ -120,7 +133,9 @@ function extractPhaseTimings(claims) {
|
|
|
120
133
|
const match = claim.id.match(/^([a-z]+)/);
|
|
121
134
|
if (!match) continue;
|
|
122
135
|
const letters = match[1];
|
|
123
|
-
const prefix =
|
|
136
|
+
const prefix =
|
|
137
|
+
Object.keys(PHASE_PREFIXES).find((k) => letters.startsWith(k)) ||
|
|
138
|
+
letters.charAt(0);
|
|
124
139
|
const phaseName = PHASE_PREFIXES[prefix] || prefix;
|
|
125
140
|
|
|
126
141
|
if (!phases[phaseName]) {
|
|
@@ -132,11 +147,17 @@ function extractPhaseTimings(claims) {
|
|
|
132
147
|
if (date) {
|
|
133
148
|
const d = new Date(date);
|
|
134
149
|
if (!isNaN(d.getTime())) {
|
|
135
|
-
if (
|
|
136
|
-
phases[phaseName].firstDate
|
|
150
|
+
if (
|
|
151
|
+
!phases[phaseName].firstDate ||
|
|
152
|
+
d < new Date(phases[phaseName].firstDate)
|
|
153
|
+
) {
|
|
154
|
+
phases[phaseName].firstDate = d.toISOString().split("T")[0];
|
|
137
155
|
}
|
|
138
|
-
if (
|
|
139
|
-
phases[phaseName].lastDate
|
|
156
|
+
if (
|
|
157
|
+
!phases[phaseName].lastDate ||
|
|
158
|
+
d > new Date(phases[phaseName].lastDate)
|
|
159
|
+
) {
|
|
160
|
+
phases[phaseName].lastDate = d.toISOString().split("T")[0];
|
|
140
161
|
}
|
|
141
162
|
}
|
|
142
163
|
}
|
|
@@ -149,14 +170,19 @@ function generateVelocityInsight(results, totalStalls) {
|
|
|
149
170
|
const parts = [];
|
|
150
171
|
|
|
151
172
|
if (results.length === 0) {
|
|
152
|
-
return
|
|
173
|
+
return "No sprint timing data available.";
|
|
153
174
|
}
|
|
154
175
|
|
|
155
|
-
const avgDuration =
|
|
156
|
-
|
|
176
|
+
const avgDuration =
|
|
177
|
+
results.reduce((a, r) => a + r.durationDays, 0) / results.length;
|
|
178
|
+
parts.push(
|
|
179
|
+
`Average sprint duration: ${Math.round(avgDuration * 10) / 10} days.`,
|
|
180
|
+
);
|
|
157
181
|
|
|
158
182
|
if (totalStalls > 0) {
|
|
159
|
-
parts.push(
|
|
183
|
+
parts.push(
|
|
184
|
+
`${totalStalls} stall(s) detected across sprints (gaps > 2 days between activity).`,
|
|
185
|
+
);
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
// Find the slowest phase across all sprints
|
|
@@ -168,10 +194,12 @@ function generateVelocityInsight(results, totalStalls) {
|
|
|
168
194
|
}
|
|
169
195
|
const topPhase = Object.entries(phaseTotals).sort((a, b) => b[1] - a[1])[0];
|
|
170
196
|
if (topPhase) {
|
|
171
|
-
parts.push(
|
|
197
|
+
parts.push(
|
|
198
|
+
`Most active phase: ${topPhase[0]} (${topPhase[1]} claims across all sprints).`,
|
|
199
|
+
);
|
|
172
200
|
}
|
|
173
201
|
|
|
174
|
-
return parts.join(
|
|
202
|
+
return parts.join(" ");
|
|
175
203
|
}
|
|
176
204
|
|
|
177
205
|
module.exports = { measureVelocity };
|