@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

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 (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -0,0 +1,755 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any,
2
+ @typescript-eslint/no-unsafe-assignment,
3
+ @typescript-eslint/no-unsafe-member-access,
4
+ @typescript-eslint/no-unsafe-return,
5
+ @typescript-eslint/no-unsafe-call,
6
+ @typescript-eslint/no-unsafe-argument,
7
+ @typescript-eslint/no-require-imports */
8
+ // Mechanical extraction from bin/install.js; keep behavior parity before typing.
9
+ 'use strict';
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ /**
14
+ * Install Engine Module — ADR-1239 Phase B.
15
+ *
16
+ * Runtime-artifact install/uninstall cluster extracted from bin/install.js.
17
+ * bin/install.js imports this module for the layout-driven install/uninstall
18
+ * orchestrators and their private helpers. getCommitAttribution STAYS in
19
+ * bin/install.js (impure install-time config I/O); it is injected via the
20
+ * `resolveAttribution` parameter at each call site.
21
+ */
22
+ const node_fs_1 = __importDefault(require("node:fs"));
23
+ const node_os_1 = __importDefault(require("node:os"));
24
+ const node_path_1 = __importDefault(require("node:path"));
25
+ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
26
+ const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
27
+ const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
28
+ const runtimeNamePolicy = require("./runtime-name-policy.cjs");
29
+ const { processAttribution } = runtimeArtifactConversion;
30
+ // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
31
+ // test stubs that monkeypatch the module's exports are seen at call time.
32
+ const { getDirName } = runtimeNamePolicy;
33
+ // ---------------------------------------------------------------------------
34
+ // USER_OWNED_ARTIFACTS
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Single source of truth for user-owned artifacts inside gsd-core/.
38
+ *
39
+ * These files are created/refreshed by user-facing workflows (e.g.
40
+ * /gsd-profile-user) and must be preserved across reinstalls. Critically, they
41
+ * MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
42
+ * will compare a refreshed file against a stale manifest hash and emit a
43
+ * spurious "locally modified GSD file" warning (bug #2771).
44
+ *
45
+ * Invariant: a file is either distribution (manifest-tracked, diff'd against
46
+ * manifest) or user artifact (preserved across installs, never diff'd). Never
47
+ * both. Both preserveUserArtifacts call sites and writeManifest must agree on
48
+ * this list, which is why it lives here as a single constant.
49
+ *
50
+ * Paths are relative to the gsd-core/ directory.
51
+ */
52
+ const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
53
+ // ---------------------------------------------------------------------------
54
+ // Conversion helpers
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Apply per-runtime path-prefix rewrites for OpenCode-family skill bodies.
58
+ * Replaces ~/.claude/, $HOME/.claude/, ./.claude/ and OpenCode-variant paths
59
+ * with the computed pathPrefix for the install.
60
+ */
61
+ function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
62
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
63
+ content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
64
+ content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
65
+ content = content.replace(/~\/\.opencode\//g, pathPrefix);
66
+ content = content.replace(/~\/\.kilo\//g, pathPrefix);
67
+ return content;
68
+ }
69
+ /**
70
+ * Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
71
+ * The canonical OpenCode-family writer lives in runtime-artifact-conversion.cjs
72
+ * (single source of truth — avoids a duplicate writer drifting per
73
+ * DEFECT.GENERATIVE-FIX); this thin wrapper delegates to it.
74
+ */
75
+ function convertClaudeCommandToOpencodeSkill(content, skillName) {
76
+ return runtimeArtifactConversion.convertClaudeCommandToOpencodeSkill(content, skillName);
77
+ }
78
+ /**
79
+ * Convert a Claude command (.md) to a Kilo skill (SKILL.md).
80
+ * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
81
+ */
82
+ function convertClaudeCommandToKiloSkill(content, skillName) {
83
+ return runtimeArtifactConversion.convertClaudeCommandToKiloSkill(content, skillName);
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // User-artifact preservation helpers
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Save user-generated files from destDir to an in-memory map before a wipe.
90
+ *
91
+ * @param destDir - Directory that is about to be wiped
92
+ * @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
93
+ * @returns Map of fileName → file content (only entries that existed)
94
+ */
95
+ function preserveUserArtifacts(destDir, fileNames) {
96
+ const saved = new Map();
97
+ for (const name of fileNames) {
98
+ const fullPath = node_path_1.default.join(destDir, name);
99
+ if (node_fs_1.default.existsSync(fullPath)) {
100
+ try {
101
+ saved.set(name, node_fs_1.default.readFileSync(fullPath, 'utf8'));
102
+ }
103
+ catch { /* skip unreadable files */ }
104
+ }
105
+ }
106
+ return saved;
107
+ }
108
+ /**
109
+ * Restore user-generated files saved by preserveUserArtifacts after a wipe.
110
+ *
111
+ * @param destDir - Directory that was wiped and recreated
112
+ * @param saved - Map returned by preserveUserArtifacts
113
+ */
114
+ function restoreUserArtifacts(destDir, saved) {
115
+ for (const [name, content] of saved) {
116
+ const fullPath = node_path_1.default.join(destDir, name);
117
+ try {
118
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(fullPath), { recursive: true });
119
+ node_fs_1.default.writeFileSync(fullPath, content, 'utf8');
120
+ }
121
+ catch { /* skip unwritable paths */ }
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Symlink-escape guard
126
+ // ---------------------------------------------------------------------------
127
+ /**
128
+ * Returns true if any path component between `root` and `fullPath` is a
129
+ * symbolic link (which could redirect writes outside the install root).
130
+ */
131
+ function hasExistingSymlinkBetween(root, fullPath) {
132
+ const resolvedRoot = node_path_1.default.resolve(root);
133
+ const resolvedFullPath = node_path_1.default.resolve(fullPath);
134
+ if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
135
+ return true;
136
+ }
137
+ let cursor = resolvedRoot;
138
+ if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
139
+ return true;
140
+ }
141
+ const relative = node_path_1.default.relative(resolvedRoot, resolvedFullPath);
142
+ for (const segment of relative.split(node_path_1.default.sep)) {
143
+ if (!segment)
144
+ continue;
145
+ cursor = node_path_1.default.join(cursor, segment);
146
+ if (!node_fs_1.default.existsSync(cursor))
147
+ return false;
148
+ if (node_fs_1.default.lstatSync(cursor).isSymbolicLink())
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // migrateLegacyDevPreferencesToSkill
155
+ // ---------------------------------------------------------------------------
156
+ /**
157
+ * Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
158
+ * runtime-aware SKILL.md location used by the writer after #2973.
159
+ *
160
+ * For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
161
+ * the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
162
+ * For runtimes with a flat skills layout (prefix='gsd-'), the target is
163
+ * <configDir>/skills/gsd-dev-preferences/SKILL.md.
164
+ *
165
+ * Skips silently if no legacy file was preserved, or if a SKILL.md already
166
+ * exists at the new location (don't clobber user-customized skill content
167
+ * — they may have edited the new file directly). Returns true on actual
168
+ * migration so callers can log a one-line confirmation.
169
+ *
170
+ * @param targetDir - Resolved runtime config directory (e.g. ~/.claude)
171
+ * @param saved - Map returned by preserveUserArtifacts
172
+ * @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
173
+ * @param scope - install scope
174
+ * @returns true if a file was migrated, false otherwise
175
+ */
176
+ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
177
+ if (!saved || !saved.has('dev-preferences.md'))
178
+ return false;
179
+ let skillDir;
180
+ if (runtime) {
181
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope);
182
+ const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
183
+ if (!skillsKindEntry)
184
+ return false; // runtime has no skills layout at this scope (e.g. cline local)
185
+ const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
186
+ skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
187
+ }
188
+ else {
189
+ // Legacy fallback for callers that have not yet been updated to pass runtime
190
+ skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
191
+ }
192
+ const skillFile = node_path_1.default.join(skillDir, 'SKILL.md');
193
+ if (node_fs_1.default.existsSync(skillFile))
194
+ return false;
195
+ // Symlink-escape guard: reject if any path component between targetDir and
196
+ // skillDir is a symlink that would redirect writes outside the config root.
197
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir)) {
198
+ throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
199
+ }
200
+ try {
201
+ node_fs_1.default.mkdirSync(skillDir, { recursive: true });
202
+ node_fs_1.default.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
203
+ return true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // _copyStaged
211
+ // ---------------------------------------------------------------------------
212
+ /**
213
+ * Copy a staged directory's contents into destDir.
214
+ * Additive — does not prune (surface.cjs handles pruning).
215
+ *
216
+ * For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
217
+ * the whole dir into destDir.
218
+ * For commands/agents kind: iterate .md files and write them into destDir.
219
+ * - commands: write as `${prefix}${stem}.md` unless destSubpath already
220
+ * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
221
+ * which case write as `${stem}.md` (directory IS the namespace).
222
+ * - agents: write as-is (files already carry their own `gsd-` prefix).
223
+ * For kimi-agents kind: recursively copy generated YAML/prompt files.
224
+ */
225
+ function _copyStaged(stagedDir, destDir, kind, configDir) {
226
+ // Defense-in-depth: verify destDir is within the install root even if the
227
+ // upstream assertDestWithinConfigHome check was somehow bypassed. This guards
228
+ // the actual write site against any future call-site drift.
229
+ // Fail-closed: every _copyStaged write must declare its install root so the gate
230
+ // can confine it. All callers pass configDir; an omitted root is a bug, not a copy.
231
+ if (configDir === undefined) {
232
+ throw new Error('_copyStaged: configDir (install root) is required to confine writes — refusing to write');
233
+ }
234
+ // Strict-subpath + NUL containment via the canonical gate (shared with the
235
+ // layout-driven install plan); throws if destDir escapes the install root.
236
+ // destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
237
+ const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
238
+ // Symlink-escape guard: reject if any path component between configDir and
239
+ // destDir is a symlink that would redirect writes outside configDir.
240
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), resolvedDest)) {
241
+ throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`);
242
+ }
243
+ // Use the validated absolute path for the actual writes below.
244
+ destDir = resolvedDest;
245
+ if (!node_fs_1.default.existsSync(stagedDir))
246
+ return;
247
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
248
+ if (kind.kind === 'skills') {
249
+ // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
250
+ for (const entry of node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true })) {
251
+ if (!entry.isDirectory())
252
+ continue;
253
+ const src = node_path_1.default.join(stagedDir, entry.name);
254
+ const dest = node_path_1.default.join(destDir, entry.name);
255
+ node_fs_1.default.cpSync(src, dest, { recursive: true });
256
+ }
257
+ return;
258
+ }
259
+ if (kind.kind === 'kimi-agents') {
260
+ node_fs_1.default.cpSync(stagedDir, destDir, { recursive: true });
261
+ return;
262
+ }
263
+ // commands or agents
264
+ const entries = node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true });
265
+ // For commands: apply prefix unless the destSubpath's last segment already
266
+ // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
267
+ const destLast = node_path_1.default.basename(kind.destSubpath);
268
+ const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
269
+ const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
270
+ for (const entry of entries) {
271
+ if (!entry.isFile())
272
+ continue;
273
+ if (!entry.name.endsWith('.md'))
274
+ continue;
275
+ const stem = entry.name.slice(0, -3); // strip .md
276
+ let destName;
277
+ if (kind.kind === 'agents') {
278
+ // Agent files already carry the gsd- prefix in the source dir
279
+ destName = entry.name;
280
+ }
281
+ else if (namespacedByDir) {
282
+ // Directory is the namespace; don't double-prefix the filename
283
+ destName = entry.name;
284
+ }
285
+ else {
286
+ // Flat commands directory (e.g. command/ for opencode/kilo)
287
+ destName = `${kind.prefix}${stem}.md`;
288
+ }
289
+ node_fs_1.default.copyFileSync(node_path_1.default.join(stagedDir, entry.name), node_path_1.default.join(destDir, destName));
290
+ }
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // _removeGsdEntries
294
+ // ---------------------------------------------------------------------------
295
+ /**
296
+ * Remove GSD-prefixed entries from destDir matching kind.prefix.
297
+ * For the prefix='' case: the destSubpath IS the namespace — remove the entire
298
+ * destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
299
+ * as a defensive guard for future runtimes.)
300
+ */
301
+ function _removeGsdEntries(destDir, kind) {
302
+ if (!node_fs_1.default.existsSync(destDir))
303
+ return;
304
+ if (kind.kind === 'kimi-agents') {
305
+ for (const fileName of ['gsd.yaml', 'gsd.md']) {
306
+ node_fs_1.default.rmSync(node_path_1.default.join(destDir, fileName), { force: true });
307
+ }
308
+ const subagentsDir = node_path_1.default.join(destDir, 'subagents');
309
+ if (node_fs_1.default.existsSync(subagentsDir)) {
310
+ for (const entry of node_fs_1.default.readdirSync(subagentsDir, { withFileTypes: true })) {
311
+ if (!entry.isFile())
312
+ continue;
313
+ if (!entry.name.startsWith('gsd-'))
314
+ continue;
315
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md'))
316
+ continue;
317
+ node_fs_1.default.rmSync(node_path_1.default.join(subagentsDir, entry.name), { force: true });
318
+ }
319
+ }
320
+ return;
321
+ }
322
+ if (kind.prefix === '') {
323
+ // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
324
+ // The directory itself is the GSD namespace, so remove it entirely.
325
+ node_fs_1.default.rmSync(destDir, { recursive: true, force: true });
326
+ return;
327
+ }
328
+ for (const entry of node_fs_1.default.readdirSync(destDir, { withFileTypes: true })) {
329
+ if (!entry.name.startsWith(kind.prefix))
330
+ continue;
331
+ node_fs_1.default.rmSync(node_path_1.default.join(destDir, entry.name), { recursive: true, force: true });
332
+ }
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // _snapshotDir / _restoreDir
336
+ // ---------------------------------------------------------------------------
337
+ /**
338
+ * Deep-snapshot a directory tree into a Map<relPath, Buffer>.
339
+ * Returns an empty Map if the directory doesn't exist.
340
+ */
341
+ function _snapshotDir(dir) {
342
+ const files = new Map();
343
+ if (!node_fs_1.default.existsSync(dir))
344
+ return files;
345
+ const walk = (relPath, absPath) => {
346
+ for (const e of node_fs_1.default.readdirSync(absPath, { withFileTypes: true })) {
347
+ const childRel = relPath ? node_path_1.default.join(relPath, e.name) : e.name;
348
+ const childAbs = node_path_1.default.join(absPath, e.name);
349
+ if (e.isDirectory())
350
+ walk(childRel, childAbs);
351
+ else if (e.isFile())
352
+ files.set(childRel, node_fs_1.default.readFileSync(childAbs));
353
+ }
354
+ };
355
+ walk('', dir);
356
+ return files;
357
+ }
358
+ /**
359
+ * Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
360
+ */
361
+ function _restoreDir(dir, snapshot) {
362
+ for (const [relPath, buf] of snapshot) {
363
+ const absPath = node_path_1.default.join(dir, relPath);
364
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(absPath), { recursive: true });
365
+ node_fs_1.default.writeFileSync(absPath, buf);
366
+ }
367
+ }
368
+ // ---------------------------------------------------------------------------
369
+ // _removeHermesBareStemDirs
370
+ // ---------------------------------------------------------------------------
371
+ /**
372
+ * After the layout-driven install loop writes new gsd-<stem>/ dirs to
373
+ * skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
374
+ * that correspond to the newly installed gsd-<stem> entries.
375
+ *
376
+ * @param nestedGsdDir absolute path to skills/gsd/ category dir
377
+ */
378
+ function _removeHermesBareStemDirs(nestedGsdDir) {
379
+ if (!node_fs_1.default.existsSync(nestedGsdDir))
380
+ return;
381
+ const entries = node_fs_1.default.readdirSync(nestedGsdDir, { withFileTypes: true });
382
+ // Collect the set of stems that were installed as gsd-<stem>/ this run.
383
+ const installedStems = new Set();
384
+ for (const entry of entries) {
385
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
386
+ installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
387
+ }
388
+ }
389
+ // Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
390
+ for (const entry of entries) {
391
+ if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
392
+ node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDir, entry.name), { recursive: true });
393
+ }
394
+ }
395
+ }
396
+ // ---------------------------------------------------------------------------
397
+ // Legacy migration helpers
398
+ // ---------------------------------------------------------------------------
399
+ /**
400
+ * Run legacy install migrations that must execute BEFORE the layout-driven
401
+ * copy so stale artifacts are cleaned up before new ones are written.
402
+ *
403
+ * @param runtime
404
+ * @param configDir resolved runtime config directory
405
+ * @param scope
406
+ */
407
+ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
408
+ const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
409
+ // Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
410
+ // for migration. The actual migration call is deferred to after all layout cleanup so
411
+ // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
412
+ // created skills/gsd-dev-preferences/ skill dir.
413
+ let savedLegacyArtifacts = null;
414
+ if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
415
+ if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
416
+ savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
417
+ node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
418
+ }
419
+ }
420
+ // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
421
+ // the new skills/gsd/ nested layout.
422
+ if (runtime === 'hermes') {
423
+ const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
424
+ if (node_fs_1.default.existsSync(flatSkillsDir)) {
425
+ for (const entry of node_fs_1.default.readdirSync(flatSkillsDir, { withFileTypes: true })) {
426
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
427
+ node_fs_1.default.rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
428
+ }
429
+ }
430
+ }
431
+ // Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
432
+ // layout-driven install loop in installRuntimeArtifacts, where the exact set
433
+ // of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
434
+ // require readGsdCommandNames() which misses skills like 'dev-preferences'
435
+ // that are not in the commands directory. See _removeHermesBareStemDirs().
436
+ }
437
+ // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
438
+ // Done after all layout cleanup so Hermes flat-dir removal does not delete the
439
+ // newly created skill dir. No-op if skill file already exists.
440
+ if (savedLegacyArtifacts) {
441
+ migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
442
+ }
443
+ }
444
+ /**
445
+ * Run legacy uninstall cleanup that must execute BEFORE the layout-driven
446
+ * removal so old-format entries are also cleaned up.
447
+ *
448
+ * @param runtime
449
+ * @param configDir resolved runtime config directory
450
+ * @param scope
451
+ * @returns saved legacy artifacts for post-removal migration, or null
452
+ */
453
+ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
454
+ // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
455
+ // Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
456
+ // After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
457
+ // block (1c) handles removal of flat files; this function handles the legacy
458
+ // commands/gsd/ directory for all Claude scopes (global was already included,
459
+ // local is now added since that layout is also legacy post-#1367).
460
+ // #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
461
+ // before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
462
+ // is deferred and returned so the caller can apply it AFTER layout-driven
463
+ // removal — this prevents the layout's gsd-* prefix removal from wiping the
464
+ // freshly created skill dir (same pattern as _runLegacyInstallMigrations).
465
+ let savedLegacyArtifacts = null;
466
+ // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
467
+ // Claude local is intentionally excluded: the inline uninstall block (1c) handles
468
+ // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
469
+ // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
470
+ // (which would redirect to skills/) conflicts with the test contract for local installs.
471
+ const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
472
+ if (isLegacyCommandsGsd) {
473
+ const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
474
+ if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
475
+ savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
476
+ node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
477
+ }
478
+ }
479
+ // Hermes: pre-#2841 flat skills/gsd-*/ entries
480
+ if (runtime === 'hermes') {
481
+ const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
482
+ if (node_fs_1.default.existsSync(flatSkillsDir)) {
483
+ for (const entry of node_fs_1.default.readdirSync(flatSkillsDir, { withFileTypes: true })) {
484
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
485
+ node_fs_1.default.rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
486
+ }
487
+ }
488
+ }
489
+ // Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
490
+ // start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
491
+ // had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
492
+ const nestedGsdDirForUninstall = node_path_1.default.join(configDir, 'skills', 'gsd');
493
+ if (node_fs_1.default.existsSync(nestedGsdDirForUninstall)) {
494
+ for (const entry of node_fs_1.default.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
495
+ if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
496
+ node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
497
+ }
498
+ }
499
+ }
500
+ }
501
+ // Return saved artifacts so the caller can migrate after layout-driven removal.
502
+ return savedLegacyArtifacts;
503
+ }
504
+ // ---------------------------------------------------------------------------
505
+ // installRuntimeArtifacts
506
+ // ---------------------------------------------------------------------------
507
+ /**
508
+ * Layout-driven install orchestrator.
509
+ * Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
510
+ * determine what artifact kinds to write and where.
511
+ *
512
+ * @param runtime canonical runtime ID
513
+ * @param configDir resolved runtime config directory
514
+ * @param scope
515
+ * @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
516
+ * @param resolveAttribution injection: (runtime) => attribution string | undefined
517
+ */
518
+ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
519
+ // Legacy cleanup before layout-driven writes
520
+ _runLegacyInstallMigrations(runtime, configDir, scope);
521
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
522
+ const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
523
+ // `Layout` is structurally identical across the layout/install-plan .cjs
524
+ // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
525
+ layout: layout,
526
+ resolvedProfile,
527
+ homedir: () => node_os_1.default.homedir(),
528
+ platform: process.platform,
529
+ resolveAttribution,
530
+ });
531
+ const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
532
+ try {
533
+ if (!planResult.ok) {
534
+ throw new Error(planResult.message);
535
+ }
536
+ const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
537
+ for (const item of planResult.plan.items) {
538
+ const kind = kindsByName.get(item.kind);
539
+ if (!kind)
540
+ throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
541
+ const dest = item.destDir;
542
+ // Symlink-escape guard: reject before mkdir if dest (or any component
543
+ // between configDir and dest) is a symlink pointing outside configDir.
544
+ // mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
545
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), dest)) {
546
+ throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`);
547
+ }
548
+ node_fs_1.default.mkdirSync(dest, { recursive: true });
549
+ if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
550
+ // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
551
+ // then restore after. This preserves user dirs across a wipe-and-replace
552
+ // install (#2973 / #3664).
553
+ //
554
+ // All runtimes (incl. Hermes after #947) use prefix='gsd-'.
555
+ // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
556
+ // untouched. Preserve the explicit user-owned GSD-prefixed skill
557
+ // gsd-dev-preferences, which GSD does not reinstall from source but must
558
+ // survive the prune (#2973).
559
+ const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
560
+ {
561
+ // Preserve explicitly user-owned GSD-prefixed skill dirs.
562
+ // gsd-dev-preferences is the sole user-customisable skill in this category.
563
+ const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
564
+ for (const dirName of USER_OWNED_SKILL_DIRS) {
565
+ const skillDir = node_path_1.default.join(dest, dirName);
566
+ if (!node_fs_1.default.existsSync(skillDir))
567
+ continue;
568
+ const snap = _snapshotDir(skillDir);
569
+ if (snap.size > 0)
570
+ toPreserve.set(dirName, snap);
571
+ }
572
+ }
573
+ _removeGsdEntries(dest, kind);
574
+ _copyStaged(item.sourceDir, dest, kind, configDir);
575
+ // Restore user-owned dirs after the prune+copy
576
+ for (const [dirName, snap] of toPreserve) {
577
+ _restoreDir(node_path_1.default.join(dest, dirName), snap);
578
+ }
579
+ }
580
+ else {
581
+ // For non-skills kinds (commands, agents): no user content to preserve;
582
+ // just prune stale gsd-* entries and copy new ones.
583
+ _removeGsdEntries(dest, kind);
584
+ _copyStaged(item.sourceDir, dest, kind, configDir);
585
+ }
586
+ }
587
+ }
588
+ finally {
589
+ for (const dir of cleanupDirs) {
590
+ try {
591
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
592
+ }
593
+ catch { /* best-effort */ }
594
+ }
595
+ }
596
+ // Hermes: after the install loop has written all gsd-<stem>/ dirs to
597
+ // skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
598
+ // correspond to the newly installed gsd-<stem> entries. This is the robust
599
+ // replacement for the readGsdCommandNames()-based pre-install cleanup that
600
+ // missed skills like 'dev-preferences' (#947 adversarial review).
601
+ //
602
+ // We run this AFTER the install loop so the installed set is authoritative:
603
+ // every gsd-<stem>/ present now was written this run (or was there before
604
+ // with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
605
+ // are untouched.
606
+ if (runtime === 'hermes') {
607
+ const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
608
+ _removeHermesBareStemDirs(nestedGsdDirForCleanup);
609
+ }
610
+ }
611
+ // ---------------------------------------------------------------------------
612
+ // installOpencodeFamilySkills
613
+ // ---------------------------------------------------------------------------
614
+ /**
615
+ * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
616
+ *
617
+ * These runtimes do NOT go through installRuntimeArtifacts (their commands use a
618
+ * bespoke flattened-command writer), so this writes ONLY the skills kind
619
+ * alongside their existing command/ + agents/ surfaces. Uninstall is already
620
+ * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
621
+ * skills/ dir is cleaned up automatically once the layout declares it.
622
+ *
623
+ * @param runtime - 'opencode' or 'kilo'
624
+ * @param targetDir - resolved runtime config directory
625
+ * @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
626
+ * @param pathPrefix - computed config-path prefix for body rewrites
627
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
628
+ * @returns number of gsd-* skill directories written
629
+ */
630
+ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined) {
631
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
632
+ const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
633
+ if (!skillsKindEntry)
634
+ return 0;
635
+ const rawDir = rawCommandsDir;
636
+ if (!rawDir || !node_fs_1.default.existsSync(rawDir))
637
+ return 0;
638
+ const converter = runtime === 'kilo'
639
+ ? convertClaudeCommandToKiloSkill
640
+ : convertClaudeCommandToOpencodeSkill;
641
+ const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
642
+ // Symlink-escape guard: reject if any path component between targetDir and
643
+ // dest is a symlink that would redirect writes outside the config root.
644
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest)) {
645
+ throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
646
+ }
647
+ node_fs_1.default.mkdirSync(dest, { recursive: true });
648
+ // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
649
+ // gsd-dev-preferences is generated by the user (via generate-dev-preferences)
650
+ // and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
651
+ // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
652
+ // (#2973).
653
+ const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
654
+ const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
655
+ for (const dirName of USER_OWNED_SKILL_DIRS) {
656
+ const skillDir = node_path_1.default.join(dest, dirName);
657
+ if (!node_fs_1.default.existsSync(skillDir))
658
+ continue;
659
+ const snap = _snapshotDir(skillDir);
660
+ if (snap.size > 0)
661
+ toPreserve.set(dirName, snap);
662
+ }
663
+ _removeGsdEntries(dest, skillsKindEntry);
664
+ let count = 0;
665
+ for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
666
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
667
+ continue;
668
+ const stem = entry.name.slice(0, -3);
669
+ const skillName = `${skillsKindEntry.prefix}${stem}`;
670
+ let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
671
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
672
+ content = processAttribution(content, resolveAttribution(runtime));
673
+ content = converter(content, skillName);
674
+ const skillDir = node_path_1.default.join(dest, skillName);
675
+ node_fs_1.default.mkdirSync(skillDir, { recursive: true });
676
+ node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
677
+ count++;
678
+ }
679
+ // Restore user-owned dirs after the prune+copy.
680
+ for (const [dirName, snap] of toPreserve) {
681
+ _restoreDir(node_path_1.default.join(dest, dirName), snap);
682
+ }
683
+ return count;
684
+ }
685
+ // ---------------------------------------------------------------------------
686
+ // uninstallRuntimeArtifacts
687
+ // ---------------------------------------------------------------------------
688
+ /**
689
+ * Layout-driven uninstall orchestrator.
690
+ * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
691
+ * determine which GSD-owned entries to remove.
692
+ *
693
+ * @param runtime canonical runtime ID
694
+ * @param configDir resolved runtime config directory
695
+ * @param scope
696
+ */
697
+ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
698
+ // Legacy cleanup before layout-driven removal (scope-aware to avoid
699
+ // removing Claude local commands/gsd/ which is the primary install dir).
700
+ // Returns saved user artifacts so we can migrate AFTER layout removal
701
+ // (the layout's gsd-* prefix pass would wipe a skill dir created here).
702
+ const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
703
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
704
+ const plan = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
705
+ const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
706
+ for (const item of plan.items) {
707
+ const kind = kindsByName.get(item.kind);
708
+ if (!kind) {
709
+ throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
710
+ }
711
+ _removeGsdEntries(item.destDir, kind);
712
+ }
713
+ // Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
714
+ // the GSD-managed DESCRIPTION.md and then the category dir itself if it
715
+ // contains no user content (#947). _removeGsdEntries removed gsd-* dirs
716
+ // but left the category container and DESCRIPTION.md intact.
717
+ if (runtime === 'hermes') {
718
+ const nestedGsdDir = node_path_1.default.join(configDir, 'skills', 'gsd');
719
+ if (node_fs_1.default.existsSync(nestedGsdDir)) {
720
+ // Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
721
+ node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
722
+ // Remove the category dir if empty (no user content remaining)
723
+ const remaining = node_fs_1.default.readdirSync(nestedGsdDir, { withFileTypes: true });
724
+ if (remaining.length === 0) {
725
+ node_fs_1.default.rmSync(nestedGsdDir, { recursive: true, force: true });
726
+ }
727
+ }
728
+ }
729
+ // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
730
+ // runtime-aware SKILL.md location after all layout-driven removal is
731
+ // complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
732
+ if (savedLegacyArtifacts) {
733
+ migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
734
+ }
735
+ }
736
+ module.exports = {
737
+ installRuntimeArtifacts,
738
+ uninstallRuntimeArtifacts,
739
+ installOpencodeFamilySkills,
740
+ _copyStaged,
741
+ hasExistingSymlinkBetween,
742
+ preserveUserArtifacts,
743
+ restoreUserArtifacts,
744
+ migrateLegacyDevPreferencesToSkill,
745
+ applyOpencodeFamilyPathPrefix,
746
+ convertClaudeCommandToOpencodeSkill,
747
+ convertClaudeCommandToKiloSkill,
748
+ USER_OWNED_ARTIFACTS,
749
+ _runLegacyInstallMigrations,
750
+ _runLegacyUninstallCleanup,
751
+ _removeGsdEntries,
752
+ _snapshotDir,
753
+ _restoreDir,
754
+ _removeHermesBareStemDirs,
755
+ };