@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/CONTRIBUTING.md +6 -0
- package/README.md +12 -11
- 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 +322 -112
- 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 +7 -2
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Harvest Reports -- Spotify Wrapped for research.
|
|
5
|
+
*
|
|
6
|
+
* Generates:
|
|
7
|
+
* 1. SVG card (1200x630, dark theme, self-contained) for GitHub README embedding
|
|
8
|
+
* 2. Stats object for the full HTML report
|
|
9
|
+
*
|
|
10
|
+
* Design principles (from spec):
|
|
11
|
+
* - Pure temporal self-comparison (no percentiles)
|
|
12
|
+
* - Personal-best anchoring, not period-over-period deltas
|
|
13
|
+
* - Researcher archetype as identity artifact
|
|
14
|
+
* - Asymmetric framing: celebrate improvement, reframe decline
|
|
15
|
+
* - Template-based NLG (deterministic, zero-dep)
|
|
16
|
+
* - Sparkline of activity over time
|
|
17
|
+
* - Milestone detection (firsts, records, shifts)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { analyze } = require("./analyzer.js");
|
|
21
|
+
const { calibrate } = require("./calibration.js");
|
|
22
|
+
const { checkDecay } = require("./decay.js");
|
|
23
|
+
|
|
24
|
+
// ── Season detection ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const SEASONS = [
|
|
27
|
+
{ name: "Spring", start: [3, 20], glyph: "\u{1F331}" },
|
|
28
|
+
{ name: "Summer", start: [6, 20], glyph: "\u{1F31E}" },
|
|
29
|
+
{ name: "Autumn", start: [9, 22], glyph: "\u{1F342}" },
|
|
30
|
+
{ name: "Winter", start: [12, 21], glyph: "\u{2744}\u{FE0F}" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function getSeason(date) {
|
|
34
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
35
|
+
const month = d.getMonth() + 1;
|
|
36
|
+
const day = d.getDate();
|
|
37
|
+
const year = d.getFullYear();
|
|
38
|
+
|
|
39
|
+
for (let i = SEASONS.length - 1; i >= 0; i--) {
|
|
40
|
+
const [sm, sd] = SEASONS[i].start;
|
|
41
|
+
if (month > sm || (month === sm && day >= sd)) {
|
|
42
|
+
return { ...SEASONS[i], year };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Before March 20 = still Winter of previous year
|
|
46
|
+
return { ...SEASONS[3], year: year - 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Researcher archetype ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const ARCHETYPES = [
|
|
52
|
+
{
|
|
53
|
+
id: "evidence-hunter",
|
|
54
|
+
label: "Evidence Hunter",
|
|
55
|
+
test: (s) =>
|
|
56
|
+
(s.evidenceTier.documented +
|
|
57
|
+
s.evidenceTier.tested +
|
|
58
|
+
s.evidenceTier.production) /
|
|
59
|
+
Math.max(s.totalClaims, 1) >
|
|
60
|
+
0.6,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "risk-mapper",
|
|
64
|
+
label: "Risk Mapper",
|
|
65
|
+
test: (s) => (s.types.risk || 0) / Math.max(s.totalClaims, 1) > 0.2,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "challenge-seeker",
|
|
69
|
+
label: "Challenge Seeker",
|
|
70
|
+
test: (s) =>
|
|
71
|
+
s.challengeCount > 0 &&
|
|
72
|
+
s.challengeCount / Math.max(s.totalClaims, 1) > 0.1,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "the-prototyper",
|
|
76
|
+
label: "The Prototyper",
|
|
77
|
+
test: (s) =>
|
|
78
|
+
(s.types.estimate || 0) / Math.max(s.totalClaims, 1) > 0.15 &&
|
|
79
|
+
s.evidenceTier.tested > 0,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "deep-researcher",
|
|
83
|
+
label: "Deep Researcher",
|
|
84
|
+
test: (s) => s.avgClaimsPerSprint > 15,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "broad-explorer",
|
|
88
|
+
label: "Broad Explorer",
|
|
89
|
+
test: (s) => s.topicCount > 5,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function detectArchetype(stats) {
|
|
94
|
+
for (const arch of ARCHETYPES) {
|
|
95
|
+
if (arch.test(stats)) return arch;
|
|
96
|
+
}
|
|
97
|
+
return { id: "researcher", label: "Researcher" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Milestone detection ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function detectMilestones(sprints) {
|
|
103
|
+
const milestones = [];
|
|
104
|
+
const allClaims = sprints.flatMap((s) =>
|
|
105
|
+
s.claims.map((c) => ({ ...c, _sprint: s.name })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Firsts: first use of each claim type
|
|
109
|
+
const typeFirsts = {};
|
|
110
|
+
for (const c of allClaims) {
|
|
111
|
+
if (!typeFirsts[c.type]) typeFirsts[c.type] = c._sprint;
|
|
112
|
+
}
|
|
113
|
+
for (const [type, sprint] of Object.entries(typeFirsts)) {
|
|
114
|
+
milestones.push({
|
|
115
|
+
kind: "first",
|
|
116
|
+
label: `First ${type} claim`,
|
|
117
|
+
sprint,
|
|
118
|
+
type: "first-type",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Records: most claims in a single sprint
|
|
123
|
+
let maxClaims = 0;
|
|
124
|
+
let maxClaimsSprint = null;
|
|
125
|
+
for (const s of sprints) {
|
|
126
|
+
if (s.claims.length > maxClaims) {
|
|
127
|
+
maxClaims = s.claims.length;
|
|
128
|
+
maxClaimsSprint = s.name;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (maxClaimsSprint) {
|
|
132
|
+
milestones.push({
|
|
133
|
+
kind: "record",
|
|
134
|
+
label: `Deepest sprint: ${maxClaims} claims`,
|
|
135
|
+
sprint: maxClaimsSprint,
|
|
136
|
+
value: maxClaims,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Evidence depth record
|
|
141
|
+
const evidenceRank = {
|
|
142
|
+
stated: 1,
|
|
143
|
+
web: 2,
|
|
144
|
+
documented: 3,
|
|
145
|
+
tested: 4,
|
|
146
|
+
production: 5,
|
|
147
|
+
};
|
|
148
|
+
let maxEvidence = 0;
|
|
149
|
+
let maxEvidenceSprint = null;
|
|
150
|
+
for (const s of sprints) {
|
|
151
|
+
const avgEvidence =
|
|
152
|
+
s.claims.length > 0
|
|
153
|
+
? s.claims.reduce((a, c) => a + (evidenceRank[c.evidence] || 1), 0) /
|
|
154
|
+
s.claims.length
|
|
155
|
+
: 0;
|
|
156
|
+
if (avgEvidence > maxEvidence) {
|
|
157
|
+
maxEvidence = avgEvidence;
|
|
158
|
+
maxEvidenceSprint = s.name;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (maxEvidenceSprint) {
|
|
162
|
+
milestones.push({
|
|
163
|
+
kind: "record",
|
|
164
|
+
label: `Highest evidence depth`,
|
|
165
|
+
sprint: maxEvidenceSprint,
|
|
166
|
+
value: Math.round(maxEvidence * 10) / 10,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return milestones;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Sparkline SVG path ───────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function sparklinePath(values, width, height) {
|
|
176
|
+
if (values.length < 2) return "";
|
|
177
|
+
const max = Math.max(...values, 1);
|
|
178
|
+
const step = width / (values.length - 1);
|
|
179
|
+
const points = values.map((v, i) => {
|
|
180
|
+
const x = Math.round(i * step * 10) / 10;
|
|
181
|
+
const y = Math.round((height - (v / max) * height) * 10) / 10;
|
|
182
|
+
return `${x},${y}`;
|
|
183
|
+
});
|
|
184
|
+
return `M${points.join(" L")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Compute report stats ─────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function computeReportStats(sprints) {
|
|
190
|
+
const analysis = analyze(sprints);
|
|
191
|
+
const calibration = calibrate(sprints);
|
|
192
|
+
|
|
193
|
+
// Per-sprint claim counts for sparkline
|
|
194
|
+
const sprintCounts = sprints.map((s) => s.claims.length);
|
|
195
|
+
|
|
196
|
+
// Evidence tier counts
|
|
197
|
+
const evidenceTier = {
|
|
198
|
+
stated: analysis.evidenceDistribution.stated || 0,
|
|
199
|
+
web: analysis.evidenceDistribution.web || 0,
|
|
200
|
+
documented: analysis.evidenceDistribution.documented || 0,
|
|
201
|
+
tested: analysis.evidenceDistribution.tested || 0,
|
|
202
|
+
production: analysis.evidenceDistribution.production || 0,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Topic diversity
|
|
206
|
+
const topics = new Set();
|
|
207
|
+
for (const s of sprints) {
|
|
208
|
+
for (const c of s.claims) {
|
|
209
|
+
if (c.topic) topics.add(c.topic);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Challenge claims (x* prefix)
|
|
214
|
+
const challengeCount = sprints.reduce(
|
|
215
|
+
(a, s) => a + s.claims.filter((c) => c.id && c.id.startsWith("x")).length,
|
|
216
|
+
0,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const stats = {
|
|
220
|
+
totalSprints: sprints.length,
|
|
221
|
+
totalClaims: analysis.summary.totalClaims,
|
|
222
|
+
avgClaimsPerSprint: analysis.summary.averageClaimsPerSprint,
|
|
223
|
+
types: analysis.typeDistribution,
|
|
224
|
+
evidenceTier,
|
|
225
|
+
topicCount: topics.size,
|
|
226
|
+
challengeCount,
|
|
227
|
+
accuracyRate: calibration.summary.accuracyRate,
|
|
228
|
+
brierScore: calibration.brierScore ? calibration.brierScore.score : null,
|
|
229
|
+
calibrationCurve: calibration.calibrationCurve || null,
|
|
230
|
+
sprintCounts,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const archetype = detectArchetype(stats);
|
|
234
|
+
const milestones = detectMilestones(sprints);
|
|
235
|
+
const season = getSeason(new Date());
|
|
236
|
+
|
|
237
|
+
return { ...stats, archetype, milestones, season };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── SVG card generation ──────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function escapeXml(str) {
|
|
243
|
+
if (!str) return "";
|
|
244
|
+
return String(str)
|
|
245
|
+
.replace(/&/g, "&")
|
|
246
|
+
.replace(/</g, "<")
|
|
247
|
+
.replace(/>/g, ">")
|
|
248
|
+
.replace(/"/g, """)
|
|
249
|
+
.replace(/'/g, "'");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate a self-contained SVG card (1200x630) for GitHub README embedding.
|
|
254
|
+
*
|
|
255
|
+
* Dark theme (#0d1117 background), no external resources, Camo-compatible.
|
|
256
|
+
* Includes SMIL animation for reveal effect, with prefers-reduced-motion fallback.
|
|
257
|
+
*/
|
|
258
|
+
function generateCard(sprints) {
|
|
259
|
+
const stats = computeReportStats(sprints);
|
|
260
|
+
const s = stats.season;
|
|
261
|
+
const seasonLabel = `${s.name} ${s.year}`;
|
|
262
|
+
|
|
263
|
+
// Sparkline
|
|
264
|
+
const sparkWidth = 200;
|
|
265
|
+
const sparkHeight = 40;
|
|
266
|
+
const sparkD = sparklinePath(stats.sprintCounts, sparkWidth, sparkHeight);
|
|
267
|
+
|
|
268
|
+
// Top evidence tier
|
|
269
|
+
const topTier = Object.entries(stats.evidenceTier).sort(
|
|
270
|
+
(a, b) => b[1] - a[1],
|
|
271
|
+
)[0];
|
|
272
|
+
const topTierLabel = topTier ? `${topTier[0]} (${topTier[1]})` : "N/A";
|
|
273
|
+
|
|
274
|
+
// Calibration display
|
|
275
|
+
const calDisplay =
|
|
276
|
+
stats.accuracyRate !== null ? `${stats.accuracyRate}%` : "--";
|
|
277
|
+
const brierDisplay =
|
|
278
|
+
stats.brierScore !== null ? stats.brierScore.toFixed(2) : "--";
|
|
279
|
+
|
|
280
|
+
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
|
281
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"
|
|
282
|
+
role="img" aria-labelledby="harvest-title harvest-desc">
|
|
283
|
+
<title id="harvest-title">Harvest Report: ${escapeXml(seasonLabel)} — ${stats.totalClaims} claims, ${stats.totalSprints} sprints, ${escapeXml(stats.archetype.label)}</title>
|
|
284
|
+
<desc id="harvest-desc">Research analytics card showing ${stats.totalSprints} sprints with ${stats.totalClaims} claims. Archetype: ${escapeXml(stats.archetype.label)}. Prediction accuracy: ${calDisplay}.</desc>
|
|
285
|
+
<style>
|
|
286
|
+
@media (prefers-reduced-motion: reduce) {
|
|
287
|
+
* { animation: none !important; }
|
|
288
|
+
.fade-in { opacity: 1 !important; }
|
|
289
|
+
}
|
|
290
|
+
.fade-in { opacity: 0; animation: fadeIn 0.6s ease-out forwards; }
|
|
291
|
+
.fade-in-d1 { animation-delay: 0.2s; }
|
|
292
|
+
.fade-in-d2 { animation-delay: 0.4s; }
|
|
293
|
+
.fade-in-d3 { animation-delay: 0.6s; }
|
|
294
|
+
.fade-in-d4 { animation-delay: 0.8s; }
|
|
295
|
+
@keyframes fadeIn { to { opacity: 1; } }
|
|
296
|
+
@keyframes drawLine { from { stroke-dashoffset: 600; } to { stroke-dashoffset: 0; } }
|
|
297
|
+
.sparkline { stroke-dasharray: 600; stroke-dashoffset: 600; animation: drawLine 1.5s ease-out 0.5s forwards; }
|
|
298
|
+
@media (prefers-reduced-motion: reduce) { .sparkline { stroke-dashoffset: 0 !important; } }
|
|
299
|
+
</style>
|
|
300
|
+
|
|
301
|
+
<!-- Background -->
|
|
302
|
+
<rect width="1200" height="630" rx="16" fill="#0d1117"/>
|
|
303
|
+
<rect x="0" y="0" width="1200" height="4" fill="#f97316" rx="2"/>
|
|
304
|
+
|
|
305
|
+
<!-- Season label -->
|
|
306
|
+
<text x="60" y="60" fill="#9ca3af" font-family="system-ui, -apple-system, sans-serif" font-size="18" class="fade-in">${escapeXml(seasonLabel)}</text>
|
|
307
|
+
|
|
308
|
+
<!-- Archetype -->
|
|
309
|
+
<text x="60" y="110" fill="#f97316" font-family="system-ui, -apple-system, sans-serif" font-size="42" font-weight="700" class="fade-in fade-in-d1">${escapeXml(stats.archetype.label)}</text>
|
|
310
|
+
|
|
311
|
+
<!-- Stat blocks -->
|
|
312
|
+
<g class="fade-in fade-in-d2">
|
|
313
|
+
<!-- Sprints -->
|
|
314
|
+
<text x="60" y="190" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">SPRINTS</text>
|
|
315
|
+
<text x="60" y="230" fill="#e6edf3" font-family="system-ui, sans-serif" font-size="36" font-weight="600">${stats.totalSprints}</text>
|
|
316
|
+
|
|
317
|
+
<!-- Claims -->
|
|
318
|
+
<text x="240" y="190" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">CLAIMS</text>
|
|
319
|
+
<text x="240" y="230" fill="#e6edf3" font-family="system-ui, sans-serif" font-size="36" font-weight="600">${stats.totalClaims}</text>
|
|
320
|
+
|
|
321
|
+
<!-- Accuracy -->
|
|
322
|
+
<text x="440" y="190" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">ACCURACY</text>
|
|
323
|
+
<text x="440" y="230" fill="#e6edf3" font-family="system-ui, sans-serif" font-size="36" font-weight="600">${calDisplay}</text>
|
|
324
|
+
|
|
325
|
+
<!-- Brier Score -->
|
|
326
|
+
<text x="640" y="190" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">BRIER SCORE</text>
|
|
327
|
+
<text x="640" y="230" fill="#e6edf3" font-family="system-ui, sans-serif" font-size="36" font-weight="600">${brierDisplay}</text>
|
|
328
|
+
</g>
|
|
329
|
+
|
|
330
|
+
<!-- Evidence tier bar -->
|
|
331
|
+
<g class="fade-in fade-in-d3">
|
|
332
|
+
<text x="60" y="300" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">EVIDENCE DEPTH</text>
|
|
333
|
+
${renderEvidenceBar(stats.evidenceTier, stats.totalClaims)}
|
|
334
|
+
<text x="60" y="360" fill="#6e7681" font-family="system-ui, sans-serif" font-size="12">Top tier: ${escapeXml(topTierLabel)}</text>
|
|
335
|
+
</g>
|
|
336
|
+
|
|
337
|
+
<!-- Sparkline -->
|
|
338
|
+
<g class="fade-in fade-in-d4" transform="translate(60, 390)">
|
|
339
|
+
<text x="0" y="0" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">ACTIVITY</text>
|
|
340
|
+
${sparkD ? `<path d="${sparkD}" fill="none" stroke="#f97316" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="sparkline" transform="translate(0, 15)"/>` : '<text x="0" y="40" fill="#6e7681" font-family="system-ui, sans-serif" font-size="12">Not enough data for sparkline</text>'}
|
|
341
|
+
</g>
|
|
342
|
+
|
|
343
|
+
<!-- Milestones -->
|
|
344
|
+
<g class="fade-in fade-in-d4" transform="translate(60, 480)">
|
|
345
|
+
<text x="0" y="0" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">MILESTONES</text>
|
|
346
|
+
${stats.milestones
|
|
347
|
+
.slice(0, 3)
|
|
348
|
+
.map(
|
|
349
|
+
(m, i) =>
|
|
350
|
+
`<text x="0" y="${22 + i * 22}" fill="#8b949e" font-family="system-ui, sans-serif" font-size="13">${escapeXml(m.label)}</text>`,
|
|
351
|
+
)
|
|
352
|
+
.join("\n ")}
|
|
353
|
+
</g>
|
|
354
|
+
|
|
355
|
+
<!-- Calibration curve mini (right side) -->
|
|
356
|
+
${stats.calibrationCurve && stats.calibrationCurve.bins.length > 0 ? renderMiniCalibrationCurve(stats.calibrationCurve, 850, 160) : ""}
|
|
357
|
+
|
|
358
|
+
<!-- Topics badge -->
|
|
359
|
+
<g class="fade-in fade-in-d3" transform="translate(850, 390)">
|
|
360
|
+
<text x="0" y="0" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">TOPICS</text>
|
|
361
|
+
<text x="0" y="35" fill="#e6edf3" font-family="system-ui, sans-serif" font-size="28" font-weight="600">${stats.topicCount}</text>
|
|
362
|
+
<text x="60" y="35" fill="#6e7681" font-family="system-ui, sans-serif" font-size="13">unique</text>
|
|
363
|
+
</g>
|
|
364
|
+
|
|
365
|
+
<!-- Footer -->
|
|
366
|
+
<text x="60" y="605" fill="#484f58" font-family="system-ui, sans-serif" font-size="12">Powered by Harvest</text>
|
|
367
|
+
<text x="1140" y="605" fill="#484f58" font-family="system-ui, sans-serif" font-size="12" text-anchor="end">${escapeXml(new Date().toISOString().split("T")[0])}</text>
|
|
368
|
+
</svg>`;
|
|
369
|
+
|
|
370
|
+
return { svg, stats };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderEvidenceBar(tiers, total) {
|
|
374
|
+
if (total === 0)
|
|
375
|
+
return '<rect x="60" y="310" width="700" height="20" rx="4" fill="#161b22"/>';
|
|
376
|
+
const barWidth = 700;
|
|
377
|
+
const colors = {
|
|
378
|
+
stated: "#6e7681",
|
|
379
|
+
web: "#8b949e",
|
|
380
|
+
documented: "#58a6ff",
|
|
381
|
+
tested: "#3fb950",
|
|
382
|
+
production: "#f97316",
|
|
383
|
+
};
|
|
384
|
+
const order = ["stated", "web", "documented", "tested", "production"];
|
|
385
|
+
let x = 60;
|
|
386
|
+
const rects = [];
|
|
387
|
+
for (const tier of order) {
|
|
388
|
+
const count = tiers[tier] || 0;
|
|
389
|
+
if (count === 0) continue;
|
|
390
|
+
const w = Math.max(2, Math.round((count / total) * barWidth));
|
|
391
|
+
rects.push(
|
|
392
|
+
`<rect x="${x}" y="310" width="${w}" height="20" fill="${colors[tier]}"/>`,
|
|
393
|
+
);
|
|
394
|
+
x += w;
|
|
395
|
+
}
|
|
396
|
+
return `<rect x="60" y="310" width="${barWidth}" height="20" rx="4" fill="#161b22"/>\n ${rects.join("\n ")}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Render a mini calibration curve in the SVG card.
|
|
401
|
+
* Shows predicted vs actual as dots with a diagonal reference line.
|
|
402
|
+
*/
|
|
403
|
+
function renderMiniCalibrationCurve(curve, ox, oy) {
|
|
404
|
+
const size = 120;
|
|
405
|
+
const elements = [];
|
|
406
|
+
|
|
407
|
+
// Background
|
|
408
|
+
elements.push(
|
|
409
|
+
`<rect x="${ox}" y="${oy}" width="${size}" height="${size}" rx="4" fill="#161b22"/>`,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Diagonal (perfect calibration)
|
|
413
|
+
elements.push(
|
|
414
|
+
`<line x1="${ox}" y1="${oy + size}" x2="${ox + size}" y2="${oy}" stroke="#30363d" stroke-width="1" stroke-dasharray="4,4"/>`,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Label
|
|
418
|
+
elements.push(
|
|
419
|
+
`<text x="${ox}" y="${oy - 8}" fill="#9ca3af" font-family="system-ui, sans-serif" font-size="14">CALIBRATION</text>`,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Plot bins as dots
|
|
423
|
+
for (const bin of curve.bins) {
|
|
424
|
+
if (bin.count === 0 || bin.actual === null) continue;
|
|
425
|
+
const x = ox + (bin.predicted / 100) * size;
|
|
426
|
+
const y = oy + size - (bin.actual / 100) * size;
|
|
427
|
+
const r = Math.min(8, Math.max(3, bin.count));
|
|
428
|
+
elements.push(
|
|
429
|
+
`<circle cx="${Math.round(x)}" cy="${Math.round(y)}" r="${r}" fill="#f97316" opacity="0.8"/>`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Axis labels
|
|
434
|
+
elements.push(
|
|
435
|
+
`<text x="${ox}" y="${oy + size + 15}" fill="#484f58" font-family="system-ui, sans-serif" font-size="10">0%</text>`,
|
|
436
|
+
);
|
|
437
|
+
elements.push(
|
|
438
|
+
`<text x="${ox + size}" y="${oy + size + 15}" fill="#484f58" font-family="system-ui, sans-serif" font-size="10" text-anchor="end">100%</text>`,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
// Bias indicator
|
|
442
|
+
if (curve.bias) {
|
|
443
|
+
const biasLabel =
|
|
444
|
+
curve.bias === "overconfident"
|
|
445
|
+
? "overconfident"
|
|
446
|
+
: curve.bias === "underconfident"
|
|
447
|
+
? "underconfident"
|
|
448
|
+
: "mixed";
|
|
449
|
+
elements.push(
|
|
450
|
+
`<text x="${ox + size / 2}" y="${oy + size + 28}" fill="#8b949e" font-family="system-ui, sans-serif" font-size="11" text-anchor="middle">${biasLabel}</text>`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return elements.join("\n ");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Generate the embed snippet for the user.
|
|
459
|
+
*/
|
|
460
|
+
function generateEmbedSnippet(filename) {
|
|
461
|
+
return {
|
|
462
|
+
markdown: ``,
|
|
463
|
+
html: `<img src="./${filename}" alt="Harvest Report" width="600">`,
|
|
464
|
+
path: `./${filename}`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = {
|
|
469
|
+
generateCard,
|
|
470
|
+
computeReportStats,
|
|
471
|
+
getSeason,
|
|
472
|
+
detectArchetype,
|
|
473
|
+
detectMilestones,
|
|
474
|
+
generateEmbedSnippet,
|
|
475
|
+
};
|
package/lib/patterns.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Decision pattern detection.
|
|
@@ -28,30 +28,31 @@ function detectPatterns(sprints) {
|
|
|
28
28
|
|
|
29
29
|
const phases = extractPhases(claims);
|
|
30
30
|
const avgEvidence = averageEvidenceLevel(claims);
|
|
31
|
-
const hasPrototype = claims.some(c => c.id && c.id.startsWith(
|
|
32
|
-
const hasChallenge = claims.some(c => c.id && c.id.startsWith(
|
|
33
|
-
const hasWitness = claims.some(c => c.id && c.id.startsWith(
|
|
34
|
-
const recommendations = claims.filter(c => c.type ===
|
|
35
|
-
const estimates = claims.filter(c => c.type ===
|
|
31
|
+
const hasPrototype = claims.some((c) => c.id && c.id.startsWith("p"));
|
|
32
|
+
const hasChallenge = claims.some((c) => c.id && c.id.startsWith("x"));
|
|
33
|
+
const hasWitness = claims.some((c) => c.id && c.id.startsWith("w"));
|
|
34
|
+
const recommendations = claims.filter((c) => c.type === "recommendation");
|
|
35
|
+
const estimates = claims.filter((c) => c.type === "estimate");
|
|
36
36
|
|
|
37
37
|
// Pattern: prototype-before-recommend
|
|
38
38
|
if (hasPrototype && recommendations.length > 0) {
|
|
39
39
|
const protoIndices = claims
|
|
40
|
-
.map((c, i) => c.id && c.id.startsWith(
|
|
41
|
-
.filter(i => i >= 0);
|
|
40
|
+
.map((c, i) => (c.id && c.id.startsWith("p") ? i : -1))
|
|
41
|
+
.filter((i) => i >= 0);
|
|
42
42
|
const recIndices = claims
|
|
43
|
-
.map((c, i) => c.type ===
|
|
44
|
-
.filter(i => i >= 0);
|
|
43
|
+
.map((c, i) => (c.type === "recommendation" ? i : -1))
|
|
44
|
+
.filter((i) => i >= 0);
|
|
45
45
|
|
|
46
|
-
const prototypedFirst = protoIndices.some(pi =>
|
|
47
|
-
recIndices.some(ri => pi < ri)
|
|
46
|
+
const prototypedFirst = protoIndices.some((pi) =>
|
|
47
|
+
recIndices.some((ri) => pi < ri),
|
|
48
48
|
);
|
|
49
49
|
|
|
50
50
|
if (prototypedFirst) {
|
|
51
51
|
patterns.push({
|
|
52
52
|
sprint: sprint.name,
|
|
53
|
-
pattern:
|
|
54
|
-
description:
|
|
53
|
+
pattern: "prototype-before-recommend",
|
|
54
|
+
description:
|
|
55
|
+
"Prototyped before making recommendations -- tends to produce higher-evidence claims.",
|
|
55
56
|
evidenceLevel: avgEvidence,
|
|
56
57
|
});
|
|
57
58
|
}
|
|
@@ -61,9 +62,11 @@ function detectPatterns(sprints) {
|
|
|
61
62
|
if (hasChallenge) {
|
|
62
63
|
patterns.push({
|
|
63
64
|
sprint: sprint.name,
|
|
64
|
-
pattern:
|
|
65
|
-
description:
|
|
66
|
-
|
|
65
|
+
pattern: "adversarial-testing",
|
|
66
|
+
description:
|
|
67
|
+
"Used /challenge to stress-test claims -- builds confidence in findings.",
|
|
68
|
+
claimsChallenged: claims.filter((c) => c.id && c.id.startsWith("x"))
|
|
69
|
+
.length,
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -71,48 +74,50 @@ function detectPatterns(sprints) {
|
|
|
71
74
|
if (hasWitness) {
|
|
72
75
|
patterns.push({
|
|
73
76
|
sprint: sprint.name,
|
|
74
|
-
pattern:
|
|
75
|
-
description:
|
|
76
|
-
|
|
77
|
+
pattern: "external-corroboration",
|
|
78
|
+
description:
|
|
79
|
+
"Used /witness to corroborate claims with external sources.",
|
|
80
|
+
witnessCount: claims.filter((c) => c.id && c.id.startsWith("w")).length,
|
|
77
81
|
});
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
// Anti-pattern: recommend without research
|
|
81
|
-
const researchClaims = claims.filter(c => c.id && c.id.startsWith(
|
|
85
|
+
const researchClaims = claims.filter((c) => c.id && c.id.startsWith("r"));
|
|
82
86
|
if (recommendations.length > 0 && researchClaims.length === 0) {
|
|
83
87
|
antiPatterns.push({
|
|
84
88
|
sprint: sprint.name,
|
|
85
|
-
pattern:
|
|
86
|
-
description:
|
|
87
|
-
severity:
|
|
89
|
+
pattern: "recommend-without-research",
|
|
90
|
+
description: "Recommendations made without dedicated research claims.",
|
|
91
|
+
severity: "high",
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
// Anti-pattern: estimate without evidence
|
|
92
|
-
const weakEstimates = estimates.filter(
|
|
93
|
-
(EVIDENCE_RANK[c.evidence] || 0) <= 2
|
|
96
|
+
const weakEstimates = estimates.filter(
|
|
97
|
+
(c) => (EVIDENCE_RANK[c.evidence] || 0) <= 2,
|
|
94
98
|
);
|
|
95
99
|
if (weakEstimates.length > estimates.length * 0.5 && estimates.length > 0) {
|
|
96
100
|
antiPatterns.push({
|
|
97
101
|
sprint: sprint.name,
|
|
98
|
-
pattern:
|
|
102
|
+
pattern: "weak-estimates",
|
|
99
103
|
description: `${weakEstimates.length}/${estimates.length} estimates have weak evidence (stated/web only).`,
|
|
100
|
-
severity:
|
|
104
|
+
severity: "medium",
|
|
101
105
|
});
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
// Anti-pattern: type monoculture
|
|
105
109
|
const typeCounts = {};
|
|
106
110
|
for (const c of claims) {
|
|
107
|
-
typeCounts[c.type ||
|
|
111
|
+
typeCounts[c.type || "unknown"] =
|
|
112
|
+
(typeCounts[c.type || "unknown"] || 0) + 1;
|
|
108
113
|
}
|
|
109
114
|
const maxType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0];
|
|
110
115
|
if (maxType && maxType[1] / claims.length > 0.7 && claims.length > 5) {
|
|
111
116
|
antiPatterns.push({
|
|
112
117
|
sprint: sprint.name,
|
|
113
|
-
pattern:
|
|
114
|
-
description: `${Math.round(maxType[1] / claims.length * 100)}% of claims are "${maxType[0]}" -- diversity of analysis may be lacking.`,
|
|
115
|
-
severity:
|
|
118
|
+
pattern: "type-monoculture",
|
|
119
|
+
description: `${Math.round((maxType[1] / claims.length) * 100)}% of claims are "${maxType[0]}" -- diversity of analysis may be lacking.`,
|
|
120
|
+
severity: "low",
|
|
116
121
|
});
|
|
117
122
|
}
|
|
118
123
|
}
|
|
@@ -137,16 +142,20 @@ function extractPhases(claims) {
|
|
|
137
142
|
const phases = new Set();
|
|
138
143
|
for (const c of claims) {
|
|
139
144
|
if (!c.id) continue;
|
|
140
|
-
const prefix = c.id.replace(/\d+$/,
|
|
145
|
+
const prefix = c.id.replace(/\d+$/, "");
|
|
141
146
|
phases.add(prefix);
|
|
142
147
|
}
|
|
143
148
|
return [...phases];
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
function averageEvidenceLevel(claims) {
|
|
147
|
-
const levels = claims
|
|
152
|
+
const levels = claims
|
|
153
|
+
.map((c) => EVIDENCE_RANK[c.evidence] || 0)
|
|
154
|
+
.filter((l) => l > 0);
|
|
148
155
|
if (levels.length === 0) return 0;
|
|
149
|
-
return
|
|
156
|
+
return (
|
|
157
|
+
Math.round((levels.reduce((a, b) => a + b, 0) / levels.length) * 10) / 10
|
|
158
|
+
);
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
function analyzeCrossSprintTrends(sprints, patterns) {
|
|
@@ -160,7 +169,7 @@ function analyzeCrossSprintTrends(sprints, patterns) {
|
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
// Evidence level trend across sprints (are we getting better?)
|
|
163
|
-
const evidenceTrend = sprints.map(s => ({
|
|
172
|
+
const evidenceTrend = sprints.map((s) => ({
|
|
164
173
|
sprint: s.name,
|
|
165
174
|
avgEvidence: averageEvidenceLevel(s.claims),
|
|
166
175
|
claimCount: s.claims.length,
|
|
@@ -172,14 +181,22 @@ function analyzeCrossSprintTrends(sprints, patterns) {
|
|
|
172
181
|
function generatePatternInsight(patterns, antiPatterns, crossSprint) {
|
|
173
182
|
const parts = [];
|
|
174
183
|
|
|
175
|
-
const protoBeforeRec = patterns.filter(
|
|
184
|
+
const protoBeforeRec = patterns.filter(
|
|
185
|
+
(p) => p.pattern === "prototype-before-recommend",
|
|
186
|
+
);
|
|
176
187
|
if (protoBeforeRec.length > 0) {
|
|
177
|
-
parts.push(
|
|
188
|
+
parts.push(
|
|
189
|
+
`${protoBeforeRec.length} sprint(s) prototyped before recommending -- good practice.`,
|
|
190
|
+
);
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
const noResearch = antiPatterns.filter(
|
|
193
|
+
const noResearch = antiPatterns.filter(
|
|
194
|
+
(p) => p.pattern === "recommend-without-research",
|
|
195
|
+
);
|
|
181
196
|
if (noResearch.length > 0) {
|
|
182
|
-
parts.push(
|
|
197
|
+
parts.push(
|
|
198
|
+
`${noResearch.length} sprint(s) recommended without dedicated research -- consider adding /research steps.`,
|
|
199
|
+
);
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
const trend = crossSprint.evidenceTrend;
|
|
@@ -187,13 +204,17 @@ function generatePatternInsight(patterns, antiPatterns, crossSprint) {
|
|
|
187
204
|
const first = trend[0].avgEvidence;
|
|
188
205
|
const last = trend[trend.length - 1].avgEvidence;
|
|
189
206
|
if (last > first) {
|
|
190
|
-
parts.push(
|
|
207
|
+
parts.push("Evidence quality is trending up across sprints.");
|
|
191
208
|
} else if (last < first) {
|
|
192
|
-
parts.push(
|
|
209
|
+
parts.push(
|
|
210
|
+
"Evidence quality has declined -- recent sprints may need stronger validation.",
|
|
211
|
+
);
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
|
|
196
|
-
return parts.length > 0
|
|
215
|
+
return parts.length > 0
|
|
216
|
+
? parts.join(" ")
|
|
217
|
+
: "Not enough sprint data to detect clear patterns.";
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
module.exports = { detectPatterns };
|