@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -0
- package/README.md +2 -0
- package/bin/gdd-dashboard +91 -0
- package/package.json +2 -1
- package/scripts/lib/dashboard/graph-html.cjs +0 -0
- package/scripts/lib/health-mirror/index.cjs +146 -1
- package/sdk/cli/commands/dashboard.ts +419 -0
- package/sdk/cli/index.js +253 -2
- package/sdk/cli/index.ts +7 -0
- package/sdk/dashboard/data/_pkg-root.cjs +92 -0
- package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
- package/sdk/dashboard/data/discovery.cjs +297 -0
- package/sdk/dashboard/data/risk-surface.cjs +136 -0
- package/sdk/dashboard/data/source.cjs +576 -0
- package/sdk/dashboard/tui/ansi.cjs +355 -0
- package/sdk/dashboard/tui/index.cjs +778 -0
- package/sdk/mcp/gdd-mcp/server.js +70 -0
|
@@ -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
|
+
};
|