@opengsd/gsd-core 1.7.0 → 1.8.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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
@@ -27,7 +27,9 @@ const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
27
27
  const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
28
28
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
29
29
  const installProfiles = require("./install-profiles.cjs");
30
+ const installerMigrations = require("./installer-migrations.cjs");
30
31
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
32
+ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
31
33
  const { processAttribution } = runtimeArtifactConversion;
32
34
  // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
33
35
  // test stubs that monkeypatch the module's exports are seen at call time.
@@ -156,19 +158,93 @@ function restoreUserArtifacts(destDir, saved) {
156
158
  // ---------------------------------------------------------------------------
157
159
  // Symlink-escape guard
158
160
  // ---------------------------------------------------------------------------
161
+ /**
162
+ * Opt-in for intentional symlinked-dest layouts (#2393). When the env var is
163
+ * set to "1" or "true", `hasExistingSymlinkBetween` follows symlinks instead of
164
+ * refusing them, EXCEPT for two load-bearing cases that always refuse regardless
165
+ * of opt-in (preserving ADR-1239 Phase B's threat model):
166
+ *
167
+ * (a) The `fullPath` itself, before any symlink resolution, escapes `root`
168
+ * via `..`-traversal — protects against untrusted `destSubpath` strings
169
+ * like `../../etc`. This is the line `resolvedFullPath !== resolvedRoot
170
+ * && !resolvedFullPath.startsWith(resolvedRoot + path.sep)` below.
171
+ * (b) A symlink's resolved real path equals the install root itself — this
172
+ * would let `_removeGsdEntries` (the prune pass) wipe the install root,
173
+ * which is the config-root-wipe threat from #1704 threat model item (b).
174
+ *
175
+ * What opt-in RELAXES specifically: the "pre-existing symlink that points
176
+ * outside configHome" refusal — threat (c) in #1704. The user has asserted
177
+ * they own and trust the symlink target. The default (no env var) keeps all
178
+ * three refusals, exactly the pre-#2393 behavior.
179
+ *
180
+ * Cross-platform note: on Windows, `fs.lstatSync().isSymbolicLink()` returns
181
+ * true for both symbolic links and NTFS junctions (Node ≥ 16), so Mamiki's
182
+ * Junction case (#2393 comment) is handled by the same code path as POSIX
183
+ * symlinks.
184
+ *
185
+ * @returns true when the caller MUST refuse; false when writes may proceed.
186
+ */
187
+ function isSymlinkedDestOptIn() {
188
+ const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
189
+ return v === '1' || v === 'true';
190
+ }
159
191
  /**
160
192
  * Returns true if any path component between `root` and `fullPath` is a
161
- * symbolic link (which could redirect writes outside the install root).
193
+ * symbolic link that would redirect writes outside the install root in a way
194
+ * the caller must refuse.
195
+ *
196
+ * When `options.allowOptInFollow` is true (caller checked `isSymlinkedDestOptIn`),
197
+ * symlinks are followed instead of refused, except for the two always-refuse
198
+ * cases documented on `isSymlinkedDestOptIn` — (a) path-traversal in `fullPath`
199
+ * itself, (b) a resolved symlink target that equals the install root (would let
200
+ * the prune pass wipe it).
162
201
  */
163
- function hasExistingSymlinkBetween(root, fullPath) {
202
+ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
164
203
  const resolvedRoot = node_path_1.default.resolve(root);
165
204
  const resolvedFullPath = node_path_1.default.resolve(fullPath);
205
+ // (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
206
+ // destSubpath string that escapes the install root via '..' is rejected
207
+ // regardless of user opt-in state (ADR-1239 Phase B threat (a)).
166
208
  if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
167
209
  return true;
168
210
  }
211
+ // #2393 (security-review finding): realpathSync fully resolves all symlink
212
+ // components, path.resolve only normalizes lexically. On macOS, /var is a
213
+ // symlink to /private/var — so resolvedRoot='/var/foo/.claude' but its real
214
+ // path is '/private/var/foo/.claude'. A symlink whose real target equals the
215
+ // install root (the threat-(b) wipe case) would compare unequal without this
216
+ // normalization, defeating the guard exactly in the reporter's case (Azd325,
217
+ // nix-darwin: ~/.claude is itself a symlink). Compute realRoot once; fall
218
+ // back to the lexical form on any realpath failure (broken/missing/exotic FS)
219
+ // — threat (a) above still confines regardless.
220
+ let realRoot;
221
+ try {
222
+ realRoot = node_fs_1.default.existsSync(resolvedRoot) ? node_fs_1.default.realpathSync(resolvedRoot) : resolvedRoot;
223
+ }
224
+ catch {
225
+ realRoot = resolvedRoot;
226
+ }
227
+ const allowFollow = options.allowOptInFollow === true;
228
+ // #2393: when root itself is a symlink (e.g. nix-darwin manages ~/.claude as a
229
+ // symlink to a dotfiles repo — Azd325's #2393 report), the pre-#2393 guard
230
+ // refused unconditionally via an early return before the component loop. The
231
+ // wipe threat (b) does NOT apply to the root itself being a symlink: destDir is
232
+ // a CHILD of root, and resolving root gives root's target — there is no
233
+ // circular back-reference to root from a path that descends from a resolved
234
+ // root. So under opt-in, just follow the root symlink and continue the walk.
235
+ // Default behavior (no opt-in) preserves the pre-#2393 refuse.
169
236
  let cursor = resolvedRoot;
170
237
  if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
171
- return true;
238
+ if (!allowFollow)
239
+ return true;
240
+ try {
241
+ cursor = node_fs_1.default.realpathSync(cursor);
242
+ }
243
+ catch {
244
+ // realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
245
+ // matching fail-closed posture.
246
+ return true;
247
+ }
172
248
  }
173
249
  const relative = node_path_1.default.relative(resolvedRoot, resolvedFullPath);
174
250
  for (const segment of relative.split(node_path_1.default.sep)) {
@@ -177,8 +253,37 @@ function hasExistingSymlinkBetween(root, fullPath) {
177
253
  cursor = node_path_1.default.join(cursor, segment);
178
254
  if (!node_fs_1.default.existsSync(cursor))
179
255
  return false;
180
- if (node_fs_1.default.lstatSync(cursor).isSymbolicLink())
181
- return true;
256
+ if (node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
257
+ if (!allowFollow)
258
+ return true;
259
+ // Opt-in active: follow the symlink. Refuse if the resolved target is the
260
+ // install root itself (threat (b) — would let _removeGsdEntries wipe the
261
+ // root). Other targets are acceptable per the user's explicit opt-in. A
262
+ // broken symlink (realpathSync throws) is still refused.
263
+ //
264
+ // Threat (b) check uses BOTH lexical and real forms of root to defend
265
+ // against macOS /var ↔ /private/var-style normalization gaps: realpathSync
266
+ // fully resolves, path.resolve only normalizes lexically, so a root path
267
+ // containing a symlink component would compare unequal to a realtarget
268
+ // that matches by real path. Compare both.
269
+ //
270
+ // Transitivity note: once followed, the walk continues from the resolved
271
+ // real path WITHOUT re-checking that further segments stay inside any
272
+ // confining boundary. The user's opt-in asserts trust in the target dir
273
+ // AND any further symlinks reachable through it — transitive and unbounded
274
+ // by design (one opt-in trusts the whole reachable tree). This is the
275
+ // documented opt-in semantics; do not add a "follow one symlink only"
276
+ // expectation here without revisiting the threat model.
277
+ try {
278
+ const realTarget = node_fs_1.default.realpathSync(cursor);
279
+ if (realTarget === realRoot || realTarget === resolvedRoot)
280
+ return true; // (b)
281
+ cursor = realTarget;
282
+ }
283
+ catch {
284
+ return true;
285
+ }
286
+ }
182
287
  }
183
288
  return false;
184
289
  }
@@ -226,8 +331,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
226
331
  return false;
227
332
  // Symlink-escape guard: reject if any path component between targetDir and
228
333
  // skillDir is a symlink that would redirect writes outside the config root.
229
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir)) {
230
- throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
334
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
335
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
336
+ throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
231
337
  }
232
338
  try {
233
339
  node_fs_1.default.mkdirSync(skillDir, { recursive: true });
@@ -275,8 +381,9 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
275
381
  const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
276
382
  // Symlink-escape guard: reject if any path component between the install root and
277
383
  // destDir is a symlink that would redirect writes outside the install root.
278
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest)) {
279
- throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`);
384
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
385
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
386
+ throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
280
387
  }
281
388
  // Use the validated absolute path for the actual writes below.
282
389
  destDir = resolvedDest;
@@ -560,20 +667,29 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
560
667
  * @param scope
561
668
  * @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
562
669
  * @param resolveAttribution injection: (runtime) => attribution string | undefined
670
+ * @param capabilityRegistry #2322: optional composed capability registry
671
+ * (capabilityClusters view) — threaded into resolveRuntimeArtifactLayout so
672
+ * the skills kind can materialize installed third-party capability skills
673
+ * bound to their declaring capId. Absent -> no third-party skills staged
674
+ * (fail closed), matching the layout resolver's own optional-registry contract.
563
675
  */
564
- function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
676
+ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry) {
565
677
  // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
566
678
  // the dedicated combined commands+skills+plugin orchestrator instead of the
567
679
  // generic layout-driven loop below, mirroring the bespoke install path that
568
680
  // previously lived inline in bin/install.js.
569
681
  const behaviors = _hostBehaviors(runtime);
570
682
  if (behaviors.combinedFamilyInstall) {
571
- installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
683
+ // #2329: combined-family runtimes (OpenCode/Kilo) bypass
684
+ // _runLegacyInstallMigrations below entirely (early return), so their
685
+ // legacy-directory cleanup needs its own pre-materialization hook here.
686
+ _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
687
+ installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
572
688
  return;
573
689
  }
574
690
  // Legacy cleanup before layout-driven writes
575
691
  _runLegacyInstallMigrations(runtime, configDir, scope);
576
- const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
692
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
577
693
  const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
578
694
  // `Layout` is structurally identical across the layout/install-plan .cjs
579
695
  // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
@@ -603,8 +719,11 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
603
719
  // resolved alternate root instead, matching assertDestWithinConfigHome's
604
720
  // own root selection in createRuntimeArtifactInstallPlan.
605
721
  const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
606
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest)) {
607
- throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`);
722
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
723
+ // Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
724
+ // resolved-target-equals-root still refuse regardless of opt-in.
725
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
726
+ throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
608
727
  }
609
728
  node_fs_1.default.mkdirSync(dest, { recursive: true });
610
729
  if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
@@ -698,9 +817,20 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
698
817
  * @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
699
818
  * @param pathPrefix - computed config-path prefix for body rewrites
700
819
  * @param resolveAttribution - injection: (runtime) => attribution string | undefined
820
+ * @param resolvedProfile - #2362: from resolveProfile()/resolveEffectiveProfile(); only
821
+ * `.skills` is consulted (either the `'*'` full-profile sentinel or a concrete Set
822
+ * of stems), and only to gate which THIRD-PARTY capability stems are candidates for
823
+ * staging below. Absent -> no third-party skills staged (fail closed).
824
+ * @param capabilityRegistry - #2362: optional composed capability registry
825
+ * (capabilityClusters view). When present, installed third-party capability
826
+ * skills bound to their declaring capId are unioned into the staged output —
827
+ * the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills)
828
+ * this bespoke OpenCode/Kilo writer never called. Absent -> no third-party
829
+ * skills staged (fail closed), matching the seam's own optional-registry
830
+ * contract.
701
831
  * @returns number of gsd-* skill directories written
702
832
  */
703
- function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined) {
833
+ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined, resolvedProfile, capabilityRegistry) {
704
834
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
705
835
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
706
836
  if (!skillsKindEntry)
@@ -721,8 +851,9 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
721
851
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
722
852
  // Symlink-escape guard: reject if any path component between targetDir and
723
853
  // dest is a symlink that would redirect writes outside the config root.
724
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest)) {
725
- throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
854
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
855
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
856
+ throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
726
857
  }
727
858
  node_fs_1.default.mkdirSync(dest, { recursive: true });
728
859
  // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
@@ -742,10 +873,12 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
742
873
  }
743
874
  _removeGsdEntries(dest, skillsKindEntry);
744
875
  let count = 0;
876
+ const firstPartyStems = new Set();
745
877
  for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
746
878
  if (!entry.isFile() || !entry.name.endsWith('.md'))
747
879
  continue;
748
880
  const stem = entry.name.slice(0, -3);
881
+ firstPartyStems.add(stem);
749
882
  const skillName = `${skillsKindEntry.prefix}${stem}`;
750
883
  let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
751
884
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
@@ -756,6 +889,51 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
756
889
  node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
757
890
  count++;
758
891
  }
892
+ // #2362: materialize installed THIRD-PARTY capability skills, bound to their
893
+ // DECLARING capability via the registry's capabilityClusters view — mirrors
894
+ // install-profiles.cts stageSkillsForRuntimeAsSkills's third-party fill-in
895
+ // (the actual #2322 seam), reusing its exported security-reviewed helpers
896
+ // rather than hand-rolling a second scan (DEFECT.GENERATIVE-FIX guard).
897
+ // First-party always wins on stem collision. The full/'*' sentinel resolves
898
+ // through capabilityClusterStems (BLOCKER-2 parity: `resolveProfile`
899
+ // short-circuits `full` to `'*'` before consulting a registry, so a bare
900
+ // `resolvedProfile.skills !== '*'` gate would silently skip this pass for
901
+ // the default full install). No registry in scope -> stage NOTHING
902
+ // third-party (fail closed — never fall back to scanning).
903
+ //
904
+ // Unlike the seam (which stages third-party bodies as-is and relies on a
905
+ // later applySurface rewrite pass), this install path has no such later
906
+ // pass — so third-party bodies get the SAME inline path-prefix/attribution
907
+ // rewrite as first-party ones for on-disk parity. They do NOT go through
908
+ // `converter`: an installed capability skill is already a complete
909
+ // SKILL.md, not a Claude-command body awaiting frontmatter conversion.
910
+ if (capabilityRegistry) {
911
+ const candidateStems = resolvedProfile && resolvedProfile.skills === '*'
912
+ ? installProfiles.capabilityClusterStems(capabilityRegistry)
913
+ : (resolvedProfile && resolvedProfile.skills) || [];
914
+ for (const stem of candidateStems) {
915
+ if (firstPartyStems.has(stem))
916
+ continue; // first-party always wins
917
+ const found = installProfiles.readInstalledCapabilitySkill(stem, capabilityRegistry);
918
+ if (found === null)
919
+ continue; // absent/malformed/unowned -> skip gracefully
920
+ const skillName = `${skillsKindEntry.prefix}${stem}`;
921
+ if (!(0, external_descriptor_trust_cjs_1.isPathConfined)(skillName, dest))
922
+ continue; // defense-in-depth
923
+ let content = found.content;
924
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
925
+ content = processAttribution(content, resolveAttribution(runtime));
926
+ const skillDir = node_path_1.default.join(dest, skillName);
927
+ node_fs_1.default.mkdirSync(skillDir, { recursive: true });
928
+ node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
929
+ // #2322 HIGH-3 parity: persist the capability-owned marker so a later
930
+ // prune pass can identify this directory even once the owning
931
+ // capability is uninstalled/unsurfaced and no longer appears in any
932
+ // registry view.
933
+ node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
934
+ count++;
935
+ }
936
+ }
759
937
  // Restore user-owned dirs after the prune+copy.
760
938
  for (const [dirName, snap] of toPreserve) {
761
939
  _restoreDir(node_path_1.default.join(dest, dirName), snap);
@@ -847,13 +1025,97 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
847
1025
  if (np && np.source) {
848
1026
  const pluginSrc = node_path_1.default.join(src, np.source);
849
1027
  if (node_fs_1.default.existsSync(pluginSrc)) {
850
- const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
851
- node_fs_1.default.mkdirSync(destDir, { recursive: true });
852
- node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
1028
+ // Confine the FULL dest path (dir + file), not just the dir. Previously
1029
+ // only `np.dir` was validated and `np.file` was joined on unchecked, so a
1030
+ // descriptor whose `file` carried `..`, an absolute path, or a NUL byte
1031
+ // would have written outside configHome. Not reachable today — descriptors
1032
+ // are first-party and compiled into the capability registry at build time —
1033
+ // but `np.file` is exactly the field #2470 changes, and the guard costs
1034
+ // nothing. For a well-formed descriptor this resolves identically to the
1035
+ // previous mkdir(dir) + join(dir, file).
1036
+ const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.join(np.dir, np.file));
1037
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(destPath), { recursive: true });
1038
+ node_fs_1.default.copyFileSync(pluginSrc, destPath);
853
1039
  }
854
1040
  }
855
1041
  }
856
1042
  // ---------------------------------------------------------------------------
1043
+ // _migrateLegacyOpencodeCommandDir
1044
+ // ---------------------------------------------------------------------------
1045
+ /**
1046
+ * #2329: migrate a pre-fix OpenCode install's legacy singular `command/`
1047
+ * command directory into the current descriptor-driven destination (plural
1048
+ * `commands/` for OpenCode — the dir OpenCode actually discovers slash
1049
+ * commands from; unaffected for Kilo, whose descriptor still declares
1050
+ * `command`, so `currentName === LEGACY_NAME` short-circuits below).
1051
+ *
1052
+ * Runs BEFORE materialization writes the fresh command set to the new
1053
+ * location (mirroring `_runLegacyInstallMigrations`'s ordering for the
1054
+ * generic branch, which combined-family runtimes otherwise skip entirely).
1055
+ *
1056
+ * Ownership safety mirrors installer-migrations 003
1057
+ * (rename-get-shit-done-to-gsd-core): only files present, and unchanged or
1058
+ * locally modified, in the PRIOR install manifest under the legacy
1059
+ * `command/<file>` key are removed here — the materialization call
1060
+ * immediately following writes the current command set fresh into the new
1061
+ * location, so removing the stale copies is safe. Anything not proven
1062
+ * manifest-managed (unrelated user content someone dropped into `command/`)
1063
+ * is left untouched, never deleted. The emptied legacy directory is removed
1064
+ * only once nothing else is left inside it.
1065
+ *
1066
+ * Implemented as inline pre-materialization cleanup rather than a
1067
+ * `src/installer-migrations/*.cts` record: the formal migrations framework
1068
+ * only ever DELETES individual files (never directories, and never a
1069
+ * relocate/move primitive — see docs/installer-migrations.md's Action
1070
+ * Types), so the empty-directory removal below would need this same
1071
+ * hand-written glue regardless. It also intentionally is NOT reachable via
1072
+ * combinedFamilyInstall's early return above `_runLegacyInstallMigrations`,
1073
+ * matching the existing precedent that OpenCode/Kilo's bespoke install path
1074
+ * owns its own legacy cleanup rather than routing through the generic
1075
+ * layout-driven migrations hook.
1076
+ */
1077
+ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
1078
+ const LEGACY_NAME = 'command';
1079
+ const currentName = behaviors.flatCommandDir || LEGACY_NAME;
1080
+ if (currentName === LEGACY_NAME)
1081
+ return; // e.g. Kilo — legacy IS the current location; nothing to migrate
1082
+ const legacyDir = node_path_1.default.join(configDir, LEGACY_NAME);
1083
+ if (!node_fs_1.default.existsSync(legacyDir))
1084
+ return;
1085
+ // Never follow a symlinked legacy dir out of configDir.
1086
+ if (node_fs_1.default.lstatSync(legacyDir).isSymbolicLink())
1087
+ return;
1088
+ const manifest = installerMigrations.readInstallManifest(configDir);
1089
+ let entries;
1090
+ try {
1091
+ entries = node_fs_1.default.readdirSync(legacyDir, { withFileTypes: true });
1092
+ }
1093
+ catch {
1094
+ return;
1095
+ }
1096
+ for (const entry of entries) {
1097
+ // command/ is a flat directory of gsd-*.md files; skip anything that
1098
+ // isn't a plain file (nested dirs, symlinks) rather than guess intent.
1099
+ if (!entry.isFile())
1100
+ continue;
1101
+ const relPath = `${LEGACY_NAME}/${entry.name}`;
1102
+ const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
1103
+ if (classification === 'managed-pristine' || classification === 'managed-modified') {
1104
+ try {
1105
+ node_fs_1.default.unlinkSync(node_path_1.default.join(legacyDir, entry.name));
1106
+ }
1107
+ catch { /* best-effort */ }
1108
+ }
1109
+ // 'unknown' (not manifest-tracked) is left untouched — GSD cannot prove
1110
+ // ownership, so it must never be deleted as collateral damage.
1111
+ }
1112
+ try {
1113
+ if (node_fs_1.default.readdirSync(legacyDir).length === 0)
1114
+ node_fs_1.default.rmdirSync(legacyDir);
1115
+ }
1116
+ catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
1117
+ }
1118
+ // ---------------------------------------------------------------------------
857
1119
  // installOpencodeFamilyArtifacts
858
1120
  // ---------------------------------------------------------------------------
859
1121
  /**
@@ -869,8 +1131,13 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
869
1131
  * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
870
1132
  * @param resolveAttribution - injection: (runtime) => attribution string | undefined
871
1133
  * @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
1134
+ * @param capabilityRegistry - #2362: optional composed capability registry
1135
+ * (capabilityClusters view), threaded straight through to
1136
+ * installOpencodeFamilySkills so an installed third-party capability skill
1137
+ * materializes for this combined-family (OpenCode/Kilo) install path too.
1138
+ * Absent -> no third-party skills staged (fail closed).
872
1139
  */
873
- function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
1140
+ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}, capabilityRegistry) {
874
1141
  const isGlobal = scope === 'global';
875
1142
  // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
876
1143
  // (via the .gsd-source marker or a walk-up from __dirname) — every other
@@ -887,9 +1154,16 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
887
1154
  resolvedTarget: (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(configDir)),
888
1155
  homeDir: (0, shell_command_projection_cjs_1.posixNormalize)(node_os_1.default.homedir()),
889
1156
  });
890
- const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
1157
+ // #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir
1158
+ // descriptor value read by writeManifest's manifest-key prefix and by
1159
+ // resolveRuntimeArtifactLayout's commands-kind destSubpath — a hardcoded
1160
+ // literal here would silently diverge from the descriptor the moment either
1161
+ // is edited (Generative Fix Divergence guard). OpenCode uses 'commands'
1162
+ // (plural, the dir OpenCode actually discovers slash commands from); Kilo
1163
+ // keeps its own descriptor value ('command', singular) unchanged.
1164
+ const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, behaviors.flatCommandDir || 'command');
891
1165
  installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
892
- installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
1166
+ installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
893
1167
  _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
894
1168
  }
895
1169
  // ---------------------------------------------------------------------------
@@ -953,6 +1227,7 @@ module.exports = {
953
1227
  _hostBehaviors,
954
1228
  _copyStaged,
955
1229
  hasExistingSymlinkBetween,
1230
+ isSymlinkedDestOptIn,
956
1231
  preserveUserArtifacts,
957
1232
  restoreUserArtifacts,
958
1233
  migrateLegacyDevPreferencesToSkill,