@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/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +96 -0
- package/README.md +52 -61
- package/bin/orchard.js +227 -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/export.js +72 -44
- package/lib/farmer.js +54 -38
- package/lib/hackathon.js +349 -0
- package/lib/planner.js +150 -21
- package/lib/server.js +396 -203
- package/lib/sync.js +31 -25
- package/lib/tracker.js +52 -40
- package/package.json +10 -4
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 {
|
|
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,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:
|
|
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
|
|
|
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(
|
|
517
|
-
res.setHeader(
|
|
518
|
-
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");
|
|
519
635
|
}
|
|
520
636
|
|
|
521
|
-
if (req.method ===
|
|
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(
|
|
643
|
+
vlog("request", req.method, url.pathname);
|
|
528
644
|
|
|
529
645
|
// ── API: docs ──
|
|
530
|
-
if (req.method ===
|
|
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 =>
|
|
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, {
|
|
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 ===
|
|
661
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
546
662
|
res.writeHead(200, {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
663
|
+
"Content-Type": "text/event-stream",
|
|
664
|
+
"Cache-Control": "no-cache",
|
|
665
|
+
Connection: "keep-alive",
|
|
550
666
|
});
|
|
551
|
-
res.write(`data: ${JSON.stringify({ type:
|
|
667
|
+
res.write(`data: ${JSON.stringify({ type: "state", data: state })}\n\n`);
|
|
552
668
|
const heartbeat = setInterval(() => {
|
|
553
|
-
try {
|
|
669
|
+
try {
|
|
670
|
+
res.write(": heartbeat\n\n");
|
|
671
|
+
} catch {
|
|
672
|
+
clearInterval(heartbeat);
|
|
673
|
+
}
|
|
554
674
|
}, 15000);
|
|
555
675
|
sseClients.add(res);
|
|
556
|
-
vlog(
|
|
557
|
-
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
|
+
});
|
|
558
682
|
return;
|
|
559
683
|
}
|
|
560
684
|
|
|
561
685
|
// ── API: portfolio ──
|
|
562
|
-
if (req.method ===
|
|
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 ===
|
|
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 ===
|
|
575
|
-
|
|
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 ===
|
|
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 ===
|
|
762
|
+
if (req.method === "POST" && url.pathname === "/api/scan") {
|
|
587
763
|
refreshState();
|
|
588
|
-
json(res, {
|
|
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 (
|
|
593
|
-
if (
|
|
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
|
|
596
|
-
|
|
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, {
|
|
606
|
-
res.end(
|
|
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] ||
|
|
795
|
+
const mime = MIME[ext] || "application/octet-stream";
|
|
618
796
|
try {
|
|
619
797
|
const content = readFileSync(filePath);
|
|
620
|
-
res.writeHead(200, {
|
|
798
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
621
799
|
res.end(content);
|
|
622
800
|
} catch {
|
|
623
801
|
res.writeHead(500);
|
|
624
|
-
res.end(
|
|
802
|
+
res.end("read error");
|
|
625
803
|
}
|
|
626
804
|
return;
|
|
627
805
|
}
|
|
628
806
|
|
|
629
807
|
// ── 404 ──
|
|
630
|
-
res.writeHead(404, {
|
|
631
|
-
res.end(
|
|
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) {
|
|
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(
|
|
643
|
-
process.on(
|
|
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(
|
|
650
|
-
if (err.code ===
|
|
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:
|
|
851
|
+
const updateData = `event: update\ndata: ${JSON.stringify({ type: "update" })}\n\n`;
|
|
670
852
|
for (const client of sseClients) {
|
|
671
|
-
try {
|
|
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 {
|
|
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,
|
|
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 (
|
|
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 {
|
|
885
|
+
} catch {
|
|
886
|
+
/* ignore */
|
|
887
|
+
}
|
|
695
888
|
}
|
|
696
889
|
}
|
|
697
890
|
|
|
698
|
-
server.listen(PORT,
|
|
699
|
-
vlog(
|
|
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`);
|