@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/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 { loadSprints: loadDashboardSprints, buildHtml, 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,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 { 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
 
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('Access-Control-Allow-Origin', CORS_ORIGIN);
247
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
248
- 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");
249
302
  }
250
303
 
251
- if (req.method === 'OPTIONS' && CORS_ORIGIN) {
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('request', req.method, url.pathname);
310
+ vlog("request", req.method, url.pathname);
258
311
 
259
312
  // ── API: docs ──
260
- if (req.method === 'GET' && url.pathname === '/api/docs') {
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 => '<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("")}
268
321
  </table></body></html>`;
269
- res.writeHead(200, { 'Content-Type': 'text/html' });
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 === 'GET' && url.pathname === '/events') {
328
+ if (req.method === "GET" && url.pathname === "/events") {
276
329
  res.writeHead(200, {
277
- 'Content-Type': 'text/event-stream',
278
- 'Cache-Control': 'no-cache',
279
- 'Connection': 'keep-alive',
330
+ "Content-Type": "text/event-stream",
331
+ "Cache-Control": "no-cache",
332
+ Connection: "keep-alive",
280
333
  });
281
- 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
+ );
282
337
  const heartbeat = setInterval(() => {
283
- try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
338
+ try {
339
+ res.write(": heartbeat\n\n");
340
+ } catch {
341
+ clearInterval(heartbeat);
342
+ }
284
343
  }, 15000);
285
344
  sseClients.add(res);
286
- vlog('sse', `client connected (${sseClients.size} total)`);
287
- 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
+ });
288
351
  return;
289
352
  }
290
353
 
291
354
  // ── API: list sprints ──
292
- if (req.method === 'GET' && url.pathname === '/api/sprints') {
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 === 'GET' && url.pathname.startsWith('/api/analysis/')) {
307
- const sprintName = decodeURIComponent(url.pathname.slice('/api/analysis/'.length));
308
- 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
+ }
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) { 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
+ }
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 === 'GET' && url.pathname === '/api/calibration') {
398
+ if (req.method === "GET" && url.pathname === "/api/calibration") {
328
399
  refreshState();
329
400
  if (state.sprints.length === 0) {
330
- jsonResponse(res, 200, { calibration: { summary: { totalSprints: 0 }, sprints: [] } });
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 === 'GET' && url.pathname === '/api/decay') {
340
- 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);
341
414
  refreshState();
342
415
  if (state.sprints.length === 0) {
343
- 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
+ });
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 === 'GET' && url.pathname === '/api/dashboard') {
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 (template-injected) ──
379
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
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 sprints = loadDashboardSprints(ROOT);
382
- if (sprints.length === 0) {
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, { 'Content-Type': 'text/plain' });
392
- res.end('Error building dashboard: ' + err.message);
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] || 'application/octet-stream';
561
+ const mime = MIME[ext] || "application/octet-stream";
404
562
  try {
405
563
  const content = readFileSync(filePath);
406
- res.writeHead(200, { 'Content-Type': mime });
564
+ res.writeHead(200, { "Content-Type": mime });
407
565
  res.end(content);
408
566
  } catch {
409
567
  res.writeHead(500);
410
- res.end('read error');
568
+ res.end("read error");
411
569
  }
412
570
  return;
413
571
  }
414
572
 
415
573
  // ── 404 ──
416
- res.writeHead(404, { 'Content-Type': 'text/plain' });
417
- res.end('not found');
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) { try { res.end(); } catch {} }
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('SIGTERM', () => shutdown('SIGTERM'));
429
- process.on('SIGINT', () => shutdown('SIGINT'));
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('error', (err) => {
436
- if (err.code === 'EADDRINUSE') {
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 'node:fs';
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: 'update' })}\n\n`;
619
+ const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
458
620
  for (const client of sseClients) {
459
- try { client.write(updateData); } catch { sseClients.delete(client); }
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 { /* file may not exist yet */ }
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, 'sprints'), join(ROOT, 'archive')]) {
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 (filename && (filename === 'claims.json' || filename.includes('claims'))) {
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 { /* ignore */ }
653
+ } catch {
654
+ /* ignore */
655
+ }
483
656
  }
484
657
  }
485
658
 
486
- server.listen(PORT, '127.0.0.1', () => {
487
- vlog('listen', `port=${PORT}`, `root=${ROOT}`);
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}`);