@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.
@@ -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, "&amp;")
246
+ .replace(/</g, "&lt;")
247
+ .replace(/>/g, "&gt;")
248
+ .replace(/"/g, "&quot;")
249
+ .replace(/'/g, "&apos;");
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: `![Harvest Report](./${filename})`,
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
- 'use strict';
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('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');
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('p') ? i : -1)
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 === 'recommendation' ? i : -1)
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: 'prototype-before-recommend',
54
- description: 'Prototyped before making recommendations -- tends to produce higher-evidence claims.',
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: 'adversarial-testing',
65
- description: 'Used /challenge to stress-test claims -- builds confidence in findings.',
66
- claimsChallenged: claims.filter(c => c.id && c.id.startsWith('x')).length,
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: 'external-corroboration',
75
- description: 'Used /witness to corroborate claims with external sources.',
76
- witnessCount: claims.filter(c => c.id && c.id.startsWith('w')).length,
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('r'));
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: 'recommend-without-research',
86
- description: 'Recommendations made without dedicated research claims.',
87
- severity: 'high',
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(c =>
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: 'weak-estimates',
102
+ pattern: "weak-estimates",
99
103
  description: `${weakEstimates.length}/${estimates.length} estimates have weak evidence (stated/web only).`,
100
- severity: 'medium',
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 || 'unknown'] = (typeCounts[c.type || 'unknown'] || 0) + 1;
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: 'type-monoculture',
114
- description: `${Math.round(maxType[1] / claims.length * 100)}% of claims are "${maxType[0]}" -- diversity of analysis may be lacking.`,
115
- severity: 'low',
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.map(c => EVIDENCE_RANK[c.evidence] || 0).filter(l => l > 0);
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 Math.round(levels.reduce((a, b) => a + b, 0) / levels.length * 10) / 10;
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(p => p.pattern === 'prototype-before-recommend');
184
+ const protoBeforeRec = patterns.filter(
185
+ (p) => p.pattern === "prototype-before-recommend",
186
+ );
176
187
  if (protoBeforeRec.length > 0) {
177
- parts.push(`${protoBeforeRec.length} sprint(s) prototyped before recommending -- good practice.`);
188
+ parts.push(
189
+ `${protoBeforeRec.length} sprint(s) prototyped before recommending -- good practice.`,
190
+ );
178
191
  }
179
192
 
180
- const noResearch = antiPatterns.filter(p => p.pattern === 'recommend-without-research');
193
+ const noResearch = antiPatterns.filter(
194
+ (p) => p.pattern === "recommend-without-research",
195
+ );
181
196
  if (noResearch.length > 0) {
182
- parts.push(`${noResearch.length} sprint(s) recommended without dedicated research -- consider adding /research steps.`);
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('Evidence quality is trending up across sprints.');
207
+ parts.push("Evidence quality is trending up across sprints.");
191
208
  } else if (last < first) {
192
- parts.push('Evidence quality has declined -- recent sprints may need stronger validation.');
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 ? parts.join(' ') : 'Not enough sprint data to detect clear patterns.';
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 };