@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +88 -0
- package/README.md +4 -0
- package/SKILL.md +2 -1
- package/agents/component-taxonomy-mapper.md +3 -0
- package/agents/motion-mapper.md +1 -0
- package/agents/token-mapper.md +3 -0
- package/bin/gdd-dashboard +91 -0
- package/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
- package/package.json +2 -1
- package/reference/frameworks/astro.md +43 -0
- package/reference/frameworks/nextjs.md +44 -0
- package/reference/frameworks/remix.md +44 -0
- package/reference/frameworks/storybook.md +44 -0
- package/reference/frameworks/sveltekit.md +43 -0
- package/reference/frameworks/vite-react.md +43 -0
- package/reference/interaction.md +1 -0
- package/reference/motion/framer-motion.md +45 -0
- package/reference/motion/gsap.md +45 -0
- package/reference/motion/motion-one.md +44 -0
- package/reference/motion/react-spring.md +44 -0
- package/reference/motion.md +1 -0
- package/reference/registry.json +163 -1
- package/reference/registry.schema.json +18 -1
- package/reference/skill-graph.md +2 -1
- package/reference/systems/chakra.md +44 -0
- package/reference/systems/css-modules.md +44 -0
- package/reference/systems/mui.md +44 -0
- package/reference/systems/radix-themes.md +43 -0
- package/reference/systems/shadcn.md +45 -0
- package/reference/systems/styled-components.md +44 -0
- package/reference/systems/tailwind.md +44 -0
- package/reference/systems/vanilla-extract.md +44 -0
- package/scripts/lib/dashboard/graph-html.cjs +0 -0
- package/scripts/lib/detect/stack.cjs +455 -0
- package/scripts/lib/detect/stack.d.cts +44 -0
- package/scripts/lib/explore-parallel-runner/index.ts +138 -1
- package/scripts/lib/explore-parallel-runner/types.ts +27 -0
- package/scripts/lib/health-mirror/index.cjs +218 -1
- package/scripts/lib/manifest/skills.json +8 -0
- package/scripts/lib/mapper-spawn.cjs +257 -0
- package/scripts/lib/mapper-spawn.d.cts +60 -0
- package/scripts/lib/new-addendum.cjs +204 -0
- package/sdk/cli/commands/dashboard.ts +419 -0
- package/sdk/cli/index.js +1388 -3
- 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 +1117 -0
- package/skills/new-addendum/SKILL.md +81 -0
package/sdk/cli/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { queryCommand } from './commands/query.ts';
|
|
|
22
22
|
import { auditCommand } from './commands/audit.ts';
|
|
23
23
|
import { initCommand } from './commands/init.ts';
|
|
24
24
|
import { buildCommand } from './commands/build.ts';
|
|
25
|
+
import { dashboardCommand } from './commands/dashboard.ts';
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Top-level USAGE.
|
|
@@ -36,6 +37,7 @@ Commands:
|
|
|
36
37
|
audit Probe connections + dry-run verify.
|
|
37
38
|
init Bootstrap a new project.
|
|
38
39
|
build skills Compile per-harness skill bundles from source/skills/.
|
|
40
|
+
dashboard Open the GDD dashboard (TUI; --web for the browser graph).
|
|
39
41
|
|
|
40
42
|
Use 'gdd-sdk <command> -h' for command-specific flags.
|
|
41
43
|
|
|
@@ -60,6 +62,7 @@ export interface DispatcherDeps {
|
|
|
60
62
|
readonly audit?: typeof auditCommand;
|
|
61
63
|
readonly init?: typeof initCommand;
|
|
62
64
|
readonly build?: typeof buildCommand;
|
|
65
|
+
readonly dashboard?: typeof dashboardCommand;
|
|
63
66
|
};
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -85,6 +88,7 @@ export async function dispatch(
|
|
|
85
88
|
audit: deps.commands?.audit ?? auditCommand,
|
|
86
89
|
init: deps.commands?.init ?? initCommand,
|
|
87
90
|
build: deps.commands?.build ?? buildCommand,
|
|
91
|
+
dashboard: deps.commands?.dashboard ?? dashboardCommand,
|
|
88
92
|
};
|
|
89
93
|
|
|
90
94
|
// Bare invocation or top-level help → USAGE.
|
|
@@ -117,6 +121,8 @@ export async function dispatch(
|
|
|
117
121
|
return await commands.init(parsed, { stdout, stderr });
|
|
118
122
|
case 'build':
|
|
119
123
|
return await commands.build(parsed, { stdout, stderr });
|
|
124
|
+
case 'dashboard':
|
|
125
|
+
return await commands.dashboard(parsed, { stdout, stderr });
|
|
120
126
|
default:
|
|
121
127
|
stderr.write(
|
|
122
128
|
`gdd-sdk: unknown subcommand "${parsed.subcommand}"\n${USAGE}`,
|
|
@@ -132,6 +138,7 @@ const KNOWN_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
|
132
138
|
'audit',
|
|
133
139
|
'init',
|
|
134
140
|
'build',
|
|
141
|
+
'dashboard',
|
|
135
142
|
]);
|
|
136
143
|
|
|
137
144
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sdk/dashboard/data/_pkg-root.cjs — Phase 55 (GDD Dashboard, dep-free).
|
|
4
|
+
*
|
|
5
|
+
* Package-root walk-up for sibling resolution (the Phase 53/54 lesson): never
|
|
6
|
+
* resolve a cross-tree sibling via a fixed `__dirname`-relative `../../..`
|
|
7
|
+
* jump, because that breaks the moment a file is copied/moved or the layout
|
|
8
|
+
* shifts. Instead, walk UP from this file's directory until we find the GDD
|
|
9
|
+
* package.json (identified by `name === 'get-design-done'`), and resolve all
|
|
10
|
+
* in-repo siblings relative to that root.
|
|
11
|
+
*
|
|
12
|
+
* Even though these dashboard `.cjs` files are NOT esbuild-bundled (R8 — the
|
|
13
|
+
* bin trampoline runs them directly so the Phase 53 __dirname-rewrite trap
|
|
14
|
+
* does not apply), keeping the walk-up makes the data plane robust to future
|
|
15
|
+
* bundling or relocation. Pure + dependency-free; memoized per process.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
/** Memoized resolved package root (computed once per process). */
|
|
22
|
+
let _cachedRoot = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Walk up from `startDir` looking for the GDD package root. The GDD root is
|
|
26
|
+
* the first ancestor whose package.json declares `name: "get-design-done"`;
|
|
27
|
+
* if no such marker is found (e.g. running from an unusual layout), fall back
|
|
28
|
+
* to the FIRST ancestor that has any package.json, then to `startDir`.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} startDir
|
|
31
|
+
* @returns {string} absolute package-root directory
|
|
32
|
+
*/
|
|
33
|
+
function findPackageRoot(startDir) {
|
|
34
|
+
let dir = path.resolve(startDir);
|
|
35
|
+
let firstWithPkg = null;
|
|
36
|
+
// Bound the climb defensively (deep trees / odd mounts).
|
|
37
|
+
for (let i = 0; i < 12; i++) {
|
|
38
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
39
|
+
let pkg = null;
|
|
40
|
+
try {
|
|
41
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
pkg = null;
|
|
44
|
+
}
|
|
45
|
+
if (pkg) {
|
|
46
|
+
if (firstWithPkg === null) firstWithPkg = dir;
|
|
47
|
+
if (pkg.name === 'get-design-done') return dir;
|
|
48
|
+
}
|
|
49
|
+
const parent = path.dirname(dir);
|
|
50
|
+
if (parent === dir) break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return firstWithPkg || path.resolve(startDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolved GDD package root, memoized. Computed by walking up from THIS file's
|
|
58
|
+
* directory (`__dirname`) — which is correct regardless of the caller's cwd.
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function packageRoot() {
|
|
62
|
+
if (_cachedRoot === null) _cachedRoot = findPackageRoot(__dirname);
|
|
63
|
+
return _cachedRoot;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Absolute path to an in-repo file given its repo-relative path.
|
|
68
|
+
* @param {string} relPath e.g. 'scripts/lib/install/runtime-homes.cjs'
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function resolveFromPackageRoot(relPath) {
|
|
72
|
+
return path.join(packageRoot(), relPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* require() an in-repo sibling .cjs module by its repo-relative path, resolved
|
|
77
|
+
* via the package-root walk-up. Use ONLY for .cjs siblings — .ts libs must be
|
|
78
|
+
* loaded via dynamic import(pathToFileURL) (a .cjs cannot static-require a .ts).
|
|
79
|
+
*
|
|
80
|
+
* @param {string} relPath e.g. 'scripts/lib/design-context-query.cjs'
|
|
81
|
+
* @returns {*} the required module
|
|
82
|
+
*/
|
|
83
|
+
function requireFromPackageRoot(relPath) {
|
|
84
|
+
return require(resolveFromPackageRoot(relPath));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
findPackageRoot,
|
|
89
|
+
packageRoot,
|
|
90
|
+
resolveFromPackageRoot,
|
|
91
|
+
requireFromPackageRoot,
|
|
92
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sdk/dashboard/data/cost-aggregator.cjs — Phase 55 (GDD Dashboard, dep-free).
|
|
4
|
+
*
|
|
5
|
+
* Pure roll-up of cost events into per-runtime / cumulative / per-cycle
|
|
6
|
+
* buckets, plus a tolerant JSONL reader for `.design/telemetry/costs.jsonl`.
|
|
7
|
+
*
|
|
8
|
+
* Cost rows on disk have evolved across phases (Phase 10.1 -> 26 -> 27 -> 33.6),
|
|
9
|
+
* so this aggregator is deliberately field-shape tolerant:
|
|
10
|
+
*
|
|
11
|
+
* - cost field: `est_cost_usd` (the on-disk tier-resolver/budget-enforcer
|
|
12
|
+
* shape) OR `cost_usd` (the newer event-payload shape).
|
|
13
|
+
* - runtime key: `runtime` (Phase 27+) -> else `tier` -> else `agent`
|
|
14
|
+
* -> else "unknown". Grouping is best-effort: the dashboard
|
|
15
|
+
* just needs a stable label per row.
|
|
16
|
+
* - cycle key: `cycle` -> else "unknown".
|
|
17
|
+
* - tokens: `tokens_in` / `tokens_out`, coerced via Number(... || 0).
|
|
18
|
+
*
|
|
19
|
+
* NEVER throws. Pure (no FS) except `readCosts()`, which reads one file and
|
|
20
|
+
* tolerates malformed lines (skips them silently, like the event-stream reader).
|
|
21
|
+
*
|
|
22
|
+
* Public API:
|
|
23
|
+
* aggregateCosts(costEvents) -> { byRuntime, cumulative, byCycle }
|
|
24
|
+
* readCosts({ root?, path? }) -> cost row array (tolerant; [] when absent)
|
|
25
|
+
*
|
|
26
|
+
* Determinism: no Date.now()/Math.random(); output ordering follows input
|
|
27
|
+
* ordering of first-seen keys (object insertion order).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
const DEFAULT_COSTS_PATH = '.design/telemetry/costs.jsonl';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Coerce a possibly-missing numeric field to a finite number (0 on garbage).
|
|
37
|
+
* @param {unknown} v
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
function num(v) {
|
|
41
|
+
const n = Number(v);
|
|
42
|
+
return Number.isFinite(n) ? n : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the per-runtime label for a cost row. Prefers the explicit `runtime`
|
|
47
|
+
* tag (Phase 27+), then `tier`, then `agent`, then a literal "unknown".
|
|
48
|
+
* @param {Record<string, unknown>} row
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function runtimeKeyOf(row) {
|
|
52
|
+
if (row && typeof row.runtime === 'string' && row.runtime.length) return row.runtime;
|
|
53
|
+
if (row && typeof row.tier === 'string' && row.tier.length) return row.tier;
|
|
54
|
+
if (row && typeof row.agent === 'string' && row.agent.length) return row.agent;
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the USD cost for a row, tolerant of both on-disk shapes.
|
|
60
|
+
* @param {Record<string, unknown>} row
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
function costUsdOf(row) {
|
|
64
|
+
if (!row) return 0;
|
|
65
|
+
if (typeof row.est_cost_usd !== 'undefined') return num(row.est_cost_usd);
|
|
66
|
+
if (typeof row.cost_usd !== 'undefined') return num(row.cost_usd);
|
|
67
|
+
if (typeof row.usd !== 'undefined') return num(row.usd);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the cycle label for a row.
|
|
73
|
+
* @param {Record<string, unknown>} row
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function cycleKeyOf(row) {
|
|
77
|
+
if (row && typeof row.cycle === 'string' && row.cycle.length) return row.cycle;
|
|
78
|
+
if (row && typeof row.cycle === 'number') return String(row.cycle);
|
|
79
|
+
return 'unknown';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fresh zeroed accumulator bucket. */
|
|
83
|
+
function emptyBucket() {
|
|
84
|
+
return { tokens_in: 0, tokens_out: 0, est_cost_usd: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Add one row's measures into an accumulator bucket (mutates `bucket`).
|
|
89
|
+
* @param {{tokens_in:number,tokens_out:number,est_cost_usd:number}} bucket
|
|
90
|
+
* @param {Record<string, unknown>} row
|
|
91
|
+
*/
|
|
92
|
+
function addInto(bucket, row) {
|
|
93
|
+
bucket.tokens_in += num(row.tokens_in);
|
|
94
|
+
bucket.tokens_out += num(row.tokens_out);
|
|
95
|
+
bucket.est_cost_usd += costUsdOf(row);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Aggregate an array (or any iterable) of cost rows into per-runtime,
|
|
100
|
+
* cumulative, and per-cycle roll-ups. Pure — never throws, never reads FS.
|
|
101
|
+
*
|
|
102
|
+
* Non-array / nullish input degrades to empty buckets.
|
|
103
|
+
*
|
|
104
|
+
* @param {Iterable<Record<string, unknown>> | null | undefined} costEvents
|
|
105
|
+
* @returns {{
|
|
106
|
+
* byRuntime: Record<string, {tokens_in:number,tokens_out:number,est_cost_usd:number}>,
|
|
107
|
+
* cumulative: {tokens_in:number,tokens_out:number,est_cost_usd:number},
|
|
108
|
+
* byCycle: Record<string, {tokens_in:number,tokens_out:number,est_cost_usd:number}>,
|
|
109
|
+
* }}
|
|
110
|
+
*/
|
|
111
|
+
function aggregateCosts(costEvents) {
|
|
112
|
+
/** @type {Record<string, ReturnType<typeof emptyBucket>>} */
|
|
113
|
+
const byRuntime = {};
|
|
114
|
+
/** @type {Record<string, ReturnType<typeof emptyBucket>>} */
|
|
115
|
+
const byCycle = {};
|
|
116
|
+
const cumulative = emptyBucket();
|
|
117
|
+
|
|
118
|
+
if (!costEvents || typeof costEvents[Symbol.iterator] !== 'function') {
|
|
119
|
+
return { byRuntime, cumulative, byCycle };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const row of costEvents) {
|
|
123
|
+
if (!row || typeof row !== 'object') continue;
|
|
124
|
+
const rt = runtimeKeyOf(row);
|
|
125
|
+
const cy = cycleKeyOf(row);
|
|
126
|
+
if (!byRuntime[rt]) byRuntime[rt] = emptyBucket();
|
|
127
|
+
if (!byCycle[cy]) byCycle[cy] = emptyBucket();
|
|
128
|
+
addInto(byRuntime[rt], row);
|
|
129
|
+
addInto(byCycle[cy], row);
|
|
130
|
+
addInto(cumulative, row);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { byRuntime, cumulative, byCycle };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the costs.jsonl path: explicit `path` wins (absolute or relative to
|
|
138
|
+
* cwd); else `<root>/.design/telemetry/costs.jsonl`; else cwd-relative default.
|
|
139
|
+
* @param {{root?: string, path?: string}} [opts]
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function costsPathFor(opts = {}) {
|
|
143
|
+
if (opts.path) {
|
|
144
|
+
return path.isAbsolute(opts.path) ? opts.path : path.resolve(process.cwd(), opts.path);
|
|
145
|
+
}
|
|
146
|
+
const root = opts.root || process.cwd();
|
|
147
|
+
return path.join(root, DEFAULT_COSTS_PATH);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read + parse `.design/telemetry/costs.jsonl` into a cost-row array.
|
|
152
|
+
*
|
|
153
|
+
* Tolerant: a missing file returns []; malformed JSON lines are skipped
|
|
154
|
+
* silently (the writer guarantees well-formed output, so a bad line is a
|
|
155
|
+
* corruption signal that must not crash a read-only dashboard). NEVER throws.
|
|
156
|
+
*
|
|
157
|
+
* @param {{root?: string, path?: string}} [opts]
|
|
158
|
+
* @returns {Array<Record<string, unknown>>}
|
|
159
|
+
*/
|
|
160
|
+
function readCosts(opts = {}) {
|
|
161
|
+
const file = costsPathFor(opts);
|
|
162
|
+
let raw;
|
|
163
|
+
try {
|
|
164
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
165
|
+
} catch {
|
|
166
|
+
return []; // absent / unreadable -> graceful empty
|
|
167
|
+
}
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const line of raw.split('\n')) {
|
|
170
|
+
const trimmed = line.trim();
|
|
171
|
+
if (trimmed === '') continue;
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(trimmed);
|
|
174
|
+
if (parsed && typeof parsed === 'object') out.push(parsed);
|
|
175
|
+
} catch {
|
|
176
|
+
// tolerate a malformed line — skip it, keep reading
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
aggregateCosts,
|
|
184
|
+
readCosts,
|
|
185
|
+
costsPathFor,
|
|
186
|
+
DEFAULT_COSTS_PATH,
|
|
187
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sdk/dashboard/data/discovery.cjs — Phase 55 (GDD Dashboard, dep-free).
|
|
4
|
+
*
|
|
5
|
+
* Best-effort, graceful-absent discovery of the three "where is GDD running"
|
|
6
|
+
* surfaces the dashboard renders:
|
|
7
|
+
*
|
|
8
|
+
* - discoverRuntimes() -> the 14 installable runtimes + whether
|
|
9
|
+
* each one's global config dir is present
|
|
10
|
+
* on this machine (Phase 24/28.7 set).
|
|
11
|
+
* - discoverWorktrees({root?}) -> linked git worktrees via
|
|
12
|
+
* `git worktree list --porcelain`.
|
|
13
|
+
* - discoverSessions({root?}) -> session manifests under
|
|
14
|
+
* `<root>/.design/sessions/*.json`
|
|
15
|
+
* (Phase 55 R4: not yet persisted by the
|
|
16
|
+
* pipeline -> degrades to []).
|
|
17
|
+
* - recordSession({id, harness}) -> OPTIONAL additive writer that atomically
|
|
18
|
+
* drops `<root>/.design/sessions/<id>.json`
|
|
19
|
+
* so cross-harness visibility can grow over
|
|
20
|
+
* time (tmp + rename, same-dir, Windows-safe).
|
|
21
|
+
*
|
|
22
|
+
* Everything is graceful-absent and NEVER throws: no git -> [] worktrees; no
|
|
23
|
+
* sessions dir -> [] sessions; an unknown runtime in the catalog is skipped
|
|
24
|
+
* rather than thrown. Sibling resolution (runtime-homes) is required via a
|
|
25
|
+
* package-root walk-up so this file survives being copied around the tree
|
|
26
|
+
* (the Phase 53/54 __dirname lesson).
|
|
27
|
+
*
|
|
28
|
+
* Determinism: the runtime catalog order is fixed; worktree order follows git's
|
|
29
|
+
* porcelain output order; session order follows readdir then a stable id sort.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const { spawnSync } = require('node:child_process');
|
|
35
|
+
|
|
36
|
+
const { requireFromPackageRoot } = require('./_pkg-root.cjs');
|
|
37
|
+
|
|
38
|
+
// runtime-homes is a sibling .cjs lib; resolve it via package-root walk-up so a
|
|
39
|
+
// fixed __dirname-relative jump never breaks if this file moves (Phase 53/54).
|
|
40
|
+
const runtimeHomes = requireFromPackageRoot('scripts/lib/install/runtime-homes.cjs');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The 14 GDD runtimes locked by Phase 24 D-02 (and resolved by
|
|
44
|
+
* runtime-homes.cjs). `cline` is rules-based and has no skills dir
|
|
45
|
+
* (getGlobalSkillsBase('cline') === null) — surfaced as skillsBase: null.
|
|
46
|
+
*/
|
|
47
|
+
const RUNTIMES = Object.freeze([
|
|
48
|
+
'claude',
|
|
49
|
+
'opencode',
|
|
50
|
+
'gemini',
|
|
51
|
+
'kilo',
|
|
52
|
+
'codex',
|
|
53
|
+
'copilot',
|
|
54
|
+
'cursor',
|
|
55
|
+
'windsurf',
|
|
56
|
+
'antigravity',
|
|
57
|
+
'augment',
|
|
58
|
+
'trae',
|
|
59
|
+
'qwen',
|
|
60
|
+
'codebuddy',
|
|
61
|
+
'cline',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** True iff `p` exists and is a directory. Never throws. */
|
|
65
|
+
function dirExists(p) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(p).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discover the installable runtimes and whether each is present locally.
|
|
75
|
+
*
|
|
76
|
+
* "present" = the runtime's global config dir exists on disk. We do NOT read
|
|
77
|
+
* any file inside it (pure presence probe), and a resolver RangeError on an
|
|
78
|
+
* unexpected id is swallowed (the entry is still emitted with present:false).
|
|
79
|
+
*
|
|
80
|
+
* @returns {Array<{runtime:string, configDir:string|null, skillsBase:string|null, present:boolean}>}
|
|
81
|
+
*/
|
|
82
|
+
function discoverRuntimes() {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const runtime of RUNTIMES) {
|
|
85
|
+
let configDir = null;
|
|
86
|
+
let skillsBase = null;
|
|
87
|
+
let present = false;
|
|
88
|
+
try {
|
|
89
|
+
configDir = runtimeHomes.getGlobalConfigDir(runtime);
|
|
90
|
+
} catch {
|
|
91
|
+
configDir = null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
skillsBase = runtimeHomes.getGlobalSkillsBase(runtime); // null for cline
|
|
95
|
+
} catch {
|
|
96
|
+
skillsBase = null;
|
|
97
|
+
}
|
|
98
|
+
if (configDir) present = dirExists(configDir);
|
|
99
|
+
out.push({ runtime, configDir, skillsBase, present });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default git runner: synchronous `git <args>` in `cwd`, trimmed stdout or null
|
|
106
|
+
* on ANY failure (git missing, non-zero, not a repo). Matches the
|
|
107
|
+
* worktree-resolve.cjs injectable-exec contract: `(cmd, args) => string`.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} cmd literal 'git'
|
|
110
|
+
* @param {string[]} args
|
|
111
|
+
* @param {string} cwd
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function defaultGitExec(cmd, args, cwd) {
|
|
115
|
+
try {
|
|
116
|
+
const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
117
|
+
if (!res || res.status !== 0 || typeof res.stdout !== 'string') return null;
|
|
118
|
+
return res.stdout;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse `git worktree list --porcelain` output into structured records.
|
|
126
|
+
*
|
|
127
|
+
* Porcelain format is blank-line-separated stanzas; each stanza has lines like:
|
|
128
|
+
* worktree /abs/path
|
|
129
|
+
* HEAD <sha>
|
|
130
|
+
* branch refs/heads/<name> (or `detached` / `bare`)
|
|
131
|
+
* locked [reason] (optional)
|
|
132
|
+
*
|
|
133
|
+
* Tolerant: unknown keys are ignored; a stanza without a `worktree` line is
|
|
134
|
+
* dropped. Pure string parsing — never throws.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} porcelain
|
|
137
|
+
* @returns {Array<{path:string, head:string|null, branch:string|null, detached:boolean, bare:boolean, locked:boolean}>}
|
|
138
|
+
*/
|
|
139
|
+
function parseWorktreePorcelain(porcelain) {
|
|
140
|
+
const out = [];
|
|
141
|
+
if (typeof porcelain !== 'string' || porcelain.trim() === '') return out;
|
|
142
|
+
// Stanzas separated by one or more blank lines.
|
|
143
|
+
const stanzas = porcelain.replace(/\r\n/g, '\n').split(/\n\s*\n/);
|
|
144
|
+
for (const stanza of stanzas) {
|
|
145
|
+
const rec = { path: null, head: null, branch: null, detached: false, bare: false, locked: false };
|
|
146
|
+
for (const lineRaw of stanza.split('\n')) {
|
|
147
|
+
const line = lineRaw.trim();
|
|
148
|
+
if (line === '') continue;
|
|
149
|
+
if (line.startsWith('worktree ')) rec.path = line.slice('worktree '.length).trim();
|
|
150
|
+
else if (line.startsWith('HEAD ')) rec.head = line.slice('HEAD '.length).trim();
|
|
151
|
+
else if (line.startsWith('branch ')) {
|
|
152
|
+
const ref = line.slice('branch '.length).trim();
|
|
153
|
+
rec.branch = ref.replace(/^refs\/heads\//, '');
|
|
154
|
+
} else if (line === 'detached') rec.detached = true;
|
|
155
|
+
else if (line === 'bare') rec.bare = true;
|
|
156
|
+
else if (line === 'locked' || line.startsWith('locked ')) rec.locked = true;
|
|
157
|
+
}
|
|
158
|
+
if (rec.path) out.push(rec);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Discover linked git worktrees for the repo containing `root`.
|
|
165
|
+
*
|
|
166
|
+
* `exec` is injectable (matching worktree-resolve.cjs): `(cmd, args) => string`.
|
|
167
|
+
* Returns [] when git is unavailable / `root` is not a repo. NEVER throws.
|
|
168
|
+
*
|
|
169
|
+
* @param {{root?: string, exec?: (cmd:string, args:string[]) => string}} [opts]
|
|
170
|
+
* @returns {Array<{path:string, head:string|null, branch:string|null, detached:boolean, bare:boolean, locked:boolean}>}
|
|
171
|
+
*/
|
|
172
|
+
function discoverWorktrees(opts = {}) {
|
|
173
|
+
const root = opts.root || process.cwd();
|
|
174
|
+
const run = typeof opts.exec === 'function'
|
|
175
|
+
? (args) => {
|
|
176
|
+
try {
|
|
177
|
+
const o = opts.exec('git', args);
|
|
178
|
+
return typeof o === 'string' ? o : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
: (args) => defaultGitExec('git', args, root);
|
|
184
|
+
|
|
185
|
+
const porcelain = run(['worktree', 'list', '--porcelain']);
|
|
186
|
+
if (porcelain == null) return [];
|
|
187
|
+
return parseWorktreePorcelain(porcelain);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the sessions directory: `<root>/.design/sessions`.
|
|
192
|
+
* @param {{root?: string}} [opts]
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
function sessionsDirFor(opts = {}) {
|
|
196
|
+
const root = opts.root || process.cwd();
|
|
197
|
+
return path.join(root, '.design', 'sessions');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Discover persisted session manifests under `<root>/.design/sessions/*.json`.
|
|
202
|
+
*
|
|
203
|
+
* Phase 55 R4: the pipeline does not yet persist session manifests, so this
|
|
204
|
+
* degrades to [] in practice. When present, each `<id>.json` is read + parsed
|
|
205
|
+
* (malformed/unreadable files skipped). Results are sorted by id for
|
|
206
|
+
* determinism. NEVER throws.
|
|
207
|
+
*
|
|
208
|
+
* @param {{root?: string}} [opts]
|
|
209
|
+
* @returns {Array<Record<string, unknown>>}
|
|
210
|
+
*/
|
|
211
|
+
function discoverSessions(opts = {}) {
|
|
212
|
+
const dir = sessionsDirFor(opts);
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return []; // no sessions dir -> graceful empty
|
|
218
|
+
}
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const ent of entries) {
|
|
221
|
+
if (!ent.isFile() || !ent.name.endsWith('.json')) continue;
|
|
222
|
+
try {
|
|
223
|
+
const body = fs.readFileSync(path.join(dir, ent.name), 'utf8');
|
|
224
|
+
const parsed = JSON.parse(body);
|
|
225
|
+
if (parsed && typeof parsed === 'object') out.push(parsed);
|
|
226
|
+
} catch {
|
|
227
|
+
// skip malformed/unreadable manifest
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.sort((a, b) => String(a && a.id).localeCompare(String(b && b.id)));
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* OPTIONAL additive writer (Phase 55 R4 / D5): atomically persist a session
|
|
236
|
+
* manifest at `<root>/.design/sessions/<id>.json` so future runs / other
|
|
237
|
+
* harnesses can discover it. Uses tmp + same-dir rename (Windows-safe atomic
|
|
238
|
+
* write idiom, mirrors scripts/lib/graph/atomic-write.mjs).
|
|
239
|
+
*
|
|
240
|
+
* Stamps `updated_at` (ISO) so the manifest carries freshness — this is the one
|
|
241
|
+
* intentional non-deterministic field (a write side-effect, not part of any
|
|
242
|
+
* deterministic render contract). `id` is required.
|
|
243
|
+
*
|
|
244
|
+
* Returns the written file path. NEVER throws on a sanitizable input; throws
|
|
245
|
+
* only on a missing/empty id (a programmer error the caller must fix).
|
|
246
|
+
*
|
|
247
|
+
* @param {{id: string, harness?: string, root?: string, [k:string]: unknown}} input
|
|
248
|
+
* @returns {string} absolute path of the written manifest
|
|
249
|
+
*/
|
|
250
|
+
function recordSession(input) {
|
|
251
|
+
if (!input || typeof input.id !== 'string' || input.id.length === 0) {
|
|
252
|
+
throw new TypeError('recordSession: id is required');
|
|
253
|
+
}
|
|
254
|
+
// Sanitize id into a safe filename (no path separators / traversal).
|
|
255
|
+
const safeId = input.id.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
256
|
+
const dir = sessionsDirFor({ root: input.root });
|
|
257
|
+
const target = path.join(dir, `${safeId}.json`);
|
|
258
|
+
|
|
259
|
+
const manifest = { id: input.id };
|
|
260
|
+
if (typeof input.harness === 'string') manifest.harness = input.harness;
|
|
261
|
+
// Preserve opaque extras (anything except control keys).
|
|
262
|
+
for (const key of Object.keys(input)) {
|
|
263
|
+
if (key === 'root' || key === 'id' || key === 'harness') continue;
|
|
264
|
+
manifest[key] = input[key];
|
|
265
|
+
}
|
|
266
|
+
manifest.updated_at = new Date().toISOString();
|
|
267
|
+
|
|
268
|
+
const base = path.basename(target);
|
|
269
|
+
const tmp = path.join(
|
|
270
|
+
dir,
|
|
271
|
+
`.${base}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
|
|
272
|
+
);
|
|
273
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
274
|
+
const body = JSON.stringify(manifest, null, 2) + '\n';
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
277
|
+
fs.renameSync(tmp, target);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
try {
|
|
280
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
281
|
+
} catch {
|
|
282
|
+
/* best-effort cleanup; original error takes precedence */
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
return target;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
discoverRuntimes,
|
|
291
|
+
discoverWorktrees,
|
|
292
|
+
discoverSessions,
|
|
293
|
+
recordSession,
|
|
294
|
+
parseWorktreePorcelain,
|
|
295
|
+
sessionsDirFor,
|
|
296
|
+
RUNTIMES,
|
|
297
|
+
};
|