@grainulation/harvest 1.0.1 → 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/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
- 'use strict';
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: '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',
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: 'Insufficient timestamp data.',
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(1, Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)));
64
- const claimsPerDay = Math.round(claims.length / durationDays * 10) / 10;
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('T')[0],
73
- beforeDate: allDates[i].toISOString().split('T')[0],
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('T')[0],
82
- endDate: endDate.toISOString().split('T')[0],
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 = validResults.length > 0
95
- ? Math.round(validResults.reduce((a, r) => a + r.durationDays, 0) / validResults.length * 10) / 10
96
- : null;
97
- const avgClaimsPerDay = validResults.length > 0
98
- ? Math.round(validResults.reduce((a, r) => a + r.claimsPerDay, 0) / validResults.length * 10) / 10
99
- : null;
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 = Object.keys(PHASE_PREFIXES).find(k => letters.startsWith(k)) || letters.charAt(0);
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 (!phases[phaseName].firstDate || d < new Date(phases[phaseName].firstDate)) {
136
- phases[phaseName].firstDate = d.toISOString().split('T')[0];
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 (!phases[phaseName].lastDate || d > new Date(phases[phaseName].lastDate)) {
139
- phases[phaseName].lastDate = d.toISOString().split('T')[0];
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 'No sprint timing data available.';
173
+ return "No sprint timing data available.";
153
174
  }
154
175
 
155
- const avgDuration = results.reduce((a, r) => a + r.durationDays, 0) / results.length;
156
- parts.push(`Average sprint duration: ${Math.round(avgDuration * 10) / 10} days.`);
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(`${totalStalls} stall(s) detected across sprints (gaps > 2 days between activity).`);
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(`Most active phase: ${topPhase[0]} (${topPhase[1]} claims across all sprints).`);
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 };