@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/decay.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Knowledge freshness tracking.
|
|
@@ -10,14 +10,52 @@
|
|
|
10
10
|
* - Claims that were challenged but never resolved
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const VOLATILE_EVIDENCE = new Set([
|
|
13
|
+
const VOLATILE_EVIDENCE = new Set(["stated", "web"]);
|
|
14
14
|
const DEFAULT_THRESHOLD_DAYS = 90;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Topic-specific decay half-lives (in days).
|
|
18
|
+
* Claims in fast-moving domains go stale faster than stable ones.
|
|
19
|
+
* These are configurable defaults that can be overridden via opts.halfLives.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_HALF_LIVES = {
|
|
22
|
+
ai: 42, // ~6 weeks
|
|
23
|
+
ml: 42,
|
|
24
|
+
"ai/ml": 42,
|
|
25
|
+
security: 14, // ~2 weeks
|
|
26
|
+
vulnerability: 14,
|
|
27
|
+
market: 28, // ~4 weeks
|
|
28
|
+
pricing: 28,
|
|
29
|
+
api: 90, // ~3 months
|
|
30
|
+
sdk: 90,
|
|
31
|
+
documentation: 90,
|
|
32
|
+
architecture: 180, // ~6 months
|
|
33
|
+
pattern: 180,
|
|
34
|
+
regulatory: 365, // ~12 months
|
|
35
|
+
compliance: 365,
|
|
36
|
+
legal: 365,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tiered urgency levels for decay alerts.
|
|
41
|
+
*
|
|
42
|
+
* passive: Age badges -- claim is aging, shown as metadata.
|
|
43
|
+
* active: Inline nudge -- claim is past half-life, warn when touched.
|
|
44
|
+
* urgent: Blocking -- claim is far past half-life, flag during compilation.
|
|
45
|
+
*/
|
|
46
|
+
const URGENCY_TIERS = {
|
|
47
|
+
passive: { label: "aging", minRatio: 0.5 }, // past 50% of half-life
|
|
48
|
+
active: { label: "stale", minRatio: 1.0 }, // past full half-life
|
|
49
|
+
urgent: { label: "expired", minRatio: 2.0 }, // past 2x half-life
|
|
50
|
+
};
|
|
51
|
+
|
|
16
52
|
function checkDecay(sprints, opts = {}) {
|
|
17
53
|
const thresholdDays = opts.thresholdDays || DEFAULT_THRESHOLD_DAYS;
|
|
18
54
|
const now = new Date();
|
|
19
55
|
|
|
20
|
-
const allClaims = sprints.flatMap(s =>
|
|
56
|
+
const allClaims = sprints.flatMap((s) =>
|
|
57
|
+
s.claims.map((c) => ({ ...c, _sprint: s.name })),
|
|
58
|
+
);
|
|
21
59
|
|
|
22
60
|
const decaying = [];
|
|
23
61
|
const stale = [];
|
|
@@ -28,7 +66,11 @@ function checkDecay(sprints, opts = {}) {
|
|
|
28
66
|
const age = created ? daysBetween(new Date(created), now) : null;
|
|
29
67
|
|
|
30
68
|
// Stale: old claims with volatile evidence
|
|
31
|
-
if (
|
|
69
|
+
if (
|
|
70
|
+
age !== null &&
|
|
71
|
+
age > thresholdDays &&
|
|
72
|
+
VOLATILE_EVIDENCE.has(claim.evidence)
|
|
73
|
+
) {
|
|
32
74
|
stale.push({
|
|
33
75
|
id: claim.id,
|
|
34
76
|
sprint: claim._sprint,
|
|
@@ -54,20 +96,20 @@ function checkDecay(sprints, opts = {}) {
|
|
|
54
96
|
}
|
|
55
97
|
|
|
56
98
|
// Unresolved: challenged claims still marked as contested
|
|
57
|
-
if (claim.status ===
|
|
99
|
+
if (claim.status === "contested" || claim.status === "challenged") {
|
|
58
100
|
unresolved.push({
|
|
59
101
|
id: claim.id,
|
|
60
102
|
sprint: claim._sprint,
|
|
61
103
|
type: claim.type,
|
|
62
104
|
text: truncate(claim.text || claim.claim || claim.description, 120),
|
|
63
|
-
reason:
|
|
105
|
+
reason: "Claim was challenged but never resolved.",
|
|
64
106
|
});
|
|
65
107
|
}
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
// Deduplicate (a claim might appear in both stale and decaying)
|
|
69
|
-
const decayingIds = new Set(decaying.map(c => c.id));
|
|
70
|
-
const dedupedStale = stale.filter(c => !decayingIds.has(c.id));
|
|
111
|
+
const decayingIds = new Set(decaying.map((c) => c.id));
|
|
112
|
+
const dedupedStale = stale.filter((c) => !decayingIds.has(c.id));
|
|
71
113
|
|
|
72
114
|
// Sort by age descending
|
|
73
115
|
const sortByAge = (a, b) => (b.ageDays || 0) - (a.ageDays || 0);
|
|
@@ -85,7 +127,12 @@ function checkDecay(sprints, opts = {}) {
|
|
|
85
127
|
stale: dedupedStale,
|
|
86
128
|
decaying,
|
|
87
129
|
unresolved,
|
|
88
|
-
insight: generateDecayInsight(
|
|
130
|
+
insight: generateDecayInsight(
|
|
131
|
+
allClaims.length,
|
|
132
|
+
dedupedStale,
|
|
133
|
+
decaying,
|
|
134
|
+
unresolved,
|
|
135
|
+
),
|
|
89
136
|
};
|
|
90
137
|
}
|
|
91
138
|
|
|
@@ -94,31 +141,190 @@ function daysBetween(a, b) {
|
|
|
94
141
|
}
|
|
95
142
|
|
|
96
143
|
function truncate(str, maxLen) {
|
|
97
|
-
if (!str) return
|
|
98
|
-
return str.length > maxLen ? str.slice(0, maxLen - 3) +
|
|
144
|
+
if (!str) return "";
|
|
145
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
function generateDecayInsight(total, stale, decaying, unresolved) {
|
|
102
149
|
const parts = [];
|
|
103
150
|
|
|
104
151
|
if (decaying.length > 0) {
|
|
105
|
-
parts.push(
|
|
152
|
+
parts.push(
|
|
153
|
+
`${decaying.length} claim(s) are significantly outdated and should be revalidated or archived.`,
|
|
154
|
+
);
|
|
106
155
|
}
|
|
107
156
|
|
|
108
157
|
if (stale.length > 0) {
|
|
109
|
-
parts.push(
|
|
158
|
+
parts.push(
|
|
159
|
+
`${stale.length} claim(s) have volatile evidence (stated/web) that may no longer be accurate.`,
|
|
160
|
+
);
|
|
110
161
|
}
|
|
111
162
|
|
|
112
163
|
if (unresolved.length > 0) {
|
|
113
|
-
parts.push(
|
|
164
|
+
parts.push(
|
|
165
|
+
`${unresolved.length} challenged claim(s) remain unresolved -- use /resolve to settle them.`,
|
|
166
|
+
);
|
|
114
167
|
}
|
|
115
168
|
|
|
116
|
-
const decayRate =
|
|
169
|
+
const decayRate =
|
|
170
|
+
total > 0
|
|
171
|
+
? Math.round(((stale.length + decaying.length) / total) * 100)
|
|
172
|
+
: 0;
|
|
117
173
|
if (decayRate > 30) {
|
|
118
|
-
parts.push(
|
|
174
|
+
parts.push(
|
|
175
|
+
`Knowledge decay rate is ${decayRate}% -- consider a refresh sprint.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return parts.length > 0
|
|
180
|
+
? parts.join(" ")
|
|
181
|
+
: "Knowledge base looks fresh -- no decay detected.";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Topic-aware decay alerts with tiered urgency.
|
|
186
|
+
*
|
|
187
|
+
* Uses half-lives per topic to compute urgency. Claims in fast-moving domains
|
|
188
|
+
* (AI, security) get flagged sooner than stable domains (architecture, legal).
|
|
189
|
+
*
|
|
190
|
+
* @param {Array} sprints
|
|
191
|
+
* @param {object} [opts] - { halfLives: override map }
|
|
192
|
+
* @returns {object} - { alerts, byUrgency, topicDecayRates }
|
|
193
|
+
*/
|
|
194
|
+
function decayAlerts(sprints, opts = {}) {
|
|
195
|
+
const halfLives = { ...DEFAULT_HALF_LIVES, ...(opts.halfLives || {}) };
|
|
196
|
+
const now = new Date();
|
|
197
|
+
|
|
198
|
+
const allClaims = sprints.flatMap((s) =>
|
|
199
|
+
s.claims.map((c) => ({ ...c, _sprint: s.name })),
|
|
200
|
+
);
|
|
201
|
+
const alerts = [];
|
|
202
|
+
|
|
203
|
+
for (const claim of allClaims) {
|
|
204
|
+
const created = claim.created || claim.date || claim.timestamp;
|
|
205
|
+
if (!created) continue;
|
|
206
|
+
|
|
207
|
+
const ageDays = daysBetween(new Date(created), now);
|
|
208
|
+
const halfLife = resolveHalfLife(claim, halfLives);
|
|
209
|
+
const ratio = ageDays / halfLife;
|
|
210
|
+
|
|
211
|
+
let urgency = null;
|
|
212
|
+
if (ratio >= URGENCY_TIERS.urgent.minRatio) urgency = "urgent";
|
|
213
|
+
else if (ratio >= URGENCY_TIERS.active.minRatio) urgency = "active";
|
|
214
|
+
else if (ratio >= URGENCY_TIERS.passive.minRatio) urgency = "passive";
|
|
215
|
+
|
|
216
|
+
if (urgency) {
|
|
217
|
+
alerts.push({
|
|
218
|
+
id: claim.id,
|
|
219
|
+
sprint: claim._sprint,
|
|
220
|
+
type: claim.type,
|
|
221
|
+
evidence: claim.evidence,
|
|
222
|
+
topic: claim.topic || null,
|
|
223
|
+
ageDays,
|
|
224
|
+
halfLife,
|
|
225
|
+
decayRatio: Math.round(ratio * 100) / 100,
|
|
226
|
+
urgency,
|
|
227
|
+
text: truncate(
|
|
228
|
+
claim.text || claim.claim || claim.description || claim.content,
|
|
229
|
+
120,
|
|
230
|
+
),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Group by urgency
|
|
236
|
+
const byUrgency = {
|
|
237
|
+
urgent: alerts.filter((a) => a.urgency === "urgent"),
|
|
238
|
+
active: alerts.filter((a) => a.urgency === "active"),
|
|
239
|
+
passive: alerts.filter((a) => a.urgency === "passive"),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Topic decay rates: which topics decay fastest in practice
|
|
243
|
+
const topicStats = {};
|
|
244
|
+
for (const alert of alerts) {
|
|
245
|
+
const topic = alert.topic || "general";
|
|
246
|
+
if (!topicStats[topic])
|
|
247
|
+
topicStats[topic] = { count: 0, totalRatio: 0, urgentCount: 0 };
|
|
248
|
+
topicStats[topic].count++;
|
|
249
|
+
topicStats[topic].totalRatio += alert.decayRatio;
|
|
250
|
+
if (alert.urgency === "urgent") topicStats[topic].urgentCount++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const topicDecayRates = Object.entries(topicStats)
|
|
254
|
+
.map(([topic, stats]) => ({
|
|
255
|
+
topic,
|
|
256
|
+
alertCount: stats.count,
|
|
257
|
+
avgDecayRatio: Math.round((stats.totalRatio / stats.count) * 100) / 100,
|
|
258
|
+
urgentCount: stats.urgentCount,
|
|
259
|
+
}))
|
|
260
|
+
.sort((a, b) => b.avgDecayRatio - a.avgDecayRatio);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
summary: {
|
|
264
|
+
totalAlerts: alerts.length,
|
|
265
|
+
urgent: byUrgency.urgent.length,
|
|
266
|
+
active: byUrgency.active.length,
|
|
267
|
+
passive: byUrgency.passive.length,
|
|
268
|
+
},
|
|
269
|
+
alerts,
|
|
270
|
+
byUrgency,
|
|
271
|
+
topicDecayRates,
|
|
272
|
+
insight: generateAlertInsight(byUrgency, topicDecayRates),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the half-life for a claim based on its topic and tags.
|
|
278
|
+
*/
|
|
279
|
+
function resolveHalfLife(claim, halfLives) {
|
|
280
|
+
// Check topic field first
|
|
281
|
+
if (claim.topic) {
|
|
282
|
+
const topicLower = claim.topic.toLowerCase();
|
|
283
|
+
for (const [key, days] of Object.entries(halfLives)) {
|
|
284
|
+
if (topicLower.includes(key)) return days;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check tags
|
|
289
|
+
if (claim.tags) {
|
|
290
|
+
for (const tag of claim.tags) {
|
|
291
|
+
const tagLower = tag.toLowerCase();
|
|
292
|
+
if (halfLives[tagLower]) return halfLives[tagLower];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Default based on evidence tier: volatile evidence decays faster
|
|
297
|
+
if (VOLATILE_EVIDENCE.has(claim.evidence)) return 60;
|
|
298
|
+
return DEFAULT_THRESHOLD_DAYS;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function generateAlertInsight(byUrgency, topicDecayRates) {
|
|
302
|
+
const parts = [];
|
|
303
|
+
|
|
304
|
+
if (byUrgency.urgent.length > 0) {
|
|
305
|
+
parts.push(
|
|
306
|
+
`${byUrgency.urgent.length} claim(s) are well past their knowledge half-life and should be revalidated before use.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (byUrgency.active.length > 0) {
|
|
311
|
+
parts.push(
|
|
312
|
+
`${byUrgency.active.length} claim(s) have reached their decay threshold -- consider refreshing when you next touch these topics.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (topicDecayRates.length > 0) {
|
|
317
|
+
const fastest = topicDecayRates[0];
|
|
318
|
+
if (fastest.avgDecayRatio > 1.5) {
|
|
319
|
+
parts.push(
|
|
320
|
+
`Topic "${fastest.topic}" has the highest decay rate (${fastest.alertCount} alerts). This domain moves fast -- consider shorter review cycles.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
119
323
|
}
|
|
120
324
|
|
|
121
|
-
return parts.length > 0
|
|
325
|
+
return parts.length > 0
|
|
326
|
+
? parts.join(" ")
|
|
327
|
+
: "No topic-specific decay concerns detected.";
|
|
122
328
|
}
|
|
123
329
|
|
|
124
|
-
module.exports = { checkDecay };
|
|
330
|
+
module.exports = { checkDecay, decayAlerts, DEFAULT_HALF_LIVES };
|
package/lib/farmer.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
|
-
const fs = require(
|
|
4
|
-
const path = require(
|
|
5
|
-
const http = require(
|
|
6
|
-
const https = require(
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const http = require("node:http");
|
|
6
|
+
const https = require("node:https");
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* POST an activity event to farmer.
|
|
@@ -15,39 +15,46 @@ function notify(farmerUrl, event) {
|
|
|
15
15
|
return new Promise((resolve) => {
|
|
16
16
|
try {
|
|
17
17
|
const payload = JSON.stringify({
|
|
18
|
-
tool:
|
|
18
|
+
tool: "harvest",
|
|
19
19
|
event,
|
|
20
|
-
timestamp: new Date().toISOString()
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const url = new URL(`${farmerUrl}/hooks/activity`);
|
|
24
|
-
const transport = url.protocol ===
|
|
24
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
25
25
|
|
|
26
|
-
const req = transport.request(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
const req = transport.request(
|
|
27
|
+
{
|
|
28
|
+
hostname: url.hostname,
|
|
29
|
+
port: url.port,
|
|
30
|
+
path: url.pathname,
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
35
|
+
},
|
|
36
|
+
timeout: 5000,
|
|
34
37
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
(res) => {
|
|
39
|
+
let body = "";
|
|
40
|
+
res.on("data", (chunk) => {
|
|
41
|
+
body += chunk;
|
|
42
|
+
});
|
|
43
|
+
res.on("end", () =>
|
|
44
|
+
resolve({ ok: res.statusCode < 400, status: res.statusCode, body }),
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
41
48
|
|
|
42
|
-
req.on(
|
|
49
|
+
req.on("error", (err) => {
|
|
43
50
|
console.error(`[harvest] farmer notify failed: ${err.message}`);
|
|
44
51
|
resolve({ ok: false, error: err.message });
|
|
45
52
|
});
|
|
46
53
|
|
|
47
|
-
req.on(
|
|
54
|
+
req.on("timeout", () => {
|
|
48
55
|
req.destroy();
|
|
49
|
-
console.error(
|
|
50
|
-
resolve({ ok: false, error:
|
|
56
|
+
console.error("[harvest] farmer notify timed out");
|
|
57
|
+
resolve({ ok: false, error: "timeout" });
|
|
51
58
|
});
|
|
52
59
|
|
|
53
60
|
req.write(payload);
|
|
@@ -67,40 +74,49 @@ function notify(farmerUrl, event) {
|
|
|
67
74
|
*/
|
|
68
75
|
async function connect(targetDir, args) {
|
|
69
76
|
const subcommand = args[0];
|
|
70
|
-
if (subcommand !==
|
|
71
|
-
console.error(
|
|
77
|
+
if (subcommand !== "farmer") {
|
|
78
|
+
console.error(
|
|
79
|
+
"Usage: harvest connect farmer [--url http://localhost:9090]",
|
|
80
|
+
);
|
|
72
81
|
process.exit(1);
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
const configPath = path.join(targetDir,
|
|
84
|
+
const configPath = path.join(targetDir, ".farmer.json");
|
|
76
85
|
|
|
77
|
-
const urlIdx = args.indexOf(
|
|
86
|
+
const urlIdx = args.indexOf("--url");
|
|
78
87
|
if (urlIdx !== -1 && args[urlIdx + 1]) {
|
|
79
88
|
const url = args[urlIdx + 1];
|
|
80
89
|
const config = { url };
|
|
81
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2),
|
|
90
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
82
91
|
console.log(`Farmer connection saved to ${configPath}`);
|
|
83
92
|
console.log(` URL: ${url}`);
|
|
84
93
|
|
|
85
94
|
// Test the connection
|
|
86
|
-
const result = await notify(url, {
|
|
95
|
+
const result = await notify(url, {
|
|
96
|
+
type: "connect",
|
|
97
|
+
data: { tool: "harvest" },
|
|
98
|
+
});
|
|
87
99
|
if (result.ok) {
|
|
88
|
-
console.log(
|
|
100
|
+
console.log(" Connection test: OK");
|
|
89
101
|
} else {
|
|
90
|
-
console.log(
|
|
91
|
-
|
|
102
|
+
console.log(
|
|
103
|
+
` Connection test: failed (${result.error || "status " + result.status})`,
|
|
104
|
+
);
|
|
105
|
+
console.log(
|
|
106
|
+
" Farmer may not be running. The URL is saved and will be used when farmer is available.",
|
|
107
|
+
);
|
|
92
108
|
}
|
|
93
109
|
return;
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
// Show current config
|
|
97
113
|
if (fs.existsSync(configPath)) {
|
|
98
|
-
const config = JSON.parse(fs.readFileSync(configPath,
|
|
114
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
99
115
|
console.log(`Farmer connection: ${config.url}`);
|
|
100
116
|
console.log(`Config: ${configPath}`);
|
|
101
117
|
} else {
|
|
102
|
-
console.log(
|
|
103
|
-
console.log(
|
|
118
|
+
console.log("No farmer connection configured.");
|
|
119
|
+
console.log("Usage: harvest connect farmer --url http://localhost:9090");
|
|
104
120
|
}
|
|
105
121
|
}
|
|
106
122
|
|