@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/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/orchard.js +209 -0
- package/lib/assignments.js +98 -0
- package/lib/conflicts.js +150 -0
- package/lib/dashboard.js +291 -0
- package/lib/doctor.js +137 -0
- package/lib/export.js +98 -0
- package/lib/farmer.js +107 -0
- package/lib/planner.js +198 -0
- package/lib/server.js +707 -0
- package/lib/sync.js +100 -0
- package/lib/tracker.js +124 -0
- package/package.json +50 -0
- package/public/index.html +922 -0
- package/templates/dashboard.html +981 -0
- package/templates/orchard-dashboard.html +171 -0
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 };
|