@opengsd/gsd-core 1.7.0-rc.6 → 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 (195) 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 +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -27,6 +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");
31
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
32
+ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
30
33
  const { processAttribution } = runtimeArtifactConversion;
31
34
  // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
32
35
  // test stubs that monkeypatch the module's exports are seen at call time.
@@ -155,19 +158,93 @@ function restoreUserArtifacts(destDir, saved) {
155
158
  // ---------------------------------------------------------------------------
156
159
  // Symlink-escape guard
157
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
+ }
158
191
  /**
159
192
  * Returns true if any path component between `root` and `fullPath` is a
160
- * 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).
161
201
  */
162
- function hasExistingSymlinkBetween(root, fullPath) {
202
+ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
163
203
  const resolvedRoot = node_path_1.default.resolve(root);
164
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)).
165
208
  if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
166
209
  return true;
167
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.
168
236
  let cursor = resolvedRoot;
169
237
  if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
170
- 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
+ }
171
248
  }
172
249
  const relative = node_path_1.default.relative(resolvedRoot, resolvedFullPath);
173
250
  for (const segment of relative.split(node_path_1.default.sep)) {
@@ -176,8 +253,37 @@ function hasExistingSymlinkBetween(root, fullPath) {
176
253
  cursor = node_path_1.default.join(cursor, segment);
177
254
  if (!node_fs_1.default.existsSync(cursor))
178
255
  return false;
179
- if (node_fs_1.default.lstatSync(cursor).isSymbolicLink())
180
- 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
+ }
181
287
  }
182
288
  return false;
183
289
  }
@@ -225,8 +331,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
225
331
  return false;
226
332
  // Symlink-escape guard: reject if any path component between targetDir and
227
333
  // skillDir is a symlink that would redirect writes outside the config root.
228
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir)) {
229
- 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.`);
230
337
  }
231
338
  try {
232
339
  node_fs_1.default.mkdirSync(skillDir, { recursive: true });
@@ -274,8 +381,9 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
274
381
  const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
275
382
  // Symlink-escape guard: reject if any path component between the install root and
276
383
  // destDir is a symlink that would redirect writes outside the install root.
277
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest)) {
278
- 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.`);
279
387
  }
280
388
  // Use the validated absolute path for the actual writes below.
281
389
  destDir = resolvedDest;
@@ -559,20 +667,29 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
559
667
  * @param scope
560
668
  * @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
561
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.
562
675
  */
563
- function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
676
+ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry) {
564
677
  // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
565
678
  // the dedicated combined commands+skills+plugin orchestrator instead of the
566
679
  // generic layout-driven loop below, mirroring the bespoke install path that
567
680
  // previously lived inline in bin/install.js.
568
681
  const behaviors = _hostBehaviors(runtime);
569
682
  if (behaviors.combinedFamilyInstall) {
570
- 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);
571
688
  return;
572
689
  }
573
690
  // Legacy cleanup before layout-driven writes
574
691
  _runLegacyInstallMigrations(runtime, configDir, scope);
575
- const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
692
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
576
693
  const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
577
694
  // `Layout` is structurally identical across the layout/install-plan .cjs
578
695
  // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
@@ -602,8 +719,11 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
602
719
  // resolved alternate root instead, matching assertDestWithinConfigHome's
603
720
  // own root selection in createRuntimeArtifactInstallPlan.
604
721
  const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
605
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest)) {
606
- 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.`);
607
727
  }
608
728
  node_fs_1.default.mkdirSync(dest, { recursive: true });
609
729
  if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
@@ -697,9 +817,20 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
697
817
  * @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
698
818
  * @param pathPrefix - computed config-path prefix for body rewrites
699
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.
700
831
  * @returns number of gsd-* skill directories written
701
832
  */
702
- function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined) {
833
+ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined, resolvedProfile, capabilityRegistry) {
703
834
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
704
835
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
705
836
  if (!skillsKindEntry)
@@ -720,8 +851,9 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
720
851
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
721
852
  // Symlink-escape guard: reject if any path component between targetDir and
722
853
  // dest is a symlink that would redirect writes outside the config root.
723
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest)) {
724
- 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.`);
725
857
  }
726
858
  node_fs_1.default.mkdirSync(dest, { recursive: true });
727
859
  // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
@@ -741,10 +873,12 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
741
873
  }
742
874
  _removeGsdEntries(dest, skillsKindEntry);
743
875
  let count = 0;
876
+ const firstPartyStems = new Set();
744
877
  for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
745
878
  if (!entry.isFile() || !entry.name.endsWith('.md'))
746
879
  continue;
747
880
  const stem = entry.name.slice(0, -3);
881
+ firstPartyStems.add(stem);
748
882
  const skillName = `${skillsKindEntry.prefix}${stem}`;
749
883
  let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
750
884
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
@@ -755,6 +889,51 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
755
889
  node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
756
890
  count++;
757
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
+ }
758
937
  // Restore user-owned dirs after the prune+copy.
759
938
  for (const [dirName, snap] of toPreserve) {
760
939
  _restoreDir(node_path_1.default.join(dest, dirName), snap);
@@ -846,13 +1025,97 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
846
1025
  if (np && np.source) {
847
1026
  const pluginSrc = node_path_1.default.join(src, np.source);
848
1027
  if (node_fs_1.default.existsSync(pluginSrc)) {
849
- const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
850
- node_fs_1.default.mkdirSync(destDir, { recursive: true });
851
- 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);
852
1039
  }
853
1040
  }
854
1041
  }
855
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
+ // ---------------------------------------------------------------------------
856
1119
  // installOpencodeFamilyArtifacts
857
1120
  // ---------------------------------------------------------------------------
858
1121
  /**
@@ -868,8 +1131,13 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
868
1131
  * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
869
1132
  * @param resolveAttribution - injection: (runtime) => attribution string | undefined
870
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).
871
1139
  */
872
- function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
1140
+ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}, capabilityRegistry) {
873
1141
  const isGlobal = scope === 'global';
874
1142
  // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
875
1143
  // (via the .gsd-source marker or a walk-up from __dirname) — every other
@@ -883,12 +1151,19 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
883
1151
  isGlobal,
884
1152
  isOpencode: behaviors.skipHomePrefixSubstitution === true,
885
1153
  isWindowsHost: process.platform === 'win32',
886
- resolvedTarget: node_path_1.default.resolve(configDir).replace(/\\/g, '/'),
887
- homeDir: node_os_1.default.homedir().replace(/\\/g, '/'),
1154
+ resolvedTarget: (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(configDir)),
1155
+ homeDir: (0, shell_command_projection_cjs_1.posixNormalize)(node_os_1.default.homedir()),
888
1156
  });
889
- 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');
890
1165
  installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
891
- installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
1166
+ installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
892
1167
  _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
893
1168
  }
894
1169
  // ---------------------------------------------------------------------------
@@ -952,6 +1227,7 @@ module.exports = {
952
1227
  _hostBehaviors,
953
1228
  _copyStaged,
954
1229
  hasExistingSymlinkBetween,
1230
+ isSymlinkedDestOptIn,
955
1231
  preserveUserArtifacts,
956
1232
  restoreUserArtifacts,
957
1233
  migrateLegacyDevPreferencesToSkill,