@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/report.js CHANGED
@@ -1,7 +1,7 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
5
 
6
6
  /**
7
7
  * Generate retrospective HTML reports.
@@ -15,111 +15,293 @@ function generateReport(sprints, fns) {
15
15
  const patterns = fns.patternsFn(sprints);
16
16
  const decay = fns.decayFn(sprints);
17
17
  const velocity = fns.velocityFn(sprints);
18
+ const tokens = fns.tokensFn ? fns.tokensFn(sprints) : null;
19
+ const wrapped = fns.wrappedFn
20
+ ? fns.wrappedFn(sprints, { tokenReport: tokens })
21
+ : null;
18
22
 
19
23
  // Try to load template
20
- const templatePath = path.join(__dirname, '..', 'templates', 'retrospective.html');
24
+ const templatePath = path.join(
25
+ __dirname,
26
+ "..",
27
+ "templates",
28
+ "retrospective.html",
29
+ );
21
30
  let template;
22
31
  try {
23
- template = fs.readFileSync(templatePath, 'utf8');
32
+ template = fs.readFileSync(templatePath, "utf8");
24
33
  } catch {
25
34
  template = getDefaultTemplate();
26
35
  }
27
36
 
28
37
  // Inject data into template
29
38
  const html = template
30
- .replace('{{GENERATED_DATE}}', new Date().toISOString().split('T')[0])
31
- .replace('{{SPRINT_COUNT}}', String(analysis.summary.totalSprints))
32
- .replace('{{CLAIM_COUNT}}', String(analysis.summary.totalClaims))
33
- .replace('{{AVG_CLAIMS}}', String(analysis.summary.averageClaimsPerSprint))
34
- .replace('{{ACCURACY_RATE}}', calibration.summary.accuracyRate !== null ? calibration.summary.accuracyRate + '%' : 'N/A')
35
- .replace('{{ESTIMATE_COUNT}}', String(calibration.summary.totalEstimates))
36
- .replace('{{MATCHED_COUNT}}', String(calibration.summary.matched))
37
- .replace('{{UNMATCHED_COUNT}}', String(calibration.summary.unmatched))
38
- .replace('{{CALIBRATION_INSIGHT}}', escapeHtml(calibration.insight))
39
- .replace('{{PATTERN_COUNT}}', String(patterns.summary.patternsFound))
40
- .replace('{{ANTI_PATTERN_COUNT}}', String(patterns.summary.antiPatternsFound))
41
- .replace('{{PATTERN_INSIGHT}}', escapeHtml(patterns.insight))
42
- .replace('{{PATTERNS_LIST}}', renderPatternsList(patterns))
43
- .replace('{{ANTI_PATTERNS_LIST}}', renderAntiPatternsList(patterns))
44
- .replace('{{STALE_COUNT}}', String(decay.summary.staleCount))
45
- .replace('{{DECAYING_COUNT}}', String(decay.summary.decayingCount))
46
- .replace('{{UNRESOLVED_COUNT}}', String(decay.summary.unresolvedCount))
47
- .replace('{{DECAY_INSIGHT}}', escapeHtml(decay.insight))
48
- .replace('{{AVG_DURATION}}', velocity.summary.avgDurationDays !== null ? velocity.summary.avgDurationDays + ' days' : 'N/A')
49
- .replace('{{AVG_CLAIMS_PER_DAY}}', velocity.summary.avgClaimsPerDay !== null ? String(velocity.summary.avgClaimsPerDay) : 'N/A')
50
- .replace('{{TOTAL_STALLS}}', String(velocity.summary.totalStalls))
51
- .replace('{{VELOCITY_INSIGHT}}', escapeHtml(velocity.insight))
52
- .replace('{{TYPE_DISTRIBUTION}}', renderDistribution(analysis.typeDistribution))
53
- .replace('{{EVIDENCE_DISTRIBUTION}}', renderDistribution(analysis.evidenceDistribution))
54
- .replace('{{WEAK_CLAIMS_TABLE}}', renderWeakClaimsTable(analysis.weakClaims))
55
- .replace('{{DECAY_TABLE}}', renderDecayTable([...decay.stale, ...decay.decaying]))
56
- .replace('{{VELOCITY_TABLE}}', renderVelocityTable(velocity.sprints));
39
+ .replace("{{GENERATED_DATE}}", new Date().toISOString().split("T")[0])
40
+ .replace("{{SPRINT_COUNT}}", String(analysis.summary.totalSprints))
41
+ .replace("{{CLAIM_COUNT}}", String(analysis.summary.totalClaims))
42
+ .replace("{{AVG_CLAIMS}}", String(analysis.summary.averageClaimsPerSprint))
43
+ .replace(
44
+ "{{ACCURACY_RATE}}",
45
+ calibration.summary.accuracyRate !== null
46
+ ? calibration.summary.accuracyRate + "%"
47
+ : "N/A",
48
+ )
49
+ .replace("{{ESTIMATE_COUNT}}", String(calibration.summary.totalEstimates))
50
+ .replace("{{MATCHED_COUNT}}", String(calibration.summary.matched))
51
+ .replace("{{UNMATCHED_COUNT}}", String(calibration.summary.unmatched))
52
+ .replace("{{CALIBRATION_INSIGHT}}", escapeHtml(calibration.insight))
53
+ .replace("{{PATTERN_COUNT}}", String(patterns.summary.patternsFound))
54
+ .replace(
55
+ "{{ANTI_PATTERN_COUNT}}",
56
+ String(patterns.summary.antiPatternsFound),
57
+ )
58
+ .replace("{{PATTERN_INSIGHT}}", escapeHtml(patterns.insight))
59
+ .replace("{{PATTERNS_LIST}}", renderPatternsList(patterns))
60
+ .replace("{{ANTI_PATTERNS_LIST}}", renderAntiPatternsList(patterns))
61
+ .replace("{{STALE_COUNT}}", String(decay.summary.staleCount))
62
+ .replace("{{DECAYING_COUNT}}", String(decay.summary.decayingCount))
63
+ .replace("{{UNRESOLVED_COUNT}}", String(decay.summary.unresolvedCount))
64
+ .replace("{{DECAY_INSIGHT}}", escapeHtml(decay.insight))
65
+ .replace(
66
+ "{{AVG_DURATION}}",
67
+ velocity.summary.avgDurationDays !== null
68
+ ? velocity.summary.avgDurationDays + " days"
69
+ : "N/A",
70
+ )
71
+ .replace(
72
+ "{{AVG_CLAIMS_PER_DAY}}",
73
+ velocity.summary.avgClaimsPerDay !== null
74
+ ? String(velocity.summary.avgClaimsPerDay)
75
+ : "N/A",
76
+ )
77
+ .replace("{{TOTAL_STALLS}}", String(velocity.summary.totalStalls))
78
+ .replace("{{VELOCITY_INSIGHT}}", escapeHtml(velocity.insight))
79
+ .replace(
80
+ "{{TYPE_DISTRIBUTION}}",
81
+ renderDistribution(analysis.typeDistribution),
82
+ )
83
+ .replace(
84
+ "{{EVIDENCE_DISTRIBUTION}}",
85
+ renderDistribution(analysis.evidenceDistribution),
86
+ )
87
+ .replace(
88
+ "{{WEAK_CLAIMS_TABLE}}",
89
+ renderWeakClaimsTable(analysis.weakClaims),
90
+ )
91
+ .replace(
92
+ "{{DECAY_TABLE}}",
93
+ renderDecayTable([...decay.stale, ...decay.decaying]),
94
+ )
95
+ .replace("{{VELOCITY_TABLE}}", renderVelocityTable(velocity.sprints))
96
+ .replace("{{TOKEN_COST_SECTION}}", renderTokenCostSection(tokens))
97
+ .replace("{{WRAPPED_SECTION}}", renderWrappedSection(wrapped));
57
98
 
58
99
  return html;
59
100
  }
60
101
 
61
102
  function escapeHtml(str) {
62
- if (!str) return '';
103
+ if (!str) return "";
63
104
  return str
64
- .replace(/&/g, '&')
65
- .replace(/</g, '&lt;')
66
- .replace(/>/g, '&gt;')
67
- .replace(/"/g, '&quot;');
105
+ .replace(/&/g, "&amp;")
106
+ .replace(/</g, "&lt;")
107
+ .replace(/>/g, "&gt;")
108
+ .replace(/"/g, "&quot;");
68
109
  }
69
110
 
70
111
  function renderDistribution(dist) {
71
112
  return Object.entries(dist)
72
113
  .sort((a, b) => b[1] - a[1])
73
- .map(([key, val]) => `<div class="bar-row"><span class="bar-label">${escapeHtml(key)}</span><div class="bar" style="width: ${Math.min(100, val * 5)}%">${val}</div></div>`)
74
- .join('\n');
114
+ .map(
115
+ ([key, val]) =>
116
+ `<div class="bar-row"><span class="bar-label">${escapeHtml(key)}</span><div class="bar" style="width: ${Math.min(100, val * 5)}%">${val}</div></div>`,
117
+ )
118
+ .join("\n");
75
119
  }
76
120
 
77
121
  function renderPatternsList(patterns) {
78
- if (patterns.patterns.length === 0) return '<p class="muted">No positive patterns detected yet.</p>';
79
- return '<ul>' + patterns.patterns.map(p =>
80
- `<li><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`
81
- ).join('') + '</ul>';
122
+ if (patterns.patterns.length === 0)
123
+ return '<p class="muted">No positive patterns detected yet.</p>';
124
+ return (
125
+ "<ul>" +
126
+ patterns.patterns
127
+ .map(
128
+ (p) =>
129
+ `<li><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`,
130
+ )
131
+ .join("") +
132
+ "</ul>"
133
+ );
82
134
  }
83
135
 
84
136
  function renderAntiPatternsList(patterns) {
85
- if (patterns.antiPatterns.length === 0) return '<p class="muted">No anti-patterns detected.</p>';
86
- return '<ul>' + patterns.antiPatterns.map(p =>
87
- `<li class="severity-${p.severity}"><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`
88
- ).join('') + '</ul>';
137
+ if (patterns.antiPatterns.length === 0)
138
+ return '<p class="muted">No anti-patterns detected.</p>';
139
+ return (
140
+ "<ul>" +
141
+ patterns.antiPatterns
142
+ .map(
143
+ (p) =>
144
+ `<li class="severity-${p.severity}"><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`,
145
+ )
146
+ .join("") +
147
+ "</ul>"
148
+ );
89
149
  }
90
150
 
91
151
  function renderWeakClaimsTable(claims) {
92
- if (claims.length === 0) return '<p class="muted">All claims have solid evidence.</p>';
93
- const rows = claims.slice(0, 20).map(c =>
94
- `<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${escapeHtml(c.type)}</td><td>${escapeHtml(c.evidence)}</td><td>${escapeHtml(String(c.text || ''))}</td></tr>`
95
- ).join('');
152
+ if (claims.length === 0)
153
+ return '<p class="muted">All claims have solid evidence.</p>';
154
+ const rows = claims
155
+ .slice(0, 20)
156
+ .map(
157
+ (c) =>
158
+ `<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${escapeHtml(c.type)}</td><td>${escapeHtml(c.evidence)}</td><td>${escapeHtml(String(c.text || ""))}</td></tr>`,
159
+ )
160
+ .join("");
96
161
  return `<table><thead><tr><th>ID</th><th>Sprint</th><th>Type</th><th>Evidence</th><th>Claim</th></tr></thead><tbody>${rows}</tbody></table>`;
97
162
  }
98
163
 
99
164
  function renderDecayTable(items) {
100
- if (items.length === 0) return '<p class="muted">No knowledge decay detected.</p>';
101
- const rows = items.slice(0, 20).map(c =>
102
- `<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${c.ageDays || '?'} days</td><td>${escapeHtml(c.reason)}</td></tr>`
103
- ).join('');
165
+ if (items.length === 0)
166
+ return '<p class="muted">No knowledge decay detected.</p>';
167
+ const rows = items
168
+ .slice(0, 20)
169
+ .map(
170
+ (c) =>
171
+ `<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${c.ageDays || "?"} days</td><td>${escapeHtml(c.reason)}</td></tr>`,
172
+ )
173
+ .join("");
104
174
  return `<table><thead><tr><th>ID</th><th>Sprint</th><th>Age</th><th>Reason</th></tr></thead><tbody>${rows}</tbody></table>`;
105
175
  }
106
176
 
107
177
  function renderVelocityTable(sprintResults) {
108
- if (sprintResults.length === 0) return '<p class="muted">No velocity data available.</p>';
109
- const rows = sprintResults.map(s =>
110
- `<tr><td>${escapeHtml(s.sprint)}</td><td>${s.durationDays ?? 'N/A'}</td><td>${s.totalClaims ?? '?'}</td><td>${s.claimsPerDay ?? 'N/A'}</td><td>${s.stalls.length}</td></tr>`
111
- ).join('');
178
+ if (sprintResults.length === 0)
179
+ return '<p class="muted">No velocity data available.</p>';
180
+ const rows = sprintResults
181
+ .map(
182
+ (s) =>
183
+ `<tr><td>${escapeHtml(s.sprint)}</td><td>${s.durationDays ?? "N/A"}</td><td>${s.totalClaims ?? "?"}</td><td>${s.claimsPerDay ?? "N/A"}</td><td>${s.stalls.length}</td></tr>`,
184
+ )
185
+ .join("");
112
186
  return `<table><thead><tr><th>Sprint</th><th>Days</th><th>Claims</th><th>Claims/Day</th><th>Stalls</th></tr></thead><tbody>${rows}</tbody></table>`;
113
187
  }
114
188
 
189
+ function renderTokenCostSection(tokens) {
190
+ if (!tokens || !tokens.summary) return "";
191
+ const s = tokens.summary;
192
+
193
+ if (s.sprintsWithUsageData === 0 && s.totalCostUsd === 0) {
194
+ return '<section class="report-section"><h2>Token Costs</h2><p class="muted">No token usage data available. Integrate Agent SDK to track costs.</p></section>';
195
+ }
196
+
197
+ const rows = (tokens.perSprint || [])
198
+ .filter((sp) => sp.cost !== null)
199
+ .slice(0, 20)
200
+ .map(
201
+ (sp) =>
202
+ `<tr><td>${escapeHtml(sp.sprint)}</td><td>$${sp.cost?.toFixed(4) || "?"}</td><td>${sp.claimCount}</td><td>${sp.verifiedClaims}</td><td>${sp.costPerVerifiedClaim !== null ? "$" + sp.costPerVerifiedClaim.toFixed(4) : "N/A"}</td></tr>`,
203
+ )
204
+ .join("");
205
+
206
+ const table = rows
207
+ ? `<table><thead><tr><th>Sprint</th><th>Cost</th><th>Claims</th><th>Verified</th><th>Cost/Verified</th></tr></thead><tbody>${rows}</tbody></table>`
208
+ : "";
209
+
210
+ return `<section class="report-section">
211
+ <h2>Token Costs</h2>
212
+ <div class="stats-row">
213
+ <div class="stat-box"><span class="stat-num">$${s.totalCostUsd?.toFixed(4) || "0"}</span><span class="stat-label">Total Cost</span></div>
214
+ <div class="stat-box"><span class="stat-num">${s.costPerVerifiedClaim !== null ? "$" + s.costPerVerifiedClaim.toFixed(4) : "N/A"}</span><span class="stat-label">Cost / Verified Claim</span></div>
215
+ <div class="stat-box"><span class="stat-num">${s.avgCostPerSprint !== null ? "$" + s.avgCostPerSprint.toFixed(4) : "N/A"}</span><span class="stat-label">Avg Cost / Sprint</span></div>
216
+ <div class="stat-box"><span class="stat-num">${s.cacheHitRate !== null ? s.cacheHitRate + "%" : "N/A"}</span><span class="stat-label">Cache Hit Rate</span></div>
217
+ </div>
218
+ <p class="insight">${escapeHtml(tokens.insight || "")}</p>
219
+ ${table}
220
+ </section>`;
221
+ }
222
+
223
+ function renderWrappedSection(wrapped) {
224
+ if (!wrapped) return "";
225
+
226
+ const personality = wrapped.personality || {};
227
+ const stats = wrapped.stats || {};
228
+ const highlights = wrapped.highlights || [];
229
+ const tierProg = wrapped.tierProgression;
230
+ const tokenSum = wrapped.tokenSummary;
231
+
232
+ const highlightItems =
233
+ highlights.length > 0
234
+ ? "<ul>" +
235
+ highlights
236
+ .map(
237
+ (h) =>
238
+ `<li><strong>[${escapeHtml(h.type)}]</strong> ${escapeHtml(h.text)}</li>`,
239
+ )
240
+ .join("") +
241
+ "</ul>"
242
+ : '<p class="muted">Keep researching to unlock achievements.</p>';
243
+
244
+ const tierTrend = tierProg
245
+ ? `<p>Evidence quality trend: <strong>${escapeHtml(tierProg.trend)}</strong> (early avg: ${tierProg.firstHalfAvg}, recent avg: ${tierProg.secondHalfAvg})</p>`
246
+ : "";
247
+
248
+ const tokenLine =
249
+ tokenSum && tokenSum.totalCostUsd > 0
250
+ ? `<p>Total research cost: <strong>$${tokenSum.totalCostUsd.toFixed(4)}</strong>${tokenSum.costPerVerifiedClaim ? ` | $${tokenSum.costPerVerifiedClaim.toFixed(4)} per verified claim` : ""}${tokenSum.costTrend ? ` | Trend: ${tokenSum.costTrend.direction}` : ""}</p>`
251
+ : "";
252
+
253
+ const topTagsList = (stats.topTags || [])
254
+ .slice(0, 5)
255
+ .map(
256
+ (t) =>
257
+ `<span class="wrapped-tag">${escapeHtml(t.tag)} (${t.count})</span>`,
258
+ )
259
+ .join(" ");
260
+
261
+ return `<section class="report-section wrapped-section">
262
+ <h2>Harvest Wrapped${wrapped.period ? " -- " + escapeHtml(wrapped.period.label) : ""}</h2>
263
+ <div class="personality-card">
264
+ <h3>${escapeHtml(personality.label || "Researcher")}</h3>
265
+ <p>${escapeHtml(personality.description || "")}</p>
266
+ </div>
267
+ <div class="stats-row">
268
+ <div class="stat-box"><span class="stat-num">${wrapped.sprintCount || 0}</span><span class="stat-label">Sprints</span></div>
269
+ <div class="stat-box"><span class="stat-num">${stats.totalClaims || 0}</span><span class="stat-label">Total Claims</span></div>
270
+ <div class="stat-box"><span class="stat-num">${stats.avgClaimsPerSprint || 0}</span><span class="stat-label">Avg Claims/Sprint</span></div>
271
+ </div>
272
+ ${tierTrend}
273
+ ${tokenLine}
274
+ ${topTagsList ? '<div class="wrapped-tags">' + topTagsList + "</div>" : ""}
275
+ <h3>Highlights</h3>
276
+ ${highlightItems}
277
+ </section>`;
278
+ }
279
+
115
280
  function getDefaultTemplate() {
116
281
  // Fallback inline template if file not found
117
282
  return `<!DOCTYPE html>
118
283
  <html lang="en"><head><meta charset="utf-8"><title>Harvest Retrospective</title>
119
284
  <style>body{background:#0f0f0f;color:#e5e5e5;font-family:system-ui;padding:2rem;max-width:900px;margin:0 auto}
120
- h1,h2{color:#f97316}.muted{color:#888}</style></head>
285
+ h1,h2{color:#f97316}.muted{color:#888}
286
+ .report-section{margin-bottom:2rem;padding:1rem;background:#1a1a1a;border-radius:8px}
287
+ .stats-row{display:flex;gap:1.5rem;flex-wrap:wrap;margin:1rem 0}
288
+ .stat-box{text-align:center;flex:1;min-width:100px}
289
+ .stat-num{font-size:1.4rem;font-weight:700;display:block}
290
+ .stat-label{font-size:0.75rem;color:#888;text-transform:uppercase}
291
+ .insight{color:#94a3b8;font-style:italic;margin:0.5rem 0}
292
+ .personality-card{background:#1e293b;border-left:4px solid #f97316;padding:1rem;border-radius:4px;margin-bottom:1rem}
293
+ .personality-card h3{margin:0 0 0.5rem 0;color:#f97316}
294
+ .wrapped-tags{display:flex;gap:0.3rem;flex-wrap:wrap;margin:0.5rem 0}
295
+ .wrapped-tag{background:#1e293b;padding:0.15rem 0.5rem;border-radius:3px;font-size:0.8rem;color:#94a3b8}
296
+ table{width:100%;border-collapse:collapse;margin:1rem 0}
297
+ th,td{text-align:left;padding:0.4rem 0.6rem;border-bottom:1px solid #333}
298
+ th{color:#f97316;font-size:0.8rem;text-transform:uppercase}
299
+ </style></head>
121
300
  <body><h1>Harvest Retrospective</h1><p>Generated: {{GENERATED_DATE}}</p>
122
- <p>{{SPRINT_COUNT}} sprints, {{CLAIM_COUNT}} claims</p></body></html>`;
301
+ <p>{{SPRINT_COUNT}} sprints, {{CLAIM_COUNT}} claims</p>
302
+ {{WRAPPED_SECTION}}
303
+ {{TOKEN_COST_SECTION}}
304
+ </body></html>`;
123
305
  }
124
306
 
125
307
  module.exports = { generateReport };