@hegemonart/get-design-done 1.54.0 → 1.55.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.
@@ -0,0 +1,576 @@
1
+ 'use strict';
2
+ /**
3
+ * sdk/dashboard/data/source.cjs — Phase 55 (GDD Dashboard, dep-free).
4
+ *
5
+ * THE data plane. `loadDashboardModel({root?})` assembles the full read-only
6
+ * model the TUI + web layers render, by calling the SHARED LIBS in-process
7
+ * (R1) — the same read surface the gdd-mcp tools expose — with a `.design/*`
8
+ * file-scrape fallback per section (R1: "file-scrape fallback" = read
9
+ * STATE.md / events.jsonl / context-graph.json directly when a lib is
10
+ * unavailable).
11
+ *
12
+ * Hard contract (CONTEXT.md "Shared contracts"):
13
+ * loadDashboardModel({root?}) -> {
14
+ * status, phase, cycle,
15
+ * decisions[], blockers[], plans[],
16
+ * events[], costs, graph, health,
17
+ * runtimes[], worktrees[], sessions[],
18
+ * degraded[]
19
+ * }
20
+ *
21
+ * Invariants:
22
+ * - NEVER throws. Every section is wrapped in try/catch; on failure the
23
+ * section degrades to null/[] AND a human-readable note is pushed to
24
+ * `degraded[]` (so gsd-health + the TUI can surface what is missing).
25
+ * - Absent .design entirely -> every data section null/[] + degraded
26
+ * populated, still no throw.
27
+ * - Root resolution: opts.root || GDD_PROJECT_ROOT || package-root walk-up
28
+ * || cwd. (Package-root walk-up resolves the GDD repo root, where
29
+ * .design/.planning live.)
30
+ * - The .ts libs (sdk/state, sdk/event-stream) cannot be static-require()d
31
+ * from a .cjs — they are loaded via dynamic import(pathToFileURL),
32
+ * memoized once per process. The .cjs libs are require()d directly via the
33
+ * package-root walk-up.
34
+ * - Determinism is best-effort: ordering follows the shared libs; this layer
35
+ * adds no Date.now()/Math.random() to the model.
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+ const { pathToFileURL } = require('node:url');
41
+
42
+ const { packageRoot, resolveFromPackageRoot, requireFromPackageRoot } = require('./_pkg-root.cjs');
43
+ const { readCosts, aggregateCosts } = require('./cost-aggregator.cjs');
44
+ const { discoverRuntimes, discoverWorktrees, discoverSessions } = require('./discovery.cjs');
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // .cjs shared libs — require() directly via package-root walk-up.
48
+ // ---------------------------------------------------------------------------
49
+ // Wrapped so a missing lib (unusual layout) degrades rather than crashing
50
+ // module load. Each may be null; callers null-check before use.
51
+ function tryRequire(relPath) {
52
+ try {
53
+ return requireFromPackageRoot(relPath);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ const designContextQuery = tryRequire('scripts/lib/design-context-query.cjs');
59
+ const eventChain = tryRequire('scripts/lib/event-chain.cjs');
60
+ const healthMirror = tryRequire('scripts/lib/health-mirror/index.cjs');
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // .ts shared libs — dynamic import(pathToFileURL), memoized.
64
+ // ---------------------------------------------------------------------------
65
+ /** @type {Promise<any> | null} */
66
+ let _statePromise = null;
67
+ /** @type {Promise<any> | null} */
68
+ let _eventStreamPromise = null;
69
+
70
+ /**
71
+ * Lazily import sdk/state (a .ts module) once. Returns null if the import
72
+ * fails (e.g. running outside a --experimental-strip-types-capable runtime).
73
+ * @returns {Promise<any|null>}
74
+ */
75
+ function importState() {
76
+ if (_statePromise === null) {
77
+ const url = pathToFileURL(resolveFromPackageRoot('sdk/state/index.ts')).href;
78
+ _statePromise = import(url).catch(() => null);
79
+ }
80
+ return _statePromise;
81
+ }
82
+
83
+ /**
84
+ * Lazily import sdk/event-stream/reader (a .ts module) once. Returns null on
85
+ * failure.
86
+ * @returns {Promise<any|null>}
87
+ */
88
+ function importEventStream() {
89
+ if (_eventStreamPromise === null) {
90
+ const url = pathToFileURL(resolveFromPackageRoot('sdk/event-stream/reader.ts')).href;
91
+ _eventStreamPromise = import(url).catch(() => null);
92
+ }
93
+ return _eventStreamPromise;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Root resolution
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * Resolve the project root the dashboard reads from:
101
+ * opts.root || GDD_PROJECT_ROOT (env) || package-root walk-up || cwd.
102
+ * @param {{root?: string}} [opts]
103
+ * @returns {string}
104
+ */
105
+ function resolveRoot(opts = {}) {
106
+ if (opts.root) return path.resolve(opts.root);
107
+ if (process.env.GDD_PROJECT_ROOT) return path.resolve(process.env.GDD_PROJECT_ROOT);
108
+ try {
109
+ return packageRoot();
110
+ } catch {
111
+ return process.cwd();
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Small FS helpers (graceful)
117
+ // ---------------------------------------------------------------------------
118
+ function readFileOrNull(p) {
119
+ try {
120
+ return fs.readFileSync(p, 'utf8');
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Minimal STATE.md file-scrape fallback used when the typed `sdk/state`
128
+ * read() is unavailable or throws (e.g. malformed STATE.md the strict parser
129
+ * rejects). Extracts the few fields the dashboard surfaces without enforcing
130
+ * the full grammar — tolerant by design.
131
+ *
132
+ * @param {string} statePath
133
+ * @returns {{status:string|null, stage:string|null, cycle:string|null,
134
+ * decisions:Array<{id:string,text:string,status:string}>,
135
+ * blockers:Array<{stage:string,date:string,text:string}>} | null}
136
+ */
137
+ function scrapeStateFile(statePath) {
138
+ const raw = readFileOrNull(statePath);
139
+ if (raw == null) return null;
140
+ const text = raw.replace(/\r\n/g, '\n');
141
+
142
+ const fmStage = text.match(/^stage:\s*(.+)$/m);
143
+ const fmCycle = text.match(/^cycle:\s*(.+)$/m);
144
+
145
+ // <position> status: ... (preferred for status); fall back to frontmatter.
146
+ let status = null;
147
+ const posBlock = text.match(/<position>([\s\S]*?)<\/position>/);
148
+ if (posBlock) {
149
+ const st = posBlock[1].match(/status:\s*(.+)/);
150
+ if (st) status = st[1].trim();
151
+ }
152
+
153
+ // Decisions: "D-NN: text (locked|tentative)" inside <decisions>.
154
+ const decisions = [];
155
+ const decBlock = text.match(/<decisions>([\s\S]*?)<\/decisions>/);
156
+ if (decBlock) {
157
+ const re = /^(D-\d+):\s*(.*?)\s*\((locked|tentative)\)\s*$/gm;
158
+ let m;
159
+ while ((m = re.exec(decBlock[1])) !== null) {
160
+ decisions.push({ id: m[1], text: m[2], status: m[3] });
161
+ }
162
+ }
163
+
164
+ // Blockers: "[stage] [date]: text" inside <blockers>.
165
+ const blockers = [];
166
+ const blkBlock = text.match(/<blockers>([\s\S]*?)<\/blockers>/);
167
+ if (blkBlock) {
168
+ const re = /^\[([^\]]+)\]\s*\[([^\]]+)\]:\s*(.*)$/gm;
169
+ let m;
170
+ while ((m = re.exec(blkBlock[1])) !== null) {
171
+ blockers.push({ stage: m[1], date: m[2], text: m[3] });
172
+ }
173
+ }
174
+
175
+ return {
176
+ status,
177
+ stage: fmStage ? fmStage[1].trim() : (posBlock && posBlock[1].match(/stage:\s*(.+)/) ? posBlock[1].match(/stage:\s*(.+)/)[1].trim() : null),
178
+ cycle: fmCycle ? fmCycle[1].trim() : null,
179
+ decisions,
180
+ blockers,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * File-scrape fallback for the telemetry events stream: read
186
+ * `.design/telemetry/events.jsonl` directly, tolerant of malformed lines.
187
+ * @param {string} eventsPath
188
+ * @returns {Array<Record<string, unknown>>}
189
+ */
190
+ function scrapeEventsFile(eventsPath) {
191
+ const raw = readFileOrNull(eventsPath);
192
+ if (raw == null) return [];
193
+ const out = [];
194
+ for (const line of raw.split('\n')) {
195
+ const t = line.trim();
196
+ if (t === '') continue;
197
+ try {
198
+ const ev = JSON.parse(t);
199
+ if (ev && typeof ev === 'object') out.push(ev);
200
+ } catch {
201
+ /* skip malformed */
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Per-section loaders. Each returns its value and, on degradation, pushes a
209
+ // note to `degraded`. None throw.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Load STATE.md-derived fields: status, phase(stage), cycle, decisions[],
214
+ * blockers[]. Tries the typed sdk/state read() first, then the file scrape.
215
+ *
216
+ * @param {string} root
217
+ * @param {string[]} degraded
218
+ * @returns {Promise<{status:string|null, phase:string|null, cycle:string|null,
219
+ * decisions:Array, blockers:Array}>}
220
+ */
221
+ async function loadState(root, degraded) {
222
+ const statePath = path.join(root, '.design', 'STATE.md');
223
+ const empty = { status: null, phase: null, cycle: null, decisions: [], blockers: [] };
224
+
225
+ if (!fs.existsSync(statePath)) {
226
+ degraded.push('state: .design/STATE.md not found');
227
+ return empty;
228
+ }
229
+
230
+ // 1) Typed lib read() — the in-process shared surface (R1).
231
+ try {
232
+ const stateMod = await importState();
233
+ if (stateMod && typeof stateMod.read === 'function') {
234
+ const parsed = await stateMod.read(statePath);
235
+ return {
236
+ status: (parsed.position && parsed.position.status) || null,
237
+ phase: (parsed.position && parsed.position.stage) ||
238
+ (parsed.frontmatter && parsed.frontmatter.stage) || null,
239
+ cycle: (parsed.frontmatter && parsed.frontmatter.cycle) || null,
240
+ decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [],
241
+ blockers: Array.isArray(parsed.blockers) ? parsed.blockers : [],
242
+ };
243
+ }
244
+ degraded.push('state: sdk/state import unavailable — using file scrape');
245
+ } catch (err) {
246
+ degraded.push(`state: typed read failed (${errMsg(err)}) — using file scrape`);
247
+ }
248
+
249
+ // 2) File-scrape fallback.
250
+ const scraped = scrapeStateFile(statePath);
251
+ if (scraped) {
252
+ return {
253
+ status: scraped.status,
254
+ phase: scraped.stage,
255
+ cycle: scraped.cycle,
256
+ decisions: scraped.decisions,
257
+ blockers: scraped.blockers,
258
+ };
259
+ }
260
+ degraded.push('state: scrape fallback failed');
261
+ return empty;
262
+ }
263
+
264
+ /**
265
+ * Load plan/summary references from `.planning/phases/**` (best-effort).
266
+ * The dashboard surfaces a lightweight list of {phase, plan, file, kind}.
267
+ * This is purely a directory scrape — no shared lib is needed.
268
+ *
269
+ * @param {string} root
270
+ * @param {string[]} degraded
271
+ * @returns {Array<{phase:string, plan:string|null, kind:string, file:string}>}
272
+ */
273
+ function loadPlans(root, degraded) {
274
+ const phasesDir = path.join(root, '.planning', 'phases');
275
+ let phaseDirs;
276
+ try {
277
+ phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true });
278
+ } catch {
279
+ degraded.push('plans: .planning/phases not found');
280
+ return [];
281
+ }
282
+ const out = [];
283
+ for (const pd of phaseDirs) {
284
+ if (!pd.isDirectory()) continue;
285
+ let files;
286
+ try {
287
+ files = fs.readdirSync(path.join(phasesDir, pd.name));
288
+ } catch {
289
+ continue;
290
+ }
291
+ for (const f of files) {
292
+ const isPlan = /-PLAN\.md$/i.test(f);
293
+ const isSummary = /-SUMMARY\.md$/i.test(f);
294
+ if (!isPlan && !isSummary) continue;
295
+ const planMatch = f.match(/^(\d+)-(\d+)-/);
296
+ out.push({
297
+ phase: pd.name,
298
+ plan: planMatch ? `${planMatch[1]}-${planMatch[2]}` : null,
299
+ kind: isPlan ? 'plan' : 'summary',
300
+ file: path.join(phasesDir, pd.name, f),
301
+ });
302
+ }
303
+ }
304
+ // Stable order by file path.
305
+ out.sort((a, b) => a.file.localeCompare(b.file));
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * Load the telemetry event stream. Tries the typed reader (readEvents) first,
311
+ * then the file scrape. Returns a materialized array (dashboard renders a
312
+ * bounded tail; callers slice as needed).
313
+ *
314
+ * @param {string} root
315
+ * @param {string[]} degraded
316
+ * @returns {Promise<Array<Record<string, unknown>>>}
317
+ */
318
+ async function loadEvents(root, degraded) {
319
+ const eventsPath = path.join(root, '.design', 'telemetry', 'events.jsonl');
320
+
321
+ // 1) Typed reader — async iterable (R1 in-process surface).
322
+ try {
323
+ const esMod = await importEventStream();
324
+ if (esMod && typeof esMod.readEvents === 'function') {
325
+ const out = [];
326
+ for await (const ev of esMod.readEvents({ path: eventsPath })) out.push(ev);
327
+ return out;
328
+ }
329
+ degraded.push('events: sdk/event-stream import unavailable — using file scrape');
330
+ } catch (err) {
331
+ degraded.push(`events: typed read failed (${errMsg(err)}) — using file scrape`);
332
+ }
333
+
334
+ // 2) File-scrape fallback.
335
+ const scraped = scrapeEventsFile(eventsPath);
336
+ if (scraped.length === 0 && !fs.existsSync(eventsPath)) {
337
+ degraded.push('events: .design/telemetry/events.jsonl not found');
338
+ }
339
+ return scraped;
340
+ }
341
+
342
+ /**
343
+ * Load the causal event chain (.design/gep/events.jsonl) via event-chain.cjs.
344
+ * Returns [] gracefully when absent. Surfaced separately from telemetry events
345
+ * because it is a causal overlay (R2).
346
+ *
347
+ * @param {string} root
348
+ * @param {string[]} degraded
349
+ * @returns {Array<Record<string, unknown>>}
350
+ */
351
+ function loadChain(root, degraded) {
352
+ if (!eventChain || typeof eventChain.readChain !== 'function') {
353
+ degraded.push('chain: event-chain lib unavailable');
354
+ return [];
355
+ }
356
+ try {
357
+ const out = [];
358
+ for (const ev of eventChain.readChain({ baseDir: root })) out.push(ev);
359
+ return out;
360
+ } catch (err) {
361
+ degraded.push(`chain: read failed (${errMsg(err)})`);
362
+ return [];
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Load + aggregate costs from `.design/telemetry/costs.jsonl`.
368
+ *
369
+ * @param {string} root
370
+ * @param {string[]} degraded
371
+ * @returns {{rows:Array, byRuntime:Object, cumulative:Object, byCycle:Object} | null}
372
+ */
373
+ function loadCosts(root, degraded) {
374
+ try {
375
+ const rows = readCosts({ root });
376
+ const agg = aggregateCosts(rows);
377
+ if (rows.length === 0) {
378
+ degraded.push('costs: .design/telemetry/costs.jsonl empty or not found');
379
+ }
380
+ return { rows, byRuntime: agg.byRuntime, cumulative: agg.cumulative, byCycle: agg.byCycle };
381
+ } catch (err) {
382
+ degraded.push(`costs: load failed (${errMsg(err)})`);
383
+ return null;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Load the design-context graph via design-context-query.cjs load(), enriching
389
+ * with the lib's pure derivations (unreachable + coverage). File-scrape
390
+ * fallback reads `.design/context-graph.json` directly when the lib is absent.
391
+ *
392
+ * @param {string} root
393
+ * @param {string[]} degraded
394
+ * @returns {{graph:Object, unreachable:string[], coverage:Object} | null}
395
+ */
396
+ function loadGraph(root, degraded) {
397
+ const graphPath = path.join(root, '.design', 'context-graph.json');
398
+
399
+ if (designContextQuery && typeof designContextQuery.load === 'function') {
400
+ try {
401
+ const graph = designContextQuery.load(graphPath);
402
+ let unreachableIds = [];
403
+ let cov = null;
404
+ try { unreachableIds = designContextQuery.unreachable(graph); } catch { /* tolerate */ }
405
+ try { cov = designContextQuery.coverage(graph); } catch { /* tolerate */ }
406
+ return { graph, unreachable: unreachableIds, coverage: cov };
407
+ } catch (err) {
408
+ // load() throws on missing file / invalid JSON — fall through to scrape.
409
+ degraded.push(`graph: lib load failed (${errMsg(err)}) — using file scrape`);
410
+ }
411
+ } else {
412
+ degraded.push('graph: design-context-query lib unavailable — using file scrape');
413
+ }
414
+
415
+ // File-scrape fallback: read the JSON directly.
416
+ const raw = readFileOrNull(graphPath);
417
+ if (raw == null) {
418
+ degraded.push('graph: .design/context-graph.json not found');
419
+ return null;
420
+ }
421
+ try {
422
+ const graph = JSON.parse(raw);
423
+ return { graph, unreachable: [], coverage: null };
424
+ } catch (err) {
425
+ degraded.push(`graph: scrape parse failed (${errMsg(err)})`);
426
+ return null;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Load health checks via health-mirror getHealthChecks(root). Returns the
432
+ * { checks: [...] } shape, or null + a degraded note on failure.
433
+ *
434
+ * @param {string} root
435
+ * @param {string[]} degraded
436
+ * @returns {Promise<{checks:Array<{name:string,status:string,detail:string}>} | null>}
437
+ */
438
+ async function loadHealth(root, degraded) {
439
+ if (!healthMirror || typeof healthMirror.getHealthChecks !== 'function') {
440
+ degraded.push('health: health-mirror lib unavailable');
441
+ return null;
442
+ }
443
+ try {
444
+ return await healthMirror.getHealthChecks(root);
445
+ } catch (err) {
446
+ degraded.push(`health: getHealthChecks failed (${errMsg(err)})`);
447
+ return null;
448
+ }
449
+ }
450
+
451
+ /** Discovery sections (runtimes / worktrees / sessions) — each graceful. */
452
+ function loadRuntimes(degraded) {
453
+ try {
454
+ return discoverRuntimes();
455
+ } catch (err) {
456
+ degraded.push(`runtimes: discovery failed (${errMsg(err)})`);
457
+ return [];
458
+ }
459
+ }
460
+ function loadWorktrees(root, degraded) {
461
+ try {
462
+ return discoverWorktrees({ root });
463
+ } catch (err) {
464
+ degraded.push(`worktrees: discovery failed (${errMsg(err)})`);
465
+ return [];
466
+ }
467
+ }
468
+ function loadSessions(root, degraded) {
469
+ try {
470
+ const sessions = discoverSessions({ root });
471
+ if (sessions.length === 0) {
472
+ degraded.push('sessions: none persisted (Phase 55 R4 — best-effort)');
473
+ }
474
+ return sessions;
475
+ } catch (err) {
476
+ degraded.push(`sessions: discovery failed (${errMsg(err)})`);
477
+ return [];
478
+ }
479
+ }
480
+
481
+ /** Compact error message extractor. */
482
+ function errMsg(err) {
483
+ return err && err.message ? String(err.message) : String(err);
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Public: loadDashboardModel
488
+ // ---------------------------------------------------------------------------
489
+ /**
490
+ * Assemble the full dashboard model. NEVER throws — every section degrades to
491
+ * null/[] with a `degraded[]` note on failure.
492
+ *
493
+ * @param {{root?: string}} [opts]
494
+ * @returns {Promise<{
495
+ * status: string|null,
496
+ * phase: string|null,
497
+ * cycle: string|null,
498
+ * decisions: Array,
499
+ * blockers: Array,
500
+ * plans: Array,
501
+ * events: Array,
502
+ * chain: Array,
503
+ * costs: Object|null,
504
+ * graph: Object|null,
505
+ * health: Object|null,
506
+ * runtimes: Array,
507
+ * worktrees: Array,
508
+ * sessions: Array,
509
+ * degraded: string[],
510
+ * root: string,
511
+ * }>}
512
+ */
513
+ async function loadDashboardModel(opts = {}) {
514
+ const degraded = [];
515
+ let root;
516
+ try {
517
+ root = resolveRoot(opts);
518
+ } catch {
519
+ root = process.cwd();
520
+ degraded.push('root: resolution failed — using cwd');
521
+ }
522
+
523
+ // Synchronous sections.
524
+ const plans = loadPlans(root, degraded);
525
+ const chain = loadChain(root, degraded);
526
+ const costs = loadCosts(root, degraded);
527
+ const graph = loadGraph(root, degraded);
528
+ const runtimes = loadRuntimes(degraded);
529
+ const worktrees = loadWorktrees(root, degraded);
530
+ const sessions = loadSessions(root, degraded);
531
+
532
+ // Async sections (typed-lib backed). Each loader already catches internally;
533
+ // Promise.all is safe but we still guard defensively so one rejection can
534
+ // never escape (loaders never reject, but belt-and-suspenders).
535
+ const [stateRes, events, health] = await Promise.all([
536
+ loadState(root, degraded).catch((err) => {
537
+ degraded.push(`state: unexpected (${errMsg(err)})`);
538
+ return { status: null, phase: null, cycle: null, decisions: [], blockers: [] };
539
+ }),
540
+ loadEvents(root, degraded).catch((err) => {
541
+ degraded.push(`events: unexpected (${errMsg(err)})`);
542
+ return [];
543
+ }),
544
+ loadHealth(root, degraded).catch((err) => {
545
+ degraded.push(`health: unexpected (${errMsg(err)})`);
546
+ return null;
547
+ }),
548
+ ]);
549
+
550
+ return {
551
+ status: stateRes.status,
552
+ phase: stateRes.phase,
553
+ cycle: stateRes.cycle,
554
+ decisions: stateRes.decisions,
555
+ blockers: stateRes.blockers,
556
+ plans,
557
+ events,
558
+ chain,
559
+ costs,
560
+ graph,
561
+ health,
562
+ runtimes,
563
+ worktrees,
564
+ sessions,
565
+ degraded,
566
+ root,
567
+ };
568
+ }
569
+
570
+ module.exports = {
571
+ loadDashboardModel,
572
+ // Exposed for tests + sibling reuse (executors D/F may want the scrapers).
573
+ resolveRoot,
574
+ scrapeStateFile,
575
+ scrapeEventsFile,
576
+ };