@opengsd/gsd-core 1.9.1 → 1.11.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 (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -28,8 +28,28 @@ 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
30
  const installerMigrations = require("./installer-migrations.cjs");
31
+ const retiredArtifactCleanup = require("./retired-artifact-cleanup.cjs");
31
32
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
32
33
  const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
34
+ const commonjs_marker_cjs_1 = require("./commonjs-marker.cjs");
35
+ // #2874 (ADR-58 cleanup phase): the injectable fs seam for the
36
+ // installRuntimeArtifacts call tree. `installFs()` resolves to real
37
+ // `node:fs` unless a call is wrapped in `withInstallFs(deps.fs, ...)` —
38
+ // every fs call below in this file that installRuntimeArtifacts's own call
39
+ // tree reaches goes through it. See install-fs-adapter.cts's module doc for
40
+ // why this is an ambient swap rather than a threaded `deps` parameter.
41
+ const installFsAdapter = require("./install-fs-adapter.cjs");
42
+ const { installFs, withInstallFs } = installFsAdapter;
43
+ // #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
44
+ // across the preserve -> wipe -> restore window (#1874-F19). See
45
+ // user-artifact-staging.cts's module doc.
46
+ const userArtifactStaging = require("./user-artifact-staging.cjs");
47
+ // #2870: InstallScope is owned by install-scope.cts, not re-declared here.
48
+ // `isGlobalScope` centralizes the `scope === 'global'` boolean projection
49
+ // this module's two remaining re-derivation sites need (see the
50
+ // module-level doc comment on `isGlobalScope` for why the projection is
51
+ // centralized rather than eliminated).
52
+ const install_scope_cjs_1 = require("./install-scope.cjs");
33
53
  const { processAttribution } = runtimeArtifactConversion;
34
54
  // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
35
55
  // test stubs that monkeypatch the module's exports are seen at call time.
@@ -48,8 +68,9 @@ const { getDirName } = runtimeNamePolicy;
48
68
  *
49
69
  * Invariant: a file is either distribution (manifest-tracked, diff'd against
50
70
  * manifest) or user artifact (preserved across installs, never diff'd). Never
51
- * both. Both preserveUserArtifacts call sites and writeManifest must agree on
52
- * this list, which is why it lives here as a single constant.
71
+ * both. Both the user-artifact-staging.cts call sites (#2875) and
72
+ * writeManifest must agree on this list, which is why it lives here as a
73
+ * single constant.
53
74
  *
54
75
  * Paths are relative to the gsd-core/ directory.
55
76
  */
@@ -118,45 +139,6 @@ const SKILLS_CONVERTER_REGISTRY = {
118
139
  convertClaudeCommandToKimiCodeSkill: runtimeArtifactConversion.convertClaudeCommandToKimiCodeSkill,
119
140
  };
120
141
  // ---------------------------------------------------------------------------
121
- // User-artifact preservation helpers
122
- // ---------------------------------------------------------------------------
123
- /**
124
- * Save user-generated files from destDir to an in-memory map before a wipe.
125
- *
126
- * @param destDir - Directory that is about to be wiped
127
- * @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
128
- * @returns Map of fileName → file content (only entries that existed)
129
- */
130
- function preserveUserArtifacts(destDir, fileNames) {
131
- const saved = new Map();
132
- for (const name of fileNames) {
133
- const fullPath = node_path_1.default.join(destDir, name);
134
- if (node_fs_1.default.existsSync(fullPath)) {
135
- try {
136
- saved.set(name, node_fs_1.default.readFileSync(fullPath, 'utf8'));
137
- }
138
- catch { /* skip unreadable files */ }
139
- }
140
- }
141
- return saved;
142
- }
143
- /**
144
- * Restore user-generated files saved by preserveUserArtifacts after a wipe.
145
- *
146
- * @param destDir - Directory that was wiped and recreated
147
- * @param saved - Map returned by preserveUserArtifacts
148
- */
149
- function restoreUserArtifacts(destDir, saved) {
150
- for (const [name, content] of saved) {
151
- const fullPath = node_path_1.default.join(destDir, name);
152
- try {
153
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(fullPath), { recursive: true });
154
- node_fs_1.default.writeFileSync(fullPath, content, 'utf8');
155
- }
156
- catch { /* skip unwritable paths */ }
157
- }
158
- }
159
- // ---------------------------------------------------------------------------
160
142
  // Symlink-escape guard
161
143
  // ---------------------------------------------------------------------------
162
144
  /**
@@ -189,6 +171,23 @@ function isSymlinkedDestOptIn() {
189
171
  const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
190
172
  return v === '1' || v === 'true';
191
173
  }
174
+ /**
175
+ * `lstatSync`, never following a symlink, returning `null` instead of
176
+ * throwing when `p` does not exist AT ALL (not even as a dangling symlink).
177
+ * Unlike `existsSync` (which follows symlinks and reports `false` for a
178
+ * dangling one), this correctly distinguishes "nothing here" from "a
179
+ * symlink is here, even if its target is missing" — see
180
+ * `hasExistingSymlinkBetween`'s own doc comment for why that distinction is
181
+ * security-load-bearing.
182
+ */
183
+ function tryLstat(p) {
184
+ try {
185
+ return installFs().lstatSync(p);
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
192
191
  /**
193
192
  * Returns true if any path component between `root` and `fullPath` is a
194
193
  * symbolic link that would redirect writes outside the install root in a way
@@ -220,7 +219,7 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
220
219
  // — threat (a) above still confines regardless.
221
220
  let realRoot;
222
221
  try {
223
- realRoot = node_fs_1.default.existsSync(resolvedRoot) ? node_fs_1.default.realpathSync(resolvedRoot) : resolvedRoot;
222
+ realRoot = installFs().existsSync(resolvedRoot) ? installFs().realpathSync(resolvedRoot) : resolvedRoot;
224
223
  }
225
224
  catch {
226
225
  realRoot = resolvedRoot;
@@ -234,12 +233,28 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
234
233
  // circular back-reference to root from a path that descends from a resolved
235
234
  // root. So under opt-in, just follow the root symlink and continue the walk.
236
235
  // Default behavior (no opt-in) preserves the pre-#2393 refuse.
236
+ // #2875 defect fix: `existsSync` FOLLOWS symlinks and returns `false` for a
237
+ // DANGLING symlink (one whose target does not exist) — so the pre-fix
238
+ // `existsSync(cursor) && lstatSync(cursor).isSymbolicLink()` ordering used
239
+ // below (both here for `root` and in the per-segment loop) silently
240
+ // treated a dangling symlink as "nothing here", never even reaching the
241
+ // `lstatSync` symlink check. That let a dangling symlink planted AT a
242
+ // write destination — e.g. `<configDir>/USER-PROFILE.md ->
243
+ // <outside>/authorized_keys` — sail through this guard, after which the
244
+ // actual write (`copyFileSync` et al., which DOES follow symlinks) created
245
+ // attacker-controlled content outside the install root. `lstatSync` itself
246
+ // never follows a symlink and succeeds for a dangling one, so probing with
247
+ // it FIRST (falling back to "does not exist at all" only on ENOENT/similar)
248
+ // detects the dangling case correctly while preserving the exact same
249
+ // "cursor does not exist, stop walking" behavior for a path that truly has
250
+ // nothing there.
237
251
  let cursor = resolvedRoot;
238
- if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
252
+ const cursorLstat = tryLstat(cursor);
253
+ if (cursorLstat && cursorLstat.isSymbolicLink()) {
239
254
  if (!allowFollow)
240
255
  return true;
241
256
  try {
242
- cursor = node_fs_1.default.realpathSync(cursor);
257
+ cursor = installFs().realpathSync(cursor);
243
258
  }
244
259
  catch {
245
260
  // realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
@@ -252,9 +267,10 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
252
267
  if (!segment)
253
268
  continue;
254
269
  cursor = node_path_1.default.join(cursor, segment);
255
- if (!node_fs_1.default.existsSync(cursor))
270
+ const segmentLstat = tryLstat(cursor);
271
+ if (!segmentLstat)
256
272
  return false;
257
- if (node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
273
+ if (segmentLstat.isSymbolicLink()) {
258
274
  if (!allowFollow)
259
275
  return true;
260
276
  // Opt-in active: follow the symlink. Refuse if the resolved target is the
@@ -276,7 +292,7 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
276
292
  // documented opt-in semantics; do not add a "follow one symlink only"
277
293
  // expectation here without revisiting the threat model.
278
294
  try {
279
- const realTarget = node_fs_1.default.realpathSync(cursor);
295
+ const realTarget = installFs().realpathSync(cursor);
280
296
  if (realTarget === realRoot || realTarget === resolvedRoot)
281
297
  return true; // (b)
282
298
  cursor = realTarget;
@@ -289,6 +305,56 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
289
305
  return false;
290
306
  }
291
307
  // ---------------------------------------------------------------------------
308
+ // User-artifact staging root
309
+ // ---------------------------------------------------------------------------
310
+ /**
311
+ * Resolve the durable staging root for `configDir` (#2875 / user-artifact-
312
+ * staging.cts), confined via the SAME `assertDestWithinConfigHome` gate every
313
+ * other write on this call tree uses, and refused via the SAME
314
+ * `hasExistingSymlinkBetween` guard `_copyStaged`/
315
+ * `migrateLegacyDevPreferencesToSkill` already apply to their own writes
316
+ * (test-matrix E1/E4) — this module never reimplements either decision, only
317
+ * reuses them (user-artifact-staging.cts's own module doc, "Confinement").
318
+ *
319
+ * Fixed location: `<configDir>/.gsd-staging/user-artifacts/` — a sibling of
320
+ * every directory this phase's four call sites wipe, so staging survives all
321
+ * of them while staying inside configDir (40-design.md "Staging location").
322
+ */
323
+ function _resolveUserArtifactStagingRoot(configDir) {
324
+ const stagingRoot = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.posix.join('.gsd-staging', 'user-artifacts'));
325
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
326
+ throw new Error(`_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
327
+ }
328
+ return stagingRoot;
329
+ }
330
+ /**
331
+ * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` (defect
332
+ * fix — a hostile/broken `.gsd-staging` path, or a symlinked configDir
333
+ * itself, e.g. nix-darwin/dotfiles-managed `~/.claude`, GSD_ALLOW_SYMLINKED_DEST's
334
+ * own population) must never brick the command it is called from. Before
335
+ * this fix `_resolveUserArtifactStagingRoot` was called UNGUARDED as the
336
+ * first statement of both `install()` and `uninstall()` (bin/install.js) —
337
+ * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands,
338
+ * including uninstall, the remedy for the first problem.
339
+ *
340
+ * Returns `null` (never throws) when staging is unavailable, logging ONE
341
+ * warning naming the underlying cause. Every call site MUST treat `null` as
342
+ * "skip the staging-dependent step for this run" — the same "degrade,
343
+ * never throw" posture user-artifact-staging.cts's own recovery/restore
344
+ * functions already document (module doc "Failure posture"), extended to
345
+ * cover staging-ROOT resolution itself, not just the copy/restore that
346
+ * follows it.
347
+ */
348
+ function _tryResolveUserArtifactStagingRoot(configDir) {
349
+ try {
350
+ return _resolveUserArtifactStagingRoot(configDir);
351
+ }
352
+ catch (err) {
353
+ console.warn(` [gsd] user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
354
+ return null;
355
+ }
356
+ }
357
+ // ---------------------------------------------------------------------------
292
358
  // migrateLegacyDevPreferencesToSkill
293
359
  // ---------------------------------------------------------------------------
294
360
  /**
@@ -306,39 +372,99 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
306
372
  * migration so callers can log a one-line confirmation.
307
373
  *
308
374
  * @param targetDir - Resolved runtime config directory (e.g. ~/.claude)
309
- * @param saved - Map returned by preserveUserArtifacts
375
+ * @param saved - Map of fileName -> content, built by the caller from a
376
+ * user-artifact-staging.cts staged batch's disk contents (#2875) — every
377
+ * call site reads this back AFTER its own wipe, never held in memory
378
+ * across it.
310
379
  * @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
311
380
  * @param scope - install scope
312
381
  * @returns true if a file was migrated, false otherwise
313
382
  */
314
- function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
315
- if (!saved || !saved.has('dev-preferences.md'))
316
- return false;
383
+ /**
384
+ * Resolve the `{ skillFile, installRoot }` `migrateLegacyDevPreferencesToSkill`
385
+ * would target for `(targetDir, runtime, scope)`, WITHOUT performing any
386
+ * write. Extracted (#2875 defect fix) purely as a resolution helper so a
387
+ * caller can determine whether migration is even POSSIBLE for this
388
+ * runtime/scope, and whether it is already SATISFIED (a skill file already
389
+ * present), BEFORE deciding whether discarding a staged legacy copy would
390
+ * lose the user's file — `migrateLegacyDevPreferencesToSkill`'s own boolean
391
+ * return conflates "no skills layout for this runtime" with "the write
392
+ * failed" with "already migrated": all three return `false` today, and
393
+ * changing that return SHAPE would also change bin/install.js's own
394
+ * `if (migrateLegacyDevPreferencesToSkill(...))` call site, which this
395
+ * module does not own. This helper changes nothing about
396
+ * `migrateLegacyDevPreferencesToSkill`'s own signature or behavior — it is
397
+ * now IMPLEMENTED in terms of this helper, so there is exactly one copy of
398
+ * the resolution logic, never two that could drift.
399
+ *
400
+ * @returns `{ skillFile, installRoot }`, or `null` if this runtime/scope has
401
+ * no skills layout to migrate into (mirrors `migrateLegacyDevPreferencesToSkill`'s
402
+ * own early return for that case).
403
+ */
404
+ function _resolveDevPreferencesSkillTarget(targetDir, runtime, scope = 'global') {
317
405
  let skillDir;
406
+ // #2911: the actual install root the skill dir resolves under — defaults to
407
+ // targetDir, but a skills-kind `home` override (e.g. Codex -> $HOME/.agents)
408
+ // moves it entirely outside targetDir. Every confinement/guard check below
409
+ // must confine against installRoot, not targetDir, or it would flag the
410
+ // legitimate override destination as an escape.
411
+ let installRoot = targetDir;
318
412
  if (runtime) {
319
413
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope);
320
414
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
321
415
  if (!skillsKindEntry)
322
- return false; // runtime has no skills layout at this scope (e.g. cline local)
416
+ return null; // runtime has no skills layout at this scope (e.g. cline local)
323
417
  const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
324
- skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
418
+ // #2911: same destination-root defect as _copyStaged/applySurface — honor
419
+ // skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
420
+ // -> $HOME/.agents) instead of always resolving against targetDir, so a
421
+ // legacy dev-preferences migration lands in the SAME tree the installer
422
+ // and surface-apply use. Runtimes with no `home` override are unaffected.
423
+ installRoot = skillsKindEntry.home ?? targetDir;
424
+ skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath), stemName);
325
425
  }
326
426
  else {
327
427
  // Legacy fallback for callers that have not yet been updated to pass runtime
328
428
  skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
329
429
  }
330
- const skillFile = node_path_1.default.join(skillDir, 'SKILL.md');
331
- if (node_fs_1.default.existsSync(skillFile))
430
+ return { skillFile: node_path_1.default.join(skillDir, 'SKILL.md'), installRoot };
431
+ }
432
+ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
433
+ if (!saved || !saved.has('dev-preferences.md'))
332
434
  return false;
333
- // Symlink-escape guard: reject if any path component between targetDir and
334
- // skillDir is a symlink that would redirect writes outside the config root.
435
+ const target = _resolveDevPreferencesSkillTarget(targetDir, runtime, scope);
436
+ if (!target)
437
+ return false; // runtime has no skills layout at this scope (e.g. cline local)
438
+ const { skillFile, installRoot } = target;
439
+ const skillDir = node_path_1.default.dirname(skillFile);
440
+ // Security fix: `existsSync` FOLLOWS symlinks and reports `false` for a
441
+ // DANGLING one, so the prior `existsSync(skillFile)` check never even saw a
442
+ // dangling symlink planted AT the leaf (e.g.
443
+ // `<installRoot>/skills/gsd-dev-preferences/SKILL.md ->
444
+ // ~/.ssh/authorized_keys`) — it fell through past this "already migrated"
445
+ // bail, past the symlink-escape guard below (which only walks to `skillDir`,
446
+ // the parent DIRECTORY, and never lstats the leaf FILE itself), and into
447
+ // `writeFileSync`, which DOES follow symlinks and would have written
448
+ // attacker-chosen `saved` content to the symlink's target. `tryLstat` never
449
+ // follows a symlink and distinguishes "a real file is already here" (skip,
450
+ // same as before) from "a symlink (dangling or not) is planted here"
451
+ // (refuse — this is never a legitimate prior-migration state).
452
+ const skillFileLstat = tryLstat(skillFile);
453
+ if (skillFileLstat) {
454
+ if (skillFileLstat.isSymbolicLink()) {
455
+ throw new Error(`migrateLegacyDevPreferencesToSkill: skillFile "${skillFile}" is a symlink — refusing to write dev-preferences.md content through it (would follow the link and write to its target).`);
456
+ }
457
+ return false; // a real file is already there — already migrated, skip
458
+ }
459
+ // Symlink-escape guard: reject if any path component between installRoot and
460
+ // skillDir is a symlink that would redirect writes outside the install root.
335
461
  // #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.`);
462
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
463
+ throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" 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.`);
338
464
  }
339
465
  try {
340
- node_fs_1.default.mkdirSync(skillDir, { recursive: true });
341
- node_fs_1.default.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
466
+ installFs().mkdirSync(skillDir, { recursive: true });
467
+ installFs().writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
342
468
  return true;
343
469
  }
344
470
  catch {
@@ -388,31 +514,32 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
388
514
  }
389
515
  // Use the validated absolute path for the actual writes below.
390
516
  destDir = resolvedDest;
391
- if (!node_fs_1.default.existsSync(stagedDir))
517
+ if (!installFs().existsSync(stagedDir))
392
518
  return;
393
- node_fs_1.default.mkdirSync(destDir, { recursive: true });
519
+ installFs().mkdirSync(destDir, { recursive: true });
394
520
  if (kind.kind === 'skills') {
395
521
  // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
396
- for (const entry of node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true })) {
522
+ for (const entry of installFs().readdirSync(stagedDir, { withFileTypes: true })) {
397
523
  if (!entry.isDirectory())
398
524
  continue;
399
525
  const src = node_path_1.default.join(stagedDir, entry.name);
400
526
  const dest = node_path_1.default.join(destDir, entry.name);
401
- node_fs_1.default.cpSync(src, dest, { recursive: true });
527
+ installFs().cpSync(src, dest, { recursive: true });
402
528
  }
403
529
  return;
404
530
  }
405
531
  if (kind.kind === 'kimi-agents') {
406
- node_fs_1.default.cpSync(stagedDir, destDir, { recursive: true });
532
+ installFs().cpSync(stagedDir, destDir, { recursive: true });
407
533
  return;
408
534
  }
409
535
  // commands or agents
410
- const entries = node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true });
536
+ const entries = installFs().readdirSync(stagedDir, { withFileTypes: true });
411
537
  // For commands: apply prefix unless the destSubpath's last segment already
412
538
  // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
413
- const destLast = node_path_1.default.basename(kind.destSubpath);
414
- const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
415
- const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
539
+ // Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
540
+ // Phase 2 review finding — this rule previously drifted independently
541
+ // across install-engine.cts / surface.cts / runtime-artifact-layout.cts).
542
+ const namespacedByDir = runtimeArtifactLayout.isNamespacedByDir(kind.kind, kind.destSubpath, kind.prefix);
416
543
  for (const entry of entries) {
417
544
  if (!entry.isFile())
418
545
  continue;
@@ -431,15 +558,16 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
431
558
  ? entry.name.replace(/\.md$/, _agentExt)
432
559
  : entry.name;
433
560
  }
434
- else if (namespacedByDir) {
435
- // Directory is the namespace; don't double-prefix the filename
436
- destName = entry.name;
437
- }
438
561
  else {
439
- // Flat commands directory (e.g. command/ for opencode/kilo)
440
- destName = `${kind.prefix}${stem}.md`;
562
+ // Commands: filename composition (namespacedByDir ? `${stem}.md` :
563
+ // `${prefix}${stem}.md`) is single-sourced with resolveTriggerSurface's
564
+ // destPath prediction via composeCommandFilename (#2871 Phase 2 review
565
+ // finding). Byte-identical to the prior separate namespacedByDir/flat
566
+ // branches — see that helper's doc comment for why the namespacedByDir
567
+ // case reconstructing `${stem}.md` is always exactly `entry.name`.
568
+ destName = runtimeArtifactLayout.composeCommandFilename(namespacedByDir, kind.prefix, stem);
441
569
  }
442
- node_fs_1.default.copyFileSync(node_path_1.default.join(stagedDir, entry.name), node_path_1.default.join(destDir, destName));
570
+ installFs().copyFileSync(node_path_1.default.join(stagedDir, entry.name), node_path_1.default.join(destDir, destName));
443
571
  }
444
572
  }
445
573
  // ---------------------------------------------------------------------------
@@ -452,22 +580,22 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
452
580
  * as a defensive guard for future runtimes.)
453
581
  */
454
582
  function _removeGsdEntries(destDir, kind) {
455
- if (!node_fs_1.default.existsSync(destDir))
583
+ if (!installFs().existsSync(destDir))
456
584
  return;
457
585
  if (kind.kind === 'kimi-agents') {
458
586
  for (const fileName of ['gsd.yaml', 'gsd.md']) {
459
- node_fs_1.default.rmSync(node_path_1.default.join(destDir, fileName), { force: true });
587
+ installFs().rmSync(node_path_1.default.join(destDir, fileName), { force: true });
460
588
  }
461
589
  const subagentsDir = node_path_1.default.join(destDir, 'subagents');
462
- if (node_fs_1.default.existsSync(subagentsDir)) {
463
- for (const entry of node_fs_1.default.readdirSync(subagentsDir, { withFileTypes: true })) {
590
+ if (installFs().existsSync(subagentsDir)) {
591
+ for (const entry of installFs().readdirSync(subagentsDir, { withFileTypes: true })) {
464
592
  if (!entry.isFile())
465
593
  continue;
466
594
  if (!entry.name.startsWith('gsd-'))
467
595
  continue;
468
596
  if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md'))
469
597
  continue;
470
- node_fs_1.default.rmSync(node_path_1.default.join(subagentsDir, entry.name), { force: true });
598
+ installFs().rmSync(node_path_1.default.join(subagentsDir, entry.name), { force: true });
471
599
  }
472
600
  }
473
601
  return;
@@ -475,13 +603,13 @@ function _removeGsdEntries(destDir, kind) {
475
603
  if (kind.prefix === '') {
476
604
  // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
477
605
  // The directory itself is the GSD namespace, so remove it entirely.
478
- node_fs_1.default.rmSync(destDir, { recursive: true, force: true });
606
+ installFs().rmSync(destDir, { recursive: true, force: true });
479
607
  return;
480
608
  }
481
- for (const entry of node_fs_1.default.readdirSync(destDir, { withFileTypes: true })) {
609
+ for (const entry of installFs().readdirSync(destDir, { withFileTypes: true })) {
482
610
  if (!entry.name.startsWith(kind.prefix))
483
611
  continue;
484
- node_fs_1.default.rmSync(node_path_1.default.join(destDir, entry.name), { recursive: true, force: true });
612
+ installFs().rmSync(node_path_1.default.join(destDir, entry.name), { recursive: true, force: true });
485
613
  }
486
614
  }
487
615
  // ---------------------------------------------------------------------------
@@ -493,16 +621,16 @@ function _removeGsdEntries(destDir, kind) {
493
621
  */
494
622
  function _snapshotDir(dir) {
495
623
  const files = new Map();
496
- if (!node_fs_1.default.existsSync(dir))
624
+ if (!installFs().existsSync(dir))
497
625
  return files;
498
626
  const walk = (relPath, absPath) => {
499
- for (const e of node_fs_1.default.readdirSync(absPath, { withFileTypes: true })) {
627
+ for (const e of installFs().readdirSync(absPath, { withFileTypes: true })) {
500
628
  const childRel = relPath ? node_path_1.default.join(relPath, e.name) : e.name;
501
629
  const childAbs = node_path_1.default.join(absPath, e.name);
502
630
  if (e.isDirectory())
503
631
  walk(childRel, childAbs);
504
632
  else if (e.isFile())
505
- files.set(childRel, node_fs_1.default.readFileSync(childAbs));
633
+ files.set(childRel, installFs().readFileSync(childAbs));
506
634
  }
507
635
  };
508
636
  walk('', dir);
@@ -514,8 +642,8 @@ function _snapshotDir(dir) {
514
642
  function _restoreDir(dir, snapshot) {
515
643
  for (const [relPath, buf] of snapshot) {
516
644
  const absPath = node_path_1.default.join(dir, relPath);
517
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(absPath), { recursive: true });
518
- node_fs_1.default.writeFileSync(absPath, buf);
645
+ installFs().mkdirSync(node_path_1.default.dirname(absPath), { recursive: true });
646
+ installFs().writeFileSync(absPath, buf);
519
647
  }
520
648
  }
521
649
  // ---------------------------------------------------------------------------
@@ -529,9 +657,9 @@ function _restoreDir(dir, snapshot) {
529
657
  * @param nestedGsdDir absolute path to skills/gsd/ category dir
530
658
  */
531
659
  function _removeHermesBareStemDirs(nestedGsdDir) {
532
- if (!node_fs_1.default.existsSync(nestedGsdDir))
660
+ if (!installFs().existsSync(nestedGsdDir))
533
661
  return;
534
- const entries = node_fs_1.default.readdirSync(nestedGsdDir, { withFileTypes: true });
662
+ const entries = installFs().readdirSync(nestedGsdDir, { withFileTypes: true });
535
663
  // Collect the set of stems that were installed as gsd-<stem>/ this run.
536
664
  const installedStems = new Set();
537
665
  for (const entry of entries) {
@@ -542,7 +670,7 @@ function _removeHermesBareStemDirs(nestedGsdDir) {
542
670
  // Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
543
671
  for (const entry of entries) {
544
672
  if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
545
- node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDir, entry.name), { recursive: true });
673
+ installFs().rmSync(node_path_1.default.join(nestedGsdDir, entry.name), { recursive: true });
546
674
  }
547
675
  }
548
676
  }
@@ -563,21 +691,38 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
563
691
  // for migration. The actual migration call is deferred to after all layout cleanup so
564
692
  // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
565
693
  // created skills/gsd-dev-preferences/ skill dir.
566
- let savedLegacyArtifacts = null;
694
+ let stagedLegacyArtifacts = null;
567
695
  if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
568
- if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
569
- savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
570
- node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
696
+ if (installFs().existsSync(legacyCommandsGsd)) {
697
+ // #2875: staging root resolved lazily, only when there is actually
698
+ // something to stage — reused below by every other call site sharing
699
+ // this configDir.
700
+ // #2875 defect fix: DEGRADE, never abort the whole install, when the
701
+ // staging root itself cannot be resolved (e.g. a hostile/broken
702
+ // `.gsd-staging` symlink) — skip this legacy-migration block entirely
703
+ // rather than wipe legacyCommandsGsd without a durable backup (module
704
+ // doc "Failure posture": a wipe having staged nothing is worse than no
705
+ // staging at all). The stale legacy dir is simply left in place for a
706
+ // future successful run.
707
+ const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir);
708
+ if (stagingRoot !== null) {
709
+ // #2875 (#1874-F19): staged DURABLY to disk before the wipe below, so a
710
+ // crash anywhere in this function — including the Hermes flat-skills
711
+ // wipe further down, previously inside the same in-memory-only window
712
+ // — survives via recoverOrphanedUserArtifacts on the next run.
713
+ stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot);
714
+ installFs().rmSync(legacyCommandsGsd, { recursive: true });
715
+ }
571
716
  }
572
717
  }
573
718
  // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
574
719
  // the new skills/gsd/ nested layout.
575
720
  if (runtime === 'hermes') {
576
721
  const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
577
- if (node_fs_1.default.existsSync(flatSkillsDir)) {
578
- for (const entry of node_fs_1.default.readdirSync(flatSkillsDir, { withFileTypes: true })) {
722
+ if (installFs().existsSync(flatSkillsDir)) {
723
+ for (const entry of installFs().readdirSync(flatSkillsDir, { withFileTypes: true })) {
579
724
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
580
- node_fs_1.default.rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
725
+ installFs().rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
581
726
  }
582
727
  }
583
728
  }
@@ -590,8 +735,90 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
590
735
  // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
591
736
  // Done after all layout cleanup so Hermes flat-dir removal does not delete the
592
737
  // newly created skill dir. No-op if skill file already exists.
593
- if (savedLegacyArtifacts) {
594
- migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
738
+ if (stagedLegacyArtifacts) {
739
+ // #2875: read the content back from the DISK-staged copy (fresh, after
740
+ // every wipe above has already run) rather than an in-memory value held
741
+ // across them.
742
+ //
743
+ // #2875 defect fix (readFileSync following a staged symlink):
744
+ // readFileSync ALWAYS follows a symlink — a staged artifact that is
745
+ // itself a symlink (module doc "Symlink safety", A4: staging never
746
+ // dereferences a symlink; a symlinked USER-artifact is recreated AS a
747
+ // symlink in the staging tree, not copied by content) would have its
748
+ // REFERENT's bytes read here and land in SKILL.md, violating this
749
+ // module's own "referent bytes never read" contract. A symlinked staged
750
+ // name is excluded from migration below and restored to its original
751
+ // location instead — migrating a symlink AS skill-file text content is
752
+ // not a coherent operation to begin with.
753
+ const savedLegacyArtifacts = new Map();
754
+ const migratableNames = [];
755
+ for (const name of stagedLegacyArtifacts.names) {
756
+ const stagedPath = node_path_1.default.join(stagedLegacyArtifacts.filesDir, name);
757
+ // #2875 defect fix (crash resilience — TOCTOU): a raw `lstatSync` throws
758
+ // if `stagedPath` has vanished between staging (above) and this read —
759
+ // e.g. a co-resident attacker on a shared machine racing the staging
760
+ // dir, the exact threat class this module's own "Confinement" doc
761
+ // already treats as live. Every sibling probe in this file (`tryLstat`
762
+ // itself, and its use at `skillFileLstat` above) already degrades
763
+ // rather than throws; do the same here — a vanished staged file is
764
+ // simply not migratable, matching A2's "absent, not staged, no throw"
765
+ // precedent in user-artifact-staging.cts.
766
+ const stagedLstat = tryLstat(stagedPath);
767
+ if (!stagedLstat || stagedLstat.isSymbolicLink())
768
+ continue;
769
+ savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8'));
770
+ migratableNames.push(name);
771
+ }
772
+ // #2875 defect fix (regression closed — was previously unguarded and
773
+ // BRICKED the command): migrateLegacyDevPreferencesToSkill correctly
774
+ // THROWS when it finds a planted/dangling symlink at the skill-file leaf
775
+ // (security fix — refusing to write through it is correct) but by this
776
+ // point legacyCommandsGsd has ALREADY been wiped (rmSync above) and
777
+ // stagedLegacyArtifacts is the only surviving copy. An unguarded throw
778
+ // here propagated straight out of installRuntimeArtifacts, aborting the
779
+ // whole install/uninstall WITHOUT ever reaching the restore-or-discard
780
+ // logic below — the staged batch was orphaned on disk and every retry
781
+ // hit the same throw again (same brick-the-command failure mode this
782
+ // module's "DEGRADE, never abort" posture, see
783
+ // _tryResolveUserArtifactStagingRoot above, already closed for a broken
784
+ // `.gsd-staging` path). Degrade identically: catch, warn once, and treat
785
+ // the batch as unmigrated so the restore branch below fires.
786
+ let migrated = false;
787
+ let migrationRefused = false;
788
+ try {
789
+ migrated = migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
790
+ }
791
+ catch (err) {
792
+ console.warn(` [gsd] dev-preferences.md migration skipped for "${configDir}" (${err.message}) — restoring the legacy copy instead.`);
793
+ migrationRefused = true;
794
+ }
795
+ // #2875 defect fix (call site 1 was a loss site): migrateLegacyDevPreferencesToSkill's
796
+ // boolean return conflates "migrated", "already satisfied" (skill file
797
+ // already present — safe to discard either way), and "cannot migrate"
798
+ // (no skills layout for this runtime, or the write itself failed —
799
+ // discarding here would silently lose the user's file, the exact loss
800
+ // this whole module exists to prevent). Distinguish via the resolved
801
+ // target's actual presence rather than trusting the boolean alone; a
802
+ // symlinked staged name (excluded from migration above) is treated the
803
+ // same way — never migrated, so it must not be silently discarded.
804
+ //
805
+ // #2875 defect fix (migrationRefused must short-circuit this to `false`,
806
+ // never fall through to the existsSync probe below): when
807
+ // migrateLegacyDevPreferencesToSkill refused because skillTarget.skillFile
808
+ // is a symlink, `existsSync` FOLLOWS it — a symlink pointing at some
809
+ // OTHER real file (not dangling) would read back `true` here and mark
810
+ // the batch "satisfied", discarding it without ever restoring it. Refusal
811
+ // is never satisfaction.
812
+ const skillTarget = migrationRefused ? null : _resolveDevPreferencesSkillTarget(configDir, runtime, scope);
813
+ const migrationSatisfied = !migrationRefused && (migrated || (skillTarget !== null && installFs().existsSync(skillTarget.skillFile)));
814
+ const nothingLeftUnmigrated = migrationSatisfied && migratableNames.length === stagedLegacyArtifacts.names.length;
815
+ if (!nothingLeftUnmigrated && stagedLegacyArtifacts.names.length > 0) {
816
+ // Put the whole batch back where it came from rather than losing
817
+ // whatever migration did not (or could not) account for.
818
+ installFs().mkdirSync(legacyCommandsGsd, { recursive: true });
819
+ userArtifactStaging.restoreStagedUserArtifacts(legacyCommandsGsd, stagedLegacyArtifacts);
820
+ }
821
+ userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts);
595
822
  }
596
823
  }
597
824
  /**
@@ -601,7 +828,7 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
601
828
  * @param runtime
602
829
  * @param configDir resolved runtime config directory
603
830
  * @param scope
604
- * @returns saved legacy artifacts for post-removal migration, or null
831
+ * @returns staged legacy artifacts for post-removal migration, or null
605
832
  */
606
833
  function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
607
834
  // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
@@ -615,19 +842,42 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
615
842
  // is deferred and returned so the caller can apply it AFTER layout-driven
616
843
  // removal — this prevents the layout's gsd-* prefix removal from wiping the
617
844
  // freshly created skill dir (same pattern as _runLegacyInstallMigrations).
618
- let savedLegacyArtifacts = null;
845
+ // #2875 (#1874-F19): staged DURABLY to disk (userArtifactStaging), not just
846
+ // an in-memory Map — this function's own wipe below is raw `fs`, left
847
+ // unrouted by design (Phase 5 deliberately left the uninstall tree off the
848
+ // installFs() seam; 40-design.md "Explicitly out of scope"), but the
849
+ // staging call itself still routes through installFs() because the shared
850
+ // module does (ambient default: real fs here, since this call is never
851
+ // wrapped in withInstallFs).
852
+ let stagedLegacyArtifacts = null;
619
853
  // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
620
854
  // Claude local is intentionally excluded: the inline uninstall block (1c) handles
621
855
  // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
622
856
  // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
623
857
  // (which would redirect to skills/) conflicts with the test contract for local installs.
624
858
  const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
625
- const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && scope === 'global');
859
+ // #2870: `scope` keeps its exported `string = 'global'` signature (no
860
+ // signature change), but every real caller — `uninstallRuntimeArtifacts`'s
861
+ // own required `scope` param, always fed a validated 'global' | 'local'
862
+ // literal by bin/install.js's scope-resolution ternary, plus every direct
863
+ // test call site — only ever supplies 'global' or 'local'. The existing
864
+ // `= 'global'` default already reproduces today's behavior for an omitted
865
+ // scope, so the cast below is safe: `isGlobalScope` never sees a value
866
+ // outside its union here.
867
+ const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && (0, install_scope_cjs_1.isGlobalScope)(scope));
626
868
  if (isLegacyCommandsGsd) {
627
869
  const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
628
870
  if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
629
- savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
630
- node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
871
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
872
+ // root cannot be resolved — skip this legacy-cleanup block (leave the
873
+ // stale dir in place) rather than wipe without a durable backup.
874
+ // Uninstall in particular must always be able to proceed past this
875
+ // point regardless of a hostile/broken `.gsd-staging` path.
876
+ const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir);
877
+ if (stagingRoot !== null) {
878
+ stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot);
879
+ node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
880
+ }
631
881
  }
632
882
  }
633
883
  // Hermes: pre-#2841 flat skills/gsd-*/ entries
@@ -652,8 +902,8 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
652
902
  }
653
903
  }
654
904
  }
655
- // Return saved artifacts so the caller can migrate after layout-driven removal.
656
- return savedLegacyArtifacts;
905
+ // Return staged artifacts so the caller can migrate after layout-driven removal.
906
+ return stagedLegacyArtifacts;
657
907
  }
658
908
  // ---------------------------------------------------------------------------
659
909
  // installRuntimeArtifacts
@@ -673,133 +923,230 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
673
923
  * the skills kind can materialize installed third-party capability skills
674
924
  * bound to their declaring capId. Absent -> no third-party skills staged
675
925
  * (fail closed), matching the layout resolver's own optional-registry contract.
926
+ * @param deps #2874 (ADR-58 cleanup phase): optional injection bag, additive
927
+ * over the 6-positional-arg call shape every existing caller (bin/install.js,
928
+ * G1/G3 test doubles) already uses — an omitted/`{}` `deps` is byte-identical
929
+ * to before (AC4). `deps.fs` — a PARTIAL InstallFsAdapter
930
+ * (install-fs-adapter.cts) — is merged over the real fs adapter for the
931
+ * duration of this call (and everything it calls: layout source-root
932
+ * resolution, profile staging, content-rewrite passes) via `withInstallFs`.
933
+ * @returns an executed-plan value describing what this call wrote, never
934
+ * `undefined` (40-design.md: "Legitimate undefined returns: none after this
935
+ * phase"). Throws, rather than returning an `ok:false` shape, on stage/
936
+ * rewrite failure — the return type describes what executed; failure stays
937
+ * an exception (design doc "Rejected" #3 / AC4).
676
938
  */
677
- function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry) {
678
- // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
679
- // the dedicated combined commands+skills+plugin orchestrator instead of the
680
- // generic layout-driven loop below, mirroring the bespoke install path that
681
- // previously lived inline in bin/install.js.
682
- const behaviors = _hostBehaviors(runtime);
683
- if (behaviors.combinedFamilyInstall) {
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);
689
- return;
690
- }
691
- // Legacy cleanup before layout-driven writes
692
- _runLegacyInstallMigrations(runtime, configDir, scope);
693
- const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
694
- const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
695
- // `Layout` is structurally identical across the layout/install-plan .cjs
696
- // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
697
- layout: layout,
698
- resolvedProfile,
699
- homedir: () => node_os_1.default.homedir(),
700
- platform: process.platform,
701
- resolveAttribution,
702
- });
703
- const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
704
- try {
705
- if (!planResult.ok) {
706
- throw new Error(planResult.message);
939
+ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry, deps = {}) {
940
+ return withInstallFs(deps.fs, () => {
941
+ // A removed descriptor kind is no longer visited by the layout loop, so it
942
+ // cannot prune its own previous output. Clean manifest-proven retired files
943
+ // before materializing the current layout (#2644).
944
+ retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
945
+ // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
946
+ // the dedicated combined commands+skills+plugin orchestrator instead of the
947
+ // generic layout-driven loop below, mirroring the bespoke install path that
948
+ // previously lived inline in bin/install.js.
949
+ const behaviors = _hostBehaviors(runtime);
950
+ if (behaviors.combinedFamilyInstall) {
951
+ // #2329: combined-family runtimes (OpenCode/Kilo) bypass
952
+ // _runLegacyInstallMigrations below entirely (early return), so their
953
+ // legacy-directory cleanup needs its own pre-materialization hook here.
954
+ _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
955
+ // #2874 design row 2: this early return must ALSO return an executed
956
+ // plan — installOpencodeFamilyArtifacts reports what it wrote, so a
957
+ // whole runtime family returning undefined is no longer a hole.
958
+ return installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
707
959
  }
708
- const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
709
- for (const item of planResult.plan.items) {
710
- const kind = kindsByName.get(item.kind);
711
- if (!kind)
712
- throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
713
- const dest = item.destDir;
714
- // Symlink-escape guard: reject before mkdir if dest (or any component
715
- // between the install root and dest) is a symlink pointing outside that
716
- // root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
717
- // call. The install root is normally configDir, but a kind may declare
718
- // an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
719
- // $HOME/.agents) — in that case the guard must check against the
720
- // resolved alternate root instead, matching assertDestWithinConfigHome's
721
- // own root selection in createRuntimeArtifactInstallPlan.
722
- const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
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.`);
960
+ // Legacy cleanup before layout-driven writes
961
+ _runLegacyInstallMigrations(runtime, configDir, scope);
962
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
963
+ const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
964
+ // `Layout` is structurally identical across the layout/install-plan .cjs
965
+ // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
966
+ layout: layout,
967
+ resolvedProfile,
968
+ homedir: () => node_os_1.default.homedir(),
969
+ platform: process.platform,
970
+ resolveAttribution,
971
+ });
972
+ const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
973
+ // #2874 row 1/4/5: per-kind executed-plan entries, appended only as the
974
+ // loop below actually finishes writing each kind — a kind that throws
975
+ // mid-copy is never reported as executed.
976
+ const executedKinds = [];
977
+ // #2874 rows 10/11: { dir, ok } per cleanupDirs entry — built in the
978
+ // `finally` below regardless of whether the try block throws, so a
979
+ // caught failure that still throws (row 3) leaves this populated even
980
+ // though it is never returned on that path.
981
+ const cleanupResults = [];
982
+ try {
983
+ if (!planResult.ok) {
984
+ throw new Error(planResult.message);
728
985
  }
729
- node_fs_1.default.mkdirSync(dest, { recursive: true });
730
- if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
731
- // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
732
- // then restore after. This preserves user dirs across a wipe-and-replace
733
- // install (#2973 / #3664).
986
+ const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
987
+ for (const item of planResult.plan.items) {
988
+ const kind = kindsByName.get(item.kind);
989
+ if (!kind)
990
+ throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
991
+ const dest = item.destDir;
992
+ // Symlink-escape guard: reject before mkdir if dest (or any component
993
+ // between the install root and dest) is a symlink pointing outside that
994
+ // root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
995
+ // call. The install root is normally configDir, but a kind may declare
996
+ // an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
997
+ // $HOME/.agents) — in that case the guard must check against the
998
+ // resolved alternate root instead, matching assertDestWithinConfigHome's
999
+ // own root selection in createRuntimeArtifactInstallPlan.
734
1000
  //
735
- // All runtimes (incl. Hermes after #947) use prefix='gsd-'.
736
- // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
737
- // untouched. Preserve the explicit user-owned GSD-prefixed skill
738
- // gsd-dev-preferences, which GSD does not reinstall from source but must
739
- // survive the prune (#2973).
740
- const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
741
- {
742
- // Preserve explicitly user-owned GSD-prefixed skill dirs.
743
- // gsd-dev-preferences is the sole user-customisable skill in this category.
744
- const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
745
- for (const dirName of USER_OWNED_SKILL_DIRS) {
746
- const skillDir = node_path_1.default.join(dest, dirName);
747
- if (!node_fs_1.default.existsSync(skillDir))
748
- continue;
749
- const snap = _snapshotDir(skillDir);
750
- if (snap.size > 0)
751
- toPreserve.set(dirName, snap);
1001
+ // #2874: this REFUSAL DECISION stays outside the injected fs adapter —
1002
+ // only hasExistingSymlinkBetween's own existsSync/lstatSync/realpathSync
1003
+ // PROBES are routed through it (install-fs-adapter.cts's module doc).
1004
+ // A fake adapter can change what those probes observe for paths that
1005
+ // were never real to begin with; it cannot make this `if` pass for a
1006
+ // path the real filesystem would refuse.
1007
+ const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
1008
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
1009
+ // Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
1010
+ // resolved-target-equals-root still refuse regardless of opt-in.
1011
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1012
+ 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.`);
1013
+ }
1014
+ // #2875 defect fix (--minimal regression closed): a restricted profile
1015
+ // (e.g. --minimal) can legitimately stage ZERO agents — no skill in
1016
+ // the profile's closure references a gsd-* role. The pre-#2875-Part-2
1017
+ // inline agent-staging loop this generic layout loop's agents handling
1018
+ // replaced never created `agents/` at all under a minimal install (the
1019
+ // now-deleted `isMinimalMode` branch skipped the whole step); this
1020
+ // loop's own unconditional `mkdirSync` above regressed that — every
1021
+ // profile, restricted or not, now gets an `agents/` dir materialized
1022
+ // even when nothing will ever be written into it, breaking
1023
+ // `.changeset/zesty-rams-march.md`'s "installed output is
1024
+ // byte-identical to before for every runtime" claim. Restore the old
1025
+ // behavior exactly for the `agents` kind specifically (skills/commands
1026
+ // are unaffected — they are never legitimately empty): skip creating
1027
+ // `dest` (and pruning/copying into it) entirely when this kind's
1028
+ // already-staged `item.sourceDir` (built by createRuntimeArtifactInstallPlan
1029
+ // BEFORE this loop) has nothing in it.
1030
+ if (kind.kind === 'agents') {
1031
+ const stagedAgentFiles = installFs().existsSync(item.sourceDir)
1032
+ ? installFs().readdirSync(item.sourceDir).filter((f) => f.endsWith('.md'))
1033
+ : [];
1034
+ // #2875 defect fix, corrected: the ORIGINAL fix (see the comment
1035
+ // above `installAgentsKindStandalone`) skipped this kind's stale-
1036
+ // agent prune along with the write whenever a restricted profile
1037
+ // (e.g. --minimal) staged zero agents — that also skipped
1038
+ // `_removeGsdEntries`, so a full -> minimal downgrade left every
1039
+ // previously-installed gsd-*.md/.toml agent file in place. The
1040
+ // deleted pre-#2875 inline loop never did that: its stale-cleanup
1041
+ // pre-pass ran UNCONDITIONALLY, and only the *write* of new agent
1042
+ // files was gated on minimal mode. Restore that split here: prune
1043
+ // first (no-ops via `_removeGsdEntries`'s own existsSync check when
1044
+ // `dest` was never created, so a fresh install with nothing staged
1045
+ // still never creates it below), then skip mkdir/copy when there is
1046
+ // nothing to write.
1047
+ _removeGsdEntries(dest, kind);
1048
+ if (stagedAgentFiles.length === 0) {
1049
+ continue;
752
1050
  }
753
1051
  }
754
- _removeGsdEntries(dest, kind);
755
- _copyStaged(item.sourceDir, dest, kind, configDir, runtime);
756
- // Restore user-owned dirs after the prune+copy
757
- for (const [dirName, snap] of toPreserve) {
758
- _restoreDir(node_path_1.default.join(dest, dirName), snap);
1052
+ installFs().mkdirSync(dest, { recursive: true });
1053
+ const preserved = [];
1054
+ if (kind.kind === 'skills' && installFs().existsSync(dest)) {
1055
+ // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
1056
+ // then restore after. This preserves user dirs across a wipe-and-replace
1057
+ // install (#2973 / #3664).
1058
+ //
1059
+ // All runtimes (incl. Hermes after #947) use prefix='gsd-'.
1060
+ // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
1061
+ // untouched. Preserve the explicit user-owned GSD-prefixed skill
1062
+ // gsd-dev-preferences, which GSD does not reinstall from source but must
1063
+ // survive the prune (#2973).
1064
+ const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
1065
+ {
1066
+ // Preserve explicitly user-owned GSD-prefixed skill dirs.
1067
+ // gsd-dev-preferences is the sole user-customisable skill in this category.
1068
+ const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
1069
+ for (const dirName of USER_OWNED_SKILL_DIRS) {
1070
+ const skillDir = node_path_1.default.join(dest, dirName);
1071
+ if (!installFs().existsSync(skillDir))
1072
+ continue;
1073
+ const snap = _snapshotDir(skillDir);
1074
+ if (snap.size > 0)
1075
+ toPreserve.set(dirName, snap);
1076
+ }
1077
+ }
1078
+ _removeGsdEntries(dest, kind);
1079
+ _copyStaged(item.sourceDir, dest, kind, configDir, runtime);
1080
+ // Restore user-owned dirs after the prune+copy
1081
+ for (const [dirName, snap] of toPreserve) {
1082
+ _restoreDir(node_path_1.default.join(dest, dirName), snap);
1083
+ preserved.push(dirName);
1084
+ }
759
1085
  }
760
- }
761
- else {
762
- // For non-skills kinds (commands, agents): no user content to preserve;
763
- // just prune stale gsd-* entries and copy new ones.
764
- _removeGsdEntries(dest, kind);
765
- _copyStaged(item.sourceDir, dest, kind, configDir, runtime);
1086
+ else {
1087
+ // For non-skills kinds (commands, agents): no user content to preserve;
1088
+ // just prune stale gsd-* entries and copy new ones.
1089
+ _removeGsdEntries(dest, kind);
1090
+ _copyStaged(item.sourceDir, dest, kind, configDir, runtime);
1091
+ }
1092
+ executedKinds.push({ kind: item.kind, sourceDir: item.sourceDir, destDir: dest, preserved });
766
1093
  }
767
1094
  }
768
- }
769
- finally {
770
- for (const dir of cleanupDirs) {
771
- try {
772
- node_fs_1.default.rmSync(dir, { recursive: true, force: true });
1095
+ finally {
1096
+ // #2874 rows 10/11: cleanup stays best-effort (an install must never
1097
+ // fail on cleanup) but a failed rmSync is now VISIBLE in `cleanup`
1098
+ // rather than silently swallowed — silently absent is worse than the
1099
+ // `void` return this replaces (40-design.md negative-space section).
1100
+ for (const dir of cleanupDirs) {
1101
+ try {
1102
+ installFs().rmSync(dir, { recursive: true, force: true });
1103
+ cleanupResults.push({ dir, ok: true });
1104
+ }
1105
+ catch {
1106
+ cleanupResults.push({ dir, ok: false });
1107
+ }
773
1108
  }
774
- catch { /* best-effort */ }
775
1109
  }
776
- }
777
- // Hermes: after the install loop has written all gsd-<stem>/ dirs to
778
- // skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
779
- // correspond to the newly installed gsd-<stem> entries. This is the robust
780
- // replacement for the readGsdCommandNames()-based pre-install cleanup that
781
- // missed skills like 'dev-preferences' (#947 adversarial review).
782
- //
783
- // We run this AFTER the install loop so the installed set is authoritative:
784
- // every gsd-<stem>/ present now was written this run (or was there before
785
- // with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
786
- // are untouched.
787
- if (runtime === 'hermes') {
788
- const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
789
- _removeHermesBareStemDirs(nestedGsdDirForCleanup);
790
- }
791
- // Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
792
- // outside the OpenCode/Kilo combined-family install (e.g. pi, whose
793
- // artifactLayout is empty and which never sets combinedFamilyInstall) still
794
- // need their declared hostBehaviors.nativePlugin file copied into configDir.
795
- // findInstallSourceRoot resolves the repo/package root independent of
796
- // configDir contents (marker check, then a walk-up from __dirname), so this
797
- // is safe even when configDir has no .gsd-source marker (artifactLayout: []).
798
- if (behaviors.nativePlugin) {
799
- const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
800
- const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
801
- _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
802
- }
1110
+ // Hermes: after the install loop has written all gsd-<stem>/ dirs to
1111
+ // skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
1112
+ // correspond to the newly installed gsd-<stem> entries. This is the robust
1113
+ // replacement for the readGsdCommandNames()-based pre-install cleanup that
1114
+ // missed skills like 'dev-preferences' (#947 adversarial review).
1115
+ //
1116
+ // We run this AFTER the install loop so the installed set is authoritative:
1117
+ // every gsd-<stem>/ present now was written this run (or was there before
1118
+ // with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
1119
+ // are untouched.
1120
+ let hermesBareStemCleanup = false;
1121
+ if (runtime === 'hermes') {
1122
+ const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
1123
+ _removeHermesBareStemDirs(nestedGsdDirForCleanup);
1124
+ hermesBareStemCleanup = true;
1125
+ }
1126
+ // Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
1127
+ // outside the OpenCode/Kilo combined-family install (e.g. pi, whose
1128
+ // artifactLayout is empty and which never sets combinedFamilyInstall) still
1129
+ // need their declared hostBehaviors.nativePlugin file copied into configDir.
1130
+ // findInstallSourceRoot resolves the repo/package root independent of
1131
+ // configDir contents (marker check, then a walk-up from __dirname), so this
1132
+ // is safe even when configDir has no .gsd-source marker (artifactLayout: []).
1133
+ let nativePluginInstalled = false;
1134
+ if (behaviors.nativePlugin) {
1135
+ const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
1136
+ const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
1137
+ _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
1138
+ nativePluginInstalled = true;
1139
+ }
1140
+ // #2874 row 14: an empty `layout.kinds` still returns `kinds: []` here
1141
+ // (executedKinds was never mutated), never `undefined`.
1142
+ return {
1143
+ runtime,
1144
+ scope,
1145
+ kinds: executedKinds,
1146
+ cleanup: cleanupResults,
1147
+ postSteps: { hermesBareStemCleanup, nativePlugin: nativePluginInstalled },
1148
+ };
1149
+ });
803
1150
  }
804
1151
  // ---------------------------------------------------------------------------
805
1152
  // installOpencodeFamilySkills
@@ -837,7 +1184,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
837
1184
  if (!skillsKindEntry)
838
1185
  return 0;
839
1186
  const rawDir = rawCommandsDir;
840
- if (!rawDir || !node_fs_1.default.existsSync(rawDir))
1187
+ if (!rawDir || !installFs().existsSync(rawDir))
841
1188
  return 0;
842
1189
  // #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
843
1190
  // string (capabilities/<runtime>/capability.json artifactLayout) via the
@@ -849,14 +1196,21 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
849
1196
  if (!converter) {
850
1197
  throw new TypeError(`installOpencodeFamilySkills: unknown skills converter '${String(converterName)}' for runtime '${runtime}'`);
851
1198
  }
852
- const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
853
- // Symlink-escape guard: reject if any path component between targetDir and
854
- // dest is a symlink that would redirect writes outside the config root.
1199
+ // #2911: same destination-root defect as _copyStaged/migrateLegacyDevPreferencesToSkill
1200
+ // — honor skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
1201
+ // -> $HOME/.agents) instead of always resolving against targetDir, so this bespoke
1202
+ // OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
1203
+ // Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
1204
+ // in lockstep with the sibling writers — the destination-parity test enforces it.
1205
+ const installRoot = skillsKindEntry.home ?? targetDir;
1206
+ const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
1207
+ // Symlink-escape guard: reject if any path component between installRoot and
1208
+ // dest is a symlink that would redirect writes outside the install root.
855
1209
  // #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.`);
1210
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1211
+ throw new Error(`installOpencodeFamilySkills: destDir "${dest}" 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.`);
858
1212
  }
859
- node_fs_1.default.mkdirSync(dest, { recursive: true });
1213
+ installFs().mkdirSync(dest, { recursive: true });
860
1214
  // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
861
1215
  // gsd-dev-preferences is generated by the user (via generate-dev-preferences)
862
1216
  // and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
@@ -866,7 +1220,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
866
1220
  const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
867
1221
  for (const dirName of USER_OWNED_SKILL_DIRS) {
868
1222
  const skillDir = node_path_1.default.join(dest, dirName);
869
- if (!node_fs_1.default.existsSync(skillDir))
1223
+ if (!installFs().existsSync(skillDir))
870
1224
  continue;
871
1225
  const snap = _snapshotDir(skillDir);
872
1226
  if (snap.size > 0)
@@ -875,19 +1229,19 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
875
1229
  _removeGsdEntries(dest, skillsKindEntry);
876
1230
  let count = 0;
877
1231
  const firstPartyStems = new Set();
878
- for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
1232
+ for (const entry of installFs().readdirSync(rawDir, { withFileTypes: true })) {
879
1233
  if (!entry.isFile() || !entry.name.endsWith('.md'))
880
1234
  continue;
881
1235
  const stem = entry.name.slice(0, -3);
882
1236
  firstPartyStems.add(stem);
883
1237
  const skillName = `${skillsKindEntry.prefix}${stem}`;
884
- let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
1238
+ let content = installFs().readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
885
1239
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
886
1240
  content = processAttribution(content, resolveAttribution(runtime));
887
1241
  content = converter(content, skillName);
888
1242
  const skillDir = node_path_1.default.join(dest, skillName);
889
- node_fs_1.default.mkdirSync(skillDir, { recursive: true });
890
- node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
1243
+ installFs().mkdirSync(skillDir, { recursive: true });
1244
+ installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
891
1245
  count++;
892
1246
  }
893
1247
  // #2362: materialize installed THIRD-PARTY capability skills, bound to their
@@ -925,13 +1279,13 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
925
1279
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
926
1280
  content = processAttribution(content, resolveAttribution(runtime));
927
1281
  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);
1282
+ installFs().mkdirSync(skillDir, { recursive: true });
1283
+ installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
930
1284
  // #2322 HIGH-3 parity: persist the capability-owned marker so a later
931
1285
  // prune pass can identify this directory even once the owning
932
1286
  // capability is uninstalled/unsurfaced and no longer appears in any
933
1287
  // registry view.
934
- node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
1288
+ installFs().writeFileSync(node_path_1.default.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
935
1289
  count++;
936
1290
  }
937
1291
  }
@@ -942,6 +1296,93 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
942
1296
  return count;
943
1297
  }
944
1298
  // ---------------------------------------------------------------------------
1299
+ // installAgentsKindStandalone
1300
+ // ---------------------------------------------------------------------------
1301
+ /**
1302
+ * Install the descriptor-driven `agents` kind for a runtime OUTSIDE the
1303
+ * generic `installRuntimeArtifacts` layout loop — i.e. any runtime/scope
1304
+ * combination that never reaches that loop's own `layout.kinds` iteration.
1305
+ * Two such call sites exist (#2875 Part 2):
1306
+ *
1307
+ * 1. **OpenCode-family runtimes** (OpenCode/Kilo, Task A) — `hostBehaviors.
1308
+ * combinedFamilyInstall` makes `installRuntimeArtifacts` early-return into
1309
+ * `installOpencodeFamilyArtifacts` instead, which stages commands+skills
1310
+ * via its OWN bespoke writers and never called `resolveRuntimeArtifactLayout`
1311
+ * for agents at all before this function existed. Declaring a
1312
+ * `capability.json` `agents` entry for them without this would be inert
1313
+ * on the real install path while live on `/gsd:surface` (#1879-F15).
1314
+ * 2. **Claude local** (`bin/install.js`'s `install()`, `_isSkillsRuntime ===
1315
+ * false` branch) — `hostBehaviors.localInstallStyle === 'legacy-flat'`
1316
+ * routes claude-local's commands/skills through `copyWithPathReplacement`
1317
+ * instead of the layout loop, so it never reached `installRuntimeArtifacts`
1318
+ * either. Its agents were previously written ONLY by the now-deleted
1319
+ * inline agent-staging loop (Task C) — deleting that loop without this
1320
+ * call site regressed claude-local's agents/ to empty (caught by the
1321
+ * install-tree golden fixture, `tests/fixtures/install-tree/claude-local.json`).
1322
+ *
1323
+ * Reuses the SAME descriptor path every runtime inside the generic loop uses
1324
+ * (`layout.kinds` → `agentsKindEntry.stage(resolvedProfile, agentCtx)` →
1325
+ * `_copyStaged`), rather than forking a second agent-staging pipeline. A
1326
+ * runtime/scope whose resolved layout declares no `agents` kind at all
1327
+ * (e.g. pi, whose `artifactLayout` is empty for both scopes) is a no-op
1328
+ * (`null`) — mirrors `installOpencodeFamilySkills`'s own
1329
+ * `if (!skillsKindEntry) return 0` contract.
1330
+ *
1331
+ * @param runtime - canonical runtime id
1332
+ * @param targetDir - resolved runtime config directory
1333
+ * @param scope - install scope ('global' | 'local')
1334
+ * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
1335
+ * @param pathPrefix - computed config-path prefix for body rewrites (ADR-1235 §1 agentCtx)
1336
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
1337
+ * @param capabilityRegistry - #2362: optional composed capability registry, threaded
1338
+ * straight through to resolveRuntimeArtifactLayout (unused by the agents kind today,
1339
+ * but kept for signature parity with the skills/commands siblings on this call tree)
1340
+ * @returns `{ sourceDir, destDir }` describing what was written, or `null` when the
1341
+ * runtime's layout declares no `agents` kind.
1342
+ */
1343
+ function installAgentsKindStandalone(runtime, targetDir, scope, resolvedProfile, pathPrefix, resolveAttribution = () => undefined, capabilityRegistry) {
1344
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope, capabilityRegistry);
1345
+ const agentsKindEntry = layout.kinds.find((k) => k.kind === 'agents');
1346
+ if (!agentsKindEntry)
1347
+ return null;
1348
+ // ADR-1235 §1: same agentCtx shape createRuntimeArtifactInstallPlan builds
1349
+ // for the generic layout-driven loop (runtime-artifact-install-plan.cts) —
1350
+ // targetDir IS the install root the inline agent loop called `targetDir`.
1351
+ const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
1352
+ const agentCtx = { runtime, pathPrefix, attribution, targetDir };
1353
+ const stagedDir = agentsKindEntry.stage(resolvedProfile, agentCtx);
1354
+ const stagedAgentFiles = installFs().existsSync(stagedDir)
1355
+ ? installFs().readdirSync(stagedDir).filter((f) => f.endsWith('.md'))
1356
+ : [];
1357
+ const installRoot = (typeof agentsKindEntry.home === 'string' && agentsKindEntry.home !== '') ? agentsKindEntry.home : targetDir;
1358
+ const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, agentsKindEntry.destSubpath);
1359
+ // Symlink-escape guard — same gate _copyStaged/installOpencodeFamilySkills apply
1360
+ // to their own writes (#2393 GSD_ALLOW_SYMLINKED_DEST opt-in preserved). Runs
1361
+ // even when nothing will be written this call — the stale-agent prune below
1362
+ // (`_removeGsdEntries`) still touches `dest` whenever it already exists.
1363
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1364
+ throw new Error(`installAgentsKindStandalone: destDir "${dest}" 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.`);
1365
+ }
1366
+ // #2875 defect fix, corrected: the ORIGINAL fix returned `null` (no-op)
1367
+ // whenever a restricted profile (e.g. --minimal) staged ZERO agents,
1368
+ // which — because that early return sat ABOVE the prune call — also
1369
+ // skipped `_removeGsdEntries`, leaving every previously-installed
1370
+ // gsd-*.md/.toml agent file in place on a full -> minimal downgrade. The
1371
+ // deleted pre-#2875 inline loop never did that: its stale-cleanup pre-pass
1372
+ // ran UNCONDITIONALLY (removing gsd-*.md, plus .toml for codex), and only
1373
+ // the *write* of new agent files was gated on minimal mode. Restore that
1374
+ // split: prune first — a no-op via `_removeGsdEntries`'s own existsSync
1375
+ // check when `dest` was never created, so a fresh install with nothing
1376
+ // staged still never creates it below — then skip mkdir/copy (and return
1377
+ // `null`, matching the doc comment above) when there is nothing to write.
1378
+ _removeGsdEntries(dest, agentsKindEntry);
1379
+ if (stagedAgentFiles.length === 0)
1380
+ return null;
1381
+ installFs().mkdirSync(dest, { recursive: true });
1382
+ _copyStaged(stagedDir, dest, agentsKindEntry, targetDir, runtime);
1383
+ return { sourceDir: stagedDir, destDir: dest };
1384
+ }
1385
+ // ---------------------------------------------------------------------------
945
1386
  // installOpencodeFamilyCommands
946
1387
  // ---------------------------------------------------------------------------
947
1388
  /**
@@ -961,19 +1402,19 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
961
1402
  * @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
962
1403
  */
963
1404
  function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, resolveAttribution = () => undefined, prefix = 'gsd') {
964
- if (!node_fs_1.default.existsSync(srcDir))
1405
+ if (!installFs().existsSync(srcDir))
965
1406
  return;
966
1407
  // Remove old gsd-*.md files before copying new ones
967
- if (node_fs_1.default.existsSync(destDir)) {
968
- for (const file of node_fs_1.default.readdirSync(destDir)) {
1408
+ if (installFs().existsSync(destDir)) {
1409
+ for (const file of installFs().readdirSync(destDir)) {
969
1410
  if (file.startsWith(`${prefix}-`) && file.endsWith('.md'))
970
- node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
1411
+ installFs().unlinkSync(node_path_1.default.join(destDir, file));
971
1412
  }
972
1413
  }
973
1414
  else {
974
- node_fs_1.default.mkdirSync(destDir, { recursive: true });
1415
+ installFs().mkdirSync(destDir, { recursive: true });
975
1416
  }
976
- for (const entry of node_fs_1.default.readdirSync(srcDir, { withFileTypes: true })) {
1417
+ for (const entry of installFs().readdirSync(srcDir, { withFileTypes: true })) {
977
1418
  const srcPath = node_path_1.default.join(srcDir, entry.name);
978
1419
  if (entry.isDirectory()) {
979
1420
  installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
@@ -981,7 +1422,7 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
981
1422
  else if (entry.name.endsWith('.md')) {
982
1423
  const baseName = entry.name.replace('.md', '');
983
1424
  const destName = `${prefix}-${baseName}.md`;
984
- let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
1425
+ let content = installFs().readFileSync(srcPath, 'utf8');
985
1426
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
986
1427
  content = processAttribution(content, resolveAttribution(runtime));
987
1428
  // #2093: this commands-kind entry's descriptor `converter` field is
@@ -997,7 +1438,7 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
997
1438
  content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
998
1439
  ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
999
1440
  : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
1000
- node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, destName), content);
1441
+ installFs().writeFileSync(node_path_1.default.join(destDir, destName), content);
1001
1442
  }
1002
1443
  }
1003
1444
  }
@@ -1025,7 +1466,7 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
1025
1466
  const np = behaviors.nativePlugin;
1026
1467
  if (np && np.source) {
1027
1468
  const pluginSrc = node_path_1.default.join(src, np.source);
1028
- if (node_fs_1.default.existsSync(pluginSrc)) {
1469
+ if (installFs().existsSync(pluginSrc)) {
1029
1470
  // Confine the FULL dest path (dir + file), not just the dir. Previously
1030
1471
  // only `np.dir` was validated and `np.file` was joined on unchecked, so a
1031
1472
  // descriptor whose `file` carried `..`, an absolute path, or a NUL byte
@@ -1035,8 +1476,32 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
1035
1476
  // nothing. For a well-formed descriptor this resolves identically to the
1036
1477
  // previous mkdir(dir) + join(dir, file).
1037
1478
  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);
1479
+ installFs().mkdirSync(node_path_1.default.dirname(destPath), { recursive: true });
1480
+ installFs().copyFileSync(pluginSrc, destPath);
1481
+ // #2544: the staged adapter is a `.js` file, so Node decides its module
1482
+ // type by walking up for the nearest package.json. It used to find the
1483
+ // marker the installer wrote at the config root — the write that
1484
+ // clobbered user-authored files. Pin it from the plugin's own directory
1485
+ // instead, leaving the config root alone. The marker cannot disturb
1486
+ // plugin discovery: OpenCode auto-discovers `plugins/*.{ts,js}` and pi's
1487
+ // isExtensionFile() accepts only `.ts`/`.js` (see installer-migration
1488
+ // 006), so a package.json here is never treated as a plugin. Never
1489
+ // written over a package.json GSD does not own — but when one is already
1490
+ // there, say so: the adapter is CommonJS and will not load under a
1491
+ // foreign `"type": "module"`, and a silent no-op would leave every guard
1492
+ // the adapter spawns dead with no diagnostic (the #2305 failure shape).
1493
+ const markerOutcome = (0, commonjs_marker_cjs_1.ensureCommonJsMarker)(node_path_1.default.dirname(destPath));
1494
+ if (markerOutcome === 'preserved-foreign') {
1495
+ console.warn(` ⚠ ${np.dir}/package.json is not GSD's CommonJS marker — left untouched. `
1496
+ + `If it declares "type": "module", ${np.file} will not load.`);
1497
+ }
1498
+ else if (markerOutcome === 'failed') {
1499
+ // Best-effort, never fatal: an unwritable plugin dir must not abort the
1500
+ // install. Same warn-and-continue posture as the foreign-marker branch —
1501
+ // the adapter is staged either way, it just may not resolve as CommonJS.
1502
+ console.warn(` ⚠ Could not write ${np.dir}/package.json (CommonJS marker) — install continued. `
1503
+ + `If the config root declares "type": "module", ${np.file} will not load.`);
1504
+ }
1040
1505
  }
1041
1506
  }
1042
1507
  }
@@ -1081,15 +1546,24 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
1081
1546
  if (currentName === LEGACY_NAME)
1082
1547
  return; // e.g. Kilo — legacy IS the current location; nothing to migrate
1083
1548
  const legacyDir = node_path_1.default.join(configDir, LEGACY_NAME);
1084
- if (!node_fs_1.default.existsSync(legacyDir))
1549
+ if (!installFs().existsSync(legacyDir))
1085
1550
  return;
1086
1551
  // Never follow a symlinked legacy dir out of configDir.
1087
- if (node_fs_1.default.lstatSync(legacyDir).isSymbolicLink())
1552
+ if (installFs().lstatSync(legacyDir).isSymbolicLink())
1088
1553
  return;
1554
+ // #2874: installerMigrations.readInstallManifest/classifyArtifact are
1555
+ // routed through the injectable seam (installer-migrations.cts:36,54-58,
1556
+ // 376-380 — readInstallManifest -> readJsonIfPresent -> installFs(),
1557
+ // classifyArtifact -> sha256File -> installFs().readFileSync), so a
1558
+ // fake-adapter install of an opencode-family runtime with a legacy
1559
+ // `command/` dir present reaches the fake, not real fs. Exercised by
1560
+ // tests/executed-plan.test.cjs's F2 "opencode-family legacy command/ dir
1561
+ // migration" case, which poisons every real fs method and asserts the
1562
+ // fake store was mutated.
1089
1563
  const manifest = installerMigrations.readInstallManifest(configDir);
1090
1564
  let entries;
1091
1565
  try {
1092
- entries = node_fs_1.default.readdirSync(legacyDir, { withFileTypes: true });
1566
+ entries = installFs().readdirSync(legacyDir, { withFileTypes: true });
1093
1567
  }
1094
1568
  catch {
1095
1569
  return;
@@ -1103,7 +1577,7 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
1103
1577
  const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
1104
1578
  if (classification === 'managed-pristine' || classification === 'managed-modified') {
1105
1579
  try {
1106
- node_fs_1.default.unlinkSync(node_path_1.default.join(legacyDir, entry.name));
1580
+ installFs().unlinkSync(node_path_1.default.join(legacyDir, entry.name));
1107
1581
  }
1108
1582
  catch { /* best-effort */ }
1109
1583
  }
@@ -1111,8 +1585,8 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
1111
1585
  // ownership, so it must never be deleted as collateral damage.
1112
1586
  }
1113
1587
  try {
1114
- if (node_fs_1.default.readdirSync(legacyDir).length === 0)
1115
- node_fs_1.default.rmdirSync(legacyDir);
1588
+ if (installFs().readdirSync(legacyDir).length === 0)
1589
+ installFs().rmdirSync(legacyDir);
1116
1590
  }
1117
1591
  catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
1118
1592
  }
@@ -1137,9 +1611,18 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
1137
1611
  * installOpencodeFamilySkills so an installed third-party capability skill
1138
1612
  * materializes for this combined-family (OpenCode/Kilo) install path too.
1139
1613
  * Absent -> no third-party skills staged (fail closed).
1614
+ * @returns #2874 design row 2: an executed-plan value, same top-level shape
1615
+ * (`runtime`/`scope`/`kinds`/`cleanup`/`postSteps`) as the generic
1616
+ * `installRuntimeArtifacts` branch — this was the one early return a
1617
+ * `void`-shaped hole survived unnoticed in.
1140
1618
  */
1141
1619
  function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}, capabilityRegistry) {
1142
- const isGlobal = scope === 'global';
1620
+ // #2870: `scope` keeps its exported required `string` signature (no
1621
+ // signature change). It is always the `installRuntimeArtifacts`-forwarded
1622
+ // 'global' | 'local' literal produced by bin/install.js's scope-resolution
1623
+ // ternary (both real call sites and every test call site), so the cast is
1624
+ // safe: `isGlobalScope` never sees a value outside its union here.
1625
+ const isGlobal = (0, install_scope_cjs_1.isGlobalScope)(scope);
1143
1626
  // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
1144
1627
  // (via the .gsd-source marker or a walk-up from __dirname) — every other
1145
1628
  // call site in runtime-artifact-layout.cts feeds its return value straight
@@ -1164,8 +1647,29 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
1164
1647
  // keeps its own descriptor value ('command', singular) unchanged.
1165
1648
  const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, behaviors.flatCommandDir || 'command');
1166
1649
  installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
1167
- installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
1650
+ const skillsWritten = installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
1651
+ // #2875 Part 2 Task A: agents kind, reusing the SAME descriptor path the
1652
+ // generic layout-driven loop uses (see installAgentsKindStandalone's own
1653
+ // doc). A `null` result means this runtime's layout declares no `agents`
1654
+ // kind — nothing written, nothing reported (no #1879-F15 inert claim).
1655
+ const agentsResult = installAgentsKindStandalone(runtime, configDir, scope, resolvedProfile, pathPrefix, resolveAttribution, capabilityRegistry);
1168
1656
  _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
1657
+ // #2874 design row 2: report what this combined-family install wrote,
1658
+ // mirroring the generic branch's top-level shape. `cleanup` is `[]` — this
1659
+ // path stages via install-profiles.cts's STAGED_DIRS (process-exit
1660
+ // cleanup), not the per-call cleanupDirs mechanism createRuntimeArtifactInstallPlan
1661
+ // uses, so there is nothing this call itself attempted to clean up.
1662
+ return {
1663
+ runtime,
1664
+ scope,
1665
+ kinds: [
1666
+ { kind: 'commands', sourceDir: rawCommandsDir, destDir: commandDir },
1667
+ { kind: 'skills', sourceDir: rawCommandsDir, destDir: configDir, written: skillsWritten },
1668
+ ...(agentsResult ? [{ kind: 'agents', sourceDir: agentsResult.sourceDir, destDir: agentsResult.destDir }] : []),
1669
+ ],
1670
+ cleanup: [],
1671
+ postSteps: { hermesBareStemCleanup: false, nativePlugin: Boolean(behaviors.nativePlugin) },
1672
+ };
1169
1673
  }
1170
1674
  // ---------------------------------------------------------------------------
1171
1675
  // uninstallRuntimeArtifacts
@@ -1180,11 +1684,16 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
1180
1684
  * @param scope
1181
1685
  */
1182
1686
  function uninstallRuntimeArtifacts(runtime, configDir, scope) {
1687
+ // A retired descriptor kind is absent from the current uninstall plan, just
1688
+ // as it is absent from the install plan. Sweep manifest-proven output from
1689
+ // retired kinds before removing the current layout so a direct uninstall
1690
+ // cannot leave stale runtime surfaces behind (#2644).
1691
+ retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
1183
1692
  // Legacy cleanup before layout-driven removal (scope-aware to avoid
1184
1693
  // removing Claude local commands/gsd/ which is the primary install dir).
1185
- // Returns saved user artifacts so we can migrate AFTER layout removal
1694
+ // Returns staged user artifacts so we can migrate AFTER layout removal
1186
1695
  // (the layout's gsd-* prefix pass would wipe a skill dir created here).
1187
- const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
1696
+ const stagedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
1188
1697
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
1189
1698
  const plan = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
1190
1699
  const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
@@ -1214,8 +1723,39 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
1214
1723
  // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
1215
1724
  // runtime-aware SKILL.md location after all layout-driven removal is
1216
1725
  // complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
1217
- if (savedLegacyArtifacts) {
1726
+ if (stagedLegacyArtifacts) {
1727
+ // #2875: read the content back from the DISK-staged copy, matching
1728
+ // _runLegacyInstallMigrations's call site — never restored on failure
1729
+ // here either (the user is uninstalling; there is nothing to restore to).
1730
+ //
1731
+ // Security fix (parity with _runLegacyInstallMigrations's own guard,
1732
+ // src/install-engine.cts / bin/install.js:8478): `readFileSync` ALWAYS
1733
+ // follows a symlink. A staged `dev-preferences.md` that is itself a
1734
+ // symlink (user-artifact-staging.cts's "Symlink safety" contract: a
1735
+ // symlinked user artifact is recreated AS a symlink in the staging tree,
1736
+ // never copied by content) would previously have its REFERENT's bytes
1737
+ // read here and land in SKILL.md verbatim — e.g. a symlink to
1738
+ // `~/.ssh/id_rsa` gets its private key content written into a file GSD
1739
+ // loads into agent context. A symlink to a DIRECTORY instead throws
1740
+ // EISDIR uncaught out of this function, which the caller never expected
1741
+ // and which left the staged entry undiscarded (re-materializing on the
1742
+ // next recovery pass and failing uninstall every time thereafter).
1743
+ // lstatSync never follows a symlink; skip a symlinked name entirely
1744
+ // (never migrated) rather than dereferencing it.
1745
+ const savedLegacyArtifacts = new Map();
1746
+ for (const name of stagedLegacyArtifacts.names) {
1747
+ const stagedPath = node_path_1.default.join(stagedLegacyArtifacts.filesDir, name);
1748
+ // #2875 defect fix (crash resilience — TOCTOU, parity with
1749
+ // _runLegacyInstallMigrations's own fix above): a raw `lstatSync`
1750
+ // throws if `stagedPath` has vanished between staging and this read;
1751
+ // degrade via `tryLstat` instead of crashing uninstall.
1752
+ const stagedLstat = tryLstat(stagedPath);
1753
+ if (!stagedLstat || stagedLstat.isSymbolicLink())
1754
+ continue;
1755
+ savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8'));
1756
+ }
1218
1757
  migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
1758
+ userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts);
1219
1759
  }
1220
1760
  }
1221
1761
  module.exports = {
@@ -1223,14 +1763,15 @@ module.exports = {
1223
1763
  uninstallRuntimeArtifacts,
1224
1764
  installOpencodeFamilySkills,
1225
1765
  installOpencodeFamilyCommands,
1766
+ installAgentsKindStandalone,
1226
1767
  installOpencodeFamilyArtifacts,
1227
1768
  _installNativePluginIfDeclared,
1228
1769
  _hostBehaviors,
1229
1770
  _copyStaged,
1230
1771
  hasExistingSymlinkBetween,
1231
1772
  isSymlinkedDestOptIn,
1232
- preserveUserArtifacts,
1233
- restoreUserArtifacts,
1773
+ _resolveUserArtifactStagingRoot,
1774
+ _tryResolveUserArtifactStagingRoot,
1234
1775
  migrateLegacyDevPreferencesToSkill,
1235
1776
  applyOpencodeFamilyPathPrefix,
1236
1777
  convertClaudeCommandToOpencodeSkill,