@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/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 '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';
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('uncaughtException', (err) => {
23
- process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
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('unhandledRejection', (reason) => {
27
- process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
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, '..', 'public');
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('port', '9096'), 10);
41
- const CORS_ORIGIN = arg('cors', null);
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, 'claims.json'))) return 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, 'claims.json'))) return 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('root', process.cwd())));
59
+ const ROOT = resolveRoot(resolve(arg("root", process.cwd())));
56
60
 
57
61
  // ── Verbose logging ──────────────────────────────────────────────────────────
58
62
 
59
- const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
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(' ')}\n`);
68
+ process.stderr.write(`[${ts}] harvest: ${a.join(" ")}\n`);
64
69
  }
65
70
 
66
71
  // ── Routes manifest ──────────────────────────────────────────────────────────
67
72
 
68
73
  const ROUTES = [
69
- { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
70
- { method: 'GET', path: '/api/sprints', description: 'List discovered sprints with claim counts' },
71
- { method: 'GET', path: '/api/analysis/:name', description: 'Full analysis of a single sprint' },
72
- { method: 'GET', path: '/api/calibration', description: 'Prediction calibration across all sprints' },
73
- { method: 'GET', path: '/api/decay', description: 'Find stale claims needing refresh (?days=N)' },
74
- { method: 'GET', path: '/api/dashboard', description: 'Combined analytics dashboard summary' },
75
- { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
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('./analyzer.js');
81
- const { measureVelocity } = require('./velocity.js');
82
- const { checkDecay } = require('./decay.js');
83
- const { calibrate } = require('./calibration.js');
84
- const { claimsPaths } = require('./dashboard.js');
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, 'claims.json');
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('.')) continue;
164
+ if (entry.name.startsWith(".")) continue;
104
165
  const childDir = join(rootDir, entry.name);
105
- const childClaims = join(childDir, 'claims.json');
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('.')) continue;
175
+ if (sub.name.startsWith(".")) continue;
115
176
  const subDir = join(childDir, sub.name);
116
- const subClaims = join(subDir, 'claims.json');
177
+ const subClaims = join(subDir, "claims.json");
117
178
  if (existsSync(subClaims)) {
118
179
  sprints.push(loadSingleSprint(subDir));
119
180
  }
120
181
  }
121
- } catch { /* skip */ }
182
+ } catch {
183
+ /* skip */
184
+ }
122
185
  }
123
- } catch { /* skip if unreadable */ }
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, 'claims.json');
202
+ const claimsPath = join(dir, "claims.json");
138
203
  try {
139
- const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
204
+ const raw = JSON.parse(readFileSync(claimsPath, "utf8"));
140
205
  sprint.claims = Array.isArray(raw) ? raw : raw.claims || [];
141
- } catch { /* skip */ }
206
+ } catch {
207
+ /* skip */
208
+ }
142
209
 
143
- const compilationPath = join(dir, 'compilation.json');
210
+ const compilationPath = join(dir, "compilation.json");
144
211
  if (existsSync(compilationPath)) {
145
212
  try {
146
- sprint.compilation = JSON.parse(readFileSync(compilationPath, 'utf8'));
147
- } catch { /* skip */ }
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('node:child_process');
221
+ const { execSync } = require("node:child_process");
153
222
  sprint.gitLog = execSync(
154
223
  `git log --oneline --format="%H|%ai|%s" -- claims.json`,
155
- { cwd: dir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
156
- ).trim().split('\n').filter(Boolean).map(line => {
157
- const [hash, date, ...msg] = line.split('|');
158
- return { hash, date, message: msg.join('|') };
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 { res.write(data); } catch { sseClients.delete(res); }
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({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } });
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
- '.html': 'text/html; charset=utf-8',
193
- '.css': 'text/css; charset=utf-8',
194
- '.js': 'application/javascript; charset=utf-8',
195
- '.json': 'application/json; charset=utf-8',
196
- '.svg': 'image/svg+xml',
197
- '.png': 'image/png',
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, { 'Content-Type': 'application/json; charset=utf-8' });
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('Access-Control-Allow-Origin', CORS_ORIGIN);
215
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
216
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
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 === 'OPTIONS' && CORS_ORIGIN) {
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('request', req.method, url.pathname);
310
+ vlog("request", req.method, url.pathname);
226
311
 
227
312
  // ── API: docs ──
228
- if (req.method === 'GET' && url.pathname === '/api/docs') {
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 => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
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, { 'Content-Type': 'text/html' });
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 === 'GET' && url.pathname === '/events') {
328
+ if (req.method === "GET" && url.pathname === "/events") {
244
329
  res.writeHead(200, {
245
- 'Content-Type': 'text/event-stream',
246
- 'Cache-Control': 'no-cache',
247
- 'Connection': 'keep-alive',
330
+ "Content-Type": "text/event-stream",
331
+ "Cache-Control": "no-cache",
332
+ Connection: "keep-alive",
248
333
  });
249
- res.write(`data: ${JSON.stringify({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } })}\n\n`);
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 { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
338
+ try {
339
+ res.write(": heartbeat\n\n");
340
+ } catch {
341
+ clearInterval(heartbeat);
342
+ }
252
343
  }, 15000);
253
344
  sseClients.add(res);
254
- vlog('sse', `client connected (${sseClients.size} total)`);
255
- req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
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 === 'GET' && url.pathname === '/api/sprints') {
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 === 'GET' && url.pathname.startsWith('/api/analysis/')) {
275
- const sprintName = decodeURIComponent(url.pathname.slice('/api/analysis/'.length));
276
- if (!sprintName) { jsonResponse(res, 400, { error: 'missing sprint name' }); return; }
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) { jsonResponse(res, 404, { error: `sprint "${sprintName}" not found` }); return; }
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 === 'GET' && url.pathname === '/api/calibration') {
398
+ if (req.method === "GET" && url.pathname === "/api/calibration") {
296
399
  refreshState();
297
400
  if (state.sprints.length === 0) {
298
- jsonResponse(res, 200, { calibration: { summary: { totalSprints: 0 }, sprints: [] } });
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 === 'GET' && url.pathname === '/api/decay') {
308
- const days = parseInt(url.searchParams.get('days') || '30', 10);
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, { decay: { summary: { totalClaims: 0 }, stale: [], decaying: [], unresolved: [] } });
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 === 'GET' && url.pathname === '/api/dashboard') {
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 (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
348
- const indexPath = join(PUBLIC_DIR, 'index.html');
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, 'utf8');
351
- 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" });
352
547
  res.end(html);
353
548
  } catch (err) {
354
- res.writeHead(500, { 'Content-Type': 'text/plain' });
355
- res.end('Error reading dashboard: ' + err.message);
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] || 'application/octet-stream';
561
+ const mime = MIME[ext] || "application/octet-stream";
367
562
  try {
368
563
  const content = readFileSync(filePath);
369
- res.writeHead(200, { 'Content-Type': mime });
564
+ res.writeHead(200, { "Content-Type": mime });
370
565
  res.end(content);
371
566
  } catch {
372
567
  res.writeHead(500);
373
- res.end('read error');
568
+ res.end("read error");
374
569
  }
375
570
  return;
376
571
  }
377
572
 
378
573
  // ── 404 ──
379
- res.writeHead(404, { 'Content-Type': 'text/plain' });
380
- res.end('not found');
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) { try { res.end(); } catch {} }
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('SIGTERM', () => shutdown('SIGTERM'));
392
- process.on('SIGINT', () => shutdown('SIGINT'));
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('error', (err) => {
399
- if (err.code === 'EADDRINUSE') {
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 'node:fs';
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: 'update' })}\n\n`;
619
+ const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
421
620
  for (const client of sseClients) {
422
- try { client.write(updateData); } catch { sseClients.delete(client); }
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 { /* file may not exist yet */ }
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, 'sprints'), join(ROOT, 'archive')]) {
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 (filename && (filename === 'claims.json' || filename.includes('claims'))) {
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 { /* ignore */ }
653
+ } catch {
654
+ /* ignore */
655
+ }
446
656
  }
447
657
  }
448
658
 
449
- server.listen(PORT, '127.0.0.1', () => {
450
- vlog('listen', `port=${PORT}`, `root=${ROOT}`);
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}`);