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