@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/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +93 -0
- package/README.md +44 -45
- 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 +323 -150
- 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 +10 -3
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 {
|
|
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,66 +254,41 @@ 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
|
|
|
207
|
-
// ── SSE live-reload injection ────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
const SSE_SCRIPT = `
|
|
210
|
-
<script>
|
|
211
|
-
(function() {
|
|
212
|
-
var es, retryCount = 0;
|
|
213
|
-
var dot = document.getElementById('statusDot');
|
|
214
|
-
function connect() {
|
|
215
|
-
es = new EventSource('/events');
|
|
216
|
-
es.addEventListener('update', function() { location.reload(); });
|
|
217
|
-
es.onopen = function() {
|
|
218
|
-
retryCount = 0;
|
|
219
|
-
if (dot) dot.className = 'status-dot ok';
|
|
220
|
-
if (window._grainSetState) window._grainSetState('idle');
|
|
221
|
-
};
|
|
222
|
-
es.onerror = function() {
|
|
223
|
-
es.close();
|
|
224
|
-
if (dot) dot.className = 'status-dot';
|
|
225
|
-
if (window._grainSetState) window._grainSetState('orbit');
|
|
226
|
-
var delay = Math.min(30000, 1000 * Math.pow(2, retryCount)) + Math.random() * 1000;
|
|
227
|
-
retryCount++;
|
|
228
|
-
setTimeout(connect, delay);
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
connect();
|
|
232
|
-
})();
|
|
233
|
-
</script>`;
|
|
234
|
-
|
|
235
|
-
function injectSSE(html) {
|
|
236
|
-
return html.replace('</body>', SSE_SCRIPT + '\n</body>');
|
|
237
|
-
}
|
|
238
|
-
|
|
239
292
|
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
240
293
|
|
|
241
294
|
const server = createServer(async (req, res) => {
|
|
@@ -243,55 +296,65 @@ const server = createServer(async (req, res) => {
|
|
|
243
296
|
|
|
244
297
|
// CORS (only when --cors is passed)
|
|
245
298
|
if (CORS_ORIGIN) {
|
|
246
|
-
res.setHeader(
|
|
247
|
-
res.setHeader(
|
|
248
|
-
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");
|
|
249
302
|
}
|
|
250
303
|
|
|
251
|
-
if (req.method ===
|
|
304
|
+
if (req.method === "OPTIONS" && CORS_ORIGIN) {
|
|
252
305
|
res.writeHead(204);
|
|
253
306
|
res.end();
|
|
254
307
|
return;
|
|
255
308
|
}
|
|
256
309
|
|
|
257
|
-
vlog(
|
|
310
|
+
vlog("request", req.method, url.pathname);
|
|
258
311
|
|
|
259
312
|
// ── API: docs ──
|
|
260
|
-
if (req.method ===
|
|
313
|
+
if (req.method === "GET" && url.pathname === "/api/docs") {
|
|
261
314
|
const html = `<!DOCTYPE html><html><head><title>harvest API</title>
|
|
262
315
|
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
263
316
|
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
264
317
|
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
265
318
|
<body><h1>harvest API</h1><p>${ROUTES.length} endpoints</p>
|
|
266
319
|
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
267
|
-
${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("")}
|
|
268
321
|
</table></body></html>`;
|
|
269
|
-
res.writeHead(200, {
|
|
322
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
270
323
|
res.end(html);
|
|
271
324
|
return;
|
|
272
325
|
}
|
|
273
326
|
|
|
274
327
|
// ── SSE endpoint ──
|
|
275
|
-
if (req.method ===
|
|
328
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
276
329
|
res.writeHead(200, {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
330
|
+
"Content-Type": "text/event-stream",
|
|
331
|
+
"Cache-Control": "no-cache",
|
|
332
|
+
Connection: "keep-alive",
|
|
280
333
|
});
|
|
281
|
-
res.write(
|
|
334
|
+
res.write(
|
|
335
|
+
`data: ${JSON.stringify({ type: "state", data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } })}\n\n`,
|
|
336
|
+
);
|
|
282
337
|
const heartbeat = setInterval(() => {
|
|
283
|
-
try {
|
|
338
|
+
try {
|
|
339
|
+
res.write(": heartbeat\n\n");
|
|
340
|
+
} catch {
|
|
341
|
+
clearInterval(heartbeat);
|
|
342
|
+
}
|
|
284
343
|
}, 15000);
|
|
285
344
|
sseClients.add(res);
|
|
286
|
-
vlog(
|
|
287
|
-
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
|
+
});
|
|
288
351
|
return;
|
|
289
352
|
}
|
|
290
353
|
|
|
291
354
|
// ── API: list sprints ──
|
|
292
|
-
if (req.method ===
|
|
355
|
+
if (req.method === "GET" && url.pathname === "/api/sprints") {
|
|
293
356
|
refreshState();
|
|
294
|
-
const sprintList = state.sprints.map(s => ({
|
|
357
|
+
const sprintList = state.sprints.map((s) => ({
|
|
295
358
|
name: s.name,
|
|
296
359
|
dir: s.dir,
|
|
297
360
|
claimCount: s.claims.length,
|
|
@@ -303,14 +366,22 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
303
366
|
}
|
|
304
367
|
|
|
305
368
|
// ── API: full analysis of a sprint ──
|
|
306
|
-
if (req.method ===
|
|
307
|
-
const sprintName = decodeURIComponent(
|
|
308
|
-
|
|
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
|
+
}
|
|
309
377
|
|
|
310
378
|
// Refresh and find the sprint
|
|
311
379
|
refreshState();
|
|
312
|
-
const sprint = state.sprints.find(s => s.name === sprintName);
|
|
313
|
-
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
|
+
}
|
|
314
385
|
|
|
315
386
|
const analysis = analyze([sprint]);
|
|
316
387
|
const velocity = measureVelocity([sprint]);
|
|
@@ -324,10 +395,12 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
324
395
|
}
|
|
325
396
|
|
|
326
397
|
// ── API: calibration (all sprints) ──
|
|
327
|
-
if (req.method ===
|
|
398
|
+
if (req.method === "GET" && url.pathname === "/api/calibration") {
|
|
328
399
|
refreshState();
|
|
329
400
|
if (state.sprints.length === 0) {
|
|
330
|
-
jsonResponse(res, 200, {
|
|
401
|
+
jsonResponse(res, 200, {
|
|
402
|
+
calibration: { summary: { totalSprints: 0 }, sprints: [] },
|
|
403
|
+
});
|
|
331
404
|
return;
|
|
332
405
|
}
|
|
333
406
|
const result = calibrate(state.sprints);
|
|
@@ -336,11 +409,18 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
336
409
|
}
|
|
337
410
|
|
|
338
411
|
// ── API: decay detection ──
|
|
339
|
-
if (req.method ===
|
|
340
|
-
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);
|
|
341
414
|
refreshState();
|
|
342
415
|
if (state.sprints.length === 0) {
|
|
343
|
-
jsonResponse(res, 200, {
|
|
416
|
+
jsonResponse(res, 200, {
|
|
417
|
+
decay: {
|
|
418
|
+
summary: { totalClaims: 0 },
|
|
419
|
+
stale: [],
|
|
420
|
+
decaying: [],
|
|
421
|
+
unresolved: [],
|
|
422
|
+
},
|
|
423
|
+
});
|
|
344
424
|
return;
|
|
345
425
|
}
|
|
346
426
|
const result = checkDecay(state.sprints, { thresholdDays: days });
|
|
@@ -348,8 +428,88 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
348
428
|
return;
|
|
349
429
|
}
|
|
350
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
|
+
|
|
351
511
|
// ── API: dashboard summary (all sprints combined) ──
|
|
352
|
-
if (req.method ===
|
|
512
|
+
if (req.method === "GET" && url.pathname === "/api/dashboard") {
|
|
353
513
|
refreshState();
|
|
354
514
|
if (state.sprints.length === 0) {
|
|
355
515
|
jsonResponse(res, 200, {
|
|
@@ -375,21 +535,19 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
375
535
|
return;
|
|
376
536
|
}
|
|
377
537
|
|
|
378
|
-
// ── Dashboard UI (
|
|
379
|
-
if (
|
|
538
|
+
// ── Dashboard UI (web app from public/) ──
|
|
539
|
+
if (
|
|
540
|
+
req.method === "GET" &&
|
|
541
|
+
(url.pathname === "/" || url.pathname === "/index.html")
|
|
542
|
+
) {
|
|
543
|
+
const indexPath = join(PUBLIC_DIR, "index.html");
|
|
380
544
|
try {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
384
|
-
res.end('<html><body style="background:#0a0e1a;color:#e2e8f0;font-family:monospace;padding:40px"><h1>No claims.json files found</h1><p>Watching for changes...</p>' + SSE_SCRIPT + '</body></html>');
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
const html = injectSSE(buildHtml(sprints));
|
|
388
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
545
|
+
const html = readFileSync(indexPath, "utf8");
|
|
546
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
389
547
|
res.end(html);
|
|
390
548
|
} catch (err) {
|
|
391
|
-
res.writeHead(500, {
|
|
392
|
-
res.end(
|
|
549
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
550
|
+
res.end("Error reading dashboard: " + err.message);
|
|
393
551
|
}
|
|
394
552
|
return;
|
|
395
553
|
}
|
|
@@ -400,40 +558,44 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
400
558
|
|
|
401
559
|
if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
|
|
402
560
|
const ext = extname(filePath);
|
|
403
|
-
const mime = MIME[ext] ||
|
|
561
|
+
const mime = MIME[ext] || "application/octet-stream";
|
|
404
562
|
try {
|
|
405
563
|
const content = readFileSync(filePath);
|
|
406
|
-
res.writeHead(200, {
|
|
564
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
407
565
|
res.end(content);
|
|
408
566
|
} catch {
|
|
409
567
|
res.writeHead(500);
|
|
410
|
-
res.end(
|
|
568
|
+
res.end("read error");
|
|
411
569
|
}
|
|
412
570
|
return;
|
|
413
571
|
}
|
|
414
572
|
|
|
415
573
|
// ── 404 ──
|
|
416
|
-
res.writeHead(404, {
|
|
417
|
-
res.end(
|
|
574
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
575
|
+
res.end("not found");
|
|
418
576
|
});
|
|
419
577
|
|
|
420
578
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
421
579
|
const shutdown = (signal) => {
|
|
422
580
|
console.log(`\nharvest: ${signal} received, shutting down...`);
|
|
423
|
-
for (const res of sseClients) {
|
|
581
|
+
for (const res of sseClients) {
|
|
582
|
+
try {
|
|
583
|
+
res.end();
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
424
586
|
sseClients.clear();
|
|
425
587
|
server.close(() => process.exit(0));
|
|
426
588
|
setTimeout(() => process.exit(1), 5000);
|
|
427
589
|
};
|
|
428
|
-
process.on(
|
|
429
|
-
process.on(
|
|
590
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
591
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
430
592
|
|
|
431
593
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
432
594
|
|
|
433
595
|
refreshState();
|
|
434
596
|
|
|
435
|
-
server.on(
|
|
436
|
-
if (err.code ===
|
|
597
|
+
server.on("error", (err) => {
|
|
598
|
+
if (err.code === "EADDRINUSE") {
|
|
437
599
|
console.error(`\nharvest: port ${PORT} is already in use.`);
|
|
438
600
|
console.error(` Try: harvest serve --port ${Number(PORT) + 1}`);
|
|
439
601
|
console.error(` Or stop the process using port ${PORT}.\n`);
|
|
@@ -444,7 +606,7 @@ server.on('error', (err) => {
|
|
|
444
606
|
|
|
445
607
|
// ── File watching for live reload ────────────────────────────────────────────
|
|
446
608
|
|
|
447
|
-
import { watch as fsWatch } from
|
|
609
|
+
import { watch as fsWatch } from "node:fs";
|
|
448
610
|
|
|
449
611
|
const watchers = [];
|
|
450
612
|
let debounceTimer = null;
|
|
@@ -454,9 +616,13 @@ function onClaimsChange() {
|
|
|
454
616
|
debounceTimer = setTimeout(() => {
|
|
455
617
|
refreshState();
|
|
456
618
|
// Send update event so SSE clients reload
|
|
457
|
-
const updateData = `event: update\ndata: ${JSON.stringify({ type:
|
|
619
|
+
const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
|
|
458
620
|
for (const client of sseClients) {
|
|
459
|
-
try {
|
|
621
|
+
try {
|
|
622
|
+
client.write(updateData);
|
|
623
|
+
} catch {
|
|
624
|
+
sseClients.delete(client);
|
|
625
|
+
}
|
|
460
626
|
}
|
|
461
627
|
}, 500);
|
|
462
628
|
}
|
|
@@ -467,24 +633,31 @@ function watchClaims() {
|
|
|
467
633
|
try {
|
|
468
634
|
const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
|
|
469
635
|
watchers.push(w);
|
|
470
|
-
} catch {
|
|
636
|
+
} catch {
|
|
637
|
+
/* file may not exist yet */
|
|
638
|
+
}
|
|
471
639
|
}
|
|
472
640
|
// Watch sprint directories for new claims files
|
|
473
|
-
for (const dir of [ROOT, join(ROOT,
|
|
641
|
+
for (const dir of [ROOT, join(ROOT, "sprints"), join(ROOT, "archive")]) {
|
|
474
642
|
if (!existsSync(dir)) continue;
|
|
475
643
|
try {
|
|
476
644
|
const w = fsWatch(dir, { persistent: false }, (_, filename) => {
|
|
477
|
-
if (
|
|
645
|
+
if (
|
|
646
|
+
filename &&
|
|
647
|
+
(filename === "claims.json" || filename.includes("claims"))
|
|
648
|
+
) {
|
|
478
649
|
onClaimsChange();
|
|
479
650
|
}
|
|
480
651
|
});
|
|
481
652
|
watchers.push(w);
|
|
482
|
-
} catch {
|
|
653
|
+
} catch {
|
|
654
|
+
/* ignore */
|
|
655
|
+
}
|
|
483
656
|
}
|
|
484
657
|
}
|
|
485
658
|
|
|
486
|
-
server.listen(PORT,
|
|
487
|
-
vlog(
|
|
659
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
660
|
+
vlog("listen", `port=${PORT}`, `root=${ROOT}`);
|
|
488
661
|
console.log(`harvest: serving on http://localhost:${PORT}`);
|
|
489
662
|
console.log(` sprints: ${state.sprints.length} found`);
|
|
490
663
|
console.log(` root: ${ROOT}`);
|