@grainulation/harvest 1.0.0 → 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/decay.js CHANGED
@@ -1,4 +1,4 @@
1
- 'use strict';
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(['stated', 'web']);
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 => s.claims.map(c => ({ ...c, _sprint: s.name })));
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 (age !== null && age > thresholdDays && VOLATILE_EVIDENCE.has(claim.evidence)) {
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 === 'contested' || claim.status === 'challenged') {
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: 'Claim was challenged but never resolved.',
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(allClaims.length, dedupedStale, decaying, unresolved),
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) + '...' : str;
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(`${decaying.length} claim(s) are significantly outdated and should be revalidated or archived.`);
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(`${stale.length} claim(s) have volatile evidence (stated/web) that may no longer be accurate.`);
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(`${unresolved.length} challenged claim(s) remain unresolved -- use /resolve to settle them.`);
164
+ parts.push(
165
+ `${unresolved.length} challenged claim(s) remain unresolved -- use /resolve to settle them.`,
166
+ );
114
167
  }
115
168
 
116
- const decayRate = total > 0 ? Math.round((stale.length + decaying.length) / total * 100) : 0;
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(`Knowledge decay rate is ${decayRate}% -- consider a refresh sprint.`);
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 ? parts.join(' ') : 'Knowledge base looks fresh -- no decay detected.';
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
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
- const http = require('node:http');
6
- const https = require('node:https');
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: 'harvest',
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 === 'https:' ? https : http;
24
+ const transport = url.protocol === "https:" ? https : http;
25
25
 
26
- const req = transport.request({
27
- hostname: url.hostname,
28
- port: url.port,
29
- path: url.pathname,
30
- method: 'POST',
31
- headers: {
32
- 'Content-Type': 'application/json',
33
- 'Content-Length': Buffer.byteLength(payload)
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
- timeout: 5000
36
- }, (res) => {
37
- let body = '';
38
- res.on('data', chunk => { body += chunk; });
39
- res.on('end', () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body }));
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('error', (err) => {
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('timeout', () => {
54
+ req.on("timeout", () => {
48
55
  req.destroy();
49
- console.error('[harvest] farmer notify timed out');
50
- resolve({ ok: false, error: 'timeout' });
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 !== 'farmer') {
71
- console.error('Usage: harvest connect farmer [--url http://localhost:9090]');
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, '.farmer.json');
84
+ const configPath = path.join(targetDir, ".farmer.json");
76
85
 
77
- const urlIdx = args.indexOf('--url');
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), 'utf8');
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, { type: 'connect', data: { tool: 'harvest' } });
95
+ const result = await notify(url, {
96
+ type: "connect",
97
+ data: { tool: "harvest" },
98
+ });
87
99
  if (result.ok) {
88
- console.log(' Connection test: OK');
100
+ console.log(" Connection test: OK");
89
101
  } else {
90
- console.log(` Connection test: failed (${result.error || 'status ' + result.status})`);
91
- console.log(' Farmer may not be running. The URL is saved and will be used when farmer is available.');
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, 'utf8'));
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('No farmer connection configured.');
103
- console.log('Usage: harvest connect farmer --url http://localhost:9090');
118
+ console.log("No farmer connection configured.");
119
+ console.log("Usage: harvest connect farmer --url http://localhost:9090");
104
120
  }
105
121
  }
106
122