@hegemonart/get-design-done 1.53.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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +88 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -1
  6. package/agents/component-taxonomy-mapper.md +3 -0
  7. package/agents/motion-mapper.md +1 -0
  8. package/agents/token-mapper.md +3 -0
  9. package/bin/gdd-dashboard +91 -0
  10. package/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
  11. package/package.json +2 -1
  12. package/reference/frameworks/astro.md +43 -0
  13. package/reference/frameworks/nextjs.md +44 -0
  14. package/reference/frameworks/remix.md +44 -0
  15. package/reference/frameworks/storybook.md +44 -0
  16. package/reference/frameworks/sveltekit.md +43 -0
  17. package/reference/frameworks/vite-react.md +43 -0
  18. package/reference/interaction.md +1 -0
  19. package/reference/motion/framer-motion.md +45 -0
  20. package/reference/motion/gsap.md +45 -0
  21. package/reference/motion/motion-one.md +44 -0
  22. package/reference/motion/react-spring.md +44 -0
  23. package/reference/motion.md +1 -0
  24. package/reference/registry.json +163 -1
  25. package/reference/registry.schema.json +18 -1
  26. package/reference/skill-graph.md +2 -1
  27. package/reference/systems/chakra.md +44 -0
  28. package/reference/systems/css-modules.md +44 -0
  29. package/reference/systems/mui.md +44 -0
  30. package/reference/systems/radix-themes.md +43 -0
  31. package/reference/systems/shadcn.md +45 -0
  32. package/reference/systems/styled-components.md +44 -0
  33. package/reference/systems/tailwind.md +44 -0
  34. package/reference/systems/vanilla-extract.md +44 -0
  35. package/scripts/lib/dashboard/graph-html.cjs +0 -0
  36. package/scripts/lib/detect/stack.cjs +455 -0
  37. package/scripts/lib/detect/stack.d.cts +44 -0
  38. package/scripts/lib/explore-parallel-runner/index.ts +138 -1
  39. package/scripts/lib/explore-parallel-runner/types.ts +27 -0
  40. package/scripts/lib/health-mirror/index.cjs +218 -1
  41. package/scripts/lib/manifest/skills.json +8 -0
  42. package/scripts/lib/mapper-spawn.cjs +257 -0
  43. package/scripts/lib/mapper-spawn.d.cts +60 -0
  44. package/scripts/lib/new-addendum.cjs +204 -0
  45. package/sdk/cli/commands/dashboard.ts +419 -0
  46. package/sdk/cli/index.js +1388 -3
  47. package/sdk/cli/index.ts +7 -0
  48. package/sdk/dashboard/data/_pkg-root.cjs +92 -0
  49. package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
  50. package/sdk/dashboard/data/discovery.cjs +297 -0
  51. package/sdk/dashboard/data/risk-surface.cjs +136 -0
  52. package/sdk/dashboard/data/source.cjs +576 -0
  53. package/sdk/dashboard/tui/ansi.cjs +355 -0
  54. package/sdk/dashboard/tui/index.cjs +778 -0
  55. package/sdk/mcp/gdd-mcp/server.js +1117 -0
  56. package/skills/new-addendum/SKILL.md +81 -0
@@ -0,0 +1,778 @@
1
+ 'use strict';
2
+ /**
3
+ * sdk/dashboard/tui/index.cjs — Phase 55 (GDD Dashboard, DEP-FREE), TUI-01/02/03 (executor D).
4
+ *
5
+ * The terminal dashboard main loop + the PURE per-pane renderer it draws with.
6
+ * Consumes the two Round-1 pins:
7
+ * - A: sdk/dashboard/data/source.cjs loadDashboardModel({root?}) -> the model.
8
+ * - B: sdk/dashboard/tui/ansi.cjs box/columns/truncate/color/diffRender/... (pure).
9
+ *
10
+ * Two public functions:
11
+ *
12
+ * renderFrame(model, {pane, cols, rows}) -> string
13
+ * The TESTABLE render. PURE: maps an immutable model + a viewport spec to a single
14
+ * multi-line frame string (rows joined by '\n', every row exactly `cols` visible columns
15
+ * so diffRender stays exact). NO Date.now / Math.random / I/O — given the same model it
16
+ * returns the same bytes. `pane` selects one of the 5 panes; an optional `scroll` offset
17
+ * and `now` (a fixed clock string, excluded by default) may be passed by the loop.
18
+ *
19
+ * run({source?, stdin?, stdout?, once?, root?, interval?, now?}) -> Promise|void
20
+ * The loop. Defaults: source=loadDashboardModel, stdin=process.stdin, stdout=process.stdout.
21
+ * - once:true -> load the model ONCE, write a single frame to stdout, return (NO raw
22
+ * mode, NO alt-screen, NO timers). This is the test seam + the `--once` smoke path.
23
+ * - otherwise -> enter the alt screen, hide the cursor, enable keypress events
24
+ * (readline.emitKeypressEvents + setRawMode), poll the source + tail
25
+ * .design/telemetry/events.jsonl on `interval`, repaint via diffRender (no flicker),
26
+ * and restore the terminal (showCursor + altScreenExit) on q / Ctrl-C / SIGINT / SIGTERM.
27
+ *
28
+ * Read-only by design (D6): the dashboard NEVER mutates project state. The only "actions" are
29
+ * navigation + scroll. Worktree-aware (D7 walk-up already done by the data plane): the Sessions
30
+ * pane lists worktrees[] and flags a lightweight conflict when two worktrees report the same
31
+ * open file (best-effort — sessions carry open_files[] when a future writer persists them).
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const readline = require('node:readline');
37
+
38
+ const ansi = require('./ansi.cjs');
39
+
40
+ // Lazily require the data plane so `renderFrame` (the pure path) can be imported + unit-tested
41
+ // without paying for the data module's transitive requires. `run` resolves it on demand.
42
+ let _loadDashboardModel = null;
43
+ function defaultSource(opts) {
44
+ if (_loadDashboardModel === null) {
45
+ // sibling module in the same package; relative require is correct here (same tree, not a
46
+ // cross-tree jump — the package-root walk-up lives inside source.cjs for ITS siblings).
47
+ ({ loadDashboardModel: _loadDashboardModel } = require('../data/source.cjs'));
48
+ }
49
+ return _loadDashboardModel(opts);
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Pane registry
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** The 5 panes, in tab-cycle order. `key` is the stable id; `title` shows in the header + box. */
57
+ const PANES = Object.freeze([
58
+ { key: 'sessions', title: 'Sessions' },
59
+ { key: 'cycle', title: 'Cycle' },
60
+ { key: 'cost', title: 'Cost' },
61
+ { key: 'findings', title: 'Findings' },
62
+ { key: 'context', title: 'DesignContext' },
63
+ ]);
64
+
65
+ const PANE_KEYS = Object.freeze(PANES.map((p) => p.key));
66
+
67
+ /** Normalize a `pane` arg (string key or numeric index) to a valid 0-based index. */
68
+ function paneIndex(pane) {
69
+ if (typeof pane === 'number' && Number.isFinite(pane)) {
70
+ const n = Math.trunc(pane);
71
+ return ((n % PANES.length) + PANES.length) % PANES.length;
72
+ }
73
+ const idx = PANE_KEYS.indexOf(String(pane));
74
+ return idx === -1 ? 0 : idx;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Small formatting helpers (pure)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** A dim em-dash placeholder for an absent value. */
82
+ const NONE = '—';
83
+
84
+ function asText(v) {
85
+ return v == null || v === '' ? NONE : String(v);
86
+ }
87
+
88
+ /** Round a USD number to 4 dp as a plain decimal string (deterministic; no locale). */
89
+ function usd(n) {
90
+ const x = Number(n);
91
+ if (!Number.isFinite(x)) return '0.0000';
92
+ return x.toFixed(4);
93
+ }
94
+
95
+ /** Integer with no separators (deterministic; locale-free). */
96
+ function intStr(n) {
97
+ const x = Number(n);
98
+ return Number.isFinite(x) ? String(Math.trunc(x)) : '0';
99
+ }
100
+
101
+ /**
102
+ * Clamp a scroll offset into [0, max] where max = length - visible (never negative).
103
+ * @param {number} offset
104
+ * @param {number} length total items
105
+ * @param {number} visible rows available
106
+ */
107
+ function clampScroll(offset, length, visible) {
108
+ const max = Math.max(0, length - Math.max(0, visible));
109
+ const o = Number.isFinite(offset) ? Math.trunc(offset) : 0;
110
+ return Math.max(0, Math.min(o, max));
111
+ }
112
+
113
+ /**
114
+ * Window an array to `visible` items starting at `offset`, returning the slice plus a
115
+ * scroll-indicator suffix line when there is more above/below. Pure.
116
+ * @returns {{slice: any[], more: string|null}}
117
+ */
118
+ function windowItems(items, offset, visible) {
119
+ const list = Array.isArray(items) ? items : [];
120
+ const off = clampScroll(offset, list.length, visible);
121
+ const slice = visible > 0 ? list.slice(off, off + visible) : [];
122
+ let more = null;
123
+ if (list.length > visible && visible > 0) {
124
+ const shownEnd = off + slice.length;
125
+ more = `… ${off + 1}-${shownEnd} of ${list.length}`;
126
+ }
127
+ return { slice, more };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Per-pane body builders. Each returns string[] of CONTENT lines (the box wraps + pads them
132
+ // to the inner width, so these need not be exactly `inner` wide — but we truncate long lines
133
+ // defensively so a runaway value never breaks the layout). PURE.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Sessions pane: runtimes presence + discovered sessions + git worktrees, with a lightweight
138
+ * conflict note when two worktrees (via session.open_files, when present) report the same file.
139
+ */
140
+ function bodySessions(model, inner, scroll) {
141
+ const lines = [];
142
+ const runtimes = Array.isArray(model.runtimes) ? model.runtimes : [];
143
+ const present = runtimes.filter((r) => r && r.present);
144
+ lines.push(ansi.color('Runtimes', { bold: true }) + ` (${present.length}/${runtimes.length} present)`);
145
+ // Compact per-runtime status: name + a present/absent glyph.
146
+ const rtCells = runtimes.map((r) => {
147
+ const mark = r && r.present ? '●' : '○';
148
+ return `${mark} ${asText(r && r.runtime)}`;
149
+ });
150
+ // Pack runtime cells into rows of up to 4 columns.
151
+ const perRow = 4;
152
+ for (let i = 0; i < rtCells.length; i += perRow) {
153
+ const row = rtCells.slice(i, i + perRow);
154
+ lines.push(' ' + ansi.columns(row, new Array(row.length).fill(Math.max(8, Math.floor((inner - 2) / perRow)))));
155
+ }
156
+
157
+ const sessions = Array.isArray(model.sessions) ? model.sessions : [];
158
+ lines.push('');
159
+ lines.push(ansi.color('Sessions', { bold: true }) + ` (${sessions.length})`);
160
+ if (sessions.length === 0) {
161
+ lines.push(' ' + ansi.color('none persisted (best-effort discovery)', { dim: true }));
162
+ } else {
163
+ for (const s of sessions) {
164
+ const id = asText(s && s.id);
165
+ const harness = asText(s && s.harness);
166
+ lines.push(` ${id} [${harness}]`);
167
+ }
168
+ }
169
+
170
+ const worktrees = Array.isArray(model.worktrees) ? model.worktrees : [];
171
+ lines.push('');
172
+ lines.push(ansi.color('Worktrees', { bold: true }) + ` (${worktrees.length})`);
173
+ for (const w of worktrees) {
174
+ const branch = w && w.detached ? '(detached)' : asText(w && w.branch);
175
+ const flags = [];
176
+ if (w && w.locked) flags.push('locked');
177
+ if (w && w.bare) flags.push('bare');
178
+ const suffix = flags.length ? ` {${flags.join(',')}}` : '';
179
+ lines.push(` ${branch} ${ansi.color(asText(w && w.path), { dim: true })}${suffix}`);
180
+ }
181
+
182
+ const conflicts = detectWorktreeConflicts(sessions);
183
+ if (conflicts.length) {
184
+ lines.push('');
185
+ lines.push(ansi.color('⚠ Conflicts', { fg: 'yellow', bold: true }));
186
+ for (const c of conflicts) {
187
+ lines.push(' ' + ansi.color(`${c.file} — open in ${c.count} worktrees`, { fg: 'yellow' }));
188
+ }
189
+ }
190
+
191
+ return applyScroll(lines, scroll, inner);
192
+ }
193
+
194
+ /**
195
+ * Detect a lightweight conflict: the same open file reported by two+ sessions whose worktree
196
+ * roots differ. Sessions are the only carrier of open_files (R4 — a future writer persists
197
+ * them); absent that data this returns []. PURE.
198
+ */
199
+ function detectWorktreeConflicts(sessions) {
200
+ const byFile = new Map(); // file -> Set<worktree-or-session-id>
201
+ for (const s of Array.isArray(sessions) ? sessions : []) {
202
+ if (!s || typeof s !== 'object') continue;
203
+ const files = Array.isArray(s.open_files) ? s.open_files : [];
204
+ const owner = String(s.worktree || s.root || s.id || 'unknown');
205
+ for (const f of files) {
206
+ if (typeof f !== 'string' || f === '') continue;
207
+ if (!byFile.has(f)) byFile.set(f, new Set());
208
+ byFile.get(f).add(owner);
209
+ }
210
+ }
211
+ const out = [];
212
+ for (const [file, owners] of byFile) {
213
+ if (owners.size >= 2) out.push({ file, count: owners.size });
214
+ }
215
+ out.sort((a, b) => a.file.localeCompare(b.file));
216
+ return out;
217
+ }
218
+
219
+ /** Cycle pane: phase / cycle / status + decisions[] + blockers[] + a plan count. */
220
+ function bodyCycle(model, inner, scroll) {
221
+ const lines = [];
222
+ lines.push(`${ansi.color('Phase', { bold: true })} ${asText(model.phase)}` +
223
+ ` ${ansi.color('Cycle', { bold: true })} ${asText(model.cycle)}`);
224
+ lines.push(`${ansi.color('Status', { bold: true })} ${asText(model.status)}`);
225
+
226
+ const decisions = Array.isArray(model.decisions) ? model.decisions : [];
227
+ lines.push('');
228
+ lines.push(ansi.color('Decisions', { bold: true }) + ` (${decisions.length})`);
229
+ if (decisions.length === 0) {
230
+ lines.push(' ' + ansi.color(NONE, { dim: true }));
231
+ } else {
232
+ for (const d of decisions) {
233
+ const id = asText(d && d.id);
234
+ const status = (d && d.status) || '';
235
+ const lockGlyph = status === 'locked'
236
+ ? ansi.color('🔒', {})
237
+ : (status === 'tentative' ? ansi.color('~', { dim: true }) : ' ');
238
+ lines.push(` ${lockGlyph} ${id}: ${asText(d && d.text)}`);
239
+ }
240
+ }
241
+
242
+ const blockers = Array.isArray(model.blockers) ? model.blockers : [];
243
+ lines.push('');
244
+ const blkLabel = ansi.color('Blockers', { bold: true }) + ` (${blockers.length})`;
245
+ lines.push(blockers.length ? ansi.color(blkLabel, { fg: 'red' }) : blkLabel);
246
+ if (blockers.length === 0) {
247
+ lines.push(' ' + ansi.color('none', { dim: true }));
248
+ } else {
249
+ for (const b of blockers) {
250
+ const stage = asText(b && b.stage);
251
+ const date = asText(b && b.date);
252
+ lines.push(' ' + ansi.color(`[${stage} ${date}] ${asText(b && b.text)}`, { fg: 'red' }));
253
+ }
254
+ }
255
+
256
+ const plans = Array.isArray(model.plans) ? model.plans : [];
257
+ const planCount = plans.filter((p) => p && p.kind === 'plan').length;
258
+ const summaryCount = plans.filter((p) => p && p.kind === 'summary').length;
259
+ lines.push('');
260
+ lines.push(`${ansi.color('Plans', { bold: true })} ${planCount} plan / ${summaryCount} summary`);
261
+
262
+ return applyScroll(lines, scroll, inner);
263
+ }
264
+
265
+ /** Cost pane: a per-runtime table (runtime | tokens_in | tokens_out | est_cost_usd) + cumulative. */
266
+ function bodyCost(model, inner, scroll) {
267
+ const lines = [];
268
+ const costs = model.costs;
269
+ if (!costs || !costs.byRuntime) {
270
+ lines.push(ansi.color('no cost telemetry', { dim: true }));
271
+ return applyScroll(lines, scroll, inner);
272
+ }
273
+
274
+ // Column widths sized to the inner width: runtime gets the slack, numbers fixed.
275
+ const wIn = 10;
276
+ const wOut = 10;
277
+ const wCost = 12;
278
+ const wRt = Math.max(8, inner - (wIn + wOut + wCost) - 3); // 3 single-space separators
279
+
280
+ const header = ansi.columns(
281
+ [
282
+ ansi.color('runtime', { bold: true }),
283
+ ansi.color('tok_in', { bold: true }),
284
+ ansi.color('tok_out', { bold: true }),
285
+ ansi.color('cost_usd', { bold: true }),
286
+ ],
287
+ [wRt, wIn, wOut, wCost],
288
+ );
289
+ lines.push(header);
290
+ lines.push('─'.repeat(Math.max(0, inner)));
291
+
292
+ const entries = Object.entries(costs.byRuntime);
293
+ for (const [rt, bucket] of entries) {
294
+ lines.push(ansi.columns(
295
+ [rt, intStr(bucket.tokens_in), intStr(bucket.tokens_out), usd(bucket.est_cost_usd)],
296
+ [wRt, wIn, wOut, wCost],
297
+ ));
298
+ }
299
+ if (entries.length === 0) {
300
+ lines.push(ansi.color('(no rows)', { dim: true }));
301
+ }
302
+
303
+ // Cumulative footer.
304
+ const cum = costs.cumulative || { tokens_in: 0, tokens_out: 0, est_cost_usd: 0 };
305
+ lines.push('─'.repeat(Math.max(0, inner)));
306
+ lines.push(ansi.columns(
307
+ [
308
+ ansi.color('TOTAL', { bold: true }),
309
+ intStr(cum.tokens_in),
310
+ intStr(cum.tokens_out),
311
+ ansi.color(usd(cum.est_cost_usd), { bold: true }),
312
+ ],
313
+ [wRt, wIn, wOut, wCost],
314
+ ));
315
+
316
+ return applyScroll(lines, scroll, inner);
317
+ }
318
+
319
+ /**
320
+ * Findings pane: recent events + health checks. The risk/confidence column is a BLANK
321
+ * placeholder pre-Phase-56 (D8) — we render the header column but fill it with '·'.
322
+ */
323
+ function bodyFindings(model, inner, scroll) {
324
+ const lines = [];
325
+
326
+ // Health checks first (compact name + status glyph).
327
+ const health = model.health && Array.isArray(model.health.checks) ? model.health.checks : [];
328
+ lines.push(ansi.color('Health checks', { bold: true }) + ` (${health.length})`);
329
+ if (health.length === 0) {
330
+ lines.push(' ' + ansi.color('unavailable', { dim: true }));
331
+ } else {
332
+ for (const c of health) {
333
+ const ok = c && (c.status === 'pass' || c.status === 'ok');
334
+ const isWarn = c && c.status === 'warn';
335
+ const glyph = ok
336
+ ? ansi.color('+', { fg: 'green' })
337
+ : ansi.color(isWarn ? '!' : 'x', { fg: isWarn ? 'yellow' : 'red' });
338
+ lines.push(` ${glyph} ${asText(c && c.name)} ${ansi.color(asText(c && c.detail), { dim: true })}`);
339
+ }
340
+ }
341
+
342
+ // Recent events tail (last-N, newest last as stored). Risk column = placeholder.
343
+ const events = Array.isArray(model.events) ? model.events : [];
344
+ const TAIL = 12;
345
+ const tail = events.slice(-TAIL);
346
+ lines.push('');
347
+ lines.push(ansi.columns(
348
+ [ansi.color('event', { bold: true }), ansi.color('risk', { bold: true }), ansi.color('conf', { bold: true })],
349
+ [Math.max(8, inner - 14), 5, 5],
350
+ ));
351
+ if (tail.length === 0) {
352
+ lines.push(' ' + ansi.color('no events', { dim: true }));
353
+ } else {
354
+ for (const ev of tail) {
355
+ const name = (ev && (ev.event || ev.type || ev.kind)) || 'event';
356
+ // Pre-Phase-56: risk/confidence are blank placeholders (D8).
357
+ lines.push(ansi.columns(
358
+ [String(name), ansi.color('·', { dim: true }), ansi.color('·', { dim: true })],
359
+ [Math.max(8, inner - 14), 5, 5],
360
+ ));
361
+ }
362
+ }
363
+
364
+ return applyScroll(lines, scroll, inner);
365
+ }
366
+
367
+ /**
368
+ * DesignContext pane: a TEXT TREE of graph nodes grouped by layer
369
+ * (Atomic -> Molecular -> Organism -> Template), each layer listing per-type counts, plus a
370
+ * coverage% line. Nodes without a `layer` are grouped under "(unlayered)".
371
+ */
372
+ const LAYER_ORDER = Object.freeze(['Atomic', 'Molecular', 'Organism', 'Template']);
373
+
374
+ function bodyContext(model, inner, scroll) {
375
+ const lines = [];
376
+ const g = model.graph;
377
+ if (!g || !g.graph) {
378
+ lines.push(ansi.color('no design-context graph', { dim: true }));
379
+ lines.push(ansi.color('(run the Phase 52 mapper to populate .design/context-graph.json)', { dim: true }));
380
+ return applyScroll(lines, scroll, inner);
381
+ }
382
+
383
+ const nodes = Array.isArray(g.graph.nodes) ? g.graph.nodes : [];
384
+ const edges = Array.isArray(g.graph.edges) ? g.graph.edges : [];
385
+
386
+ // Coverage line.
387
+ const cov = g.coverage;
388
+ const covLine = cov && typeof cov.pct === 'number'
389
+ ? `Coverage ${cov.pct}% (${(cov.present_types || []).length}/${(cov.present_types || []).length + (cov.missing_types || []).length} types)`
390
+ : 'Coverage —';
391
+ lines.push(ansi.color(covLine, { bold: true }));
392
+ lines.push(`${nodes.length} nodes · ${edges.length} edges · ${(g.unreachable || []).length} unreachable`);
393
+ lines.push('');
394
+
395
+ // Group nodes by layer, then by type within a layer.
396
+ const byLayer = new Map();
397
+ for (const lyr of LAYER_ORDER) byLayer.set(lyr, new Map());
398
+ const unlayered = new Map();
399
+ for (const n of nodes) {
400
+ if (!n || typeof n !== 'object') continue;
401
+ const lyr = typeof n.layer === 'string' && LAYER_ORDER.includes(n.layer) ? n.layer : null;
402
+ const type = typeof n.type === 'string' ? n.type : '(untyped)';
403
+ const bucket = lyr ? byLayer.get(lyr) : unlayered;
404
+ bucket.set(type, (bucket.get(type) || 0) + 1);
405
+ }
406
+
407
+ const renderLayer = (label, typeMap) => {
408
+ const total = [...typeMap.values()].reduce((a, b) => a + b, 0);
409
+ lines.push(`${ansi.color('▸', { fg: 'cyan' })} ${ansi.color(label, { bold: true })} (${total})`);
410
+ const types = [...typeMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
411
+ for (let i = 0; i < types.length; i++) {
412
+ const [type, count] = types[i];
413
+ const branch = i === types.length - 1 ? '└─' : '├─';
414
+ lines.push(` ${branch} ${type} ${ansi.color(`× ${count}`, { dim: true })}`);
415
+ }
416
+ };
417
+
418
+ for (const lyr of LAYER_ORDER) {
419
+ const typeMap = byLayer.get(lyr);
420
+ if (typeMap.size > 0) renderLayer(lyr, typeMap);
421
+ }
422
+ if (unlayered.size > 0) renderLayer('(unlayered)', unlayered);
423
+
424
+ return applyScroll(lines, scroll, inner);
425
+ }
426
+
427
+ /**
428
+ * Apply a scroll offset to a list of content lines, truncating each to `inner` columns so a
429
+ * single long line can never overflow the box. Returns the visible window unbounded by height
430
+ * (the box honors `height` separately). We DO truncate horizontally here. PURE.
431
+ */
432
+ function applyScroll(lines, scroll, inner) {
433
+ // Horizontal safety only: truncate each content line to the inner width so a runaway value can
434
+ // never overflow the box. VERTICAL scrolling/windowing is owned by renderFrame (windowItems +
435
+ // the box height), so `scroll` is intentionally not consumed here.
436
+ void scroll;
437
+ return lines.map((ln) => ansi.truncate(ln, inner));
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // renderFrame — the pure render
442
+ // ---------------------------------------------------------------------------
443
+
444
+ const PANE_BODY = {
445
+ sessions: bodySessions,
446
+ cycle: bodyCycle,
447
+ cost: bodyCost,
448
+ findings: bodyFindings,
449
+ context: bodyContext,
450
+ };
451
+
452
+ /**
453
+ * Render ONE full frame for `model` at the given viewport. PURE + deterministic.
454
+ *
455
+ * @param {object} model the dashboard model (from loadDashboardModel)
456
+ * @param {{pane?: string|number, cols?: number, rows?: number, scroll?: number, now?: string}} [view]
457
+ * @returns {string} the frame: `rows` lines joined by '\n', each exactly `cols` visible columns.
458
+ */
459
+ function renderFrame(model, view = {}) {
460
+ const m = model && typeof model === 'object' ? model : {};
461
+ const cols = Math.max(20, view.cols | 0 || 80);
462
+ const rows = Math.max(6, view.rows | 0 || 24);
463
+ const idx = paneIndex(view.pane == null ? 0 : view.pane);
464
+ const active = PANES[idx];
465
+
466
+ // Header row (line 1): tab strip with the active pane highlighted.
467
+ const tabs = PANES.map((p, i) => {
468
+ const label = ` ${p.title} `;
469
+ return i === idx ? ansi.color(label, { bold: true, fg: 'black', bg: 'cyan' }) : ansi.color(label, { dim: true });
470
+ }).join(ansi.color('│', { dim: true }));
471
+ const headerLine = ansi.padRight('GDD Dashboard ' + tabs, cols);
472
+
473
+ // Degraded indicator (line 2): a compact count so the user knows data is partial.
474
+ const degraded = Array.isArray(m.degraded) ? m.degraded : [];
475
+ const rootStr = asText(m.root);
476
+ const statusBar = degraded.length
477
+ ? ansi.color(`⚠ ${degraded.length} degraded`, { fg: 'yellow' }) + ' ' + ansi.color(rootStr, { dim: true })
478
+ : ansi.color('● live', { fg: 'green' }) + ' ' + ansi.color(rootStr, { dim: true });
479
+ const statusLine = ansi.padRight(statusBar, cols);
480
+
481
+ // Footer (last line): key hints.
482
+ const footer = ansi.padRight(
483
+ ansi.color('[tab] next [shift-tab] prev [↑/↓] scroll [q] quit', { dim: true }),
484
+ cols,
485
+ );
486
+
487
+ // The box occupies the rows between the 2 header lines and the 1 footer line.
488
+ const boxHeight = Math.max(3, rows - 3);
489
+ const inner = cols - 2;
490
+ const bodyFn = PANE_BODY[active.key] || bodySessions;
491
+ const bodyLines = bodyFn(m, inner, view.scroll || 0);
492
+
493
+ // Vertically window the body to the box's inner height (boxHeight - 2 for the borders),
494
+ // appending a scroll hint when clipped.
495
+ const innerHeight = Math.max(1, boxHeight - 2);
496
+ const { slice, more } = windowItems(bodyLines, view.scroll || 0, innerHeight);
497
+ const shown = more ? slice.slice(0, Math.max(0, innerHeight - 1)).concat(ansi.color(more, { dim: true })) : slice;
498
+
499
+ const boxed = ansi.box({
500
+ title: active.title,
501
+ lines: shown,
502
+ width: cols,
503
+ height: boxHeight,
504
+ border: 'round',
505
+ });
506
+
507
+ const all = [headerLine, statusLine, ...boxed, footer];
508
+
509
+ // Enforce EXACTLY `rows` lines so diffRender is exact across frames.
510
+ while (all.length < rows) all.push(ansi.padRight('', cols));
511
+ if (all.length > rows) all.length = rows;
512
+
513
+ // Defensive: pad every line to exactly `cols` (box already does; header/footer too).
514
+ return all.map((ln) => ansi.padRight(ln, cols)).join('\n');
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // run — the loop
519
+ // ---------------------------------------------------------------------------
520
+
521
+ /** Resolve the viewport size from a stdout-ish stream (graceful defaults). */
522
+ function viewportOf(stdout) {
523
+ const cols = (stdout && Number.isInteger(stdout.columns) && stdout.columns > 0) ? stdout.columns : 80;
524
+ const rows = (stdout && Number.isInteger(stdout.rows) && stdout.rows > 0) ? stdout.rows : 24;
525
+ return { cols, rows };
526
+ }
527
+
528
+ /**
529
+ * Run the dashboard.
530
+ *
531
+ * @param {{
532
+ * source?: (opts:{root?:string}) => Promise<object>|object,
533
+ * stdin?: NodeJS.ReadStream,
534
+ * stdout?: NodeJS.WriteStream,
535
+ * once?: boolean,
536
+ * root?: string,
537
+ * interval?: number,
538
+ * now?: string,
539
+ * }} [opts]
540
+ * @returns {Promise<{frame:string}>|Promise<void>}
541
+ */
542
+ async function run(opts = {}) {
543
+ const source = typeof opts.source === 'function' ? opts.source : defaultSource;
544
+ const stdout = opts.stdout || process.stdout;
545
+ const stdin = opts.stdin || process.stdin;
546
+ const root = opts.root;
547
+
548
+ // --- once: render a single frame and return (test seam + --once smoke). -------------------
549
+ if (opts.once) {
550
+ let model;
551
+ try {
552
+ model = await source({ root });
553
+ } catch (err) {
554
+ // The data plane never throws, but a custom test source might. Degrade to an empty frame.
555
+ model = { degraded: [`source threw: ${err && err.message ? err.message : String(err)}`], root: root || null };
556
+ }
557
+ const { cols, rows } = viewportOf(stdout);
558
+ const frame = renderFrame(model, { pane: 0, cols, rows, now: opts.now });
559
+ stdout.write(frame + '\n');
560
+ return { frame };
561
+ }
562
+
563
+ // --- interactive loop ---------------------------------------------------------------------
564
+ const state = {
565
+ paneIdx: 0,
566
+ scroll: 0,
567
+ model: { degraded: ['loading…'], root: root || null },
568
+ prevLines: [],
569
+ running: true,
570
+ };
571
+
572
+ // Terminal setup: alt screen + hide cursor. Guard each call (a non-TTY test stdout lacks them).
573
+ const write = (s) => { try { stdout.write(s); } catch { /* ignore */ } };
574
+ write(ansi.altScreenEnter());
575
+ write(ansi.hideCursor());
576
+ write(ansi.clearScreen());
577
+
578
+ let rawEnabled = false;
579
+ if (stdin && typeof stdin.setRawMode === 'function' && stdin.isTTY) {
580
+ try {
581
+ readline.emitKeypressEvents(stdin);
582
+ stdin.setRawMode(true);
583
+ rawEnabled = true;
584
+ } catch { /* non-interactive — keypress nav simply won't fire */ }
585
+ } else if (stdin) {
586
+ // Non-TTY (or a fake EventEmitter in tests): still wire keypress events so injected
587
+ // 'keypress' emissions route. emitKeypressEvents is a no-op without a real stream, so the
588
+ // test feeds {name} objects directly via stdin.emit('keypress', ...).
589
+ try { readline.emitKeypressEvents(stdin); } catch { /* ignore */ }
590
+ }
591
+
592
+ // Repaint via diffRender (no flicker): only changed rows are rewritten.
593
+ const paint = () => {
594
+ const { cols, rows } = viewportOf(stdout);
595
+ const next = renderFrame(state.model, {
596
+ pane: state.paneIdx,
597
+ cols,
598
+ rows,
599
+ scroll: state.scroll,
600
+ now: opts.now,
601
+ }).split('\n');
602
+ const opsList = ansi.diffRender(state.prevLines, next);
603
+ let buf = '';
604
+ for (const op of opsList) {
605
+ buf += ansi.cursorTo(op.row, 1) + ansi.clearLine() + op.text;
606
+ }
607
+ if (buf) write(buf);
608
+ state.prevLines = next;
609
+ };
610
+
611
+ // First paint (loading state), then load real data.
612
+ paint();
613
+
614
+ const refresh = async () => {
615
+ if (!state.running) return;
616
+ try {
617
+ const model = await source({ root });
618
+ if (model && typeof model === 'object') state.model = model;
619
+ } catch {
620
+ // keep the previous model; the loop is resilient
621
+ }
622
+ if (state.running) paint();
623
+ };
624
+ await refresh();
625
+
626
+ // --- live refresh: poll the source + tail telemetry on an interval. -----------------------
627
+ const intervalMs = Number.isFinite(opts.interval) ? Math.max(250, opts.interval | 0) : 1500;
628
+ const eventsPath = root
629
+ ? path.join(root, '.design', 'telemetry', 'events.jsonl')
630
+ : (state.model && state.model.root ? path.join(state.model.root, '.design', 'telemetry', 'events.jsonl') : null);
631
+ let lastSize = -1;
632
+ try {
633
+ if (eventsPath && fs.existsSync(eventsPath)) lastSize = fs.statSync(eventsPath).size;
634
+ } catch { /* ignore */ }
635
+
636
+ const timer = setInterval(() => {
637
+ if (!state.running) return;
638
+ // Cheap tail probe: only do a full reload when the events file grew (or every tick if we
639
+ // can't stat it). This keeps a steady terminal at near-zero cost.
640
+ let changed = true;
641
+ try {
642
+ if (eventsPath && fs.existsSync(eventsPath)) {
643
+ const size = fs.statSync(eventsPath).size;
644
+ changed = size !== lastSize;
645
+ lastSize = size;
646
+ }
647
+ } catch { /* fall back to always-refresh */ }
648
+ if (changed) void refresh();
649
+ }, intervalMs);
650
+ if (timer && typeof timer.unref === 'function') timer.unref();
651
+
652
+ // --- teardown + key handling --------------------------------------------------------------
653
+ return await new Promise((resolve) => {
654
+ const cleanup = () => {
655
+ if (!state.running) return;
656
+ state.running = false;
657
+ clearInterval(timer);
658
+ if (rawEnabled && typeof stdin.setRawMode === 'function') {
659
+ try { stdin.setRawMode(false); } catch { /* ignore */ }
660
+ }
661
+ try { stdin.removeListener('keypress', onKey); } catch { /* ignore */ }
662
+ if (typeof stdin.pause === 'function') { try { stdin.pause(); } catch { /* ignore */ } }
663
+ write(ansi.showCursor());
664
+ write(ansi.altScreenExit());
665
+ resolve();
666
+ };
667
+
668
+ const onKey = (str, key) => {
669
+ // `key` is the readline keypress descriptor; tests may emit a bare {name}/{sequence}.
670
+ const k = key || {};
671
+ const name = k.name || str;
672
+ if ((k.ctrl && name === 'c') || name === 'q') return cleanup();
673
+ if (name === 'tab' && k.shift) {
674
+ state.paneIdx = (state.paneIdx - 1 + PANES.length) % PANES.length;
675
+ state.scroll = 0;
676
+ return paint();
677
+ }
678
+ if (name === 'tab') {
679
+ state.paneIdx = (state.paneIdx + 1) % PANES.length;
680
+ state.scroll = 0;
681
+ return paint();
682
+ }
683
+ if (name === 'down' || name === 'j') { state.scroll += 1; return paint(); }
684
+ if (name === 'up' || name === 'k') { state.scroll = Math.max(0, state.scroll - 1); return paint(); }
685
+ if (name === 'pagedown') { state.scroll += 10; return paint(); }
686
+ if (name === 'pageup') { state.scroll = Math.max(0, state.scroll - 10); return paint(); }
687
+ };
688
+
689
+ if (stdin && typeof stdin.on === 'function') stdin.on('keypress', onKey);
690
+
691
+ // Signal-based teardown so Ctrl-C / kill restores the terminal.
692
+ const onSig = () => cleanup();
693
+ process.once('SIGINT', onSig);
694
+ process.once('SIGTERM', onSig);
695
+ });
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // CLI entry — `node sdk/dashboard/tui/index.cjs [--once] [--root <dir>]`.
700
+ // This is what bin/gdd-dashboard spawns. Kept tiny: parse a couple of flags,
701
+ // invoke run(), and translate the result to an exit code. Read-only; never
702
+ // throws out (errors degrade to a non-zero exit + a stderr note).
703
+ // ---------------------------------------------------------------------------
704
+
705
+ /**
706
+ * Parse the dashboard CLI argv (a minimal hand-rolled parser — zero deps).
707
+ * @param {string[]} argv process.argv.slice(2)
708
+ * @returns {{once:boolean, help:boolean, root:string|undefined, interval:number|undefined}}
709
+ */
710
+ function parseCliArgs(argv) {
711
+ const out = { once: false, help: false, root: undefined, interval: undefined };
712
+ for (let i = 0; i < argv.length; i++) {
713
+ const a = argv[i];
714
+ if (a === '--once') out.once = true;
715
+ else if (a === '-h' || a === '--help') out.help = true;
716
+ else if (a === '--root') out.root = argv[++i];
717
+ else if (a.startsWith('--root=')) out.root = a.slice('--root='.length);
718
+ else if (a === '--interval') out.interval = Number.parseInt(argv[++i], 10);
719
+ else if (a.startsWith('--interval=')) out.interval = Number.parseInt(a.slice('--interval='.length), 10);
720
+ }
721
+ return out;
722
+ }
723
+
724
+ const CLI_USAGE = [
725
+ 'Usage: gdd-dashboard [--once] [--root <dir>] [--interval <ms>]',
726
+ '',
727
+ ' A read-only terminal dashboard for a GDD project: Sessions / Cycle / Cost /',
728
+ ' Findings / DesignContext panes. Live-refreshes by polling the project state +',
729
+ ' tailing .design/telemetry/events.jsonl.',
730
+ '',
731
+ ' --once Render a single frame to stdout and exit (no raw mode / loop).',
732
+ ' --root <dir> Project root to read (default: GDD_PROJECT_ROOT / package-root).',
733
+ ' --interval <ms> Live-refresh poll interval (default 1500; min 250).',
734
+ ' -h, --help Show this help.',
735
+ '',
736
+ ' Keys (interactive): [tab]/[shift-tab] cycle panes · [↑/↓] scroll · [q] quit.',
737
+ '',
738
+ ].join('\n');
739
+
740
+ async function mainCli(argv) {
741
+ const args = parseCliArgs(argv);
742
+ if (args.help) {
743
+ process.stdout.write(CLI_USAGE);
744
+ return 0;
745
+ }
746
+ try {
747
+ await run({ once: args.once, root: args.root, interval: args.interval });
748
+ return 0;
749
+ } catch (err) {
750
+ process.stderr.write(`gdd-dashboard: ${err && err.message ? err.message : String(err)}\n`);
751
+ return 1;
752
+ }
753
+ }
754
+
755
+ if (require.main === module) {
756
+ mainCli(process.argv.slice(2)).then(
757
+ (code) => process.exit(code),
758
+ (err) => {
759
+ process.stderr.write(`gdd-dashboard: ${err && err.message ? err.message : String(err)}\n`);
760
+ process.exit(1);
761
+ },
762
+ );
763
+ }
764
+
765
+ module.exports = {
766
+ run,
767
+ renderFrame,
768
+ // CLI surface (exported for tests).
769
+ parseCliArgs,
770
+ mainCli,
771
+ // Exposed for tests + sibling reuse.
772
+ PANES,
773
+ PANE_KEYS,
774
+ paneIndex,
775
+ detectWorktreeConflicts,
776
+ clampScroll,
777
+ windowItems,
778
+ };