@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/lib/wrapped.js ADDED
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Harvest Wrapped — Spotify-Wrapped-style report data for research sprints.
5
+ *
6
+ * Generates a "Harvest Report" with:
7
+ * 1. Sprint count and velocity trends
8
+ * 2. Claim type distribution and ratios
9
+ * 3. Evidence tier progression (are you testing more over time?)
10
+ * 4. "Research personality" type based on command usage patterns
11
+ * 5. Key milestones and achievements
12
+ *
13
+ * Based on claims from harvest-in-grainulator and token-economics sprints:
14
+ * - r628: Wrapped data points (sprint count, type ratios, personality)
15
+ * - r627: Spotify Wrapped viral design (personalization, identity, shareability)
16
+ * - r602: Behavioral science (make patterns feel like identity, not metrics)
17
+ * - d005: Harvest Reports = Spotify Wrapped for research
18
+ */
19
+
20
+ /**
21
+ * Research personality archetypes based on command/claim usage patterns.
22
+ * Each archetype has detection heuristics and a descriptive label.
23
+ */
24
+ const ARCHETYPES = {
25
+ challenger: {
26
+ id: "challenger",
27
+ label: "The Challenger",
28
+ emoji: "sword",
29
+ description:
30
+ "You stress-test everything. High challenge and contest rates mean your surviving claims are battle-hardened.",
31
+ detect: (stats) => stats.challengeRatio > 0.15,
32
+ },
33
+ prototyper: {
34
+ id: "prototyper",
35
+ label: "The Prototyper",
36
+ emoji: "lab",
37
+ description:
38
+ "You build to learn. Heavy prototype and tested-evidence usage means you validate through hands-on experimentation.",
39
+ detect: (stats) => stats.testedRatio > 0.25,
40
+ },
41
+ scholar: {
42
+ id: "scholar",
43
+ label: "The Scholar",
44
+ emoji: "book",
45
+ description:
46
+ "You research deeply. High factual claim counts and documented evidence show rigorous investigation.",
47
+ detect: (stats) => stats.factualRatio > 0.45 && stats.documentedRatio > 0.2,
48
+ },
49
+ strategist: {
50
+ id: "strategist",
51
+ label: "The Strategist",
52
+ emoji: "chess",
53
+ description:
54
+ "You drive toward decisions. High recommendation-to-factual ratios show you synthesize findings into action.",
55
+ detect: (stats) => stats.recommendationRatio > 0.2,
56
+ },
57
+ sentinel: {
58
+ id: "sentinel",
59
+ label: "The Sentinel",
60
+ emoji: "shield",
61
+ description:
62
+ "You spot what could go wrong. High risk identification means you protect decisions from blind spots.",
63
+ detect: (stats) => stats.riskRatio > 0.15,
64
+ },
65
+ explorer: {
66
+ id: "explorer",
67
+ label: "The Explorer",
68
+ emoji: "compass",
69
+ description:
70
+ "You cover wide ground. Diverse topics and broad tag coverage show you leave no stone unturned.",
71
+ detect: (stats) => stats.topicDiversity > 0.7,
72
+ },
73
+ balanced: {
74
+ id: "balanced",
75
+ label: "The Balanced Researcher",
76
+ emoji: "scales",
77
+ description:
78
+ "You maintain equilibrium across research activities. A well-rounded approach with no single dominant pattern.",
79
+ detect: () => true, // fallback
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Generate Harvest Wrapped report data.
85
+ *
86
+ * @param {Array} sprints - Sprint objects (from loadSprintData)
87
+ * @param {object} [opts] - Options
88
+ * @param {object} [opts.tokenReport] - Token tracking report (from trackCosts or analyzeTokens)
89
+ * @returns {object} Wrapped report data
90
+ */
91
+ function generateWrapped(sprints, opts = {}) {
92
+ const stats = computeWrappedStats(sprints);
93
+ const personality = detectPersonality(stats);
94
+ const tierProgression = computeTierProgression(sprints);
95
+ const highlights = computeHighlights(sprints, stats);
96
+ const tokenSummary = summarizeTokens(opts.tokenReport);
97
+
98
+ return {
99
+ period: determinePeriod(sprints),
100
+ sprintCount: sprints.length,
101
+ personality,
102
+ stats: {
103
+ totalClaims: stats.totalClaims,
104
+ totalActiveClaims: stats.activeClaims,
105
+ typeDistribution: stats.typeDistribution,
106
+ evidenceDistribution: stats.evidenceDistribution,
107
+ topTags: stats.topTags,
108
+ topTopics: stats.topTopics,
109
+ avgClaimsPerSprint: stats.avgClaimsPerSprint,
110
+ },
111
+ tierProgression,
112
+ highlights,
113
+ tokenSummary,
114
+ shareCard: buildShareCard(sprints.length, stats, personality),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Compute raw stats for wrapped report.
120
+ */
121
+ function computeWrappedStats(sprints) {
122
+ const typeDistribution = {};
123
+ const evidenceDistribution = {};
124
+ const tagCounts = {};
125
+ const topicCounts = {};
126
+ let totalClaims = 0;
127
+ let activeClaims = 0;
128
+ let challengeClaims = 0;
129
+ let contestedClaims = 0;
130
+
131
+ for (const sprint of sprints) {
132
+ for (const c of sprint.claims || []) {
133
+ totalClaims++;
134
+ if (c.status === "active") activeClaims++;
135
+ if (c.status === "contested") contestedClaims++;
136
+
137
+ const type = c.type || "unknown";
138
+ typeDistribution[type] = (typeDistribution[type] || 0) + 1;
139
+
140
+ // Handle both string and object evidence formats
141
+ const evidence =
142
+ typeof c.evidence === "string"
143
+ ? c.evidence
144
+ : c.evidence?.tier || c.evidence_tier || "unknown";
145
+ evidenceDistribution[evidence] =
146
+ (evidenceDistribution[evidence] || 0) + 1;
147
+
148
+ // Track challenge claims (x-prefixed IDs)
149
+ if (c.id && c.id.startsWith("x")) challengeClaims++;
150
+
151
+ // Tags
152
+ for (const tag of c.tags || []) {
153
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
154
+ }
155
+
156
+ // Topics
157
+ const topic = c.topic || null;
158
+ if (topic) {
159
+ topicCounts[topic] = (topicCounts[topic] || 0) + 1;
160
+ }
161
+ }
162
+ }
163
+
164
+ // Ratios for personality detection
165
+ const factualRatio =
166
+ totalClaims > 0 ? (typeDistribution.factual || 0) / totalClaims : 0;
167
+ const recommendationRatio =
168
+ totalClaims > 0 ? (typeDistribution.recommendation || 0) / totalClaims : 0;
169
+ const riskRatio =
170
+ totalClaims > 0 ? (typeDistribution.risk || 0) / totalClaims : 0;
171
+ const testedRatio =
172
+ totalClaims > 0 ? (evidenceDistribution.tested || 0) / totalClaims : 0;
173
+ const documentedRatio =
174
+ totalClaims > 0 ? (evidenceDistribution.documented || 0) / totalClaims : 0;
175
+ const challengeRatio =
176
+ totalClaims > 0 ? (challengeClaims + contestedClaims) / totalClaims : 0;
177
+
178
+ // Topic diversity: unique topics / total claims (normalized 0-1)
179
+ const uniqueTopics = Object.keys(topicCounts).length;
180
+ const topicDiversity =
181
+ totalClaims > 0 ? Math.min(1, uniqueTopics / Math.sqrt(totalClaims)) : 0;
182
+
183
+ // Top tags and topics
184
+ const topTags = Object.entries(tagCounts)
185
+ .sort((a, b) => b[1] - a[1])
186
+ .slice(0, 10)
187
+ .map(([tag, count]) => ({ tag, count }));
188
+
189
+ const topTopics = Object.entries(topicCounts)
190
+ .sort((a, b) => b[1] - a[1])
191
+ .slice(0, 5)
192
+ .map(([topic, count]) => ({ topic, count }));
193
+
194
+ return {
195
+ totalClaims,
196
+ activeClaims,
197
+ typeDistribution,
198
+ evidenceDistribution,
199
+ factualRatio,
200
+ recommendationRatio,
201
+ riskRatio,
202
+ testedRatio,
203
+ documentedRatio,
204
+ challengeRatio,
205
+ topicDiversity,
206
+ topTags,
207
+ topTopics,
208
+ avgClaimsPerSprint:
209
+ sprints.length > 0 ? Math.round(totalClaims / sprints.length) : 0,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Detect research personality archetype from usage stats.
215
+ */
216
+ function detectPersonality(stats) {
217
+ // Check archetypes in priority order (most specific first)
218
+ const priorityOrder = [
219
+ "challenger",
220
+ "prototyper",
221
+ "scholar",
222
+ "strategist",
223
+ "sentinel",
224
+ "explorer",
225
+ "balanced",
226
+ ];
227
+
228
+ for (const id of priorityOrder) {
229
+ const archetype = ARCHETYPES[id];
230
+ if (archetype.detect(stats)) {
231
+ return {
232
+ id: archetype.id,
233
+ label: archetype.label,
234
+ description: archetype.description,
235
+ confidence: computePersonalityConfidence(id, stats),
236
+ };
237
+ }
238
+ }
239
+
240
+ return ARCHETYPES.balanced;
241
+ }
242
+
243
+ /**
244
+ * Compute confidence score for a personality match.
245
+ */
246
+ function computePersonalityConfidence(id, stats) {
247
+ switch (id) {
248
+ case "challenger":
249
+ return Math.min(1, stats.challengeRatio / 0.3);
250
+ case "prototyper":
251
+ return Math.min(1, stats.testedRatio / 0.5);
252
+ case "scholar":
253
+ return Math.min(1, (stats.factualRatio + stats.documentedRatio) / 0.8);
254
+ case "strategist":
255
+ return Math.min(1, stats.recommendationRatio / 0.4);
256
+ case "sentinel":
257
+ return Math.min(1, stats.riskRatio / 0.3);
258
+ case "explorer":
259
+ return Math.min(1, stats.topicDiversity);
260
+ default:
261
+ return 0.5;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Compute evidence tier progression across sprints (ordered by time).
267
+ * Shows if the researcher is using higher-quality evidence over time.
268
+ */
269
+ function computeTierProgression(sprints) {
270
+ const tierWeight = {
271
+ stated: 1,
272
+ web: 2,
273
+ documented: 3,
274
+ tested: 4,
275
+ production: 5,
276
+ };
277
+
278
+ if (sprints.length < 2) return null;
279
+
280
+ const sprintScores = sprints.map((sprint) => {
281
+ const claims = sprint.claims || [];
282
+ if (claims.length === 0)
283
+ return { sprint: sprint.name, avgTier: 0, claims: 0 };
284
+
285
+ let totalWeight = 0;
286
+ for (const c of claims) {
287
+ const evidence =
288
+ typeof c.evidence === "string"
289
+ ? c.evidence
290
+ : c.evidence?.tier || c.evidence_tier || "stated";
291
+ totalWeight += tierWeight[evidence] || 1;
292
+ }
293
+
294
+ return {
295
+ sprint: sprint.name,
296
+ avgTier: Math.round((totalWeight / claims.length) * 100) / 100,
297
+ claims: claims.length,
298
+ };
299
+ });
300
+
301
+ // Trend: compare first half vs second half
302
+ const mid = Math.floor(sprintScores.length / 2);
303
+ const firstHalf = sprintScores.slice(0, mid).filter((s) => s.claims > 0);
304
+ const secondHalf = sprintScores.slice(mid).filter((s) => s.claims > 0);
305
+
306
+ const avgFirst =
307
+ firstHalf.length > 0
308
+ ? firstHalf.reduce((a, s) => a + s.avgTier, 0) / firstHalf.length
309
+ : 0;
310
+ const avgSecond =
311
+ secondHalf.length > 0
312
+ ? secondHalf.reduce((a, s) => a + s.avgTier, 0) / secondHalf.length
313
+ : 0;
314
+
315
+ return {
316
+ sprints: sprintScores,
317
+ trend:
318
+ avgSecond > avgFirst
319
+ ? "improving"
320
+ : avgSecond < avgFirst
321
+ ? "declining"
322
+ : "stable",
323
+ firstHalfAvg: Math.round(avgFirst * 100) / 100,
324
+ secondHalfAvg: Math.round(avgSecond * 100) / 100,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Compute highlights and achievements.
330
+ */
331
+ function computeHighlights(sprints, stats) {
332
+ const highlights = [];
333
+
334
+ // Sprint milestones
335
+ if (sprints.length >= 10)
336
+ highlights.push({
337
+ type: "milestone",
338
+ text: `${sprints.length} sprints completed`,
339
+ });
340
+ if (sprints.length >= 50)
341
+ highlights.push({
342
+ type: "milestone",
343
+ text: "Power researcher: 50+ sprints",
344
+ });
345
+
346
+ // Claim milestones
347
+ if (stats.totalClaims >= 100)
348
+ highlights.push({
349
+ type: "milestone",
350
+ text: `${stats.totalClaims} claims across all sprints`,
351
+ });
352
+
353
+ // Evidence quality
354
+ const tested = stats.evidenceDistribution.tested || 0;
355
+ const production = stats.evidenceDistribution.production || 0;
356
+ const highQuality = tested + production;
357
+ if (highQuality > 0 && stats.totalClaims > 0) {
358
+ const pct = Math.round((highQuality / stats.totalClaims) * 100);
359
+ if (pct >= 30)
360
+ highlights.push({
361
+ type: "quality",
362
+ text: `${pct}% of claims backed by tested or production evidence`,
363
+ });
364
+ }
365
+
366
+ // Challenge rate
367
+ if (stats.challengeRatio > 0.1) {
368
+ highlights.push({
369
+ type: "rigor",
370
+ text: `${Math.round(stats.challengeRatio * 100)}% challenge rate -- strong adversarial testing`,
371
+ });
372
+ }
373
+
374
+ // Top tag
375
+ if (stats.topTags.length > 0) {
376
+ highlights.push({
377
+ type: "focus",
378
+ text: `Most researched tag: "${stats.topTags[0].tag}" (${stats.topTags[0].count} claims)`,
379
+ });
380
+ }
381
+
382
+ // Most productive sprint
383
+ let maxClaims = 0;
384
+ let maxSprint = null;
385
+ for (const s of sprints) {
386
+ if ((s.claims || []).length > maxClaims) {
387
+ maxClaims = (s.claims || []).length;
388
+ maxSprint = s.name;
389
+ }
390
+ }
391
+ if (maxSprint) {
392
+ highlights.push({
393
+ type: "record",
394
+ text: `Most productive sprint: "${maxSprint}" with ${maxClaims} claims`,
395
+ });
396
+ }
397
+
398
+ return highlights;
399
+ }
400
+
401
+ /**
402
+ * Summarize token costs for the wrapped report.
403
+ */
404
+ function summarizeTokens(tokenReport) {
405
+ if (!tokenReport || !tokenReport.summary) return null;
406
+
407
+ const s = tokenReport.summary;
408
+ return {
409
+ totalCostUsd: s.totalCostUsd || 0,
410
+ costPerVerifiedClaim: s.costPerVerifiedClaim || null,
411
+ avgCostPerSprint: s.avgCostPerSprint || null,
412
+ costTrend: tokenReport.costTrend || null,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Determine the reporting period from sprint data.
418
+ */
419
+ function determinePeriod(sprints) {
420
+ const timestamps = [];
421
+ for (const sprint of sprints) {
422
+ for (const c of sprint.claims || []) {
423
+ const ts = c.timestamp || c.created;
424
+ if (ts) timestamps.push(new Date(ts));
425
+ }
426
+ }
427
+
428
+ if (timestamps.length === 0) {
429
+ return { start: null, end: null, label: "All time" };
430
+ }
431
+
432
+ timestamps.sort((a, b) => a - b);
433
+ const start = timestamps[0];
434
+ const end = timestamps[timestamps.length - 1];
435
+
436
+ // Determine label
437
+ const months = [
438
+ "Jan",
439
+ "Feb",
440
+ "Mar",
441
+ "Apr",
442
+ "May",
443
+ "Jun",
444
+ "Jul",
445
+ "Aug",
446
+ "Sep",
447
+ "Oct",
448
+ "Nov",
449
+ "Dec",
450
+ ];
451
+ const label =
452
+ start.getFullYear() === end.getFullYear()
453
+ ? `${months[start.getMonth()]}--${months[end.getMonth()]} ${end.getFullYear()}`
454
+ : `${months[start.getMonth()]} ${start.getFullYear()}--${months[end.getMonth()]} ${end.getFullYear()}`;
455
+
456
+ return {
457
+ start: start.toISOString(),
458
+ end: end.toISOString(),
459
+ label,
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Build shareable summary card data (no proprietary content).
465
+ */
466
+ function buildShareCard(sprintCount, stats, personality) {
467
+ return {
468
+ sprintCount,
469
+ totalClaims: stats.totalClaims,
470
+ personality: personality.label,
471
+ topEvidenceTier: getTopEvidenceTier(stats.evidenceDistribution),
472
+ avgClaimsPerSprint: stats.avgClaimsPerSprint,
473
+ };
474
+ }
475
+
476
+ function getTopEvidenceTier(distribution) {
477
+ const tiers = ["production", "tested", "documented", "web", "stated"];
478
+ for (const tier of tiers) {
479
+ if (distribution[tier] > 0) return tier;
480
+ }
481
+ return "stated";
482
+ }
483
+
484
+ module.exports = {
485
+ generateWrapped,
486
+ computeWrappedStats,
487
+ detectPersonality,
488
+ ARCHETYPES,
489
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grainulation/harvest",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Analytics and retrospective layer for research sprints -- learn from every decision you've made",
5
5
  "main": "lib/analyzer.js",
6
6
  "exports": {
@@ -10,6 +10,10 @@
10
10
  "./decay": "./lib/decay.js",
11
11
  "./patterns": "./lib/patterns.js",
12
12
  "./velocity": "./lib/velocity.js",
13
+ "./tokens": "./lib/tokens.js",
14
+ "./token-tracker": "./lib/token-tracker.js",
15
+ "./wrapped": "./lib/wrapped.js",
16
+ "./harvest-card": "./lib/harvest-card.js",
13
17
  "./package.json": "./package.json"
14
18
  },
15
19
  "bin": {
@@ -29,16 +33,19 @@
29
33
  ],
30
34
  "author": "grainulation contributors",
31
35
  "license": "MIT",
36
+ "type": "module",
32
37
  "files": [
33
38
  "bin/",
34
39
  "lib/",
35
40
  "public/",
36
41
  "templates/",
37
42
  "README.md",
38
- "LICENSE"
43
+ "LICENSE",
44
+ "CODE_OF_CONDUCT.md",
45
+ "CONTRIBUTING.md"
39
46
  ],
40
47
  "engines": {
41
- "node": ">=18.0.0"
48
+ "node": ">=20"
42
49
  },
43
50
  "repository": {
44
51
  "type": "git",