@grainulation/harvest 1.0.0

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 ADDED
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harvest serve -- local HTTP server for the harvest retrospective dashboard
4
+ *
5
+ * Sprint analytics: type distribution, evidence quality, velocity, decay alerts.
6
+ * SSE for live updates. Zero npm dependencies (node:http only).
7
+ *
8
+ * Usage:
9
+ * harvest serve [--port 9096] [--root /path/to/sprints]
10
+ */
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';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const require = createRequire(import.meta.url);
20
+
21
+ // ── Crash handlers ──
22
+ process.on('uncaughtException', (err) => {
23
+ process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
24
+ process.exit(1);
25
+ });
26
+ process.on('unhandledRejection', (reason) => {
27
+ process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
28
+ });
29
+
30
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
31
+
32
+ // ── CLI args ──────────────────────────────────────────────────────────────────
33
+
34
+ const args = process.argv.slice(2);
35
+ function arg(name, fallback) {
36
+ const i = args.indexOf(`--${name}`);
37
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
38
+ }
39
+
40
+ const PORT = parseInt(arg('port', '9096'), 10);
41
+ const CORS_ORIGIN = arg('cors', null);
42
+
43
+ // Resolve ROOT: walk up from cwd to find a directory with claims.json
44
+ function resolveRoot(initial) {
45
+ if (existsSync(join(initial, 'claims.json'))) return initial;
46
+ let dir = initial;
47
+ for (let i = 0; i < 5; i++) {
48
+ const parent = resolve(dir, '..');
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ if (existsSync(join(dir, 'claims.json'))) return dir;
52
+ }
53
+ return initial; // fall back to original
54
+ }
55
+ const ROOT = resolveRoot(resolve(arg('root', process.cwd())));
56
+
57
+ // ── Verbose logging ──────────────────────────────────────────────────────────
58
+
59
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
60
+ function vlog(...a) {
61
+ if (!verbose) return;
62
+ const ts = new Date().toISOString();
63
+ process.stderr.write(`[${ts}] harvest: ${a.join(' ')}\n`);
64
+ }
65
+
66
+ // ── Routes manifest ──────────────────────────────────────────────────────────
67
+
68
+ 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' },
76
+ ];
77
+
78
+ // ── Load existing CJS modules via createRequire ──────────────────────────────
79
+
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');
85
+
86
+ // ── Sprint discovery ─────────────────────────────────────────────────────────
87
+
88
+ function discoverSprints(rootDir) {
89
+ const sprints = [];
90
+ if (!existsSync(rootDir)) return sprints;
91
+
92
+ // Include root if it has claims.json
93
+ const directClaims = join(rootDir, 'claims.json');
94
+ if (existsSync(directClaims)) {
95
+ sprints.push(loadSingleSprint(rootDir));
96
+ }
97
+
98
+ // Scan subdirectories (two levels deep to catch sprints/<name>/claims.json)
99
+ try {
100
+ const entries = readdirSync(rootDir, { withFileTypes: true });
101
+ for (const entry of entries) {
102
+ if (!entry.isDirectory()) continue;
103
+ if (entry.name.startsWith('.')) continue;
104
+ const childDir = join(rootDir, entry.name);
105
+ const childClaims = join(childDir, 'claims.json');
106
+ if (existsSync(childClaims)) {
107
+ sprints.push(loadSingleSprint(childDir));
108
+ }
109
+ // Second level: scan subdirectories of this directory
110
+ try {
111
+ const subEntries = readdirSync(childDir, { withFileTypes: true });
112
+ for (const sub of subEntries) {
113
+ if (!sub.isDirectory()) continue;
114
+ if (sub.name.startsWith('.')) continue;
115
+ const subDir = join(childDir, sub.name);
116
+ const subClaims = join(subDir, 'claims.json');
117
+ if (existsSync(subClaims)) {
118
+ sprints.push(loadSingleSprint(subDir));
119
+ }
120
+ }
121
+ } catch { /* skip */ }
122
+ }
123
+ } catch { /* skip if unreadable */ }
124
+
125
+ return sprints;
126
+ }
127
+
128
+ function loadSingleSprint(dir) {
129
+ const sprint = {
130
+ name: basename(dir),
131
+ dir,
132
+ claims: [],
133
+ compilation: null,
134
+ gitLog: [],
135
+ };
136
+
137
+ const claimsPath = join(dir, 'claims.json');
138
+ try {
139
+ const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
140
+ sprint.claims = Array.isArray(raw) ? raw : raw.claims || [];
141
+ } catch { /* skip */ }
142
+
143
+ const compilationPath = join(dir, 'compilation.json');
144
+ if (existsSync(compilationPath)) {
145
+ try {
146
+ sprint.compilation = JSON.parse(readFileSync(compilationPath, 'utf8'));
147
+ } catch { /* skip */ }
148
+ }
149
+
150
+ // Git log for velocity
151
+ try {
152
+ const { execSync } = require('node:child_process');
153
+ sprint.gitLog = execSync(
154
+ `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
+ });
160
+ } catch {
161
+ sprint.gitLog = [];
162
+ }
163
+
164
+ return sprint;
165
+ }
166
+
167
+ // ── State ─────────────────────────────────────────────────────────────────────
168
+
169
+ let state = {
170
+ sprints: [],
171
+ lastRefresh: null,
172
+ };
173
+
174
+ const sseClients = new Set();
175
+
176
+ function broadcast(event) {
177
+ const data = `data: ${JSON.stringify(event)}\n\n`;
178
+ for (const res of sseClients) {
179
+ try { res.write(data); } catch { sseClients.delete(res); }
180
+ }
181
+ }
182
+
183
+ function refreshState() {
184
+ state.sprints = discoverSprints(ROOT);
185
+ state.lastRefresh = new Date().toISOString();
186
+ broadcast({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } });
187
+ }
188
+
189
+ // ── MIME types ────────────────────────────────────────────────────────────────
190
+
191
+ 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',
198
+ };
199
+
200
+ // ── Helpers ───────────────────────────────────────────────────────────────────
201
+
202
+ function jsonResponse(res, code, data) {
203
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
204
+ res.end(JSON.stringify(data));
205
+ }
206
+
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
+ // ── HTTP server ───────────────────────────────────────────────────────────────
240
+
241
+ const server = createServer(async (req, res) => {
242
+ const url = new URL(req.url, `http://localhost:${PORT}`);
243
+
244
+ // CORS (only when --cors is passed)
245
+ 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');
249
+ }
250
+
251
+ if (req.method === 'OPTIONS' && CORS_ORIGIN) {
252
+ res.writeHead(204);
253
+ res.end();
254
+ return;
255
+ }
256
+
257
+ vlog('request', req.method, url.pathname);
258
+
259
+ // ── API: docs ──
260
+ if (req.method === 'GET' && url.pathname === '/api/docs') {
261
+ const html = `<!DOCTYPE html><html><head><title>harvest API</title>
262
+ <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
263
+ table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
264
+ th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
265
+ <body><h1>harvest API</h1><p>${ROUTES.length} endpoints</p>
266
+ <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('')}
268
+ </table></body></html>`;
269
+ res.writeHead(200, { 'Content-Type': 'text/html' });
270
+ res.end(html);
271
+ return;
272
+ }
273
+
274
+ // ── SSE endpoint ──
275
+ if (req.method === 'GET' && url.pathname === '/events') {
276
+ res.writeHead(200, {
277
+ 'Content-Type': 'text/event-stream',
278
+ 'Cache-Control': 'no-cache',
279
+ 'Connection': 'keep-alive',
280
+ });
281
+ res.write(`data: ${JSON.stringify({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } })}\n\n`);
282
+ const heartbeat = setInterval(() => {
283
+ try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
284
+ }, 15000);
285
+ 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)`); });
288
+ return;
289
+ }
290
+
291
+ // ── API: list sprints ──
292
+ if (req.method === 'GET' && url.pathname === '/api/sprints') {
293
+ refreshState();
294
+ const sprintList = state.sprints.map(s => ({
295
+ name: s.name,
296
+ dir: s.dir,
297
+ claimCount: s.claims.length,
298
+ hasCompilation: !!s.compilation,
299
+ gitCommits: s.gitLog.length,
300
+ }));
301
+ jsonResponse(res, 200, { sprints: sprintList });
302
+ return;
303
+ }
304
+
305
+ // ── 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; }
309
+
310
+ // Refresh and find the sprint
311
+ refreshState();
312
+ const sprint = state.sprints.find(s => s.name === sprintName);
313
+ if (!sprint) { jsonResponse(res, 404, { error: `sprint "${sprintName}" not found` }); return; }
314
+
315
+ const analysis = analyze([sprint]);
316
+ const velocity = measureVelocity([sprint]);
317
+
318
+ jsonResponse(res, 200, {
319
+ sprint: sprintName,
320
+ analysis,
321
+ velocity,
322
+ });
323
+ return;
324
+ }
325
+
326
+ // ── API: calibration (all sprints) ──
327
+ if (req.method === 'GET' && url.pathname === '/api/calibration') {
328
+ refreshState();
329
+ if (state.sprints.length === 0) {
330
+ jsonResponse(res, 200, { calibration: { summary: { totalSprints: 0 }, sprints: [] } });
331
+ return;
332
+ }
333
+ const result = calibrate(state.sprints);
334
+ jsonResponse(res, 200, { calibration: result });
335
+ return;
336
+ }
337
+
338
+ // ── API: decay detection ──
339
+ if (req.method === 'GET' && url.pathname === '/api/decay') {
340
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
341
+ refreshState();
342
+ if (state.sprints.length === 0) {
343
+ jsonResponse(res, 200, { decay: { summary: { totalClaims: 0 }, stale: [], decaying: [], unresolved: [] } });
344
+ return;
345
+ }
346
+ const result = checkDecay(state.sprints, { thresholdDays: days });
347
+ jsonResponse(res, 200, { decay: result });
348
+ return;
349
+ }
350
+
351
+ // ── API: dashboard summary (all sprints combined) ──
352
+ if (req.method === 'GET' && url.pathname === '/api/dashboard') {
353
+ refreshState();
354
+ if (state.sprints.length === 0) {
355
+ jsonResponse(res, 200, {
356
+ sprintCount: 0,
357
+ totalClaims: 0,
358
+ analysis: null,
359
+ velocity: null,
360
+ decay: null,
361
+ });
362
+ return;
363
+ }
364
+ const analysis = analyze(state.sprints);
365
+ const velocity = measureVelocity(state.sprints);
366
+ const decay = checkDecay(state.sprints, { thresholdDays: 30 });
367
+
368
+ jsonResponse(res, 200, {
369
+ sprintCount: state.sprints.length,
370
+ totalClaims: state.sprints.reduce((a, s) => a + s.claims.length, 0),
371
+ analysis,
372
+ velocity,
373
+ decay,
374
+ });
375
+ return;
376
+ }
377
+
378
+ // ── Dashboard UI (template-injected) ──
379
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
380
+ 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' });
389
+ res.end(html);
390
+ } catch (err) {
391
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
392
+ res.end('Error building dashboard: ' + err.message);
393
+ }
394
+ return;
395
+ }
396
+
397
+ // ── Static files (public/) ──
398
+ let filePath = url.pathname;
399
+ filePath = join(PUBLIC_DIR, filePath);
400
+
401
+ if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
402
+ const ext = extname(filePath);
403
+ const mime = MIME[ext] || 'application/octet-stream';
404
+ try {
405
+ const content = readFileSync(filePath);
406
+ res.writeHead(200, { 'Content-Type': mime });
407
+ res.end(content);
408
+ } catch {
409
+ res.writeHead(500);
410
+ res.end('read error');
411
+ }
412
+ return;
413
+ }
414
+
415
+ // ── 404 ──
416
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
417
+ res.end('not found');
418
+ });
419
+
420
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
421
+ const shutdown = (signal) => {
422
+ console.log(`\nharvest: ${signal} received, shutting down...`);
423
+ for (const res of sseClients) { try { res.end(); } catch {} }
424
+ sseClients.clear();
425
+ server.close(() => process.exit(0));
426
+ setTimeout(() => process.exit(1), 5000);
427
+ };
428
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
429
+ process.on('SIGINT', () => shutdown('SIGINT'));
430
+
431
+ // ── Start ─────────────────────────────────────────────────────────────────────
432
+
433
+ refreshState();
434
+
435
+ server.on('error', (err) => {
436
+ if (err.code === 'EADDRINUSE') {
437
+ console.error(`\nharvest: port ${PORT} is already in use.`);
438
+ console.error(` Try: harvest serve --port ${Number(PORT) + 1}`);
439
+ console.error(` Or stop the process using port ${PORT}.\n`);
440
+ process.exit(1);
441
+ }
442
+ throw err;
443
+ });
444
+
445
+ // ── File watching for live reload ────────────────────────────────────────────
446
+
447
+ import { watch as fsWatch } from 'node:fs';
448
+
449
+ const watchers = [];
450
+ let debounceTimer = null;
451
+
452
+ function onClaimsChange() {
453
+ if (debounceTimer) clearTimeout(debounceTimer);
454
+ debounceTimer = setTimeout(() => {
455
+ refreshState();
456
+ // Send update event so SSE clients reload
457
+ const updateData = `event: update\ndata: ${JSON.stringify({ type: 'update' })}\n\n`;
458
+ for (const client of sseClients) {
459
+ try { client.write(updateData); } catch { sseClients.delete(client); }
460
+ }
461
+ }, 500);
462
+ }
463
+
464
+ function watchClaims() {
465
+ const paths = claimsPaths(ROOT);
466
+ for (const p of paths) {
467
+ try {
468
+ const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
469
+ watchers.push(w);
470
+ } catch { /* file may not exist yet */ }
471
+ }
472
+ // Watch sprint directories for new claims files
473
+ for (const dir of [ROOT, join(ROOT, 'sprints'), join(ROOT, 'archive')]) {
474
+ if (!existsSync(dir)) continue;
475
+ try {
476
+ const w = fsWatch(dir, { persistent: false }, (_, filename) => {
477
+ if (filename && (filename === 'claims.json' || filename.includes('claims'))) {
478
+ onClaimsChange();
479
+ }
480
+ });
481
+ watchers.push(w);
482
+ } catch { /* ignore */ }
483
+ }
484
+ }
485
+
486
+ server.listen(PORT, '127.0.0.1', () => {
487
+ vlog('listen', `port=${PORT}`, `root=${ROOT}`);
488
+ console.log(`harvest: serving on http://localhost:${PORT}`);
489
+ console.log(` sprints: ${state.sprints.length} found`);
490
+ console.log(` root: ${ROOT}`);
491
+ watchClaims();
492
+ });
493
+
494
+ export { server, PORT };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * harvest -> barn edge: template discovery for report formatting.
5
+ *
6
+ * If barn is available (filesystem or HTTP), harvest can offer its
7
+ * templates as alternative report formats. Graceful fallback to
8
+ * harvest's built-in formatting when barn is not reachable.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const http = require('node:http');
14
+
15
+ const BARN_PORT = 9093;
16
+ const BARN_SIBLINGS = [
17
+ path.join(__dirname, '..', '..', 'barn', 'templates'),
18
+ path.join(__dirname, '..', '..', '..', 'barn', 'templates'),
19
+ ];
20
+
21
+ /**
22
+ * Probe barn via filesystem (sibling checkout) or localhost API.
23
+ * Returns { available: true, templates: [...] } or { available: false }.
24
+ */
25
+ function discoverTemplates() {
26
+ // Strategy 1: filesystem sibling
27
+ for (const dir of BARN_SIBLINGS) {
28
+ if (fs.existsSync(dir)) {
29
+ try {
30
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.html'));
31
+ const templates = files.map(f => {
32
+ const content = fs.readFileSync(path.join(dir, f), 'utf8');
33
+ const placeholders = [...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || [])];
34
+ const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
35
+ return {
36
+ name: f.replace('.html', ''),
37
+ placeholders,
38
+ description: commentMatch ? commentMatch[1] : '',
39
+ source: 'filesystem',
40
+ };
41
+ });
42
+ return { available: true, templates, source: dir };
43
+ } catch {
44
+ continue;
45
+ }
46
+ }
47
+ }
48
+ return { available: false, templates: [] };
49
+ }
50
+
51
+ /**
52
+ * Async probe: try barn's HTTP API for template list.
53
+ * Falls back to filesystem discovery if the server is not running.
54
+ */
55
+ function discoverTemplatesAsync() {
56
+ return new Promise((resolve) => {
57
+ const req = http.get(`http://127.0.0.1:${BARN_PORT}/api/state`, { timeout: 2000 }, (res) => {
58
+ let body = '';
59
+ res.on('data', (chunk) => { body += chunk; });
60
+ res.on('end', () => {
61
+ try {
62
+ const state = JSON.parse(body);
63
+ const templates = (state.templates || []).map(t => ({
64
+ name: t.name,
65
+ placeholders: t.placeholders || [],
66
+ description: t.description || '',
67
+ source: 'http',
68
+ }));
69
+ resolve({ available: true, templates, source: `http://127.0.0.1:${BARN_PORT}` });
70
+ } catch {
71
+ resolve(discoverTemplates());
72
+ }
73
+ });
74
+ });
75
+ req.on('error', () => resolve(discoverTemplates()));
76
+ req.on('timeout', () => { req.destroy(); resolve(discoverTemplates()); });
77
+ });
78
+ }
79
+
80
+ module.exports = { discoverTemplates, discoverTemplatesAsync, BARN_PORT };