@hegemonart/get-design-done 1.54.0 → 1.56.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 (36) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/README.md +6 -0
  5. package/SKILL.md +1 -0
  6. package/agents/design-fixer.md +16 -0
  7. package/bin/gdd-dashboard +91 -0
  8. package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
  9. package/hooks/gdd-decision-injector.js +58 -0
  10. package/hooks/gdd-fact-force.js +345 -0
  11. package/hooks/gdd-risk-gate.js +406 -0
  12. package/hooks/hooks.json +18 -0
  13. package/package.json +2 -1
  14. package/reference/schemas/events.schema.json +61 -1
  15. package/reference/skill-graph.md +2 -1
  16. package/scripts/lib/dashboard/graph-html.cjs +0 -0
  17. package/scripts/lib/health-mirror/index.cjs +146 -1
  18. package/scripts/lib/manifest/skills.json +8 -0
  19. package/scripts/lib/risk/calibration.cjs +385 -0
  20. package/scripts/lib/risk/compute-risk.cjs +229 -0
  21. package/scripts/lib/risk/consumers.cjs +211 -0
  22. package/scripts/lib/risk/override.cjs +87 -0
  23. package/scripts/lib/risk/route.cjs +59 -0
  24. package/scripts/lib/risk/tables.cjs +221 -0
  25. package/sdk/cli/commands/dashboard.ts +419 -0
  26. package/sdk/cli/index.js +253 -2
  27. package/sdk/cli/index.ts +7 -0
  28. package/sdk/dashboard/data/_pkg-root.cjs +92 -0
  29. package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
  30. package/sdk/dashboard/data/discovery.cjs +297 -0
  31. package/sdk/dashboard/data/risk-surface.cjs +136 -0
  32. package/sdk/dashboard/data/source.cjs +576 -0
  33. package/sdk/dashboard/tui/ansi.cjs +355 -0
  34. package/sdk/dashboard/tui/index.cjs +778 -0
  35. package/sdk/mcp/gdd-mcp/server.js +70 -0
  36. package/skills/override/SKILL.md +86 -0
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/risk/tables.cjs — frozen, dependency-free static tables for the
4
+ * Phase 56 risk scorer. PURE DATA + linear-only regexes (CodeQL js/redos safe:
5
+ * no nested quantifiers, no `(a+)+`, no `(.*)*`; the secret-shaped pattern is
6
+ * anchored on fixed prefixes with bounded character classes).
7
+ *
8
+ * Consumed by scripts/lib/risk/compute-risk.cjs. Tables are
9
+ * `Object.freeze`-d so a downstream consumer cannot mutate the shared defaults;
10
+ * config overrides EXTEND (never shrink) these via the loadConfig pattern in
11
+ * compute-risk.cjs (protected-paths discipline — D7).
12
+ *
13
+ * Exports:
14
+ * BASE_TOOL_RISK — { [toolName]: number, __default: number }
15
+ * FILE_SENSITIVITY — ordered [{ test:RegExp, mult, add, label }]
16
+ * INPUT_PATTERN_RISK— ordered [{ when:(tool,input)=>bool|hit, add:number|fn, label }]
17
+ * THRESHOLDS — { review, require_confirmation, block }
18
+ * SECRET_SHAPED_RE — the (linear) secret detector, exported for reuse/tests
19
+ * _SEVERITY_ADD — dangerous-bash severity -> addend map
20
+ */
21
+
22
+ const dangerous = require('../dangerous-patterns.cjs');
23
+ const blast = require('../blast-radius.cjs');
24
+
25
+ // ── Base per-tool risk ─────────────────────────────────────────────────────
26
+ // Bash is the riskiest (arbitrary shell), then bulk edits, then single edits,
27
+ // then whole-file writes; read-only tools are ~zero. __default covers unknown
28
+ // tools conservatively.
29
+ const BASE_TOOL_RISK = Object.freeze({
30
+ Bash: 0.55,
31
+ MultiEdit: 0.40,
32
+ Edit: 0.35,
33
+ NotebookEdit: 0.35,
34
+ Write: 0.30,
35
+ Read: 0.02,
36
+ Glob: 0,
37
+ Grep: 0,
38
+ __default: 0.20,
39
+ });
40
+
41
+ // ── Secret-shaped content detector ─────────────────────────────────────────
42
+ // Linear: each alternative is a fixed prefix + a bounded/anchored class. No
43
+ // alternative can backtrack into another (distinct literal prefixes).
44
+ // AWS access key id | PEM private-key header | OpenAI sk- | GitHub ghp_ | Slack xox?-
45
+ const SECRET_SHAPED_RE =
46
+ /AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36}|xox[baprs]-/;
47
+
48
+ // ── File-sensitivity table (mirrors reference/protected-paths.default.json) ──
49
+ // ORDERED, highest-weight first. compute-risk.cjs picks the single
50
+ // highest-weight matching entry (pickMaxFileSensitivity). `test` matches a
51
+ // forward-slash-normalized path. All regexes linear.
52
+ //
53
+ // mult multiplies the base tool risk; add is a flat addend. De-risking entries
54
+ // (tests/fixtures, docs) use mult<1 + add 0 to pull benign edits below review.
55
+ const FILE_SENSITIVITY = Object.freeze([
56
+ // State + config: the audit/control spine.
57
+ { test: /(^|\/)STATE\.md$/i, mult: 1.6, add: 0.25, label: 'planning-state' },
58
+ { test: /(^|\/)config\.json$/i, mult: 1.6, add: 0.25, label: 'config' },
59
+ // Schemas + lockfiles + generated styling contracts.
60
+ { test: /\.schema\.json$/i, mult: 1.5, add: 0.25, label: 'schema' },
61
+ { test: /(^|\/)package-lock\.json$/i, mult: 1.5, add: 0.20, label: 'lockfile' },
62
+ { test: /(^|\/)package\.json$/i, mult: 1.5, add: 0.20, label: 'package-manifest' },
63
+ { test: /\.css\.ts$/i, mult: 1.5, add: 0.20, label: 'css-in-ts' },
64
+ // Hooks + CI: execution surface.
65
+ { test: /(^|\/)hooks\//i, mult: 1.5, add: 0.20, label: 'hook' },
66
+ { test: /(^|\/)\.github\/workflows\//i, mult: 1.5, add: 0.20, label: 'ci-workflow' },
67
+ // Design-token / theme sources.
68
+ { test: /(^|\/)(tokens|theme)(\/|[.-])/i, mult: 1.4, add: 0.18, label: 'design-tokens' },
69
+ // Build/runtime config files.
70
+ { test: /(^|\/)(tsconfig[^/]*\.json|\.npmrc|Dockerfile|\.gitleaks(\.toml)?)$/i, mult: 1.3, add: 0.15, label: 'build-config' },
71
+ // Plugin authoring surface (skills/commands/agents).
72
+ { test: /(^|\/)(skills|commands|agents)\//i, mult: 1.3, add: 0.12, label: 'authoring-surface' },
73
+ // De-risking: tests + fixtures are low-stakes.
74
+ { test: /(^|\/)(tests?|fixtures?|__tests__|__fixtures__)\//i, mult: 0.6, add: 0, label: 'test-or-fixture' },
75
+ // De-risking: docs / markdown.
76
+ { test: /(^|\/)docs?\/|\.mdx?$/i, mult: 0.5, add: 0, label: 'docs' },
77
+ ]);
78
+
79
+ // ── Severity -> addend for destructive bash (via dangerous-patterns.cjs) ────
80
+ const _SEVERITY_ADD = Object.freeze({ critical: 0.6, high: 0.4, medium: 0.2 });
81
+
82
+ // ── Helpers shared by INPUT_PATTERN_RISK predicates ─────────────────────────
83
+ function textOf(input) {
84
+ if (!input || typeof input !== 'object') return '';
85
+ const parts = [];
86
+ if (typeof input.content === 'string') parts.push(input.content);
87
+ if (typeof input.new_string === 'string') parts.push(input.new_string);
88
+ if (typeof input.new_str === 'string') parts.push(input.new_str);
89
+ if (Array.isArray(input.edits)) {
90
+ for (const e of input.edits) {
91
+ if (e && typeof e.new_string === 'string') parts.push(e.new_string);
92
+ }
93
+ }
94
+ if (typeof input.command === 'string') parts.push(input.command);
95
+ return parts.join('\n');
96
+ }
97
+
98
+ // Approximate the changed-line count for a Write/Edit/MultiEdit input by
99
+ // counting newlines in the new text. Reuses blast-radius.estimate for the
100
+ // capped addend math so the large-diff curve matches the blast-radius primitive.
101
+ function changedLineCount(tool, input) {
102
+ if (!input || typeof input !== 'object') return 0;
103
+ let lines = 0;
104
+ if (typeof input.content === 'string') lines += countLines(input.content);
105
+ if (typeof input.new_string === 'string') lines += countLines(input.new_string);
106
+ if (Array.isArray(input.edits)) {
107
+ for (const e of input.edits) {
108
+ if (e && typeof e.new_string === 'string') lines += countLines(e.new_string);
109
+ }
110
+ }
111
+ return lines;
112
+ }
113
+
114
+ function countLines(s) {
115
+ if (typeof s !== 'string' || s.length === 0) return 0;
116
+ // A non-empty string is at least one line; each newline adds one.
117
+ let n = 1;
118
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
119
+ return n;
120
+ }
121
+
122
+ // File-path-ish fields, normalized to forward slashes (for schema/migration sniff).
123
+ function pathHintsOf(input) {
124
+ if (!input || typeof input !== 'object') return '';
125
+ const parts = [];
126
+ for (const k of ['file_path', 'notebook_path', 'path']) {
127
+ if (typeof input[k] === 'string') parts.push(input[k]);
128
+ }
129
+ return parts.join('\n').replace(/\\/g, '/');
130
+ }
131
+
132
+ // Linear regexes only.
133
+ const SCHEMA_MIGRATION_RE = /(^|\/)migrations?\/|\.schema\.json$|\bALTER\s+TABLE\b|\bCREATE\s+TABLE\b|\bDROP\s+TABLE\b/i;
134
+ const DEP_MUTATION_RE = /\b(npm|pnpm|yarn|bun)\s+(install|add|i|remove|rm|uninstall|up|update|upgrade)\b|\b(pip|pip3)\s+install\b|\bcargo\s+(add|install)\b/;
135
+ // Broad glob: a pattern argument touching the repo root with ** or a bare /* .
136
+ const BROAD_GLOB_RE = /(\*\*|(^|\s)\.?\/?\*(\s|$)|--include=\*|\s-r\b.*\*)/;
137
+
138
+ // ── Input-pattern risk table ────────────────────────────────────────────────
139
+ // ORDERED. Each `when(tool, input)` returns a truthy value (bool or a "hit"
140
+ // object) when it applies; `add` is either a flat number or a function of the
141
+ // hit/(tool,input) returning the addend. compute-risk.cjs accumulates every
142
+ // applicable entry in this fixed order.
143
+ const INPUT_PATTERN_RISK = Object.freeze([
144
+ {
145
+ label: 'dangerous-bash',
146
+ when: (tool, input) => {
147
+ if (tool !== 'Bash' || !input || typeof input.command !== 'string') return false;
148
+ const hit = dangerous.match(input.command);
149
+ return hit.matched ? hit : false;
150
+ },
151
+ add: (hit) => _SEVERITY_ADD[hit && hit.severity] || 0.2,
152
+ },
153
+ {
154
+ label: 'large-diff',
155
+ when: (tool, input) => {
156
+ const lines = changedLineCount(tool, input);
157
+ return lines > 0 ? lines : false;
158
+ },
159
+ // Cap at +0.30; curve = lines / 1500 (matches the shared contract).
160
+ add: (lines) => {
161
+ // Route through blast-radius.estimate so the line accounting stays in
162
+ // lockstep with the blast-radius primitive (pure: explicit DEFAULTS-like
163
+ // config, no disk read).
164
+ const est = blast.estimate({ diffStats: { insertions: lines, deletions: 0 }, config: { max_files_per_task: 0, max_lines_per_task: 0, max_mcp_calls_per_task: 0 } });
165
+ return Math.min(0.30, est.lines / 1500);
166
+ },
167
+ },
168
+ {
169
+ label: 'schema-migration',
170
+ when: (tool, input) => SCHEMA_MIGRATION_RE.test(pathHintsOf(input)) || SCHEMA_MIGRATION_RE.test(textOf(input)),
171
+ add: 0.25,
172
+ },
173
+ {
174
+ label: 'secret-shaped',
175
+ when: (tool, input) => SECRET_SHAPED_RE.test(textOf(input)),
176
+ add: 0.5,
177
+ },
178
+ {
179
+ label: 'broad-glob',
180
+ when: (tool, input) => {
181
+ if (tool === 'Bash' && input && typeof input.command === 'string') return BROAD_GLOB_RE.test(input.command);
182
+ if ((tool === 'Glob' || tool === 'Grep') && input && typeof input.pattern === 'string') return BROAD_GLOB_RE.test(input.pattern);
183
+ return false;
184
+ },
185
+ add: 0.15,
186
+ },
187
+ {
188
+ label: 'dependency-mutation',
189
+ when: (tool, input) => {
190
+ if (tool === 'Bash' && input && typeof input.command === 'string' && DEP_MUTATION_RE.test(input.command)) return true;
191
+ // Editing a manifest/lockfile is also a dependency mutation surface.
192
+ const hints = pathHintsOf(input);
193
+ return /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.toml|requirements\.txt)$/i.test(hints);
194
+ },
195
+ add: 0.15,
196
+ },
197
+ ]);
198
+
199
+ // ── Thresholds ──────────────────────────────────────────────────────────────
200
+ // score < review -> allow
201
+ // review <= score < require_confirmation -> review
202
+ // require_confirmation <= score < block -> require_confirmation
203
+ // score >= block -> block
204
+ const THRESHOLDS = Object.freeze({
205
+ review: 0.30,
206
+ require_confirmation: 0.60,
207
+ block: 0.85,
208
+ });
209
+
210
+ module.exports = {
211
+ BASE_TOOL_RISK,
212
+ FILE_SENSITIVITY,
213
+ INPUT_PATTERN_RISK,
214
+ THRESHOLDS,
215
+ SECRET_SHAPED_RE,
216
+ _SEVERITY_ADD,
217
+ // internal helpers exported for compute-risk.cjs + unit visibility
218
+ _textOf: textOf,
219
+ _changedLineCount: changedLineCount,
220
+ _pathHintsOf: pathHintsOf,
221
+ };
@@ -0,0 +1,419 @@
1
+ // sdk/cli/commands/dashboard.ts — Phase 55 (GDD Dashboard, dep-free) — Web launcher (WEB-03).
2
+ //
3
+ // `gdd dashboard [--web] [--once] [--no-open]` — the launcher that surfaces the GDD
4
+ // dashboard. Two paths, ONE entrypoint:
5
+ //
6
+ // * `gdd dashboard` -> spawn the TUI (`bin/gdd-dashboard`, executor D's CJS
7
+ // trampoline) and forward stdio + the child exit code.
8
+ // * `gdd dashboard --web` -> load `.design/context-graph.json` (via the dep-free
9
+ // design-context-query `load`; absent/invalid -> a graceful
10
+ // EMPTY graph), build a self-contained HTML page with C's
11
+ // `buildGraphHtml`, write it to a temp file, serve it over
12
+ // `node:http` on an EPHEMERAL free port, open the browser
13
+ // (platform `open`/`start`/`xdg-open`), and print the URL.
14
+ // Headless (no DISPLAY / CI / `--no-open`) -> print the URL
15
+ // only and keep serving.
16
+ // * `gdd dashboard --web --once` -> write the HTML to `<root>/.design/dashboard.html` and
17
+ // EXIT 0 without serving or opening (the CI-friendly seam +
18
+ // the hermetic test surface).
19
+ //
20
+ // Constraints (CONTEXT.md D1/D6/D7): ZERO new dependency (Node builtins only:
21
+ // node:http, node:child_process, node:fs, node:os, node:path, node:url); READ-ONLY
22
+ // (writes nothing but the rendered HTML artifact); sibling resolution via a package-root
23
+ // walk-up (the Phase 53/54 lesson), never a fixed `__dirname` cross-tree jump.
24
+ //
25
+ // Exit codes:
26
+ // * 0 — TUI exited 0 / HTML written (--once) / server launched.
27
+ // * non-0 — forwarded from the spawned TUI.
28
+ // * 3 — arg / config error (invalid flags) or the TUI bin could not be located.
29
+
30
+ import { spawn, spawnSync } from 'node:child_process';
31
+ import { createRequire } from 'node:module';
32
+ import { createServer, type Server } from 'node:http';
33
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+
36
+ import {
37
+ coerceFlags,
38
+ COMMON_FLAGS,
39
+ type FlagSpec,
40
+ type ParsedArgs,
41
+ } from '../parse-args.ts';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Flags + usage.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const DASHBOARD_FLAGS: readonly FlagSpec[] = [
48
+ ...COMMON_FLAGS,
49
+ { name: 'web', type: 'boolean', default: false },
50
+ { name: 'once', type: 'boolean', default: false },
51
+ { name: 'no-open', type: 'boolean', default: false },
52
+ { name: 'root', type: 'string' },
53
+ ];
54
+
55
+ export const DASHBOARD_USAGE = `gdd-sdk dashboard [flags]
56
+
57
+ Open the GDD dashboard. Read-only. Dep-free (Node builtins only).
58
+
59
+ Default (no --web) launches the terminal UI (bin/gdd-dashboard). With --web it
60
+ emits a self-contained HTML graph of the design-context, serves it on an ephemeral
61
+ local port, and opens your browser.
62
+
63
+ Flags:
64
+ --web Web mode: build + serve the design-context graph as HTML.
65
+ --once Write the HTML to .design/dashboard.html and exit (no server). Implies --web.
66
+ --no-open Web mode: serve + print the URL but do NOT open a browser (headless/CI).
67
+ --root <dir> Project root to read .design/ from (default: GDD_PROJECT_ROOT or walk-up).
68
+ -h, --help Show this help.
69
+
70
+ Exit codes: 0 ok · 3 arg error / TUI not found · (TUI exit code forwarded otherwise)
71
+ `;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Deps — the dispatcher injects { stdout, stderr }; tests inject the rest for
75
+ // hermetic runs (root, a fake browser opener, an explicit headless flag).
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** A browser opener. Returns true if a launch was attempted. Injected in tests. */
79
+ export type BrowserOpener = (url: string) => boolean;
80
+
81
+ export interface DashboardDeps {
82
+ readonly stdout?: NodeJS.WritableStream;
83
+ readonly stderr?: NodeJS.WritableStream;
84
+ /** Project root to read `.design/` from. Overrides flags + env when provided. */
85
+ readonly root?: string;
86
+ /** Inject a fake opener for tests (default: the real platform opener). */
87
+ readonly openBrowser?: BrowserOpener;
88
+ /** Force headless detection (default: auto from env). When true, never opens a browser. */
89
+ readonly headless?: boolean;
90
+ /** Override the TUI bin path resolution (tests). */
91
+ readonly tuiBin?: string;
92
+ /** Override stdio mode for the spawned TUI (tests); default 'inherit'. */
93
+ readonly tuiStdio?: 'inherit' | 'ignore';
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Package-root walk-up. Resolve sibling files (the graph lib, the TUI bin)
98
+ // relative to the GDD package root — never a fixed __dirname cross-tree jump
99
+ // (the Phase 53/54 lesson). Works from the raw .ts (in-repo) AND the bundled
100
+ // .js (packed tarball): we climb from the CLI entry (process.argv[1]) and from
101
+ // cwd to the package.json whose name is "get-design-done".
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Candidate start directories for the package-root climb, most-specific first.
106
+ * `import.meta`/`__dirname` are unavailable here (tsc rejects `import.meta` for
107
+ * CommonJS output, and strip-types reparses this module as ESM at runtime so
108
+ * `__dirname` is undefined) — so we anchor on the CLI entry (process.argv[1] is
109
+ * `<root>/sdk/cli/index.{ts,js}` or `<root>/bin/gdd-sdk` in every real launch,
110
+ * the same anchor build.ts uses) and on cwd (a consumer running from their
111
+ * project root, where the lib lives under node_modules/get-design-done/).
112
+ */
113
+ function anchorDirs(): string[] {
114
+ const out: string[] = [];
115
+ const entry = process.argv[1];
116
+ if (typeof entry === 'string' && entry.length > 0) out.push(dirname(entry));
117
+ out.push(process.cwd());
118
+ return out;
119
+ }
120
+
121
+ /** Walk up from `startDir` to the GDD package root (package.json name === get-design-done). */
122
+ function climbToMarker(startDir: string): { root: string | null; firstWithPkg: string | null } {
123
+ const req = createRequire(join(startDir, 'noop.js'));
124
+ let dir = startDir;
125
+ let firstWithPkg: string | null = null;
126
+ for (let i = 0; i < 12; i++) {
127
+ const pkgPath = join(dir, 'package.json');
128
+ if (existsSync(pkgPath)) {
129
+ if (firstWithPkg === null) firstWithPkg = dir;
130
+ try {
131
+ const pkg = req(pkgPath) as { name?: string };
132
+ if (pkg && pkg.name === 'get-design-done') return { root: dir, firstWithPkg };
133
+ } catch {
134
+ /* unreadable package.json — keep climbing */
135
+ }
136
+ }
137
+ const parent = dirname(dir);
138
+ if (parent === dir) break;
139
+ dir = parent;
140
+ }
141
+ return { root: null, firstWithPkg };
142
+ }
143
+
144
+ /**
145
+ * Resolve the GDD package root by climbing from each anchor (CLI entry, then cwd)
146
+ * until the `get-design-done` marker is found. Falls back to the first ancestor
147
+ * that has ANY package.json, then to cwd. Memoized per process.
148
+ */
149
+ let _cachedPkgRoot: string | null = null;
150
+ function findPackageRoot(): string {
151
+ if (_cachedPkgRoot !== null) return _cachedPkgRoot;
152
+ let fallback: string | null = null;
153
+ for (const anchor of anchorDirs()) {
154
+ const { root, firstWithPkg } = climbToMarker(anchor);
155
+ if (root) {
156
+ _cachedPkgRoot = root;
157
+ return root;
158
+ }
159
+ if (fallback === null && firstWithPkg !== null) fallback = firstWithPkg;
160
+ }
161
+ _cachedPkgRoot = fallback ?? process.cwd();
162
+ return _cachedPkgRoot;
163
+ }
164
+
165
+ /** require() an in-repo .cjs sibling resolved from the package root. */
166
+ function requireFromRoot<T>(relPath: string): T {
167
+ const root = findPackageRoot();
168
+ const req = createRequire(join(root, 'noop.js'));
169
+ return req(join(root, relPath)) as T;
170
+ }
171
+
172
+ // The C-interface graph HTML emitter + the dep-free graph query lib. Both are
173
+ // .cjs — require()d via the walk-up. Typed loosely (no .d.ts for these libs).
174
+ interface GraphLib {
175
+ load(graphPath: string): unknown;
176
+ }
177
+ interface GraphHtmlLib {
178
+ buildGraphHtml(graph: unknown, opts?: { title?: string }): string;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Root resolution: deps.root > --root flag > GDD_PROJECT_ROOT > package root.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function resolveRoot(deps: DashboardDeps, flags: Record<string, unknown>): string {
186
+ if (typeof deps.root === 'string' && deps.root.length > 0) return deps.root;
187
+ const flagRoot = flags['root'];
188
+ if (typeof flagRoot === 'string' && flagRoot.length > 0) return flagRoot;
189
+ if (process.env['GDD_PROJECT_ROOT']) return process.env['GDD_PROJECT_ROOT'];
190
+ return findPackageRoot();
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Graph -> HTML. Graceful: missing/invalid graph -> a valid EMPTY-graph doc.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** Load the design-context graph, or an empty graph on any failure (never throws). */
198
+ function loadGraphGraceful(root: string, stderr: NodeJS.WritableStream): unknown {
199
+ const graphPath = join(root, '.design', 'context-graph.json');
200
+ try {
201
+ const query = requireFromRoot<GraphLib>('scripts/lib/design-context-query.cjs');
202
+ if (typeof query.load === 'function') return query.load(graphPath);
203
+ } catch (err) {
204
+ stderr.write(
205
+ `gdd-sdk dashboard: no design-context graph at ${graphPath} (${errMsg(err)}); rendering an empty graph.\n`,
206
+ );
207
+ }
208
+ return { nodes: [], edges: [] };
209
+ }
210
+
211
+ /** Build the dashboard HTML string from the project's graph (graceful-empty). */
212
+ export function buildDashboardHtml(root: string, stderr: NodeJS.WritableStream): string {
213
+ const graph = loadGraphGraceful(root, stderr);
214
+ const htmlLib = requireFromRoot<GraphHtmlLib>('scripts/lib/dashboard/graph-html.cjs');
215
+ return htmlLib.buildGraphHtml(graph, { title: 'GDD Design Context Graph' });
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Headless detection + the platform browser opener.
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Headless when: explicitly forced, `--no-open`, CI is set, or no display surface
224
+ * (Linux without DISPLAY/WAYLAND_DISPLAY). macOS + Windows always have a GUI shell.
225
+ */
226
+ function isHeadless(deps: DashboardDeps, flags: Record<string, unknown>): boolean {
227
+ if (typeof deps.headless === 'boolean') return deps.headless;
228
+ if (flags['no-open'] === true) return true;
229
+ if (process.env['CI']) return true;
230
+ if (process.platform === 'linux') {
231
+ return !process.env['DISPLAY'] && !process.env['WAYLAND_DISPLAY'];
232
+ }
233
+ return false;
234
+ }
235
+
236
+ /** Real platform opener: macOS `open`, Windows `start ""`, else `xdg-open`. Detached, never blocks. */
237
+ function defaultOpenBrowser(url: string): boolean {
238
+ try {
239
+ if (process.platform === 'darwin') {
240
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
241
+ } else if (process.platform === 'win32') {
242
+ // `start` is a cmd builtin; the empty "" is the window-title arg so a URL with
243
+ // spaces/ampersands is treated as the target, not the title.
244
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
245
+ } else {
246
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
247
+ }
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Ephemeral free-port HTTP server. Listen on port 0; the OS assigns a free port
256
+ // which we read back from server.address(). Serves the single HTML page for any
257
+ // path; everything else is the same document (a one-page app).
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export interface ServeResult {
261
+ readonly server: Server;
262
+ readonly port: number;
263
+ readonly url: string;
264
+ }
265
+
266
+ /**
267
+ * Start a read-only HTTP server on an ephemeral port serving `html`. Resolves once
268
+ * the OS has bound a port. The caller owns the returned server's lifecycle (close()).
269
+ */
270
+ export function serveHtml(html: string): Promise<ServeResult> {
271
+ return new Promise((resolve, reject) => {
272
+ const server = createServer((_req, res) => {
273
+ res.writeHead(200, {
274
+ 'content-type': 'text/html; charset=utf-8',
275
+ 'cache-control': 'no-store',
276
+ });
277
+ res.end(html);
278
+ });
279
+ server.on('error', reject);
280
+ // Bind loopback only (never expose on a LAN interface) + ephemeral port 0.
281
+ server.listen(0, '127.0.0.1', () => {
282
+ const addr = server.address();
283
+ if (addr === null || typeof addr === 'string') {
284
+ server.close();
285
+ reject(new Error('could not determine the ephemeral server port'));
286
+ return;
287
+ }
288
+ const port = addr.port;
289
+ resolve({ server, port, url: `http://127.0.0.1:${port}/` });
290
+ });
291
+ });
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Command.
296
+ // ---------------------------------------------------------------------------
297
+
298
+ function errMsg(err: unknown): string {
299
+ if (err instanceof Error) return err.message;
300
+ return String(err);
301
+ }
302
+
303
+ export async function dashboardCommand(
304
+ parsed: ParsedArgs,
305
+ deps: DashboardDeps = {},
306
+ ): Promise<number> {
307
+ const stdout = deps.stdout ?? process.stdout;
308
+ const stderr = deps.stderr ?? process.stderr;
309
+
310
+ if (parsed.flags['help'] === true || parsed.flags['h'] === true) {
311
+ stdout.write(DASHBOARD_USAGE);
312
+ return 0;
313
+ }
314
+
315
+ let flags: Record<string, unknown>;
316
+ try {
317
+ flags = coerceFlags(parsed, DASHBOARD_FLAGS);
318
+ } catch {
319
+ stderr.write(`gdd-sdk dashboard: invalid flags\n${DASHBOARD_USAGE}`);
320
+ return 3;
321
+ }
322
+
323
+ // `--once` implies web mode (write-and-exit makes no sense for the TUI).
324
+ const once = flags['once'] === true;
325
+ const web = flags['web'] === true || once;
326
+
327
+ if (!web) {
328
+ return runTui(deps, stdout, stderr);
329
+ }
330
+
331
+ const root = resolveRoot(deps, flags);
332
+ const html = buildDashboardHtml(root, stderr);
333
+
334
+ // --- --once: write the artifact to .design/dashboard.html and exit. ---------
335
+ if (once) {
336
+ const designDir = join(root, '.design');
337
+ try {
338
+ mkdirSync(designDir, { recursive: true });
339
+ } catch {
340
+ /* dir may already exist or be unwritable; the write below surfaces real errors */
341
+ }
342
+ const outFile = join(designDir, 'dashboard.html');
343
+ try {
344
+ writeFileSync(outFile, html, 'utf8');
345
+ } catch (err) {
346
+ stderr.write(`gdd-sdk dashboard: could not write ${outFile}: ${errMsg(err)}\n`);
347
+ return 3;
348
+ }
349
+ stdout.write(`Wrote dashboard HTML to ${outFile}\n`);
350
+ return 0;
351
+ }
352
+
353
+ // --- --web: serve on an ephemeral port + open the browser (or print URL). ---
354
+ let served: ServeResult;
355
+ try {
356
+ served = await serveHtml(html);
357
+ } catch (err) {
358
+ stderr.write(`gdd-sdk dashboard: could not start the web server: ${errMsg(err)}\n`);
359
+ return 3;
360
+ }
361
+
362
+ const headless = isHeadless(deps, flags);
363
+ const opener = deps.openBrowser ?? defaultOpenBrowser;
364
+
365
+ stdout.write(`GDD dashboard serving at ${served.url}\n`);
366
+ if (headless) {
367
+ stdout.write('Headless environment detected — open the URL above in a browser.\n');
368
+ stdout.write('Press Ctrl+C to stop the server.\n');
369
+ } else {
370
+ const launched = opener(served.url);
371
+ if (!launched) {
372
+ stdout.write('Could not auto-open a browser — open the URL above manually.\n');
373
+ }
374
+ stdout.write('Press Ctrl+C to stop the server.\n');
375
+ }
376
+
377
+ // Keep the process alive while serving. The server keeps the event loop busy;
378
+ // SIGINT/SIGTERM close it and resolve so the CLI exits 0 cleanly.
379
+ await new Promise<void>((resolve) => {
380
+ const shutdown = (): void => {
381
+ served.server.close(() => resolve());
382
+ };
383
+ process.once('SIGINT', shutdown);
384
+ process.once('SIGTERM', shutdown);
385
+ served.server.on('close', () => resolve());
386
+ });
387
+ return 0;
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // TUI launch (default path). Spawn bin/gdd-dashboard, forward stdio + exit code.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ function runTui(
395
+ deps: DashboardDeps,
396
+ _stdout: NodeJS.WritableStream,
397
+ stderr: NodeJS.WritableStream,
398
+ ): number {
399
+ let bin = deps.tuiBin;
400
+ if (!bin) {
401
+ const root = findPackageRoot();
402
+ const candidate = join(root, 'bin', 'gdd-dashboard');
403
+ bin = existsSync(candidate) ? candidate : undefined;
404
+ }
405
+ if (!bin || !existsSync(bin)) {
406
+ stderr.write(
407
+ 'gdd-sdk dashboard: could not locate bin/gdd-dashboard (the terminal UI).\n' +
408
+ 'Try `gdd dashboard --web` for the browser graph instead.\n',
409
+ );
410
+ return 3;
411
+ }
412
+ const stdio = deps.tuiStdio ?? 'inherit';
413
+ const res = spawnSync(process.execPath, [bin], { stdio });
414
+ if (res.error) {
415
+ stderr.write(`gdd-sdk dashboard: failed to launch the TUI: ${res.error.message}\n`);
416
+ return 3;
417
+ }
418
+ return typeof res.status === 'number' ? res.status : 0;
419
+ }