@grainulation/orchard 1.0.1 → 1.0.4
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/CONTRIBUTING.md +7 -1
- package/README.md +21 -20
- package/bin/orchard.js +238 -80
- package/lib/assignments.js +19 -17
- package/lib/conflicts.js +177 -29
- package/lib/dashboard.js +100 -47
- package/lib/decompose.js +268 -0
- package/lib/doctor.js +48 -32
- package/lib/emit.js +72 -0
- package/lib/export.js +72 -44
- package/lib/farmer.js +126 -42
- package/lib/hackathon.js +349 -0
- package/lib/planner.js +150 -21
- package/lib/server.js +395 -165
- package/lib/sync.js +31 -25
- package/lib/tracker.js +52 -40
- package/package.json +7 -3
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
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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(
|
|
26
|
-
process.stderr.write(
|
|
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(
|
|
30
|
-
process.stderr.write(
|
|
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,
|
|
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(
|
|
44
|
-
const CORS_ORIGIN = arg(
|
|
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 (
|
|
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 (
|
|
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(
|
|
76
|
+
const ROOT = resolveRoot(resolve(arg("root", process.cwd())));
|
|
59
77
|
|
|
60
78
|
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
61
79
|
|
|
62
|
-
const verbose =
|
|
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(
|
|
85
|
+
process.stderr.write(`[${ts}] orchard: ${a.join(" ")}\n`);
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
70
89
|
|
|
71
90
|
const ROUTES = [
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{
|
|
78
|
-
|
|
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 { claimsPaths } = require(
|
|
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 {
|
|
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,
|
|
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,
|
|
188
|
+
const config = JSON.parse(readFileSync(orchardJson, "utf8"));
|
|
115
189
|
configSprints = config.sprints || [];
|
|
116
|
-
} catch {
|
|
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(
|
|
203
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
204
|
+
continue;
|
|
128
205
|
const sub = join(dir, entry.name);
|
|
129
|
-
const claimsPath = join(sub,
|
|
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 {
|
|
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(
|
|
238
|
+
const name = dir.split("/").pop();
|
|
160
239
|
const info = {
|
|
161
240
|
path: dir,
|
|
162
241
|
name,
|
|
163
|
-
phase:
|
|
164
|
-
status:
|
|
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,
|
|
257
|
+
const claimsPath = join(dir, "claims.json");
|
|
179
258
|
if (existsSync(claimsPath)) {
|
|
180
259
|
try {
|
|
181
|
-
const raw = JSON.parse(readFileSync(claimsPath,
|
|
182
|
-
const claims = Array.isArray(raw) ? raw :
|
|
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 ||
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
else if (prefixes.some(p => p ===
|
|
201
|
-
else if (prefixes.some(p => p ===
|
|
202
|
-
else if (prefixes.some(p => p ===
|
|
203
|
-
else if (prefixes.some(p => p ===
|
|
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 {
|
|
289
|
+
} catch {
|
|
290
|
+
/* ignore parse errors */
|
|
291
|
+
}
|
|
208
292
|
}
|
|
209
293
|
|
|
210
294
|
// Check compilation
|
|
211
|
-
const compilationPath = join(dir,
|
|
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,
|
|
299
|
+
const comp = JSON.parse(readFileSync(compilationPath, "utf8"));
|
|
216
300
|
if (comp.question) info.question = comp.question;
|
|
217
|
-
} catch {
|
|
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,
|
|
308
|
+
const claudePath = join(dir, "CLAUDE.md");
|
|
223
309
|
if (existsSync(claudePath)) {
|
|
224
310
|
try {
|
|
225
|
-
const md = readFileSync(claudePath,
|
|
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 {
|
|
314
|
+
} catch {
|
|
315
|
+
/* ignore */
|
|
316
|
+
}
|
|
229
317
|
}
|
|
230
318
|
}
|
|
231
319
|
|
|
232
320
|
// Infer status
|
|
233
|
-
if (info.claimCount === 0) info.status =
|
|
234
|
-
else if (info.hasCompilation) info.status =
|
|
235
|
-
else info.status =
|
|
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:
|
|
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,
|
|
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,
|
|
269
|
-
const claims = Array.isArray(raw) ? raw :
|
|
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(
|
|
278
|
-
e
|
|
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({
|
|
372
|
+
edges.push({
|
|
373
|
+
from: other.path,
|
|
374
|
+
to: sprint.path,
|
|
375
|
+
type: "reference",
|
|
376
|
+
});
|
|
282
377
|
}
|
|
283
378
|
}
|
|
284
379
|
}
|
|
285
|
-
} catch {
|
|
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,
|
|
394
|
+
const claimsPath = join(sprint.path, "claims.json");
|
|
298
395
|
if (!existsSync(claimsPath)) continue;
|
|
299
396
|
try {
|
|
300
|
-
const raw = JSON.parse(readFileSync(claimsPath,
|
|
301
|
-
const claims = Array.isArray(raw) ? raw :
|
|
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({
|
|
400
|
+
allClaims.push({
|
|
401
|
+
...c,
|
|
402
|
+
_sprint: sprint.name,
|
|
403
|
+
_sprintPath: sprint.path,
|
|
404
|
+
});
|
|
304
405
|
}
|
|
305
|
-
} catch {
|
|
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 ===
|
|
429
|
+
if (a.type === "recommendation" && b.type === "recommendation") {
|
|
327
430
|
if (couldContradict(a.text, b.text)) {
|
|
328
431
|
conflicts.push({
|
|
329
|
-
type:
|
|
432
|
+
type: "opposing-recommendations",
|
|
330
433
|
tag,
|
|
331
|
-
claimA: {
|
|
332
|
-
|
|
333
|
-
|
|
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 ===
|
|
341
|
-
(a.type ===
|
|
451
|
+
(a.type === "constraint" && b.type === "recommendation") ||
|
|
452
|
+
(a.type === "recommendation" && b.type === "constraint")
|
|
342
453
|
) {
|
|
343
454
|
conflicts.push({
|
|
344
|
-
type:
|
|
455
|
+
type: "constraint-tension",
|
|
345
456
|
tag,
|
|
346
|
-
claimA: {
|
|
347
|
-
|
|
348
|
-
|
|
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 = [
|
|
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,
|
|
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,
|
|
378
|
-
const claims = Array.isArray(raw) ? raw :
|
|
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 ||
|
|
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 ===
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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, {
|
|
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 {
|
|
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,36 +586,39 @@ function refreshState() {
|
|
|
441
586
|
state.conflicts = detectConflicts(sprints);
|
|
442
587
|
state.timeline = buildTimeline(sprints);
|
|
443
588
|
state.lastScan = new Date().toISOString();
|
|
444
|
-
broadcast({ type:
|
|
589
|
+
broadcast({ type: "state", data: state });
|
|
445
590
|
}
|
|
446
591
|
|
|
447
592
|
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
448
593
|
|
|
449
594
|
const MIME = {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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, {
|
|
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(
|
|
469
|
-
req.on(
|
|
470
|
-
try {
|
|
471
|
-
|
|
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(
|
|
621
|
+
req.on("error", reject);
|
|
474
622
|
});
|
|
475
623
|
}
|
|
476
624
|
|
|
@@ -481,92 +629,159 @@ const server = createServer(async (req, res) => {
|
|
|
481
629
|
|
|
482
630
|
// CORS (only when --cors is passed)
|
|
483
631
|
if (CORS_ORIGIN) {
|
|
484
|
-
res.setHeader(
|
|
485
|
-
res.setHeader(
|
|
486
|
-
res.setHeader(
|
|
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");
|
|
487
635
|
}
|
|
488
636
|
|
|
489
|
-
if (req.method ===
|
|
637
|
+
if (req.method === "OPTIONS" && CORS_ORIGIN) {
|
|
490
638
|
res.writeHead(204);
|
|
491
639
|
res.end();
|
|
492
640
|
return;
|
|
493
641
|
}
|
|
494
642
|
|
|
495
|
-
vlog(
|
|
643
|
+
vlog("request", req.method, url.pathname);
|
|
496
644
|
|
|
497
645
|
// ── API: docs ──
|
|
498
|
-
if (req.method ===
|
|
646
|
+
if (req.method === "GET" && url.pathname === "/api/docs") {
|
|
499
647
|
const html = `<!DOCTYPE html><html><head><title>orchard API</title>
|
|
500
648
|
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
501
649
|
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
502
650
|
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
503
651
|
<body><h1>orchard API</h1><p>${ROUTES.length} endpoints</p>
|
|
504
652
|
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
505
|
-
${ROUTES.map(r =>
|
|
653
|
+
${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
|
|
506
654
|
</table></body></html>`;
|
|
507
|
-
res.writeHead(200, {
|
|
655
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
508
656
|
res.end(html);
|
|
509
657
|
return;
|
|
510
658
|
}
|
|
511
659
|
|
|
512
660
|
// ── SSE endpoint ──
|
|
513
|
-
if (req.method ===
|
|
661
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
514
662
|
res.writeHead(200, {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
663
|
+
"Content-Type": "text/event-stream",
|
|
664
|
+
"Cache-Control": "no-cache",
|
|
665
|
+
Connection: "keep-alive",
|
|
518
666
|
});
|
|
519
|
-
res.write(`data: ${JSON.stringify({ type:
|
|
667
|
+
res.write(`data: ${JSON.stringify({ type: "state", data: state })}\n\n`);
|
|
520
668
|
const heartbeat = setInterval(() => {
|
|
521
|
-
try {
|
|
669
|
+
try {
|
|
670
|
+
res.write(": heartbeat\n\n");
|
|
671
|
+
} catch {
|
|
672
|
+
clearInterval(heartbeat);
|
|
673
|
+
}
|
|
522
674
|
}, 15000);
|
|
523
675
|
sseClients.add(res);
|
|
524
|
-
vlog(
|
|
525
|
-
req.on(
|
|
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
|
+
});
|
|
526
682
|
return;
|
|
527
683
|
}
|
|
528
684
|
|
|
529
685
|
// ── API: portfolio ──
|
|
530
|
-
if (req.method ===
|
|
686
|
+
if (req.method === "GET" && url.pathname === "/api/portfolio") {
|
|
531
687
|
json(res, { portfolio: state.portfolio, lastScan: state.lastScan });
|
|
532
688
|
return;
|
|
533
689
|
}
|
|
534
690
|
|
|
535
691
|
// ── API: dependencies ──
|
|
536
|
-
if (req.method ===
|
|
692
|
+
if (req.method === "GET" && url.pathname === "/api/dependencies") {
|
|
537
693
|
json(res, state.dependencies);
|
|
538
694
|
return;
|
|
539
695
|
}
|
|
540
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
|
+
|
|
541
715
|
// ── API: conflicts ──
|
|
542
|
-
if (req.method ===
|
|
543
|
-
|
|
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
|
+
}
|
|
544
752
|
return;
|
|
545
753
|
}
|
|
546
754
|
|
|
547
755
|
// ── API: timeline ──
|
|
548
|
-
if (req.method ===
|
|
756
|
+
if (req.method === "GET" && url.pathname === "/api/timeline") {
|
|
549
757
|
json(res, { timeline: state.timeline });
|
|
550
758
|
return;
|
|
551
759
|
}
|
|
552
760
|
|
|
553
761
|
// ── API: scan ──
|
|
554
|
-
if (req.method ===
|
|
762
|
+
if (req.method === "POST" && url.pathname === "/api/scan") {
|
|
555
763
|
refreshState();
|
|
556
|
-
json(res, {
|
|
764
|
+
json(res, {
|
|
765
|
+
ok: true,
|
|
766
|
+
sprintCount: state.portfolio.length,
|
|
767
|
+
lastScan: state.lastScan,
|
|
768
|
+
});
|
|
557
769
|
return;
|
|
558
770
|
}
|
|
559
771
|
|
|
560
772
|
// ── Dashboard UI (web app from public/) ──
|
|
561
|
-
if (
|
|
562
|
-
|
|
773
|
+
if (
|
|
774
|
+
req.method === "GET" &&
|
|
775
|
+
(url.pathname === "/" || url.pathname === "/index.html")
|
|
776
|
+
) {
|
|
777
|
+
const indexPath = join(PUBLIC_DIR, "index.html");
|
|
563
778
|
try {
|
|
564
|
-
const html = readFileSync(indexPath,
|
|
565
|
-
res.writeHead(200, {
|
|
779
|
+
const html = readFileSync(indexPath, "utf8");
|
|
780
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
566
781
|
res.end(html);
|
|
567
782
|
} catch (err) {
|
|
568
|
-
res.writeHead(500, {
|
|
569
|
-
res.end(
|
|
783
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
784
|
+
res.end("Error reading dashboard: " + err.message);
|
|
570
785
|
}
|
|
571
786
|
return;
|
|
572
787
|
}
|
|
@@ -577,40 +792,44 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
577
792
|
|
|
578
793
|
if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
|
|
579
794
|
const ext = extname(filePath);
|
|
580
|
-
const mime = MIME[ext] ||
|
|
795
|
+
const mime = MIME[ext] || "application/octet-stream";
|
|
581
796
|
try {
|
|
582
797
|
const content = readFileSync(filePath);
|
|
583
|
-
res.writeHead(200, {
|
|
798
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
584
799
|
res.end(content);
|
|
585
800
|
} catch {
|
|
586
801
|
res.writeHead(500);
|
|
587
|
-
res.end(
|
|
802
|
+
res.end("read error");
|
|
588
803
|
}
|
|
589
804
|
return;
|
|
590
805
|
}
|
|
591
806
|
|
|
592
807
|
// ── 404 ──
|
|
593
|
-
res.writeHead(404, {
|
|
594
|
-
res.end(
|
|
808
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
809
|
+
res.end("not found");
|
|
595
810
|
});
|
|
596
811
|
|
|
597
812
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
598
813
|
const shutdown = (signal) => {
|
|
599
814
|
console.log(`\norchard: ${signal} received, shutting down...`);
|
|
600
|
-
for (const res of sseClients) {
|
|
815
|
+
for (const res of sseClients) {
|
|
816
|
+
try {
|
|
817
|
+
res.end();
|
|
818
|
+
} catch {}
|
|
819
|
+
}
|
|
601
820
|
sseClients.clear();
|
|
602
821
|
server.close(() => process.exit(0));
|
|
603
822
|
setTimeout(() => process.exit(1), 5000);
|
|
604
823
|
};
|
|
605
|
-
process.on(
|
|
606
|
-
process.on(
|
|
824
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
825
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
607
826
|
|
|
608
827
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
609
828
|
|
|
610
829
|
refreshState();
|
|
611
830
|
|
|
612
|
-
server.on(
|
|
613
|
-
if (err.code ===
|
|
831
|
+
server.on("error", (err) => {
|
|
832
|
+
if (err.code === "EADDRINUSE") {
|
|
614
833
|
console.error(`\norchard: port ${PORT} is already in use.`);
|
|
615
834
|
console.error(` Try: orchard serve --port ${Number(PORT) + 1}`);
|
|
616
835
|
console.error(` Or stop the process using port ${PORT}.\n`);
|
|
@@ -629,9 +848,13 @@ function onClaimsChange() {
|
|
|
629
848
|
debounceTimer = setTimeout(() => {
|
|
630
849
|
refreshState();
|
|
631
850
|
// Send update event so SSE clients reload
|
|
632
|
-
const updateData = `event: update\ndata: ${JSON.stringify({ type:
|
|
851
|
+
const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
|
|
633
852
|
for (const client of sseClients) {
|
|
634
|
-
try {
|
|
853
|
+
try {
|
|
854
|
+
client.write(updateData);
|
|
855
|
+
} catch {
|
|
856
|
+
sseClients.delete(client);
|
|
857
|
+
}
|
|
635
858
|
}
|
|
636
859
|
}, 500);
|
|
637
860
|
}
|
|
@@ -642,24 +865,31 @@ function watchClaims() {
|
|
|
642
865
|
try {
|
|
643
866
|
const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
|
|
644
867
|
watchers.push(w);
|
|
645
|
-
} catch {
|
|
868
|
+
} catch {
|
|
869
|
+
/* file may not exist yet */
|
|
870
|
+
}
|
|
646
871
|
}
|
|
647
872
|
// Watch sprint directories for new claims files
|
|
648
|
-
for (const dir of [ROOT, join(ROOT,
|
|
873
|
+
for (const dir of [ROOT, join(ROOT, "sprints"), join(ROOT, "archive")]) {
|
|
649
874
|
if (!existsSync(dir)) continue;
|
|
650
875
|
try {
|
|
651
876
|
const w = fsWatch(dir, { persistent: false }, (_, filename) => {
|
|
652
|
-
if (
|
|
877
|
+
if (
|
|
878
|
+
filename &&
|
|
879
|
+
(filename === "claims.json" || filename.includes("claims"))
|
|
880
|
+
) {
|
|
653
881
|
onClaimsChange();
|
|
654
882
|
}
|
|
655
883
|
});
|
|
656
884
|
watchers.push(w);
|
|
657
|
-
} catch {
|
|
885
|
+
} catch {
|
|
886
|
+
/* ignore */
|
|
887
|
+
}
|
|
658
888
|
}
|
|
659
889
|
}
|
|
660
890
|
|
|
661
|
-
server.listen(PORT,
|
|
662
|
-
vlog(
|
|
891
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
892
|
+
vlog("listen", `port=${PORT}`, `root=${ROOT}`);
|
|
663
893
|
console.log(`orchard: serving on http://localhost:${PORT}`);
|
|
664
894
|
console.log(` sprints: ${state.portfolio.length} found`);
|
|
665
895
|
console.log(` conflicts: ${state.conflicts.length} detected`);
|