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