@grainulation/harvest 1.0.1 → 1.0.4
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 +127 -42
- 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 +6 -2
package/lib/server.js
CHANGED
|
@@ -9,25 +9,29 @@
|
|
|
9
9
|
* harvest serve [--port 9096] [--root /path/to/sprints]
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createServer } from
|
|
13
|
-
import { readFileSync, existsSync, readdirSync, statSync } from
|
|
14
|
-
import { join, resolve, extname, dirname, basename } from
|
|
15
|
-
import { fileURLToPath } from
|
|
16
|
-
import { createRequire } from
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { join, resolve, extname, dirname, basename } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
17
|
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
20
|
|
|
21
21
|
// ── Crash handlers ──
|
|
22
|
-
process.on(
|
|
23
|
-
process.stderr.write(
|
|
22
|
+
process.on("uncaughtException", (err) => {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`,
|
|
25
|
+
);
|
|
24
26
|
process.exit(1);
|
|
25
27
|
});
|
|
26
|
-
process.on(
|
|
27
|
-
process.stderr.write(
|
|
28
|
+
process.on("unhandledRejection", (reason) => {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`,
|
|
31
|
+
);
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
const PUBLIC_DIR = join(__dirname,
|
|
34
|
+
const PUBLIC_DIR = join(__dirname, "..", "public");
|
|
31
35
|
|
|
32
36
|
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
33
37
|
|
|
@@ -37,51 +41,108 @@ function arg(name, fallback) {
|
|
|
37
41
|
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const PORT = parseInt(arg(
|
|
41
|
-
const CORS_ORIGIN = arg(
|
|
44
|
+
const PORT = parseInt(arg("port", "9096"), 10);
|
|
45
|
+
const CORS_ORIGIN = arg("cors", null);
|
|
42
46
|
|
|
43
47
|
// Resolve ROOT: walk up from cwd to find a directory with claims.json
|
|
44
48
|
function resolveRoot(initial) {
|
|
45
|
-
if (existsSync(join(initial,
|
|
49
|
+
if (existsSync(join(initial, "claims.json"))) return initial;
|
|
46
50
|
let dir = initial;
|
|
47
51
|
for (let i = 0; i < 5; i++) {
|
|
48
|
-
const parent = resolve(dir,
|
|
52
|
+
const parent = resolve(dir, "..");
|
|
49
53
|
if (parent === dir) break;
|
|
50
54
|
dir = parent;
|
|
51
|
-
if (existsSync(join(dir,
|
|
55
|
+
if (existsSync(join(dir, "claims.json"))) return dir;
|
|
52
56
|
}
|
|
53
57
|
return initial; // fall back to original
|
|
54
58
|
}
|
|
55
|
-
const ROOT = resolveRoot(resolve(arg(
|
|
59
|
+
const ROOT = resolveRoot(resolve(arg("root", process.cwd())));
|
|
56
60
|
|
|
57
61
|
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
58
62
|
|
|
59
|
-
const verbose =
|
|
63
|
+
const verbose =
|
|
64
|
+
process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
60
65
|
function vlog(...a) {
|
|
61
66
|
if (!verbose) return;
|
|
62
67
|
const ts = new Date().toISOString();
|
|
63
|
-
process.stderr.write(`[${ts}] harvest: ${a.join(
|
|
68
|
+
process.stderr.write(`[${ts}] harvest: ${a.join(" ")}\n`);
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
67
72
|
|
|
68
73
|
const ROUTES = [
|
|
69
|
-
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
|
|
74
|
+
{
|
|
75
|
+
method: "GET",
|
|
76
|
+
path: "/events",
|
|
77
|
+
description: "SSE event stream for live updates",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: "/api/sprints",
|
|
82
|
+
description: "List discovered sprints with claim counts",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
method: "GET",
|
|
86
|
+
path: "/api/analysis/:name",
|
|
87
|
+
description: "Full analysis of a single sprint",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
method: "GET",
|
|
91
|
+
path: "/api/calibration",
|
|
92
|
+
description:
|
|
93
|
+
"Prediction calibration with Brier score and calibration curve",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
method: "GET",
|
|
97
|
+
path: "/api/decay",
|
|
98
|
+
description: "Find stale claims needing refresh (?days=N)",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
method: "GET",
|
|
102
|
+
path: "/api/decay-alerts",
|
|
103
|
+
description: "Topic-aware decay alerts with tiered urgency",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
method: "GET",
|
|
107
|
+
path: "/api/tokens",
|
|
108
|
+
description: "Token cost tracking and efficiency metrics",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
method: "GET",
|
|
112
|
+
path: "/api/harvest-card",
|
|
113
|
+
description: "Generate Harvest Report SVG card",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
method: "GET",
|
|
117
|
+
path: "/api/harvest-report",
|
|
118
|
+
description: "Harvest Report stats (JSON)",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
method: "GET",
|
|
122
|
+
path: "/api/intelligence",
|
|
123
|
+
description: "Full intelligence report (all features)",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
method: "GET",
|
|
127
|
+
path: "/api/dashboard",
|
|
128
|
+
description: "Combined analytics dashboard summary",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
method: "GET",
|
|
132
|
+
path: "/api/docs",
|
|
133
|
+
description: "This API documentation page",
|
|
134
|
+
},
|
|
76
135
|
];
|
|
77
136
|
|
|
78
137
|
// ── Load existing CJS modules via createRequire ──────────────────────────────
|
|
79
138
|
|
|
80
|
-
const { analyze } = require(
|
|
81
|
-
const { measureVelocity } = require(
|
|
82
|
-
const { checkDecay } = require(
|
|
83
|
-
const { calibrate } = require(
|
|
84
|
-
const { claimsPaths } = require(
|
|
139
|
+
const { analyze } = require("./analyzer.js");
|
|
140
|
+
const { measureVelocity } = require("./velocity.js");
|
|
141
|
+
const { checkDecay, decayAlerts } = require("./decay.js");
|
|
142
|
+
const { calibrate } = require("./calibration.js");
|
|
143
|
+
const { claimsPaths } = require("./dashboard.js");
|
|
144
|
+
const { analyzeTokens } = require("./tokens.js");
|
|
145
|
+
const { generateCard, computeReportStats } = require("./harvest-card.js");
|
|
85
146
|
|
|
86
147
|
// ── Sprint discovery ─────────────────────────────────────────────────────────
|
|
87
148
|
|
|
@@ -90,7 +151,7 @@ function discoverSprints(rootDir) {
|
|
|
90
151
|
if (!existsSync(rootDir)) return sprints;
|
|
91
152
|
|
|
92
153
|
// Include root if it has claims.json
|
|
93
|
-
const directClaims = join(rootDir,
|
|
154
|
+
const directClaims = join(rootDir, "claims.json");
|
|
94
155
|
if (existsSync(directClaims)) {
|
|
95
156
|
sprints.push(loadSingleSprint(rootDir));
|
|
96
157
|
}
|
|
@@ -100,9 +161,9 @@ function discoverSprints(rootDir) {
|
|
|
100
161
|
const entries = readdirSync(rootDir, { withFileTypes: true });
|
|
101
162
|
for (const entry of entries) {
|
|
102
163
|
if (!entry.isDirectory()) continue;
|
|
103
|
-
if (entry.name.startsWith(
|
|
164
|
+
if (entry.name.startsWith(".")) continue;
|
|
104
165
|
const childDir = join(rootDir, entry.name);
|
|
105
|
-
const childClaims = join(childDir,
|
|
166
|
+
const childClaims = join(childDir, "claims.json");
|
|
106
167
|
if (existsSync(childClaims)) {
|
|
107
168
|
sprints.push(loadSingleSprint(childDir));
|
|
108
169
|
}
|
|
@@ -111,16 +172,20 @@ function discoverSprints(rootDir) {
|
|
|
111
172
|
const subEntries = readdirSync(childDir, { withFileTypes: true });
|
|
112
173
|
for (const sub of subEntries) {
|
|
113
174
|
if (!sub.isDirectory()) continue;
|
|
114
|
-
if (sub.name.startsWith(
|
|
175
|
+
if (sub.name.startsWith(".")) continue;
|
|
115
176
|
const subDir = join(childDir, sub.name);
|
|
116
|
-
const subClaims = join(subDir,
|
|
177
|
+
const subClaims = join(subDir, "claims.json");
|
|
117
178
|
if (existsSync(subClaims)) {
|
|
118
179
|
sprints.push(loadSingleSprint(subDir));
|
|
119
180
|
}
|
|
120
181
|
}
|
|
121
|
-
} catch {
|
|
182
|
+
} catch {
|
|
183
|
+
/* skip */
|
|
184
|
+
}
|
|
122
185
|
}
|
|
123
|
-
} catch {
|
|
186
|
+
} catch {
|
|
187
|
+
/* skip if unreadable */
|
|
188
|
+
}
|
|
124
189
|
|
|
125
190
|
return sprints;
|
|
126
191
|
}
|
|
@@ -134,29 +199,42 @@ function loadSingleSprint(dir) {
|
|
|
134
199
|
gitLog: [],
|
|
135
200
|
};
|
|
136
201
|
|
|
137
|
-
const claimsPath = join(dir,
|
|
202
|
+
const claimsPath = join(dir, "claims.json");
|
|
138
203
|
try {
|
|
139
|
-
const raw = JSON.parse(readFileSync(claimsPath,
|
|
204
|
+
const raw = JSON.parse(readFileSync(claimsPath, "utf8"));
|
|
140
205
|
sprint.claims = Array.isArray(raw) ? raw : raw.claims || [];
|
|
141
|
-
} catch {
|
|
206
|
+
} catch {
|
|
207
|
+
/* skip */
|
|
208
|
+
}
|
|
142
209
|
|
|
143
|
-
const compilationPath = join(dir,
|
|
210
|
+
const compilationPath = join(dir, "compilation.json");
|
|
144
211
|
if (existsSync(compilationPath)) {
|
|
145
212
|
try {
|
|
146
|
-
sprint.compilation = JSON.parse(readFileSync(compilationPath,
|
|
147
|
-
} catch {
|
|
213
|
+
sprint.compilation = JSON.parse(readFileSync(compilationPath, "utf8"));
|
|
214
|
+
} catch {
|
|
215
|
+
/* skip */
|
|
216
|
+
}
|
|
148
217
|
}
|
|
149
218
|
|
|
150
219
|
// Git log for velocity
|
|
151
220
|
try {
|
|
152
|
-
const { execSync } = require(
|
|
221
|
+
const { execSync } = require("node:child_process");
|
|
153
222
|
sprint.gitLog = execSync(
|
|
154
223
|
`git log --oneline --format="%H|%ai|%s" -- claims.json`,
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
224
|
+
{
|
|
225
|
+
cwd: dir,
|
|
226
|
+
encoding: "utf8",
|
|
227
|
+
timeout: 5000,
|
|
228
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
.trim()
|
|
232
|
+
.split("\n")
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.map((line) => {
|
|
235
|
+
const [hash, date, ...msg] = line.split("|");
|
|
236
|
+
return { hash, date, message: msg.join("|") };
|
|
237
|
+
});
|
|
160
238
|
} catch {
|
|
161
239
|
sprint.gitLog = [];
|
|
162
240
|
}
|
|
@@ -176,31 +254,38 @@ const sseClients = new Set();
|
|
|
176
254
|
function broadcast(event) {
|
|
177
255
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
178
256
|
for (const res of sseClients) {
|
|
179
|
-
try {
|
|
257
|
+
try {
|
|
258
|
+
res.write(data);
|
|
259
|
+
} catch {
|
|
260
|
+
sseClients.delete(res);
|
|
261
|
+
}
|
|
180
262
|
}
|
|
181
263
|
}
|
|
182
264
|
|
|
183
265
|
function refreshState() {
|
|
184
266
|
state.sprints = discoverSprints(ROOT);
|
|
185
267
|
state.lastRefresh = new Date().toISOString();
|
|
186
|
-
broadcast({
|
|
268
|
+
broadcast({
|
|
269
|
+
type: "state",
|
|
270
|
+
data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh },
|
|
271
|
+
});
|
|
187
272
|
}
|
|
188
273
|
|
|
189
274
|
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
190
275
|
|
|
191
276
|
const MIME = {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
277
|
+
".html": "text/html; charset=utf-8",
|
|
278
|
+
".css": "text/css; charset=utf-8",
|
|
279
|
+
".js": "application/javascript; charset=utf-8",
|
|
280
|
+
".json": "application/json; charset=utf-8",
|
|
281
|
+
".svg": "image/svg+xml",
|
|
282
|
+
".png": "image/png",
|
|
198
283
|
};
|
|
199
284
|
|
|
200
285
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
201
286
|
|
|
202
287
|
function jsonResponse(res, code, data) {
|
|
203
|
-
res.writeHead(code, {
|
|
288
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
204
289
|
res.end(JSON.stringify(data));
|
|
205
290
|
}
|
|
206
291
|
|
|
@@ -211,55 +296,65 @@ const server = createServer(async (req, res) => {
|
|
|
211
296
|
|
|
212
297
|
// CORS (only when --cors is passed)
|
|
213
298
|
if (CORS_ORIGIN) {
|
|
214
|
-
res.setHeader(
|
|
215
|
-
res.setHeader(
|
|
216
|
-
res.setHeader(
|
|
299
|
+
res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
|
|
300
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
301
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
217
302
|
}
|
|
218
303
|
|
|
219
|
-
if (req.method ===
|
|
304
|
+
if (req.method === "OPTIONS" && CORS_ORIGIN) {
|
|
220
305
|
res.writeHead(204);
|
|
221
306
|
res.end();
|
|
222
307
|
return;
|
|
223
308
|
}
|
|
224
309
|
|
|
225
|
-
vlog(
|
|
310
|
+
vlog("request", req.method, url.pathname);
|
|
226
311
|
|
|
227
312
|
// ── API: docs ──
|
|
228
|
-
if (req.method ===
|
|
313
|
+
if (req.method === "GET" && url.pathname === "/api/docs") {
|
|
229
314
|
const html = `<!DOCTYPE html><html><head><title>harvest API</title>
|
|
230
315
|
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
231
316
|
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
232
317
|
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
233
318
|
<body><h1>harvest API</h1><p>${ROUTES.length} endpoints</p>
|
|
234
319
|
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
235
|
-
${ROUTES.map(r =>
|
|
320
|
+
${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
|
|
236
321
|
</table></body></html>`;
|
|
237
|
-
res.writeHead(200, {
|
|
322
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
238
323
|
res.end(html);
|
|
239
324
|
return;
|
|
240
325
|
}
|
|
241
326
|
|
|
242
327
|
// ── SSE endpoint ──
|
|
243
|
-
if (req.method ===
|
|
328
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
244
329
|
res.writeHead(200, {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
330
|
+
"Content-Type": "text/event-stream",
|
|
331
|
+
"Cache-Control": "no-cache",
|
|
332
|
+
Connection: "keep-alive",
|
|
248
333
|
});
|
|
249
|
-
res.write(
|
|
334
|
+
res.write(
|
|
335
|
+
`data: ${JSON.stringify({ type: "state", data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } })}\n\n`,
|
|
336
|
+
);
|
|
250
337
|
const heartbeat = setInterval(() => {
|
|
251
|
-
try {
|
|
338
|
+
try {
|
|
339
|
+
res.write(": heartbeat\n\n");
|
|
340
|
+
} catch {
|
|
341
|
+
clearInterval(heartbeat);
|
|
342
|
+
}
|
|
252
343
|
}, 15000);
|
|
253
344
|
sseClients.add(res);
|
|
254
|
-
vlog(
|
|
255
|
-
req.on(
|
|
345
|
+
vlog("sse", `client connected (${sseClients.size} total)`);
|
|
346
|
+
req.on("close", () => {
|
|
347
|
+
clearInterval(heartbeat);
|
|
348
|
+
sseClients.delete(res);
|
|
349
|
+
vlog("sse", `client disconnected (${sseClients.size} total)`);
|
|
350
|
+
});
|
|
256
351
|
return;
|
|
257
352
|
}
|
|
258
353
|
|
|
259
354
|
// ── API: list sprints ──
|
|
260
|
-
if (req.method ===
|
|
355
|
+
if (req.method === "GET" && url.pathname === "/api/sprints") {
|
|
261
356
|
refreshState();
|
|
262
|
-
const sprintList = state.sprints.map(s => ({
|
|
357
|
+
const sprintList = state.sprints.map((s) => ({
|
|
263
358
|
name: s.name,
|
|
264
359
|
dir: s.dir,
|
|
265
360
|
claimCount: s.claims.length,
|
|
@@ -271,14 +366,22 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
271
366
|
}
|
|
272
367
|
|
|
273
368
|
// ── API: full analysis of a sprint ──
|
|
274
|
-
if (req.method ===
|
|
275
|
-
const sprintName = decodeURIComponent(
|
|
276
|
-
|
|
369
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/analysis/")) {
|
|
370
|
+
const sprintName = decodeURIComponent(
|
|
371
|
+
url.pathname.slice("/api/analysis/".length),
|
|
372
|
+
);
|
|
373
|
+
if (!sprintName) {
|
|
374
|
+
jsonResponse(res, 400, { error: "missing sprint name" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
277
377
|
|
|
278
378
|
// Refresh and find the sprint
|
|
279
379
|
refreshState();
|
|
280
|
-
const sprint = state.sprints.find(s => s.name === sprintName);
|
|
281
|
-
if (!sprint) {
|
|
380
|
+
const sprint = state.sprints.find((s) => s.name === sprintName);
|
|
381
|
+
if (!sprint) {
|
|
382
|
+
jsonResponse(res, 404, { error: `sprint "${sprintName}" not found` });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
282
385
|
|
|
283
386
|
const analysis = analyze([sprint]);
|
|
284
387
|
const velocity = measureVelocity([sprint]);
|
|
@@ -292,10 +395,12 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
292
395
|
}
|
|
293
396
|
|
|
294
397
|
// ── API: calibration (all sprints) ──
|
|
295
|
-
if (req.method ===
|
|
398
|
+
if (req.method === "GET" && url.pathname === "/api/calibration") {
|
|
296
399
|
refreshState();
|
|
297
400
|
if (state.sprints.length === 0) {
|
|
298
|
-
jsonResponse(res, 200, {
|
|
401
|
+
jsonResponse(res, 200, {
|
|
402
|
+
calibration: { summary: { totalSprints: 0 }, sprints: [] },
|
|
403
|
+
});
|
|
299
404
|
return;
|
|
300
405
|
}
|
|
301
406
|
const result = calibrate(state.sprints);
|
|
@@ -304,11 +409,18 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
304
409
|
}
|
|
305
410
|
|
|
306
411
|
// ── API: decay detection ──
|
|
307
|
-
if (req.method ===
|
|
308
|
-
const days = parseInt(url.searchParams.get(
|
|
412
|
+
if (req.method === "GET" && url.pathname === "/api/decay") {
|
|
413
|
+
const days = parseInt(url.searchParams.get("days") || "30", 10);
|
|
309
414
|
refreshState();
|
|
310
415
|
if (state.sprints.length === 0) {
|
|
311
|
-
jsonResponse(res, 200, {
|
|
416
|
+
jsonResponse(res, 200, {
|
|
417
|
+
decay: {
|
|
418
|
+
summary: { totalClaims: 0 },
|
|
419
|
+
stale: [],
|
|
420
|
+
decaying: [],
|
|
421
|
+
unresolved: [],
|
|
422
|
+
},
|
|
423
|
+
});
|
|
312
424
|
return;
|
|
313
425
|
}
|
|
314
426
|
const result = checkDecay(state.sprints, { thresholdDays: days });
|
|
@@ -316,8 +428,88 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
316
428
|
return;
|
|
317
429
|
}
|
|
318
430
|
|
|
431
|
+
// ── API: topic-aware decay alerts ──
|
|
432
|
+
if (req.method === "GET" && url.pathname === "/api/decay-alerts") {
|
|
433
|
+
refreshState();
|
|
434
|
+
if (state.sprints.length === 0) {
|
|
435
|
+
jsonResponse(res, 200, {
|
|
436
|
+
decayAlerts: {
|
|
437
|
+
summary: { totalAlerts: 0 },
|
|
438
|
+
alerts: [],
|
|
439
|
+
byUrgency: {},
|
|
440
|
+
topicDecayRates: [],
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const result = decayAlerts(state.sprints);
|
|
446
|
+
jsonResponse(res, 200, { decayAlerts: result });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── API: token cost tracking ──
|
|
451
|
+
if (req.method === "GET" && url.pathname === "/api/tokens") {
|
|
452
|
+
refreshState();
|
|
453
|
+
if (state.sprints.length === 0) {
|
|
454
|
+
jsonResponse(res, 200, {
|
|
455
|
+
tokens: { summary: { totalSprints: 0 }, perSprint: [] },
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const result = analyzeTokens(state.sprints);
|
|
460
|
+
jsonResponse(res, 200, { tokens: result });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── API: Harvest Report SVG card ──
|
|
465
|
+
if (req.method === "GET" && url.pathname === "/api/harvest-card") {
|
|
466
|
+
refreshState();
|
|
467
|
+
if (state.sprints.length === 0) {
|
|
468
|
+
res.writeHead(200, { "Content-Type": "image/svg+xml" });
|
|
469
|
+
res.end(
|
|
470
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"><rect width="1200" height="630" fill="#0d1117"/><text x="600" y="315" fill="#6e7681" font-family="system-ui" font-size="24" text-anchor="middle">No sprint data</text></svg>',
|
|
471
|
+
);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const { svg } = generateCard(state.sprints);
|
|
475
|
+
res.writeHead(200, { "Content-Type": "image/svg+xml" });
|
|
476
|
+
res.end(svg);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── API: Harvest Report stats (JSON) ──
|
|
481
|
+
if (req.method === "GET" && url.pathname === "/api/harvest-report") {
|
|
482
|
+
refreshState();
|
|
483
|
+
if (state.sprints.length === 0) {
|
|
484
|
+
jsonResponse(res, 200, { report: null });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const stats = computeReportStats(state.sprints);
|
|
488
|
+
jsonResponse(res, 200, { report: stats });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── API: full intelligence report ──
|
|
493
|
+
if (req.method === "GET" && url.pathname === "/api/intelligence") {
|
|
494
|
+
refreshState();
|
|
495
|
+
if (state.sprints.length === 0) {
|
|
496
|
+
jsonResponse(res, 200, { intelligence: null });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const result = {
|
|
500
|
+
analysis: analyze(state.sprints),
|
|
501
|
+
calibration: calibrate(state.sprints),
|
|
502
|
+
decayAlerts: decayAlerts(state.sprints),
|
|
503
|
+
tokens: analyzeTokens(state.sprints),
|
|
504
|
+
velocity: measureVelocity(state.sprints),
|
|
505
|
+
harvestReport: computeReportStats(state.sprints),
|
|
506
|
+
};
|
|
507
|
+
jsonResponse(res, 200, { intelligence: result });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
319
511
|
// ── API: dashboard summary (all sprints combined) ──
|
|
320
|
-
if (req.method ===
|
|
512
|
+
if (req.method === "GET" && url.pathname === "/api/dashboard") {
|
|
321
513
|
refreshState();
|
|
322
514
|
if (state.sprints.length === 0) {
|
|
323
515
|
jsonResponse(res, 200, {
|
|
@@ -344,15 +536,18 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
344
536
|
}
|
|
345
537
|
|
|
346
538
|
// ── Dashboard UI (web app from public/) ──
|
|
347
|
-
if (
|
|
348
|
-
|
|
539
|
+
if (
|
|
540
|
+
req.method === "GET" &&
|
|
541
|
+
(url.pathname === "/" || url.pathname === "/index.html")
|
|
542
|
+
) {
|
|
543
|
+
const indexPath = join(PUBLIC_DIR, "index.html");
|
|
349
544
|
try {
|
|
350
|
-
const html = readFileSync(indexPath,
|
|
351
|
-
res.writeHead(200, {
|
|
545
|
+
const html = readFileSync(indexPath, "utf8");
|
|
546
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
352
547
|
res.end(html);
|
|
353
548
|
} catch (err) {
|
|
354
|
-
res.writeHead(500, {
|
|
355
|
-
res.end(
|
|
549
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
550
|
+
res.end("Error reading dashboard: " + err.message);
|
|
356
551
|
}
|
|
357
552
|
return;
|
|
358
553
|
}
|
|
@@ -363,40 +558,44 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
363
558
|
|
|
364
559
|
if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
|
|
365
560
|
const ext = extname(filePath);
|
|
366
|
-
const mime = MIME[ext] ||
|
|
561
|
+
const mime = MIME[ext] || "application/octet-stream";
|
|
367
562
|
try {
|
|
368
563
|
const content = readFileSync(filePath);
|
|
369
|
-
res.writeHead(200, {
|
|
564
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
370
565
|
res.end(content);
|
|
371
566
|
} catch {
|
|
372
567
|
res.writeHead(500);
|
|
373
|
-
res.end(
|
|
568
|
+
res.end("read error");
|
|
374
569
|
}
|
|
375
570
|
return;
|
|
376
571
|
}
|
|
377
572
|
|
|
378
573
|
// ── 404 ──
|
|
379
|
-
res.writeHead(404, {
|
|
380
|
-
res.end(
|
|
574
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
575
|
+
res.end("not found");
|
|
381
576
|
});
|
|
382
577
|
|
|
383
578
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
384
579
|
const shutdown = (signal) => {
|
|
385
580
|
console.log(`\nharvest: ${signal} received, shutting down...`);
|
|
386
|
-
for (const res of sseClients) {
|
|
581
|
+
for (const res of sseClients) {
|
|
582
|
+
try {
|
|
583
|
+
res.end();
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
387
586
|
sseClients.clear();
|
|
388
587
|
server.close(() => process.exit(0));
|
|
389
588
|
setTimeout(() => process.exit(1), 5000);
|
|
390
589
|
};
|
|
391
|
-
process.on(
|
|
392
|
-
process.on(
|
|
590
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
591
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
393
592
|
|
|
394
593
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
395
594
|
|
|
396
595
|
refreshState();
|
|
397
596
|
|
|
398
|
-
server.on(
|
|
399
|
-
if (err.code ===
|
|
597
|
+
server.on("error", (err) => {
|
|
598
|
+
if (err.code === "EADDRINUSE") {
|
|
400
599
|
console.error(`\nharvest: port ${PORT} is already in use.`);
|
|
401
600
|
console.error(` Try: harvest serve --port ${Number(PORT) + 1}`);
|
|
402
601
|
console.error(` Or stop the process using port ${PORT}.\n`);
|
|
@@ -407,7 +606,7 @@ server.on('error', (err) => {
|
|
|
407
606
|
|
|
408
607
|
// ── File watching for live reload ────────────────────────────────────────────
|
|
409
608
|
|
|
410
|
-
import { watch as fsWatch } from
|
|
609
|
+
import { watch as fsWatch } from "node:fs";
|
|
411
610
|
|
|
412
611
|
const watchers = [];
|
|
413
612
|
let debounceTimer = null;
|
|
@@ -417,9 +616,13 @@ function onClaimsChange() {
|
|
|
417
616
|
debounceTimer = setTimeout(() => {
|
|
418
617
|
refreshState();
|
|
419
618
|
// Send update event so SSE clients reload
|
|
420
|
-
const updateData = `event: update\ndata: ${JSON.stringify({ type:
|
|
619
|
+
const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
|
|
421
620
|
for (const client of sseClients) {
|
|
422
|
-
try {
|
|
621
|
+
try {
|
|
622
|
+
client.write(updateData);
|
|
623
|
+
} catch {
|
|
624
|
+
sseClients.delete(client);
|
|
625
|
+
}
|
|
423
626
|
}
|
|
424
627
|
}, 500);
|
|
425
628
|
}
|
|
@@ -430,24 +633,31 @@ function watchClaims() {
|
|
|
430
633
|
try {
|
|
431
634
|
const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
|
|
432
635
|
watchers.push(w);
|
|
433
|
-
} catch {
|
|
636
|
+
} catch {
|
|
637
|
+
/* file may not exist yet */
|
|
638
|
+
}
|
|
434
639
|
}
|
|
435
640
|
// Watch sprint directories for new claims files
|
|
436
|
-
for (const dir of [ROOT, join(ROOT,
|
|
641
|
+
for (const dir of [ROOT, join(ROOT, "sprints"), join(ROOT, "archive")]) {
|
|
437
642
|
if (!existsSync(dir)) continue;
|
|
438
643
|
try {
|
|
439
644
|
const w = fsWatch(dir, { persistent: false }, (_, filename) => {
|
|
440
|
-
if (
|
|
645
|
+
if (
|
|
646
|
+
filename &&
|
|
647
|
+
(filename === "claims.json" || filename.includes("claims"))
|
|
648
|
+
) {
|
|
441
649
|
onClaimsChange();
|
|
442
650
|
}
|
|
443
651
|
});
|
|
444
652
|
watchers.push(w);
|
|
445
|
-
} catch {
|
|
653
|
+
} catch {
|
|
654
|
+
/* ignore */
|
|
655
|
+
}
|
|
446
656
|
}
|
|
447
657
|
}
|
|
448
658
|
|
|
449
|
-
server.listen(PORT,
|
|
450
|
-
vlog(
|
|
659
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
660
|
+
vlog("listen", `port=${PORT}`, `root=${ROOT}`);
|
|
451
661
|
console.log(`harvest: serving on http://localhost:${PORT}`);
|
|
452
662
|
console.log(` sprints: ${state.sprints.length} found`);
|
|
453
663
|
console.log(` root: ${ROOT}`);
|