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