@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
package/lib/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
|
-
const fs = require(
|
|
4
|
-
const path = require(
|
|
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(
|
|
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,
|
|
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(
|
|
31
|
-
.replace(
|
|
32
|
-
.replace(
|
|
33
|
-
.replace(
|
|
34
|
-
.replace(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.replace(
|
|
41
|
-
.replace(
|
|
42
|
-
.replace(
|
|
43
|
-
.replace(
|
|
44
|
-
.replace(
|
|
45
|
-
.replace(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
.replace(
|
|
50
|
-
.replace(
|
|
51
|
-
.replace(
|
|
52
|
-
.replace(
|
|
53
|
-
.replace(
|
|
54
|
-
.replace(
|
|
55
|
-
.replace(
|
|
56
|
-
.replace(
|
|
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,
|
|
66
|
-
.replace(/>/g,
|
|
67
|
-
.replace(/"/g,
|
|
105
|
+
.replace(/&/g, "&")
|
|
106
|
+
.replace(/</g, "<")
|
|
107
|
+
.replace(/>/g, ">")
|
|
108
|
+
.replace(/"/g, """);
|
|
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(
|
|
74
|
-
|
|
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)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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}
|
|
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
|
|
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 };
|