@grainulation/orchard 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,707 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orchard serve -- local HTTP server for the orchard portfolio dashboard
4
+ *
5
+ * Multi-sprint portfolio dashboard with dependency tracking,
6
+ * cross-sprint conflict detection, and timeline views.
7
+ * SSE for live updates, POST endpoints for actions.
8
+ * Zero npm dependencies (node:http only).
9
+ *
10
+ * Usage:
11
+ * orchard serve [--port 9097] [--root /path/to/repo]
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
16
+ import { join, resolve, extname, dirname, basename } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createRequire } from 'node:module';
19
+ import { watch as fsWatch } from 'node:fs';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const require = createRequire(import.meta.url);
23
+
24
+ // ── Crash handlers ──
25
+ process.on('uncaughtException', (err) => {
26
+ process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
27
+ process.exit(1);
28
+ });
29
+ process.on('unhandledRejection', (reason) => {
30
+ process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
31
+ });
32
+
33
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
34
+
35
+ // ── CLI args ──────────────────────────────────────────────────────────────────
36
+
37
+ const args = process.argv.slice(2);
38
+ function arg(name, fallback) {
39
+ const i = args.indexOf(`--${name}`);
40
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
41
+ }
42
+
43
+ const PORT = parseInt(arg('port', '9097'), 10);
44
+ const CORS_ORIGIN = arg('cors', null);
45
+
46
+ // Resolve ROOT: walk up from cwd to find a directory with claims.json or orchard.json
47
+ function resolveRoot(initial) {
48
+ if (existsSync(join(initial, 'claims.json')) || existsSync(join(initial, 'orchard.json'))) return initial;
49
+ let dir = initial;
50
+ for (let i = 0; i < 5; i++) {
51
+ const parent = resolve(dir, '..');
52
+ if (parent === dir) break;
53
+ dir = parent;
54
+ if (existsSync(join(dir, 'claims.json')) || existsSync(join(dir, 'orchard.json'))) return dir;
55
+ }
56
+ return initial;
57
+ }
58
+ const ROOT = resolveRoot(resolve(arg('root', process.cwd())));
59
+
60
+ // ── Verbose logging ──────────────────────────────────────────────────────────
61
+
62
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
63
+ function vlog(...a) {
64
+ if (!verbose) return;
65
+ const ts = new Date().toISOString();
66
+ process.stderr.write(`[${ts}] orchard: ${a.join(' ')}\n`);
67
+ }
68
+
69
+ // ── Routes manifest ──────────────────────────────────────────────────────────
70
+
71
+ const ROUTES = [
72
+ { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
73
+ { method: 'GET', path: '/api/portfolio', description: 'Sprint portfolio with status and metadata' },
74
+ { method: 'GET', path: '/api/dependencies', description: 'Sprint dependency graph (nodes + edges)' },
75
+ { method: 'GET', path: '/api/conflicts', description: 'Cross-sprint claim conflicts' },
76
+ { method: 'GET', path: '/api/timeline', description: 'Sprint phase timeline with dates' },
77
+ { method: 'POST', path: '/api/scan', description: 'Rescan directories for sprint changes' },
78
+ { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
79
+ ];
80
+
81
+ // ── Load existing CJS modules via createRequire ──────────────────────────────
82
+
83
+ const { loadSprints: loadDashboardSprints, buildHtml, claimsPaths } = require('./dashboard.js');
84
+
85
+ // ── State ─────────────────────────────────────────────────────────────────────
86
+
87
+ let state = {
88
+ portfolio: [],
89
+ dependencies: { nodes: [], edges: [] },
90
+ conflicts: [],
91
+ timeline: [],
92
+ lastScan: null,
93
+ };
94
+
95
+ const sseClients = new Set();
96
+
97
+ function broadcast(event) {
98
+ const data = `data: ${JSON.stringify(event)}\n\n`;
99
+ for (const res of sseClients) {
100
+ try { res.write(data); } catch { sseClients.delete(res); }
101
+ }
102
+ }
103
+
104
+ // ── Scanner — find sprint directories ─────────────────────────────────────────
105
+
106
+ function scanForSprints(rootDir) {
107
+ const sprints = [];
108
+ const orchardJson = join(rootDir, 'orchard.json');
109
+
110
+ // If there's an orchard.json, use its sprint list as hints
111
+ let configSprints = [];
112
+ if (existsSync(orchardJson)) {
113
+ try {
114
+ const config = JSON.parse(readFileSync(orchardJson, 'utf8'));
115
+ configSprints = config.sprints || [];
116
+ } catch { /* ignore */ }
117
+ }
118
+
119
+ // Also scan directory tree for claims.json files (up to 3 levels deep)
120
+ const seen = new Set();
121
+ function walk(dir, depth) {
122
+ if (depth > 3) return;
123
+ try {
124
+ const entries = readdirSync(dir, { withFileTypes: true });
125
+ for (const entry of entries) {
126
+ if (!entry.isDirectory()) continue;
127
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
128
+ const sub = join(dir, entry.name);
129
+ const claimsPath = join(sub, 'claims.json');
130
+ if (existsSync(claimsPath) && !seen.has(sub)) {
131
+ seen.add(sub);
132
+ sprints.push(readSprintDir(sub));
133
+ }
134
+ walk(sub, depth + 1);
135
+ }
136
+ } catch { /* permission errors, etc */ }
137
+ }
138
+
139
+ // Add configured sprints first
140
+ for (const cs of configSprints) {
141
+ const absPath = resolve(rootDir, cs.path || cs);
142
+ if (existsSync(absPath) && !seen.has(absPath)) {
143
+ seen.add(absPath);
144
+ const info = readSprintDir(absPath);
145
+ // Merge config metadata
146
+ if (cs.assigned_to) info.assignedTo = cs.assigned_to;
147
+ if (cs.deadline) info.deadline = cs.deadline;
148
+ if (cs.status) info.configStatus = cs.status;
149
+ if (cs.depends_on) info.dependsOn = cs.depends_on;
150
+ sprints.push(info);
151
+ }
152
+ }
153
+
154
+ walk(rootDir, 0);
155
+ return sprints;
156
+ }
157
+
158
+ function readSprintDir(dir) {
159
+ const name = dir.split('/').pop();
160
+ const info = {
161
+ path: dir,
162
+ name,
163
+ phase: 'unknown',
164
+ status: 'unknown',
165
+ claimCount: 0,
166
+ claimTypes: {},
167
+ hasCompilation: false,
168
+ lastModified: null,
169
+ question: null,
170
+ assignedTo: null,
171
+ deadline: null,
172
+ dependsOn: [],
173
+ configStatus: null,
174
+ tags: [],
175
+ };
176
+
177
+ // Read claims.json
178
+ const claimsPath = join(dir, 'claims.json');
179
+ if (existsSync(claimsPath)) {
180
+ try {
181
+ const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
182
+ const claims = Array.isArray(raw) ? raw : (raw.claims || []);
183
+ info.claimCount = claims.length;
184
+
185
+ // Count types
186
+ for (const c of claims) {
187
+ const t = c.type || 'unknown';
188
+ info.claimTypes[t] = (info.claimTypes[t] || 0) + 1;
189
+ // Collect tags
190
+ for (const tag of c.tags || []) {
191
+ if (!info.tags.includes(tag)) info.tags.push(tag);
192
+ }
193
+ }
194
+
195
+ // Infer phase from claim ID prefixes
196
+ const prefixes = claims.map(c => (c.id || '').replace(/\d+$/, '')).filter(Boolean);
197
+ if (prefixes.some(p => p.startsWith('cal'))) info.phase = 'calibrate';
198
+ else if (prefixes.some(p => p === 'f')) info.phase = 'feedback';
199
+ else if (prefixes.some(p => p === 'e')) info.phase = 'evaluate';
200
+ else if (prefixes.some(p => p === 'p')) info.phase = 'prototype';
201
+ else if (prefixes.some(p => p === 'x' || p === 'w')) info.phase = 'challenge';
202
+ else if (prefixes.some(p => p === 'r')) info.phase = 'research';
203
+ else if (prefixes.some(p => p === 'd')) info.phase = 'define';
204
+
205
+ const stat = statSync(claimsPath);
206
+ info.lastModified = stat.mtime.toISOString();
207
+ } catch { /* ignore parse errors */ }
208
+ }
209
+
210
+ // Check compilation
211
+ const compilationPath = join(dir, 'compilation.json');
212
+ if (existsSync(compilationPath)) {
213
+ info.hasCompilation = true;
214
+ try {
215
+ const comp = JSON.parse(readFileSync(compilationPath, 'utf8'));
216
+ if (comp.question) info.question = comp.question;
217
+ } catch { /* ignore */ }
218
+ }
219
+
220
+ // Check CLAUDE.md for question
221
+ if (!info.question) {
222
+ const claudePath = join(dir, 'CLAUDE.md');
223
+ if (existsSync(claudePath)) {
224
+ try {
225
+ const md = readFileSync(claudePath, 'utf8');
226
+ const match = md.match(/\*\*Question:\*\*\s*(.+)/);
227
+ if (match) info.question = match[1].trim();
228
+ } catch { /* ignore */ }
229
+ }
230
+ }
231
+
232
+ // Infer status
233
+ if (info.claimCount === 0) info.status = 'not-started';
234
+ else if (info.hasCompilation) info.status = 'compiled';
235
+ else info.status = 'active';
236
+
237
+ return info;
238
+ }
239
+
240
+ // ── Dependencies — detect cross-sprint references ─────────────────────────────
241
+
242
+ function buildDependencies(sprints) {
243
+ const nodes = sprints.map(s => ({
244
+ id: s.path,
245
+ name: s.name,
246
+ phase: s.phase,
247
+ status: s.configStatus || s.status,
248
+ claimCount: s.claimCount,
249
+ }));
250
+
251
+ const edges = [];
252
+ const sprintPaths = new Set(sprints.map(s => s.path));
253
+
254
+ for (const sprint of sprints) {
255
+ // Check explicit depends_on from orchard.json
256
+ for (const dep of sprint.dependsOn || []) {
257
+ const resolved_dep = resolve(ROOT, dep);
258
+ if (sprintPaths.has(resolved_dep)) {
259
+ edges.push({ from: resolved_dep, to: sprint.path, type: 'explicit' });
260
+ }
261
+ }
262
+
263
+ // Check claims for cross-references (claim IDs from other sprints)
264
+ const claimsPath_dep = join(sprint.path, 'claims.json');
265
+ if (!existsSync(claimsPath_dep)) continue;
266
+
267
+ try {
268
+ const raw = JSON.parse(readFileSync(claimsPath_dep, 'utf8'));
269
+ const claims = Array.isArray(raw) ? raw : (raw.claims || []);
270
+ const text = JSON.stringify(claims);
271
+
272
+ for (const other of sprints) {
273
+ if (other.path === sprint.path) continue;
274
+ const otherName = other.name;
275
+ // Check if claims mention other sprint by name or path
276
+ if (text.includes(otherName) && otherName.length > 3) {
277
+ const exists = edges.some(e =>
278
+ e.from === other.path && e.to === sprint.path && e.type === 'reference'
279
+ );
280
+ if (!exists) {
281
+ edges.push({ from: other.path, to: sprint.path, type: 'reference' });
282
+ }
283
+ }
284
+ }
285
+ } catch { /* ignore */ }
286
+ }
287
+
288
+ return { nodes, edges };
289
+ }
290
+
291
+ // ── Conflicts — find cross-sprint contradictions ──────────────────────────────
292
+
293
+ function detectConflicts(sprints) {
294
+ const allClaims = [];
295
+
296
+ for (const sprint of sprints) {
297
+ const claimsPath = join(sprint.path, 'claims.json');
298
+ if (!existsSync(claimsPath)) continue;
299
+ try {
300
+ const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
301
+ const claims = Array.isArray(raw) ? raw : (raw.claims || []);
302
+ for (const c of claims) {
303
+ allClaims.push({ ...c, _sprint: sprint.name, _sprintPath: sprint.path });
304
+ }
305
+ } catch { /* ignore */ }
306
+ }
307
+
308
+ const conflicts = [];
309
+ const byTag = new Map();
310
+
311
+ for (const claim of allClaims) {
312
+ for (const tag of claim.tags || []) {
313
+ if (!byTag.has(tag)) byTag.set(tag, []);
314
+ byTag.get(tag).push(claim);
315
+ }
316
+ }
317
+
318
+ for (const [tag, claims] of byTag) {
319
+ for (let i = 0; i < claims.length; i++) {
320
+ for (let j = i + 1; j < claims.length; j++) {
321
+ const a = claims[i];
322
+ const b = claims[j];
323
+ if (a._sprintPath === b._sprintPath) continue;
324
+
325
+ // Opposing recommendations
326
+ if (a.type === 'recommendation' && b.type === 'recommendation') {
327
+ if (couldContradict(a.text, b.text)) {
328
+ conflicts.push({
329
+ type: 'opposing-recommendations',
330
+ tag,
331
+ claimA: { id: a.id, text: (a.text || '').substring(0, 120), sprint: a._sprint },
332
+ claimB: { id: b.id, text: (b.text || '').substring(0, 120), sprint: b._sprint },
333
+ severity: 'high',
334
+ });
335
+ }
336
+ }
337
+
338
+ // Constraint vs recommendation
339
+ if (
340
+ (a.type === 'constraint' && b.type === 'recommendation') ||
341
+ (a.type === 'recommendation' && b.type === 'constraint')
342
+ ) {
343
+ conflicts.push({
344
+ type: 'constraint-tension',
345
+ tag,
346
+ claimA: { id: a.id, text: (a.text || '').substring(0, 120), sprint: a._sprint, type: a.type },
347
+ claimB: { id: b.id, text: (b.text || '').substring(0, 120), sprint: b._sprint, type: b.type },
348
+ severity: 'medium',
349
+ });
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ return conflicts;
356
+ }
357
+
358
+ function couldContradict(textA, textB) {
359
+ if (!textA || !textB) return false;
360
+ const negators = ['not', 'no', 'never', 'avoid', 'instead', 'rather', 'without', "don't"];
361
+ const aWords = new Set(textA.toLowerCase().split(/\s+/));
362
+ const bWords = new Set(textB.toLowerCase().split(/\s+/));
363
+ const aNeg = negators.some(n => aWords.has(n));
364
+ const bNeg = negators.some(n => bWords.has(n));
365
+ return aNeg !== bNeg;
366
+ }
367
+
368
+ // ── Timeline — extract phase transitions ──────────────────────────────────────
369
+
370
+ function buildTimeline(sprints) {
371
+ return sprints.map(s => {
372
+ const phases = [];
373
+ const claimsPath = join(s.path, 'claims.json');
374
+
375
+ if (existsSync(claimsPath)) {
376
+ try {
377
+ const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
378
+ const claims = Array.isArray(raw) ? raw : (raw.claims || []);
379
+
380
+ // Group claims by prefix to detect phase transitions
381
+ const phaseMap = new Map();
382
+ for (const c of claims) {
383
+ const prefix = (c.id || '').replace(/\d+$/, '');
384
+ const date = c.created || c.date || null;
385
+ if (!prefix) continue;
386
+
387
+ const phaseName =
388
+ prefix === 'd' ? 'define' :
389
+ prefix === 'r' ? 'research' :
390
+ prefix === 'p' ? 'prototype' :
391
+ prefix === 'e' ? 'evaluate' :
392
+ prefix === 'f' ? 'feedback' :
393
+ prefix === 'x' ? 'challenge' :
394
+ prefix === 'w' ? 'witness' :
395
+ prefix.startsWith('cal') ? 'calibrate' :
396
+ 'other';
397
+
398
+ if (!phaseMap.has(phaseName)) {
399
+ phaseMap.set(phaseName, { name: phaseName, claimCount: 0, firstDate: date, lastDate: date });
400
+ }
401
+ const p = phaseMap.get(phaseName);
402
+ p.claimCount++;
403
+ if (date && (!p.firstDate || date < p.firstDate)) p.firstDate = date;
404
+ if (date && (!p.lastDate || date > p.lastDate)) p.lastDate = date;
405
+ }
406
+
407
+ phases.push(...phaseMap.values());
408
+ } catch { /* ignore */ }
409
+ }
410
+
411
+ return {
412
+ name: s.name,
413
+ path: s.path,
414
+ status: s.configStatus || s.status,
415
+ deadline: s.deadline,
416
+ phases,
417
+ lastModified: s.lastModified,
418
+ };
419
+ });
420
+ }
421
+
422
+ // ── Refresh state ─────────────────────────────────────────────────────────────
423
+
424
+ function refreshState() {
425
+ const sprints = scanForSprints(ROOT);
426
+ state.portfolio = sprints.map(s => ({
427
+ name: s.name,
428
+ path: s.path,
429
+ phase: s.phase,
430
+ status: s.configStatus || s.status,
431
+ claimCount: s.claimCount,
432
+ claimTypes: s.claimTypes,
433
+ hasCompilation: s.hasCompilation,
434
+ question: s.question,
435
+ assignedTo: s.assignedTo,
436
+ deadline: s.deadline,
437
+ lastModified: s.lastModified,
438
+ tags: s.tags.slice(0, 10),
439
+ }));
440
+ state.dependencies = buildDependencies(sprints);
441
+ state.conflicts = detectConflicts(sprints);
442
+ state.timeline = buildTimeline(sprints);
443
+ state.lastScan = new Date().toISOString();
444
+ broadcast({ type: 'state', data: state });
445
+ }
446
+
447
+ // ── MIME types ────────────────────────────────────────────────────────────────
448
+
449
+ const MIME = {
450
+ '.html': 'text/html; charset=utf-8',
451
+ '.css': 'text/css; charset=utf-8',
452
+ '.js': 'application/javascript; charset=utf-8',
453
+ '.json': 'application/json; charset=utf-8',
454
+ '.svg': 'image/svg+xml',
455
+ '.png': 'image/png',
456
+ };
457
+
458
+ // ── Helpers ───────────────────────────────────────────────────────────────────
459
+
460
+ function json(res, data, status = 200) {
461
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
462
+ res.end(JSON.stringify(data));
463
+ }
464
+
465
+ function readBody(req) {
466
+ return new Promise((resolve, reject) => {
467
+ const chunks = [];
468
+ req.on('data', c => chunks.push(c));
469
+ req.on('end', () => {
470
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
471
+ catch { resolve({}); }
472
+ });
473
+ req.on('error', reject);
474
+ });
475
+ }
476
+
477
+ // ── SSE live-reload injection ────────────────────────────────────────────────
478
+
479
+ const SSE_SCRIPT = `
480
+ <script>
481
+ (function() {
482
+ var es, retryCount = 0;
483
+ var dot = document.getElementById('statusDot');
484
+ function connect() {
485
+ es = new EventSource('/events');
486
+ es.addEventListener('update', function() { location.reload(); });
487
+ es.onopen = function() {
488
+ retryCount = 0;
489
+ if (dot) dot.className = 'status-dot ok';
490
+ if (window._grainSetState) window._grainSetState('idle');
491
+ };
492
+ es.onerror = function() {
493
+ es.close();
494
+ if (dot) dot.className = 'status-dot';
495
+ if (window._grainSetState) window._grainSetState('orbit');
496
+ var delay = Math.min(30000, 1000 * Math.pow(2, retryCount)) + Math.random() * 1000;
497
+ retryCount++;
498
+ setTimeout(connect, delay);
499
+ };
500
+ }
501
+ connect();
502
+ })();
503
+ </script>`;
504
+
505
+ function injectSSE(html) {
506
+ return html.replace('</body>', SSE_SCRIPT + '\n</body>');
507
+ }
508
+
509
+ // ── HTTP server ───────────────────────────────────────────────────────────────
510
+
511
+ const server = createServer(async (req, res) => {
512
+ const url = new URL(req.url, `http://localhost:${PORT}`);
513
+
514
+ // CORS (only when --cors is passed)
515
+ if (CORS_ORIGIN) {
516
+ res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
517
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
518
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
519
+ }
520
+
521
+ if (req.method === 'OPTIONS' && CORS_ORIGIN) {
522
+ res.writeHead(204);
523
+ res.end();
524
+ return;
525
+ }
526
+
527
+ vlog('request', req.method, url.pathname);
528
+
529
+ // ── API: docs ──
530
+ if (req.method === 'GET' && url.pathname === '/api/docs') {
531
+ const html = `<!DOCTYPE html><html><head><title>orchard API</title>
532
+ <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
533
+ table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
534
+ th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
535
+ <body><h1>orchard API</h1><p>${ROUTES.length} endpoints</p>
536
+ <table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
537
+ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
538
+ </table></body></html>`;
539
+ res.writeHead(200, { 'Content-Type': 'text/html' });
540
+ res.end(html);
541
+ return;
542
+ }
543
+
544
+ // ── SSE endpoint ──
545
+ if (req.method === 'GET' && url.pathname === '/events') {
546
+ res.writeHead(200, {
547
+ 'Content-Type': 'text/event-stream',
548
+ 'Cache-Control': 'no-cache',
549
+ 'Connection': 'keep-alive',
550
+ });
551
+ res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
552
+ const heartbeat = setInterval(() => {
553
+ try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
554
+ }, 15000);
555
+ sseClients.add(res);
556
+ vlog('sse', `client connected (${sseClients.size} total)`);
557
+ req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
558
+ return;
559
+ }
560
+
561
+ // ── API: portfolio ──
562
+ if (req.method === 'GET' && url.pathname === '/api/portfolio') {
563
+ json(res, { portfolio: state.portfolio, lastScan: state.lastScan });
564
+ return;
565
+ }
566
+
567
+ // ── API: dependencies ──
568
+ if (req.method === 'GET' && url.pathname === '/api/dependencies') {
569
+ json(res, state.dependencies);
570
+ return;
571
+ }
572
+
573
+ // ── API: conflicts ──
574
+ if (req.method === 'GET' && url.pathname === '/api/conflicts') {
575
+ json(res, { conflicts: state.conflicts, count: state.conflicts.length });
576
+ return;
577
+ }
578
+
579
+ // ── API: timeline ──
580
+ if (req.method === 'GET' && url.pathname === '/api/timeline') {
581
+ json(res, { timeline: state.timeline });
582
+ return;
583
+ }
584
+
585
+ // ── API: scan ──
586
+ if (req.method === 'POST' && url.pathname === '/api/scan') {
587
+ refreshState();
588
+ json(res, { ok: true, sprintCount: state.portfolio.length, lastScan: state.lastScan });
589
+ return;
590
+ }
591
+
592
+ // ── Dashboard UI (template-injected) ──
593
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
594
+ try {
595
+ const graphData = loadDashboardSprints(ROOT);
596
+ if (graphData.sprints.length === 0) {
597
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
598
+ 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>');
599
+ return;
600
+ }
601
+ const html = injectSSE(buildHtml(graphData));
602
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
603
+ res.end(html);
604
+ } catch (err) {
605
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
606
+ res.end('Error building dashboard: ' + err.message);
607
+ }
608
+ return;
609
+ }
610
+
611
+ // ── Static files (public/) ──
612
+ let filePath = url.pathname;
613
+ filePath = join(PUBLIC_DIR, filePath);
614
+
615
+ if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
616
+ const ext = extname(filePath);
617
+ const mime = MIME[ext] || 'application/octet-stream';
618
+ try {
619
+ const content = readFileSync(filePath);
620
+ res.writeHead(200, { 'Content-Type': mime });
621
+ res.end(content);
622
+ } catch {
623
+ res.writeHead(500);
624
+ res.end('read error');
625
+ }
626
+ return;
627
+ }
628
+
629
+ // ── 404 ──
630
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
631
+ res.end('not found');
632
+ });
633
+
634
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
635
+ const shutdown = (signal) => {
636
+ console.log(`\norchard: ${signal} received, shutting down...`);
637
+ for (const res of sseClients) { try { res.end(); } catch {} }
638
+ sseClients.clear();
639
+ server.close(() => process.exit(0));
640
+ setTimeout(() => process.exit(1), 5000);
641
+ };
642
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
643
+ process.on('SIGINT', () => shutdown('SIGINT'));
644
+
645
+ // ── Start ─────────────────────────────────────────────────────────────────────
646
+
647
+ refreshState();
648
+
649
+ server.on('error', (err) => {
650
+ if (err.code === 'EADDRINUSE') {
651
+ console.error(`\norchard: port ${PORT} is already in use.`);
652
+ console.error(` Try: orchard serve --port ${Number(PORT) + 1}`);
653
+ console.error(` Or stop the process using port ${PORT}.\n`);
654
+ process.exit(1);
655
+ }
656
+ throw err;
657
+ });
658
+
659
+ // ── File watching for live reload ────────────────────────────────────────────
660
+
661
+ const watchers = [];
662
+ let debounceTimer = null;
663
+
664
+ function onClaimsChange() {
665
+ if (debounceTimer) clearTimeout(debounceTimer);
666
+ debounceTimer = setTimeout(() => {
667
+ refreshState();
668
+ // Send update event so SSE clients reload
669
+ const updateData = `event: update\ndata: ${JSON.stringify({ type: 'update' })}\n\n`;
670
+ for (const client of sseClients) {
671
+ try { client.write(updateData); } catch { sseClients.delete(client); }
672
+ }
673
+ }, 500);
674
+ }
675
+
676
+ function watchClaims() {
677
+ const paths = claimsPaths(ROOT);
678
+ for (const p of paths) {
679
+ try {
680
+ const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
681
+ watchers.push(w);
682
+ } catch { /* file may not exist yet */ }
683
+ }
684
+ // Watch sprint directories for new claims files
685
+ for (const dir of [ROOT, join(ROOT, 'sprints'), join(ROOT, 'archive')]) {
686
+ if (!existsSync(dir)) continue;
687
+ try {
688
+ const w = fsWatch(dir, { persistent: false }, (_, filename) => {
689
+ if (filename && (filename === 'claims.json' || filename.includes('claims'))) {
690
+ onClaimsChange();
691
+ }
692
+ });
693
+ watchers.push(w);
694
+ } catch { /* ignore */ }
695
+ }
696
+ }
697
+
698
+ server.listen(PORT, '127.0.0.1', () => {
699
+ vlog('listen', `port=${PORT}`, `root=${ROOT}`);
700
+ console.log(`orchard: serving on http://localhost:${PORT}`);
701
+ console.log(` sprints: ${state.portfolio.length} found`);
702
+ console.log(` conflicts: ${state.conflicts.length} detected`);
703
+ console.log(` root: ${ROOT}`);
704
+ watchClaims();
705
+ });
706
+
707
+ export { server, PORT };