@opengsd/gsd-core 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
package/bin/install.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  projectPathActionProjection,
15
15
  projectPortableHookBaseDir,
16
16
  projectPersistentPathExportActions,
17
+ PATH_ACTION_REASON,
17
18
  projectShellCommandText,
18
19
  projectCodexHookTomlCommand,
19
20
  shellHookOmitsBashRunner,
@@ -33,11 +34,20 @@ const {
33
34
  getGlobalConfigDir,
34
35
  getGlobalSkillsBase,
35
36
  resolveKimiHooksTomlDir,
37
+ isRegisteredRuntimeId,
36
38
  } = require('../gsd-core/bin/lib/runtime-homes.cjs');
39
+ // #2870: the Install Scope Module — turns a bare 'global' | 'local' scope id
40
+ // plus a runtime into one resolved value (configHome, settingsFile,
41
+ // consentRequired, hostPrecedenceRank) instead of the id being re-derived
42
+ // and re-interpreted at each call site. See src/install-scope.cts.
43
+ const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
37
44
  // getDirName (runtime -> local config dir name) is relocated out of this
38
45
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
39
46
  // conversion module's rewrite engine can consume it without importing
40
- // bin/install.js. Re-exported below for back-compat consumers/tests.
47
+ // bin/install.js. Imported here for install.js's own internal call sites
48
+ // (getConfigDirFromHome and the runtime-content-rewrite loops below) — #2876
49
+ // retired the re-export; tests now import getDirName directly from
50
+ // gsd-core/bin/lib/runtime-name-policy.cjs.
41
51
  const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
42
52
  const {
43
53
  applyWorktreeBaseRef,
@@ -45,7 +55,23 @@ const {
45
55
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
46
56
  const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
47
57
  const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
58
+ // #2930 (epic #1671 Phase 3): strips `<!-- gsd:section -->` markers from
59
+ // workflow .md content at emit time, before any per-runtime rewrite runs.
60
+ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
61
+ // #3072: THE shared composition-scope predicate (also consumed by the served
62
+ // MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
63
+ const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
48
64
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
65
+ const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
66
+ // #2873: cross-scope shadow detection — reports (never fails) when a
67
+ // GSD-owned scope shadows another on this machine (design doc:
68
+ // .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md).
69
+ const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
70
+ // #2544: the CommonJS marker's single source of truth. classifyMarker() backs
71
+ // BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
72
+ // so the write side can no longer clobber a package.json the remove side would
73
+ // correctly refuse to delete.
74
+ const { ensureCommonJsMarker, removeCommonJsMarker } = require('../gsd-core/bin/lib/commonjs-marker.cjs');
49
75
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
50
76
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
51
77
  // the manifest and the installed hooks/ dir structurally identical. Avoids the
@@ -53,9 +79,14 @@ const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-
53
79
  const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
54
80
  const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
55
81
 
56
- // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
57
- // bin/install.js re-exports everything from hooksSurface so existing callers
58
- // (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
82
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated
83
+ // module. install.js used to re-export the whole hooksSurface surface so
84
+ // existing callers (require('../bin/install.js').writeCursorHooksJson etc.)
85
+ // kept working — #2876 found zero production/test consumers of any of those
86
+ // re-exports (tests import runtime-hooks-surface.cjs directly) and retired
87
+ // them from module.exports. install.js still requires hooksSurface below for
88
+ // its own internal call sites (writeCursorHooksJson, writeClineArtifacts,
89
+ // resolveNodeRunner, applySettingsJsonHooks, etc.).
59
90
  const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
60
91
 
61
92
  /**
@@ -283,13 +314,26 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
283
314
  // subagentStart → gsd-cursor-subagent-start.js (subagent context injection)
284
315
  // subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder)
285
316
  // Cursor docs: https://cursor.com/docs/hooks
286
- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
287
- const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
288
- const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
289
- const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
290
- const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
291
- const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
292
- // All GSD-managed Cursor hook scripts (used by uninstall cleanup).
317
+ //
318
+ // These script-name/marker constants used to be independently re-declared
319
+ // here with their own string literals — a second, unlinked copy of exactly
320
+ // the values runtime-hooks-surface.cts also defines for its own internal use
321
+ // (buildCursorHookEntry, writeCursorHooksJson, etc.). #2876's code review
322
+ // found tests reading the constant from install.js's copy while calling
323
+ // functions built from hooksSurface's copy, with nothing guarding the two
324
+ // staying equal — the same unlinked-duplicate-value hazard the ADR-1508
325
+ // dedup elsewhere in this file exists to prevent. Fixed at the root: these
326
+ // are now bare references to hooksSurface's own exports, so there is exactly
327
+ // one literal definition of each value, full stop.
328
+ const GSD_CURSOR_SESSION_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SESSION_HOOK_SCRIPT;
329
+ const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_POST_TOOL_HOOK_SCRIPT;
330
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT;
331
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_STOP_HOOK_SCRIPT;
332
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT;
333
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT;
334
+ // All GSD-managed Cursor hook scripts (used by uninstall cleanup). Not
335
+ // independently defined in hooksSurface — built here from the bare
336
+ // references above, so it can never drift from them either.
293
337
  const GSD_CURSOR_HOOK_SCRIPTS = [
294
338
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
295
339
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
@@ -299,7 +343,7 @@ const GSD_CURSOR_HOOK_SCRIPTS = [
299
343
  GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
300
344
  ];
301
345
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
302
- const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
346
+ const GSD_CURSOR_HOOK_MARKER = hooksSurface.GSD_CURSOR_HOOK_MARKER;
303
347
 
304
348
  // #2100 Stage 2 — Windsurf/Cascade lifecycle hook constants.
305
349
  // Windsurf/Cascade reads hook configs from <project-root>/.windsurf/hooks.json
@@ -315,15 +359,17 @@ const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
315
359
  // have no Windsurf counterpart and are deliberately NOT ported.
316
360
  // Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
317
361
  // https://docs.devin.ai/desktop/cascade/hooks
318
- const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
319
- const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
362
+ //
363
+ // Same #2876 fix as the Cursor block above: bare references to hooksSurface's
364
+ // own exports instead of a second, unlinked literal copy.
365
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT;
366
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT;
320
367
  // All GSD-managed Windsurf hook scripts (used by uninstall cleanup).
321
- const GSD_WINDSURF_HOOK_SCRIPTS = [
322
- GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
323
- GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
324
- ];
368
+ // hooksSurface independently defines the same array — bare reference here so
369
+ // the two can never drift.
370
+ const GSD_WINDSURF_HOOK_SCRIPTS = hooksSurface.GSD_WINDSURF_HOOK_SCRIPTS;
325
371
 
326
- // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
372
+ // GSD-managed files under hooks/lib/ (helpers required by gsd-*.js hooks).
327
373
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
328
374
  // cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
329
375
  // are staged individually by writeCursorHooksJson (Cursor sets
@@ -331,7 +377,87 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
331
377
  // copy below) — that function stages this helper alongside them. Listing it
332
378
  // here keeps uninstall and the manifest managing it for every OTHER runtime
333
379
  // that does receive hooks/lib.
334
- const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
380
+ // injection-patterns.js (#3504) is required by gsd-prompt-guard.js and
381
+ // gsd-read-injection-scanner.js — the shared prompt-injection pattern list the
382
+ // two guards require so their copies cannot drift.
383
+ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
384
+
385
+ /**
386
+ * Directory name GSD stages its shared hook bundle under, inside a runtime's
387
+ * install root. Defaults to 'hooks' — the name every runtime used before #3023.
388
+ *
389
+ * pi (pi.dev) reserves `hooks/` as its own now-deprecated extension location and
390
+ * prints a migration warning on every startup when one exists, so pi overrides
391
+ * this via hostBehaviors.sharedHooksDirName. Following pi's advised remediation
392
+ * (move it into extensions/) would break the adapter's path resolution AND expose
393
+ * GSD's .js helpers to pi's extension auto-discovery, so the bundle is renamed in
394
+ * place instead — same depth, so every `__dirname/..`-relative resolution inside
395
+ * the bundle (e.g. hooks/gsd-context-monitor.js reaching ../gsd-core/bin/) keeps
396
+ * working.
397
+ */
398
+ const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
399
+
400
+ // #3184 — GSD-managed file enumerations for scripts/changeset/ and scripts/lib/
401
+ // uninstall. The install-side copy of both directories is wholesale ("copy every
402
+ // file present"), so these enumerations MUST be kept in parity with the real
403
+ // directory contents or an added file ships on install and then orphans on
404
+ // uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
405
+ // Hoisted to module scope (and exported below) so tests/install.test.cjs can
406
+ // assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
407
+ // without source-grepping this file.
408
+ const GSD_CHANGESET_FILES = [
409
+ 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
410
+ 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
411
+ 'README.md', // documentation only — not user-authored
412
+ ];
413
+ const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs'];
414
+
415
+ /**
416
+ * Resolve a runtime's shared-hooks directory name from its descriptor.
417
+ *
418
+ * The value is a single path SEGMENT. This string is joined onto a user's config
419
+ * root and then written to and recursively read, so anything that is not a plain,
420
+ * non-empty, separator-free, non-dot segment is rejected back to the default —
421
+ * a descriptor typo must never let the installer write outside the install root.
422
+ *
423
+ * The "non-dot" part of that contract is enforced beyond the literal '.' / '..'
424
+ * segments: an all-dot (or dot-and-whitespace-only) segment is rejected as a
425
+ * meaningless name, a segment with a trailing dot or space is rejected because
426
+ * Windows silently strips it at directory-creation time (which would split the
427
+ * name the installer creates from the name callers probe for), and a Windows
428
+ * reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9, with or without an
429
+ * extension) is rejected because it cannot exist as a directory on Windows at
430
+ * all. These checks are unconditional on every platform: the descriptor is
431
+ * authored once and shipped everywhere, so a value invalid on Windows must be
432
+ * rejected identically on Linux/macOS, or the install and its fixtures disagree
433
+ * cross-platform.
434
+ *
435
+ * @param {string} runtime
436
+ * @returns {string}
437
+ */
438
+ function resolveSharedHooksDirName(runtime) {
439
+ const raw = _hostBehaviors(runtime).sharedHooksDirName;
440
+ if (typeof raw !== 'string') return SHARED_HOOKS_DIR_DEFAULT;
441
+ const name = raw.trim();
442
+ if (name === '') return SHARED_HOOKS_DIR_DEFAULT;
443
+ if (name === '.' || name === '..') return SHARED_HOOKS_DIR_DEFAULT;
444
+ // All-dot or dot+whitespace segments ('...', '. .') are not meaningful
445
+ // directory names and are almost certainly a descriptor typo.
446
+ if (name.replace(/[.\s]/g, '') === '') return SHARED_HOOKS_DIR_DEFAULT;
447
+ // Windows silently strips a trailing dot or space at creation time, so the
448
+ // directory the installer creates would not match the name the adapter
449
+ // probes for — a split-brain that only reproduces off-Linux.
450
+ if (/[. ]$/.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
451
+ // Windows reserved device names cannot exist as directories.
452
+ if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
453
+ if (name.includes('/') || name.includes('\\')) return SHARED_HOOKS_DIR_DEFAULT;
454
+ // Belt-and-braces: reject anything path.basename() would reduce, and any
455
+ // Windows drive/UNC-flavoured value.
456
+ if (path.basename(name) !== name) return SHARED_HOOKS_DIR_DEFAULT;
457
+ if (path.isAbsolute(name)) return SHARED_HOOKS_DIR_DEFAULT;
458
+ if (name.includes('\0')) return SHARED_HOOKS_DIR_DEFAULT;
459
+ return name;
460
+ }
335
461
 
336
462
  const CODEX_AGENT_SANDBOX = {
337
463
  'gsd-executor': 'workspace-write',
@@ -375,14 +501,13 @@ const pkg = require('../package.json');
375
501
  // of cwd, but keeping the require at the top makes the dependency explicit and
376
502
  // surfaces resolution failures at process start instead of at first install call.
377
503
  const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
378
- const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
379
504
  const {
380
505
  RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
506
+ isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel,
381
507
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
382
- const {
383
- resolveTierEntry: gsdResolveTierEntry,
384
- CLAUDE_AGENT_ALIASES,
385
- } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
508
+ // #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
509
+ // install-model-override-resolver.cjs's readGsdRuntimeProfileResolver
510
+ // (required below) — this installer no longer needs its own bindings.
386
511
 
387
512
  // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
388
513
  // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile
@@ -455,6 +580,17 @@ try {
455
580
  // hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
456
581
  // The live descriptor (capabilities/claude/capability.json) remains the source of
457
582
  // truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
583
+ //
584
+ // #2870: NOT routed through the Install Scope Module (resolveScope,
585
+ // src/install-scope.cts) despite that module owning per-scope settings-file
586
+ // resolution elsewhere in this file. resolveScope's own descriptor lookup
587
+ // goes through the SAME capability registry require this floor exists to
588
+ // survive the failure of (see getRegistry() in install-scope.cts) — so on
589
+ // exactly the "registry failed to load" path this constant is for,
590
+ // resolveScope would throw too. Routing through it here would trade a
591
+ // graceful, documented degrade for a crash in the one case this floor was
592
+ // added to prevent. This hardcoded literal is the correct, honest answer,
593
+ // not an un-migrated leftover.
458
594
  const FALLBACK_HOST_BEHAVIORS = Object.freeze({
459
595
  claude: Object.freeze({
460
596
  settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
@@ -516,6 +652,22 @@ function _hostIntegrationDispatch(runtime) {
516
652
  return dispatch || {};
517
653
  }
518
654
 
655
+ /**
656
+ * #2870: shared install()/uninstall() scope resolution. Routes `id` through
657
+ * the Install Scope Module (src/install-scope.cts) and degrades to `null` on
658
+ * failure (unknown/non-installable runtime, broken registry bundle) instead
659
+ * of throwing, so each call site's own plain-id fallback keeps working
660
+ * exactly as it did before this migration. Both call sites previously carried
661
+ * their own copy of this try/catch; this is the one shared copy.
662
+ */
663
+ function _resolveScopeSafe(id, runtime) {
664
+ try {
665
+ return resolveScope({ id, runtime });
666
+ } catch (_) {
667
+ return null;
668
+ }
669
+ }
670
+
519
671
  /**
520
672
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
521
673
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -550,6 +702,7 @@ function _runtimeAdapter(runtime) {
550
702
  const {
551
703
  applyInstallerMigrationPlan,
552
704
  discoverInstallerMigrations,
705
+ MANIFEST_SCHEMA_VERSION,
553
706
  runInstallerMigrations,
554
707
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
555
708
  const {
@@ -578,29 +731,78 @@ const {
578
731
  // getCommitAttribution STAYS here (impure install-time config I/O); it is injected
579
732
  // into the engine functions via the resolveAttribution parameter at each call site.
580
733
  const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
734
+ // #2876: _copyStaged, convertClaudeCommandToOpencodeSkill, and
735
+ // convertClaudeCommandToKiloSkill used to be destructured here too — all
736
+ // three had no install.js internal caller and no export consumer (tests
737
+ // import all three directly from gsd-core/bin/lib/install-engine.cjs), so
738
+ // the retired bindings were dead code. applyOpencodeFamilyPathPrefix,
739
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup,
740
+ // _removeGsdEntries, _restoreDir, and _removeHermesBareStemDirs were the
741
+ // same — unused local bindings that were never part of this module's export
742
+ // surface either — found and retired in the same sweep.
581
743
  const {
582
744
  installRuntimeArtifacts,
583
745
  uninstallRuntimeArtifacts,
584
746
  installOpencodeFamilySkills,
747
+ installAgentsKindStandalone,
585
748
  _installNativePluginIfDeclared,
586
- _copyStaged,
587
749
  hasExistingSymlinkBetween,
588
750
  isSymlinkedDestOptIn,
589
- preserveUserArtifacts,
590
- restoreUserArtifacts,
591
751
  migrateLegacyDevPreferencesToSkill,
592
- applyOpencodeFamilyPathPrefix,
593
- convertClaudeCommandToOpencodeSkill,
594
- convertClaudeCommandToKiloSkill,
595
752
  USER_OWNED_ARTIFACTS,
596
- _runLegacyInstallMigrations,
597
- _runLegacyUninstallCleanup,
598
- _removeGsdEntries,
599
753
  _snapshotDir,
600
- _restoreDir,
601
- _removeHermesBareStemDirs,
602
754
  } = installEngine;
603
755
 
756
+ // #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
757
+ // across the preserve -> wipe -> restore window (#1874-F19). See
758
+ // src/user-artifact-staging.cts's module doc.
759
+ const {
760
+ stageUserArtifacts,
761
+ restoreStagedUserArtifacts,
762
+ discardStagedUserArtifacts,
763
+ recoverOrphanedUserArtifacts,
764
+ } = require(path.join(_gsdLibDir, 'user-artifact-staging.cjs'));
765
+
766
+ /**
767
+ * Resolve the durable staging root for `configDir`, confined via
768
+ * `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween`
769
+ * (test-matrix E1/E4) — mirrors install-engine.cts's
770
+ * `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's
771
+ * two call sites (uninstall's legacy-migration block, install's mainline
772
+ * gsd-core copy) are not inside that module.
773
+ */
774
+ function _resolveUserArtifactStagingRoot(configDir) {
775
+ const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.gsd-staging', 'user-artifacts'));
776
+ if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
777
+ throw new Error(
778
+ `_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.`,
779
+ );
780
+ }
781
+ return stagingRoot;
782
+ }
783
+
784
+ /**
785
+ * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors
786
+ * install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local
787
+ * here for the same reason the throwing version above is: bin/install.js's
788
+ * own call sites are not inside that module). A hostile/broken
789
+ * `.gsd-staging` path (or a symlinked configDir itself) must never brick
790
+ * `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot`
791
+ * was called UNGUARDED as the first statement of both, so
792
+ * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands, including
793
+ * uninstall, the remedy for the first problem. Returns `null` (never throws),
794
+ * logging one warning; every call site MUST treat `null` as "skip the
795
+ * staging-dependent step for this run".
796
+ */
797
+ function _tryResolveUserArtifactStagingRoot(configDir) {
798
+ try {
799
+ return _resolveUserArtifactStagingRoot(configDir);
800
+ } catch (err) {
801
+ console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
802
+ return null;
803
+ }
804
+ }
805
+
604
806
  // Parse args
605
807
  const args = process.argv.slice(2);
606
808
  const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -786,7 +988,8 @@ Then re-run: npx ${pkg.name}@latest
786
988
  }
787
989
 
788
990
  // getDirName (runtime -> local config dir name) now lives in
789
- // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported + re-exported.
991
+ // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported above for
992
+ // install.js's own internal use only — #2876 retired the re-export.
790
993
 
791
994
  /**
792
995
  * Get the config directory path relative to home directory for a runtime
@@ -902,22 +1105,25 @@ if (hasUninstall) {
902
1105
 
903
1106
  // Show help if requested
904
1107
  if (hasHelp) {
905
- console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
1108
+ console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
906
1109
  process.exit(0);
907
1110
  }
908
1111
 
909
1112
  // computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix
910
- // (ADR-1508 / #1511 Phase 2 — single owner). The const binding above (~line 638)
911
- // re-exports it here for call sites and module.exports.
1113
+ // (ADR-1508 / #1511 Phase 2 — single owner). The const binding above re-binds
1114
+ // it here for install.js's own internal call sites only — #2876 retired the
1115
+ // module.exports entry (zero consumers found; tests import
1116
+ // runtimeArtifactConversion._computePathPrefix directly).
912
1117
  // Original doc: Compute the path prefix used for `@file` references in installed
913
1118
  // command/skill markdown. For global installs under $HOME uses $HOME/... form;
914
1119
  // OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux).
915
1120
 
916
- // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
917
- // now owned by the runtime-hooks-surface module. Import them here so
918
- // install.js callers continue to work and so there is a single implementation
919
- // of these helpers.
920
- const normalizeNodePath = hooksSurface.normalizeNodePath;
1121
+ // resolveNodeRunner, resolveBashRunner, referencesHook are now owned by the
1122
+ // runtime-hooks-surface module. Import them here so install.js callers
1123
+ // continue to work and so there is a single implementation of these helpers.
1124
+ // (normalizeNodePath was re-bound here too until #2876 found bin/install.js
1125
+ // had no internal caller and no export consumer for it — hooksSurface owns
1126
+ // the single implementation now, used internally by resolveNodeRunner there.)
921
1127
  const resolveNodeRunner = hooksSurface.resolveNodeRunner;
922
1128
  const resolveBashRunner = hooksSurface.resolveBashRunner;
923
1129
  // referencesHook: pure predicate over hook entry objects, shared between
@@ -935,138 +1141,50 @@ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
935
1141
  // callers continue to work and there is a single implementation. (All call
936
1142
  // sites are below this line, so the const binding has no TDZ hazard.)
937
1143
  const processAttribution = runtimeArtifactConversion.processAttribution;
938
- // computePathPrefix / applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace:
939
- // Single implementations now live in runtimeArtifactConversion (ADR-1508 / #1511 Phase 2).
940
- // Re-bound here so install.js call sites and exports continue to work unchanged.
941
- // Local bodies replaced by breadcrumb comments at their original locations.
942
- // All call sites are below this line → no TDZ hazard.
1144
+ // computePathPrefix: implementation lives in runtimeArtifactConversion
1145
+ // (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call
1146
+ // sites continue to work. #2876 retired the sibling
1147
+ // applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace
1148
+ // re-bindings that used to sit alongside it, and the entire #1675 Augment
1149
+ // converter family re-binding (convertClaudeToAugmentMarkdown /
1150
+ // convertClaudeCommandToAugmentSkill / convertClaudeAgentToAugmentAgent) that
1151
+ // used to follow — bin/install.js had no internal caller for any of them (the
1152
+ // descriptor pipeline in runtimeArtifactConversion calls them directly).
1153
+ // (All call sites are below this line → no TDZ hazard.)
943
1154
  const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
944
- const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
945
- const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
946
- // #1675 (ADR-1508): the augment converter family is single-sourced in the
947
- // conversion module. install.js re-binds (does not re-define) these so there
948
- // is exactly one body — the generative-drift hazard the dedup removes. The two
949
- // private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
950
- // live only in the conversion module now; they are no longer duplicated here.
1155
+ // #2931 (ADR-1508): the windsurf converter family is single-sourced in the
1156
+ // conversion module. install.js re-binds (does not re-define) the one member
1157
+ // it still calls internally so there is exactly one body — the
1158
+ // generative-drift hazard the dedup removes. #2876 retired the sibling
1159
+ // convertClaudeCommandToWindsurfSkill / convertClaudeCommandToWindsurfWorkflow /
1160
+ // convertClaudeAgentToWindsurfAgent re-bindings — bin/install.js had no
1161
+ // internal caller for any of them (the descriptor pipeline in
1162
+ // runtimeArtifactConversion calls them directly).
951
1163
  // (All call sites are below this line → no TDZ hazard.)
952
- const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
953
- const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
954
- const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
1164
+ const convertClaudeToWindsurfMarkdown = runtimeArtifactConversion.convertClaudeToWindsurfMarkdown;
1165
+ // #2931 (ADR-1508): single-sourced in the conversion module — was a second,
1166
+ // unlinked verbatim copy here (used by the local Cursor/Trae/CodeBuddy/Cline
1167
+ // converters below), the exact drift class this PR exists to reduce. Verified
1168
+ // behaviorally identical (no block / one block / adjacent blocks / whole-
1169
+ // content block / unclosed opening tag / nested-looking tags / repeated
1170
+ // sequential calls for global-regex lastIndex leakage) before merging.
1171
+ // install.js re-binds (does not re-define) — RUNTIME_COMPATIBILITY_BLOCK_RE
1172
+ // is no longer duplicated here either. (All call sites are below this line
1173
+ // → no TDZ hazard.)
1174
+ const applyClaudeCodeBrandSwap = runtimeArtifactConversion.applyClaudeCodeBrandSwap;
955
1175
 
956
1176
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
957
1177
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
958
1178
  }
959
1179
 
960
- /**
961
- * Build the GSD-managed Codex SessionStart hook block for config.toml.
962
- *
963
- * Issue #3017: the previous shape inlined `command = "node ${path}"` which
964
- * fails under GUI/minimal-PATH runtimes where bare `node` doesn't resolve
965
- * (same failure mode as #2979 → fixed for settings.json by #3002, this
966
- * helper closes the gap for Codex's TOML hook surface).
967
- *
968
- * Returns null when `absoluteRunner` is null so callers can warn-and-skip
969
- * registration — emitting a broken bare-node hook is strictly worse than
970
- * not registering one (the user can re-run install once node is on PATH).
971
- *
972
- * @param {string} targetDir - Resolved absolute Codex config dir (e.g. ~/.codex).
973
- * @param {{ absoluteRunner: string|null, eol?: string }} opts
974
- * absoluteRunner: result of resolveNodeRunner() — a JSON-stringified
975
- * absolute node path with forward slashes (e.g. `"/usr/local/bin/node"`),
976
- * or null when process.execPath was unavailable.
977
- * eol: line ending to emit ('\n' or '\r\n') — caller passes
978
- * detectLineEnding(configContent) so existing CRLF files stay CRLF.
979
- * Defaults to '\n'.
980
- * @returns {string|null} The toml block to append, or null on missing runner.
981
- */
982
- function buildCodexHookBlock(targetDir, opts) {
983
- return hooksSurface.buildCodexHookBlock(targetDir, opts);
984
- }
985
-
986
- /**
987
- * Rewrite legacy bare-`node` managed-hook command lines in a Codex
988
- * config.toml string to use the absolute Node runner. Mirror of
989
- * rewriteLegacyManagedNodeHookCommands but for the toml surface (#3017).
990
- *
991
- * Only rewrites entries whose script basename matches CODEX_MANAGED_HOOK_BASENAMES
992
- * (basename equality, not substring containment) — user-authored bare-node
993
- * hooks pointing at scripts outside the managed allowlist are left alone.
994
- *
995
- * @param {string} content - Current config.toml contents.
996
- * @param {string|null} absoluteRunner - Result of resolveNodeRunner().
997
- * @returns {{ content: string, changed: boolean }}
998
- */
999
- function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
1000
- return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
1001
- }
1002
-
1003
- /**
1004
- * Generic reconcile helper: ensure hooks.json contains exactly one managed GSD
1005
- * hook entry for `eventName`, while preserving all user-owned entries.
1006
- *
1007
- * Supports both known hooks.json shapes:
1008
- * 1) { "<EventName>": [...] }
1009
- * 2) { "hooks": { "<EventName>": [...] } }
1010
- *
1011
- * @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.codex).
1012
- * @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop').
1013
- * @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts
1014
- * managedCommand: POSIX hook command string to register, or null to remove.
1015
- * commandWindows: Windows .cmd shim path to emit as `commandWindows` field
1016
- * (#772). When provided, Codex uses this path on Windows and `managedCommand`
1017
- * on POSIX without needing per-platform config regeneration.
1018
- * matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write').
1019
- * timeout: optional timeout in seconds.
1020
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
1021
- */
1022
- function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1023
- return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
1024
- }
1025
-
1026
- /**
1027
- * Reconcile the GSD-managed SessionStart hook entry in hooks.json.
1028
- * Delegates to the generic reconcileCodexHooksJsonEvent helper.
1029
- *
1030
- * @param {string} targetDir
1031
- * @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts
1032
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
1033
- */
1034
- function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1035
- return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
1036
- }
1037
-
1038
- /**
1039
- * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
1040
- *
1041
- * On Windows, Codex runs hook commands from a PowerShell/cmd execution
1042
- * environment. The previous command format was:
1043
- *
1044
- * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
1045
- *
1046
- * This caused `bash.exe: bash.exe: cannot execute binary file` because
1047
- * Codex's hook dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe
1048
- * (a Windows PE binary) via execvp(), which fails with ENOEXEC on Windows PE
1049
- * binaries that the MSYS layer doesn't know how to fork-exec natively.
1050
- *
1051
- * Fix: write a .cmd shim (using the same CRLF .cmd shim pattern) whose
1052
- * content is `@ECHO OFF / @SETLOCAL / @"node.exe" "script.js" %*`.
1053
- * cmd.exe executes
1054
- * .cmd natively via CreateProcess — no POSIX exec layer, no MSYS shebang
1055
- * walk, no PE binary fork-exec failure.
1056
- *
1057
- * Returns the typed IR `{ invocation, cmdPath, hookCommand, render }` so
1058
- * callers can assert on the structured shape (CONTRIBUTING.md L558–L565
1059
- * IR-first discipline). Returns null when absoluteRunnerToken is null so
1060
- * callers can warn-and-skip instead of writing a broken hook.
1061
- *
1062
- * @param {string} scriptAbsPath - Absolute path to the .js hook script.
1063
- * @param {string|null} absoluteRunnerToken - JSON-quoted absolute node path
1064
- * (result of resolveNodeRunner()), e.g. `"C:/Program Files/nodejs/node.exe"`.
1065
- * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
1066
- */
1067
- function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1068
- return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
1069
- }
1180
+ // #2876: reconcileManagedShellHookCommands, buildCodexHookBlock,
1181
+ // rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, and
1182
+ // reconcileCodexHooksJsonSessionStart used to be re-bound here as one-line
1183
+ // delegates to the equivalent hooksSurface.* implementations. None had an
1184
+ // install.js internal caller or an export consumer (tests import all five
1185
+ // directly from gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired
1186
+ // bindings were dead code with no reachable body — removed rather than kept
1187
+ // as unreachable wrappers.
1070
1188
 
1071
1189
  /**
1072
1190
  * Ensure Codex hooks.json contains exactly one managed SessionStart
@@ -1265,275 +1383,30 @@ function writeSettings(settingsPath, settings) {
1265
1383
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1266
1384
  }
1267
1385
 
1268
- /**
1269
- * Read model_overrides from ~/.gsd/defaults.json at install time.
1270
- * Returns an object mapping agent names to model IDs, or null if the file
1271
- * doesn't exist or has no model_overrides entry.
1272
- * Used by Codex TOML and OpenCode agent file generators to embed per-agent
1273
- * model assignments so that model_overrides is respected on non-Claude runtimes (#2256).
1274
- */
1275
- function readGsdGlobalModelOverrides(options = {}) {
1276
- try {
1277
- const home = options.homedir ? options.homedir() : os.homedir();
1278
- const defaultsPath = path.join(home, '.gsd', 'defaults.json');
1279
- if (!fs.existsSync(defaultsPath)) return null;
1280
- const raw = fs.readFileSync(defaultsPath, 'utf-8');
1281
- const parsed = JSON.parse(raw);
1282
- const overrides = parsed.model_overrides;
1283
- if (!overrides || typeof overrides !== 'object') return null;
1284
- return overrides;
1285
- } catch {
1286
- return null;
1287
- }
1288
- }
1289
-
1290
- /**
1291
- * Effective per-agent model_overrides for the Codex / OpenCode install paths.
1292
- *
1293
- * Merges `~/.gsd/defaults.json` (global) with per-project
1294
- * `<project>/.planning/config.json`. Per-project keys win on conflict so a
1295
- * user can tune a single agent's model in one repo without re-setting the
1296
- * global defaults for every other repo. Non-conflicting keys from both
1297
- * sources are preserved.
1298
- *
1299
- * This is the fix for #2256: both adapters previously read only the global
1300
- * file, so a per-project `model_overrides` (the common case the reporter
1301
- * described — a per-project override for `gsd-codebase-mapper` in
1302
- * `.planning/config.json`) was silently dropped and child agents inherited
1303
- * the session default.
1304
- *
1305
- * `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for
1306
- * a global install, or `<project>/.codex` for a local install). We walk up
1307
- * from there looking for `.planning/` so both cases resolve the correct
1308
- * project root. When `targetDir` is null/undefined only the global file is
1309
- * consulted (matches prior behavior for code paths that have no project
1310
- * context).
1311
- *
1312
- * Returns a plain `{ agentName: modelId }` object, or `null` when neither
1313
- * source defines `model_overrides`.
1314
- */
1315
- function readGsdEffectiveModelOverrides(targetDir = null, options = {}) {
1316
- const global = readGsdGlobalModelOverrides(options);
1317
-
1318
- let projectOverrides = null;
1319
- if (targetDir) {
1320
- let probeDir = path.resolve(targetDir);
1321
- for (let depth = 0; depth < 8; depth += 1) {
1322
- const candidate = path.join(probeDir, '.planning', 'config.json');
1323
- if (fs.existsSync(candidate)) {
1324
- try {
1325
- const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
1326
- if (parsed && typeof parsed === 'object' && parsed.model_overrides
1327
- && typeof parsed.model_overrides === 'object') {
1328
- projectOverrides = parsed.model_overrides;
1329
- }
1330
- } catch {
1331
- // Malformed config.json — fall back to global; readGsdRuntimeProfileResolver
1332
- // surfaces a parse warning via _readGsdConfigFile already.
1333
- }
1334
- break;
1335
- }
1336
- const parent = path.dirname(probeDir);
1337
- if (parent === probeDir) break;
1338
- probeDir = parent;
1339
- }
1340
- }
1341
-
1342
- if (!global && !projectOverrides) return null;
1343
- // Per-project wins on conflict; preserve non-conflicting global keys.
1344
- return { ...(global || {}), ...(projectOverrides || {}) };
1345
- }
1346
-
1347
- /**
1348
- * #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
1349
- * file in a newline-agnostic way (LF and CRLF source files are both handled).
1350
- *
1351
- * The function:
1352
- * - Detects the file's EOL (CRLF if the first `---` line ends with \r\n,
1353
- * otherwise LF).
1354
- * - Skips injection if an `effort:` key already exists in the frontmatter
1355
- * (idempotent).
1356
- * - Inserts `effort: <value>` immediately before the closing `---` delimiter,
1357
- * using the same EOL as the surrounding frontmatter so the output file
1358
- * stays EOL-consistent.
1359
- * - Returns the original content unchanged when no YAML frontmatter is found.
1360
- *
1361
- * @param {string} content Raw file content (may have LF or CRLF endings).
1362
- * @param {string} effortValue Rendered effort string, e.g. "xhigh".
1363
- * @returns {string} Updated content with `effort:` injected, or the
1364
- * original content when no frontmatter is found.
1365
- */
1366
- function injectEffortFrontmatter(content, effortValue) {
1367
- // Detect the dominant EOL from the first line (the opening `---`).
1368
- // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1369
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1370
-
1371
- // Build a frontmatter-matching regex that tolerates an optional \r before
1372
- // each \n, so we handle both LF and CRLF files without needing to normalise
1373
- // the whole content.
1374
- //
1375
- // Breakdown:
1376
- // ^---\r?\n — opening delimiter (with optional \r)
1377
- // ([\s\S]*?) — frontmatter body (non-greedy)
1378
- // ^---\r?$ — closing delimiter line (optional \r, $ before \n in
1379
- // multiline mode)
1380
- // (\r?\n|$) — newline after closing --- (or end of string)
1381
- //
1382
- // The `m` flag makes ^ / $ match at every line boundary.
1383
- const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1384
- const match = fmRe.exec(content);
1385
- if (!match) return content; // no YAML frontmatter — leave unchanged
1386
-
1387
- // Idempotency guard: don't insert a second effort: line.
1388
- const fmBody = match[1]; // content between the two `---` lines
1389
- if (/^effort:/m.test(fmBody)) return content;
1390
-
1391
- // Locate the exact position of the closing `---` line so we can insert
1392
- // before it using a simple string splice (avoids re-running the regex and
1393
- // avoids any edge-cases with $ matching \r differently per engine).
1394
- const closeIdx = match.index + 4 + fmBody.length; // 4 = len("---\n") (opening)
1395
- // Actually compute based on the full match start + captured group length:
1396
- // match[0] = full frontmatter block; match.index = start of that block.
1397
- // The closing `---` starts at: match.index + ("---" + eol).length + fmBody.length
1398
- const openLen = 3 + eol.length; // "---" + eol
1399
- const closingStart = match.index + openLen + fmBody.length;
1400
-
1401
- const before = content.slice(0, closingStart);
1402
- const after = content.slice(closingStart);
1403
- return `${before}effort: ${effortValue}${eol}${after}`;
1404
- }
1405
-
1406
- /**
1407
- * #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
1408
- * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
1409
- * inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
1410
- * which break on unknown frontmatter keys.
1411
- */
1412
- function injectDisallowedToolsFrontmatter(content, disallowedValue) {
1413
- // Detect the dominant EOL from the first line (the opening `---`).
1414
- // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1415
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1416
-
1417
- // Build a frontmatter-matching regex that tolerates an optional \r before
1418
- // each \n, so we handle both LF and CRLF files without needing to normalise
1419
- // the whole content.
1420
- const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1421
- const match = fmRe.exec(content);
1422
- if (!match) return content; // no YAML frontmatter — leave unchanged
1423
-
1424
- // Idempotency guard: don't insert a second disallowedTools: line.
1425
- const fmBody = match[1]; // content between the two `---` lines
1426
- if (/^disallowedTools:/m.test(fmBody)) return content;
1427
-
1428
- // Locate the exact position of the closing `---` line so we can insert
1429
- // before it using a simple string splice.
1430
- const openLen = 3 + eol.length; // "---" + eol
1431
- const closingStart = match.index + openLen + fmBody.length;
1432
-
1433
- const before = content.slice(0, closingStart);
1434
- const after = content.slice(closingStart);
1435
- return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
1436
- }
1437
-
1438
- // #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
1439
- // Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
1440
- // output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
1441
- // excluded (it legitimately uses Write AND Edit to create/patch test files).
1442
- const READONLY_AGENT_DISALLOWED_TOOLS = {
1443
- 'gsd-plan-checker': 'Write, Edit, MultiEdit',
1444
- 'gsd-integration-checker': 'Write, Edit, MultiEdit',
1445
- 'gsd-ui-checker': 'Write, Edit, MultiEdit',
1446
- 'gsd-verifier': 'Edit, MultiEdit',
1447
- 'gsd-doc-verifier': 'Edit, MultiEdit',
1448
- 'gsd-eval-auditor': 'Edit, MultiEdit',
1449
- 'gsd-ui-auditor': 'Edit, MultiEdit',
1450
- };
1451
-
1452
- /**
1453
- * #2517 — Build a runtime-aware tier resolver for the install path.
1454
- *
1455
- * Probes BOTH per-project `<targetDir>/.planning/config.json` AND
1456
- * `~/.gsd/defaults.json`, with per-project keys winning over global. This
1457
- * matches `loadConfig`'s precedence and is the only way the PR's headline claim
1458
- * — "set runtime in .planning/config.json and the Codex TOML emit picks it up"
1459
- * — actually holds end-to-end (review finding #1).
1460
- *
1461
- * `targetDir` should be the consuming runtime's install root — install code
1462
- * passes `path.dirname(<runtime root>)` so `.planning/config.json` resolves
1463
- * relative to the user's project. When `targetDir` is null/undefined, only the
1464
- * global defaults are consulted.
1465
- *
1466
- * Returns null if no `runtime` is configured (preserves prior behavior — only
1467
- * model_overrides is embedded, no tier/reasoning-effort inference). Returns
1468
- * null when `model_profile` is `inherit` so the literal alias passes through
1469
- * unchanged.
1470
- *
1471
- * Returns { runtime, resolve(agentName) -> { model, reasoning_effort? } | null }
1472
- */
1473
- function readGsdRuntimeProfileResolver(targetDir = null) {
1474
- const homeDefaults = _readGsdConfigFile(
1475
- path.join(os.homedir(), '.gsd', 'defaults.json'),
1476
- '~/.gsd/defaults.json'
1477
- );
1478
-
1479
- // Per-project config probe. Resolve the project root by walking up from
1480
- // targetDir until we hit a `.planning/` directory; this covers both the
1481
- // common case (caller passes the project root) and the case where caller
1482
- // passes a nested install dir like `<root>/.codex/`.
1483
- let projectConfig = null;
1484
- if (targetDir) {
1485
- let probeDir = path.resolve(targetDir);
1486
- for (let depth = 0; depth < 8; depth += 1) {
1487
- const candidate = path.join(probeDir, '.planning', 'config.json');
1488
- if (fs.existsSync(candidate)) {
1489
- projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
1490
- break;
1491
- }
1492
- const parent = path.dirname(probeDir);
1493
- if (parent === probeDir) break;
1494
- probeDir = parent;
1495
- }
1496
- }
1497
-
1498
- // Per-project wins. Only fall back to ~/.gsd/defaults.json when the project
1499
- // didn't set the field. Field-level merge (not whole-object replace) so a
1500
- // user can keep `runtime` global while overriding only `model_profile` per
1501
- // project, and vice versa.
1502
- const merged = {
1503
- runtime:
1504
- (projectConfig && projectConfig.runtime) ||
1505
- (homeDefaults && homeDefaults.runtime) ||
1506
- null,
1507
- model_profile:
1508
- (projectConfig && projectConfig.model_profile) ||
1509
- (homeDefaults && homeDefaults.model_profile) ||
1510
- 'balanced',
1511
- model_profile_overrides:
1512
- (projectConfig && projectConfig.model_profile_overrides) ||
1513
- (homeDefaults && homeDefaults.model_profile_overrides) ||
1514
- null,
1515
- };
1516
-
1517
- if (!merged.runtime) return null;
1518
-
1519
- const profile = String(merged.model_profile).toLowerCase();
1520
- if (profile === 'inherit') return null;
1521
-
1522
- return {
1523
- runtime: merged.runtime,
1524
- resolve(agentName) {
1525
- const agentModels = GSD_MODEL_PROFILES[agentName];
1526
- if (!agentModels) return null;
1527
- const tier = agentModels[profile] || agentModels.balanced;
1528
- if (!tier) return null;
1529
- return gsdResolveTierEntry({
1530
- runtime: merged.runtime,
1531
- tier,
1532
- overrides: merged.model_profile_overrides,
1533
- });
1534
- },
1535
- };
1536
- }
1386
+ // #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides /
1387
+ // readGsdEffectiveModelOverrides / readGsdRuntimeProfileResolver, plus the
1388
+ // shared resolveAgentModelOverride precedence chain) was extracted into the
1389
+ // shipped gsd-core/bin/lib/install-model-override-resolver.cjs, mirroring
1390
+ // install-effort-resolver.cjs's existing #2071 precedent, so the descriptor-
1391
+ // driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind)
1392
+ // and this installer resolve model_overrides / model_profile_overrides
1393
+ // through the SAME code — a single source of truth for the precedence chain
1394
+ // the inline agent loop below used to duplicate across ~24 lines per runtime
1395
+ // (kilo, opencode). See install-model-override-resolver.cts's module doc.
1396
+ const {
1397
+ readGsdEffectiveModelOverrides,
1398
+ readGsdRuntimeProfileResolver,
1399
+ resolveAgentModelOverride,
1400
+ } = require(path.join(_gsdLibDir, 'install-model-override-resolver.cjs'));
1401
+
1402
+ // #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion
1403
+ // (single source of truth with the descriptor pipeline's
1404
+ // applyAgentFrontmatterExtensions step, which now also owns disallowedTools
1405
+ // injection + the read-only agent deny-list internally — see its module doc
1406
+ // in src/runtime-artifact-conversion.cts). injectEffortFrontmatter used to be
1407
+ // re-bound here purely to stay on this module's export surface; #2876 found
1408
+ // no install.js internal caller either (tests import it directly from
1409
+ // gsd-core/bin/lib/runtime-artifact-conversion.cjs) and retired the binding.
1537
1410
 
1538
1411
  // Cache for attribution settings (populated once per runtime during install)
1539
1412
  const attributionCache = new Map();
@@ -1822,10 +1695,6 @@ function buildKiloAgentPermissionBlock(claudeTools) {
1822
1695
  return lines;
1823
1696
  }
1824
1697
 
1825
- function escapeRegExp(value) {
1826
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1827
- }
1828
-
1829
1698
  function replaceRelativePathReference(content, fromPath, toPath) {
1830
1699
  const escapedPath = escapeRegExp(fromPath);
1831
1700
  return content.replace(
@@ -1946,10 +1815,6 @@ function skillFrontmatterName(skillDirName) {
1946
1815
  return skillDirName;
1947
1816
  }
1948
1817
 
1949
- function normalizeClaudeSkillEffort(effort) {
1950
- return effort === 'xhigh' ? 'max' : effort;
1951
- }
1952
-
1953
1818
  /**
1954
1819
  * Qwen Code skills accept an optional numeric `priority` frontmatter field.
1955
1820
  * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -2006,10 +1871,11 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2006
1871
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2007
1872
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2008
1873
  const agent = extractFrontmatterField(frontmatter, 'agent');
2009
- // #769: preserve context: and effort: from source command files so they
2010
- // are emitted into the installed SKILL.md frontmatter unchanged.
1874
+ // #769: preserve context: from source command files so it is emitted into
1875
+ // the installed SKILL.md frontmatter unchanged. (#3151: effort: is no longer
1876
+ // emitted into skill frontmatter — a static effort value invalidates the
1877
+ // caller's prompt cache at both scope boundaries.)
2011
1878
  const context = extractFrontmatterField(frontmatter, 'context');
2012
- const effort = extractFrontmatterField(frontmatter, 'effort');
2013
1879
 
2014
1880
  // Preserve allowed-tools as YAML multiline list (Claude native format)
2015
1881
  const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
@@ -2043,12 +1909,13 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2043
1909
  }
2044
1910
  if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
2045
1911
  if (agent) fm += `agent: ${agent}\n`;
2046
- // #769: emit context: and effort: when present so the runtime can honour
2047
- // them natively (context: fork = isolated subagent window; effort: =
2048
- // token-budget tier). Fields are Claude-specific; unknown frontmatter
2049
- // fields are silently ignored by other runtimes (backward-compatible).
1912
+ // #769: emit context: when present so the runtime can honour it natively
1913
+ // (context: fork = isolated subagent window). Claude-specific; unknown
1914
+ // frontmatter fields are silently ignored by other runtimes (backward-compatible).
1915
+ // (#3151: effort: is intentionally NOT emitted into skill frontmatter — a
1916
+ // static effort value changes output_config.effort on invocation and
1917
+ // invalidates the caller's prompt cache at both scope boundaries.)
2050
1918
  if (context) fm += `context: ${context}\n`;
2051
- if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
2052
1919
  if (toolsBlock) fm += toolsBlock;
2053
1920
  fm += '---';
2054
1921
 
@@ -2501,56 +2368,6 @@ function extractFrontmatterField(frontmatter, fieldName) {
2501
2368
  return match[1].trim().replace(/^['"]|['"]$/g, '');
2502
2369
  }
2503
2370
 
2504
- // #2284 finding (b): the `<runtime_compatibility>` block appearing in
2505
- // gsd-core/workflows/{plan-phase,execute-phase}.md is a runtime-COMPARISON
2506
- // table ("**Claude Code:** Uses `Agent(...)`" / "a backgrounded Claude Code
2507
- // agent" / "top-level Claude Code") — every "Claude Code" mention inside it
2508
- // is a COMPARED-RUNTIME LABEL, not a host self-reference. The brand swap
2509
- // below (`Claude Code` → the installing runtime's own display name) is
2510
- // meant only for host self-references; applying it inside this block
2511
- // mislabels the comparison (e.g. Windsurf installs would read "**Windsurf:**
2512
- // Uses `Agent(...)`" describing what is actually Claude Code's behavior).
2513
- // This is cross-cutting across every runtime that brand-swaps workflow
2514
- // content (cursor/windsurf/trae/cline/codebuddy hardcoded; qwen/hermes
2515
- // descriptor-driven via hostBehaviors.brandingRewrites) — confirmed to
2516
- // reproduce on unmodified Windsurf, not Hermes-specific.
2517
- const RUNTIME_COMPATIBILITY_BLOCK_RE = /<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g;
2518
-
2519
- /**
2520
- * Rewrite bare "Claude Code" self-references in workflow content to
2521
- * `brandName`, EXCEPT inside `<runtime_compatibility>...</runtime_compatibility>`
2522
- * blocks, which are left byte-for-byte verbatim. Every other content
2523
- * transform in a runtime's `.md` converter (tool-name renames, path
2524
- * rewrites, etc.) is unaffected — only this literal brand-name swap is
2525
- * protected-region-aware, since only it risks mislabeling a
2526
- * runtime-comparison table.
2527
- *
2528
- * Implementation: SPLIT `content` on the protected-block regex, brand-swap
2529
- * only the GAP text between (and around) matches, then rejoin gap+block
2530
- * alternately. No placeholder/sentinel token of any kind is substituted in
2531
- * — a prior version used a sentinel-token mask/restore, which is exactly the
2532
- * kind of invisible landmine this rewrite eliminates (a sentinel string, no
2533
- * matter how obscure, is a theoretical collision risk with real content and
2534
- * is easy to silently reintroduce in a future edit without it showing in a
2535
- * diff). Behavior-identical to the removed sentinel-token version — verified
2536
- * via `npm run gen:golden` producing zero further diff.
2537
- */
2538
- function applyClaudeCodeBrandSwap(content, brandName) {
2539
- if (!brandName) return content;
2540
- let result = '';
2541
- let lastIndex = 0;
2542
- RUNTIME_COMPATIBILITY_BLOCK_RE.lastIndex = 0; // reset shared global-regex state before each use
2543
- let m;
2544
- while ((m = RUNTIME_COMPATIBILITY_BLOCK_RE.exec(content))) {
2545
- const gap = content.slice(lastIndex, m.index);
2546
- result += gap.replace(/\bClaude Code\b/g, brandName);
2547
- result += m[0]; // protected block, verbatim — never brand-swapped
2548
- lastIndex = m.index + m[0].length;
2549
- }
2550
- result += content.slice(lastIndex).replace(/\bClaude Code\b/g, brandName);
2551
- return result;
2552
- }
2553
-
2554
2371
  // Tool name mapping from Claude Code to Cursor CLI
2555
2372
  const claudeToCursorTools = {
2556
2373
  Bash: 'Shell',
@@ -2629,36 +2446,11 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2629
2446
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2630
2447
  const adapter = getCursorSkillAdapterHeader(skillName);
2631
2448
 
2632
- // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
2633
- // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
2634
- // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
2635
- // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
2636
- // stay model-invocable background knowledge. (user-invocable:false hides from
2637
- // '/' while keeping model invocation — distinct from disable-model-invocation.)
2638
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
2639
- }
2640
-
2641
- /**
2642
- * Convert a Claude Code command to a Cursor 1.6 slash command (#785).
2643
- *
2644
- * Cursor slash commands live in `.cursor/commands/<name>.md` and are
2645
- * plain markdown — no YAML frontmatter, no adapter header. The filename
2646
- * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`).
2647
- *
2648
- * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill
2649
- * converter (tool renames, brand substitution, slash-command normalisation),
2650
- * then strips the YAML frontmatter block so only the prose body remains.
2651
- *
2652
- * @param {string} content raw Claude Code command markdown (may have frontmatter)
2653
- * @param {string} _commandName the target command name (unused; present for
2654
- * API symmetry with other converters so the runtime-artifact-layout stage
2655
- * function can call it uniformly)
2656
- * @returns {string} plain markdown body, no frontmatter
2657
- */
2658
- function convertClaudeCommandToCursorCommand(content, _commandName) {
2659
- const converted = convertClaudeToCursorMarkdown(content);
2660
- const { body } = extractFrontmatterAndBody(converted);
2661
- return body.trimStart();
2449
+ // Cursor skills are both slash-invocable and model-invocable. Do not emit the
2450
+ // unsupported `user-invocable` field: it is ignored by Cursor and previously
2451
+ // hid the real cause of duplicate entries, the parallel commands/ surface
2452
+ // retired in #2644.
2453
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2662
2454
  }
2663
2455
 
2664
2456
  /**
@@ -2681,142 +2473,16 @@ function convertClaudeAgentToCursorAgent(content) {
2681
2473
  }
2682
2474
 
2683
2475
  // --- Windsurf converters ---
2684
- // Windsurf uses a tool set similar to Cursor.
2685
- // Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
2686
-
2687
- // Tool name mapping from Claude Code to Windsurf Cascade
2688
- const claudeToWindsurfTools = {
2689
- Bash: 'Shell',
2690
- Edit: 'StrReplace',
2691
- AskUserQuestion: null, // No direct equivalent — use conversational prompting
2692
- SlashCommand: null, // No equivalent — skills are auto-discovered
2693
- };
2694
-
2695
- function convertSlashCommandsToWindsurfSkillMentions(content) {
2696
- // Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
2697
- return content.replace(/gsd:/gi, 'gsd-');
2698
- }
2699
-
2700
- function convertClaudeToWindsurfMarkdown(content) {
2701
- let converted = convertSlashCommandsToWindsurfSkillMentions(content);
2702
- // Replace tool name references in body text
2703
- converted = converted.replace(/\bBash\(/g, 'Shell(');
2704
- converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
2705
- converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
2706
- // Replace subagent_type from Claude to Windsurf format
2707
- converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2708
- converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2709
- // Replace project-level Claude conventions with Windsurf equivalents.
2710
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
2711
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
2712
- converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
2713
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
2714
- converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
2715
- converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
2716
- converted = converted.replace(/\.claude\//g, '.windsurf/');
2717
- // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2718
- // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2719
- converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
2720
- converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
2721
- // Environment variable name rewrite
2722
- converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
2723
- // Remove Claude Code-specific bug workarounds before brand replacement
2724
- converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2725
- converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2726
- // Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips
2727
- // <runtime_compatibility> comparison-table content (protected region).
2728
- converted = applyClaudeCodeBrandSwap(converted, 'Windsurf');
2729
- return converted;
2730
- }
2731
-
2732
- function getWindsurfSkillAdapterHeader(skillName) {
2733
- return `<windsurf_skill_adapter>
2734
- ## A. Skill Invocation
2735
- - This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
2736
- - Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
2737
- - If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
2738
-
2739
- ## B. User Prompting
2740
- When the workflow needs user input, prompt the user conversationally:
2741
- - Present options as a numbered list in your response text
2742
- - Ask the user to reply with their choice
2743
- - For multi-select, ask for comma-separated numbers
2744
-
2745
- ## C. Tool Usage
2746
- Use these Windsurf tools when executing GSD workflows:
2747
- - \`Shell\` for running commands (terminal operations)
2748
- - \`StrReplace\` for editing existing files
2749
- - \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed
2750
-
2751
- ## D. Subagent Spawning
2752
- When the workflow needs to spawn a subagent:
2753
- - Use \`Task(subagent_type="generalPurpose", ...)\`
2754
- - The \`model\` parameter maps to Windsurf's model options (e.g., "fast")
2755
- </windsurf_skill_adapter>`;
2756
- }
2757
-
2758
- function convertClaudeCommandToWindsurfSkill(content, skillName) {
2759
- const converted = convertClaudeToWindsurfMarkdown(content);
2760
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2761
- let description = `Run GSD workflow ${skillName}.`;
2762
- if (frontmatter) {
2763
- const maybeDescription = extractFrontmatterField(frontmatter, 'description');
2764
- if (maybeDescription) {
2765
- description = maybeDescription;
2766
- }
2767
- }
2768
- description = toSingleLine(description);
2769
- const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2770
- const adapter = getWindsurfSkillAdapterHeader(skillName);
2771
-
2772
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2773
- }
2774
-
2775
- function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
2776
- // #1615 security: commandName flows unsanitized into a markdown body that
2777
- // Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
2778
- // (a) prompt injection via newlines / markdown structure in the filename,
2779
- // (b) path-component injection via .., /, \ in stem → @-reference target.
2780
- // Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
2781
- // everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
2782
- // PR #1622 security review.
2783
- if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
2784
- const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
2785
- throw new Error(
2786
- `convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
2787
- 'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
2788
- );
2789
- }
2790
- const converted = convertClaudeToWindsurfMarkdown(content);
2791
- const { frontmatter } = extractFrontmatterAndBody(converted);
2792
- const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
2793
- const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
2794
- const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
2795
- const byteLength = Buffer.byteLength(workflow, 'utf8');
2796
- if (byteLength > 12000) {
2797
- throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
2798
- }
2799
- return workflow;
2800
- }
2801
-
2802
- /**
2803
- * Convert Claude Code agent markdown to Windsurf agent format.
2804
- * Strips frontmatter fields Windsurf doesn't support (color, skills),
2805
- * converts tool references, and adds a role context header.
2806
- */
2807
- function convertClaudeAgentToWindsurfAgent(content) {
2808
- let converted = convertClaudeToWindsurfMarkdown(content);
2809
-
2810
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2811
- if (!frontmatter) return converted;
2812
-
2813
- const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
2814
- const description = extractFrontmatterField(frontmatter, 'description') || '';
2815
-
2816
- const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
2817
-
2818
- return `${cleanFrontmatter}\n${body}`;
2819
- }
2476
+ // #2931 (ADR-1508): single-sourced in runtimeArtifactConversion, bound near
2477
+ // the top of this file alongside the #1675 Augment family. This block
2478
+ // previously carried byte-identical local duplicates of
2479
+ // convertSlashCommandsToWindsurfSkillMentions, convertClaudeToWindsurfMarkdown,
2480
+ // getWindsurfSkillAdapterHeader, convertClaudeCommandToWindsurfSkill,
2481
+ // convertClaudeCommandToWindsurfWorkflow, and convertClaudeAgentToWindsurfAgent,
2482
+ // plus an unused claudeToWindsurfTools table. Deleted here; the two
2483
+ // unexported helpers (getWindsurfSkillAdapterHeader,
2484
+ // convertSlashCommandsToWindsurfSkillMentions) now live only in the
2485
+ // conversion module, with no other caller in this file.
2820
2486
 
2821
2487
  // --- Augment converters ---
2822
2488
  // Augment uses a tool set similar to Cursor/Windsurf.
@@ -2844,10 +2510,55 @@ function convertClaudeToTraeMarkdown(content) {
2844
2510
  // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task"
2845
2511
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"');
2846
2512
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2847
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`');
2848
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/');
2849
- converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`');
2850
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/');
2513
+ // #2658: full-path forms (with a leading dot-claude-slash prefix) MUST be
2514
+ // replaced before the bare Claude-instruction-file pattern and before the
2515
+ // generic dot-claude-slash rewrite below — otherwise the bare pattern
2516
+ // consumes only the instruction-filename tail, leaving that prefix stale
2517
+ // in place, and the generic rewrite then mutates the stale leftover too,
2518
+ // producing a doubled trae-prefix segment ahead of the rules path instead
2519
+ // of a single clean one. (Deliberately never spelling the instruction
2520
+ // filename as one contiguous "CLAUDE" + dot + "md" token, and never
2521
+ // spelling either malformed shape out as a literal contiguous string, in
2522
+ // ANY comment in this function: this file ships verbatim into local
2523
+ // `--trae` installs, where it is itself run through this same class of
2524
+ // find/replace — a literal instruction-filename token sitting in a
2525
+ // comment gets "fixed" right along with real code, and the emitted-content
2526
+ // regression test added alongside this fix asserts neither malformed
2527
+ // shape appears anywhere in the installed tree, comments included; this
2528
+ // bit the fix itself twice during development.) All forms converge on the
2529
+ // same concrete file (never a bare directory) so this stays in parity
2530
+ // with the `trae.js` RUNTIME_CONTENT_DISPATCH entry.
2531
+ converted = converted.replace(/`\.\/\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2532
+ converted = converted.replace(/\.\/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
2533
+ converted = converted.replace(/`\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2534
+ converted = converted.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
2535
+ // #2658 (found via the end-to-end install regression test, not the static
2536
+ // trace above): `copyWithPathReplacement` runs a GENERIC dot-claude-slash
2537
+ // -> runtime-config-dir rewrite on every .md file before calling this
2538
+ // converter — for `~/.claude/`, `$HOME/.claude/`, AND `./.claude/` alike —
2539
+ // substituting a runtime-appropriate `pathPrefix` this function is never
2540
+ // given and cannot itself compute (it differs per install invocation: a
2541
+ // relative `./.trae/` for a project-local install, an arbitrary absolute
2542
+ // path for a local install rooted elsewhere, `~/.trae/` for a global one).
2543
+ // So for source using any of those prefixed forms, the patterns above
2544
+ // never fire here — this converter only ever sees the ALREADY-rewritten
2545
+ // "<runtime-config-dir>/" + instruction-filename shape, with whatever
2546
+ // prefix the install actually used. The generic pattern below preserves
2547
+ // that prefix verbatim (via the capture group) and only fixes the
2548
+ // filename suffix, rather than assuming a fixed `./.trae/` shape — a
2549
+ // narrower fixed-prefix version of this pattern shipped first and still
2550
+ // left the doubled-prefix defect live for the `$HOME/.claude/` and
2551
+ // `~/.claude/` forms specifically (found the same way, one regression-test
2552
+ // run later). Scoped to a `.trae/` tail so it cannot also swallow the
2553
+ // unprefixed `./CLAUDE.md` form the very next pattern handles differently
2554
+ // (discarding the prefix entirely, not preserving it). Must run before
2555
+ // the bare pattern for the same consume-the-full-match-first reason.
2556
+ converted = converted.replace(/`([^\s`]*\.trae\/)CLAUDE\.md`/g, '`$1rules/rules.md`');
2557
+ converted = converted.replace(/([^\s`]*\.trae\/)CLAUDE\.md/g, '$1rules/rules.md');
2558
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2559
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/rules.md');
2560
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2561
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/rules.md');
2851
2562
  converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/');
2852
2563
  converted = converted.replace(/\.\/\.claude\//g, './.trae/');
2853
2564
  converted = converted.replace(/\.claude\//g, '.trae/');
@@ -4018,7 +3729,9 @@ Typed mapping (agent_type-capable schema only):
4018
3729
  to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty,
4019
3730
  inherited, or unsupported values; do not invent one-off effort literals in
4020
3731
  workflow prose.
4021
- - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
3732
+ - \`fork_context: false\` by default — GSD agents load their own context via \`<required_reading>\` blocks
3733
+ - \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task
3734
+ - \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement)
4022
3735
  - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
4023
3736
  but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
4024
3737
  \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
@@ -4055,11 +3768,13 @@ Spawn restriction:
4055
3768
  defaulting to inline execution.
4056
3769
 
4057
3770
  Parallel fan-out:
4058
- - Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
3771
+ - Spawn multiple agents → collect agent IDs → \`collaboration.wait_agent(timeout_ms=...)\` for each to complete
3772
+ - Do NOT use \`functions.wait(cell_id=...)\` — that is an unrelated exec-cell tool, not the collaboration wait
4059
3773
 
4060
3774
  Result parsing:
4061
3775
  - Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
4062
- - \`close_agent(id)\` after collecting results from each agent
3776
+ - \`close_agent(id)\` after collecting results — but only if \`close_agent\` is visible in the current
3777
+ tool schema (check via \`tool_search\` first, same schema-detection gate as \`spawn_agent\` above)
4063
3778
  </codex_skill_adapter>`;
4064
3779
  }
4065
3780
 
@@ -4109,15 +3824,19 @@ purpose: ${toSingleLine(description)}
4109
3824
  /**
4110
3825
  * #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
4111
3826
  * Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
4112
- * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES, imported from
4113
- * src/model-resolver.cts so it can't diverge); (b) any Claude model id in any provider
4114
- * namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*` (the forms the
4115
- * catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via the runtime-
4116
- * resolver path). No OpenAI/Codex model id contains "claude", so a case-insensitive
4117
- * substring test is a safe, exhaustive guard for (b). Codex/ChatGPT rejects all of these.
3827
+ * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES); (b) any Claude model
3828
+ * id in any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
3829
+ * (the forms the catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via
3830
+ * the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
3831
+ * case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
3832
+ * rejects all of these.
3833
+ *
3834
+ * #3241 — thin delegation to the shared predicate on src/model-catalog.cts (moved there
3835
+ * so it can't diverge across Codex-posture surfaces); kept as a local name because it
3836
+ * reads better at the call sites below.
4118
3837
  */
4119
3838
  function _isAnthropicFlavoredModel(model) {
4120
- return typeof model === 'string' && (CLAUDE_AGENT_ALIASES.has(model) || model.toLowerCase().includes('claude'));
3839
+ return gsdIsAnthropicFlavoredModel(model);
4121
3840
  }
4122
3841
 
4123
3842
  // #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
@@ -4136,6 +3855,42 @@ function _warnCodexModelOverrideDropped(agentName, value) {
4136
3855
  );
4137
3856
  }
4138
3857
 
3858
+ // #3241 — one-time per-install deprecation notice: the automatic runtime-resolver
3859
+ // per-tier Codex model embed was removed (D1/D5, ADR-2313 passive-posture epic). When
3860
+ // the resolver *would have* supplied a model and nothing else ends up pinned, this
3861
+ // notice points the user at model_overrides as the explicit-pin replacement. Dedupes
3862
+ // with a module-level boolean (mirrors _codexModelOverrideDroppedWarned's Set above)
3863
+ // so a multi-agent install — every Codex agent hits this condition simultaneously —
3864
+ // emits exactly one line, not one per agent. Reset once per install() call (see
3865
+ // install()) so the "at most once" window is per-install, not per-process. That
3866
+ // reset lives ONLY inside install() (~:10116) — generateCodexAgentToml is also
3867
+ // exported standalone (~:13460), and a caller invoking it directly/repeatedly
3868
+ // outside install() gets process-lifetime dedupe instead of per-install. No
3869
+ // current test depends on the standalone caller's dedupe window.
3870
+ let _codexResolverModelOmittedWarned = false;
3871
+ function _warnCodexResolverModelOmitted() {
3872
+ if (_codexResolverModelOmittedWarned) return;
3873
+ _codexResolverModelOmittedWarned = true;
3874
+ process.stderr.write(
3875
+ 'gsd: notice — Codex agents no longer auto-pin a per-tier model from the runtime ' +
3876
+ 'resolver; set model_overrides for an agent if you want a specific Codex model ' +
3877
+ 'instead of the session model.\n',
3878
+ );
3879
+ }
3880
+
3881
+ // Test seam only — bin/install.js deliberately keeps per-install warning/notice
3882
+ // dedupe in module scope (both the _codexModelOverrideDroppedWarned Set above and
3883
+ // the _codexResolverModelOmittedWarned boolean; install() resets the latter at
3884
+ // ~:10116). A unit test that drives generateCodexAgentToml() directly, without
3885
+ // going through install(), has no other way to reset either store between
3886
+ // assertions without busting the require.cache (which breaks module-instance
3887
+ // sharing with the rest of the suite). This is the single sanctioned way for a
3888
+ // unit test to clear both dedupe stores — exported so tests can call it instead.
3889
+ function _resetCodexWarningDedupeForTests() {
3890
+ _codexModelOverrideDroppedWarned.clear();
3891
+ _codexResolverModelOmittedWarned = false;
3892
+ }
3893
+
4139
3894
  /**
4140
3895
  * Generate a per-agent .toml config file for Codex.
4141
3896
  * Sets required agent metadata, sandbox_mode, and developer_instructions
@@ -4168,38 +3923,58 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4168
3923
  // Embed model override when configured in ~/.gsd/defaults.json so that
4169
3924
  // model_overrides is respected on Codex (which uses static TOML, not inline
4170
3925
  // Task() model parameters). See #2256.
4171
- // Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
4172
3926
  // #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
4173
3927
  // passive/session-only model host (ADR-1239): GSD cannot reliably route per-agent
4174
3928
  // tiers, and a bare GSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
4175
3929
  // id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
4176
3930
  // using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
4177
3931
  // model pin from model_overrides; omit anything Anthropic-flavored so the agent
4178
- // inherits the always-available session model. (Removing the runtime-resolver
4179
- // per-tier embedding below is the ADR-2310 passive-posture epic.)
3932
+ // inherits the always-available session model.
3933
+ // #3241 (D1/D5) — the runtime-aware tier-resolver auto-embed that used to fall
3934
+ // through here when model_overrides had nothing was removed: Codex is passive by
3935
+ // default now, and only an explicit model_overrides pin survives. See the
3936
+ // deprecation-notice block below for the population that used to get a pin from
3937
+ // the resolver and no longer does.
4180
3938
  const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
4181
3939
  let pinnedModel = null;
4182
3940
  if (rawModelOverride) {
4183
- if (typeof rawModelOverride === 'string' && rawModelOverride && !_isAnthropicFlavoredModel(rawModelOverride)) {
4184
- pinnedModel = rawModelOverride; // explicit real-Codex model pin → embed verbatim (#2256)
3941
+ // Trim before the truthiness test (#3241 defect fix): a whitespace-only value
3942
+ // (e.g. ' ') is a truthy JS string but not a model id — it must not be
3943
+ // embedded verbatim (`model = " "`, a live pre-fix defect) or routed to
3944
+ // _warnCodexModelOverrideDropped, whose "is not a valid Codex model
3945
+ // (Anthropic alias/id)" text would misdescribe a blank config field. It is
3946
+ // silently dropped, matching how '' already behaves (no pin, no warning).
3947
+ const trimmedOverride = typeof rawModelOverride === 'string' ? rawModelOverride.trim() : rawModelOverride;
3948
+ if (typeof trimmedOverride === 'string' && trimmedOverride && !_isAnthropicFlavoredModel(trimmedOverride)) {
3949
+ pinnedModel = trimmedOverride; // explicit real-Codex model pin → embed verbatim (#2256)
3950
+ } else if (typeof rawModelOverride === 'string' && trimmedOverride === '') {
3951
+ // whitespace-only override — no pin, no warning (#3241).
4185
3952
  } else {
4186
3953
  _warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
4187
3954
  }
4188
3955
  }
4189
- if (!pinnedModel && runtimeResolver) {
4190
- // #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
4191
- // from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
4192
- // (Superseded on the default path by the ADR-2310 passive-posture epic.)
4193
- const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
4194
- if (entry?.model) pinnedModel = entry.model;
4195
- }
4196
3956
  // #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
4197
- // .toml, even from the runtime-resolver path (e.g. a defaults.json runtime that
4198
- // does not match the codex install target).
3957
+ // .toml, even one that reached here through some other path than the override
3958
+ // check above.
4199
3959
  if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
4200
3960
  _warnCodexModelOverrideDropped(resolvedName, pinnedModel);
4201
3961
  pinnedModel = null;
4202
3962
  }
3963
+ // #3241 — one-time deprecation notice: if nothing ends up pinned but the
3964
+ // runtime resolver would have supplied a per-tier model that would actually
3965
+ // have been EMBEDDED (the population that loses a pin now that the
3966
+ // auto-embed above is gone), point the user at model_overrides. The would-be
3967
+ // model must also clear the #2310 Anthropic-flavored gate above — if it
3968
+ // wouldn't have survived that gate, the user never had that pin pre-Phase-1
3969
+ // either, and the notice would be false. Never fires when the resolver is
3970
+ // null, resolves to nothing, resolves to an Anthropic-flavored model, or an
3971
+ // explicit real-Codex pin survived — in all of those cases nothing was lost.
3972
+ if (!pinnedModel && runtimeResolver) {
3973
+ const wouldHavePinned = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
3974
+ if (wouldHavePinned?.model && !_isAnthropicFlavoredModel(wouldHavePinned.model)) {
3975
+ _warnCodexResolverModelOmitted();
3976
+ }
3977
+ }
4203
3978
  let hasPinnedModel = false;
4204
3979
  if (pinnedModel) {
4205
3980
  lines.push(`model = ${JSON.stringify(pinnedModel)}`);
@@ -4219,8 +3994,12 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4219
3994
  // follows GSD. Keep those knobs coupled unless GSD also pins the model.
4220
3995
  if (hasPinnedModel) {
4221
3996
  const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
4222
- const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
4223
- lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
3997
+ // #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
3998
+ // own effort default. Never write the literal.
3999
+ if (_universalEffortCodex !== 'inherit') {
4000
+ const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
4001
+ lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4002
+ }
4224
4003
  }
4225
4004
 
4226
4005
  // #774 — Emit service_tier and model_verbosity for light-tier agents.
@@ -6445,17 +6224,40 @@ function mergeCodexConfig(configPath, gsdBlock) {
6445
6224
  const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
6446
6225
  const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
6447
6226
 
6448
- // Case 2: Has GSD marker — truncate and re-append
6227
+ // Case 2: Has GSD marker — preserve user content on BOTH sides, regenerate the GSD block.
6228
+ //
6229
+ // #2940: the marker delimits where GSD's OWN block begins, NOT where every post-marker byte
6230
+ // is GSD-owned. A fresh install writes the GSD block as the file's entire content, so any
6231
+ // settings the user or Codex CLI later adds ([model], [mcp_servers.*], [profiles.*]) land
6232
+ // AFTER the block. The previous truncate-to-marker logic discarded that trailing region on
6233
+ // every update, destroying user config. The fix routes the trailing region through the
6234
+ // existing AST-based `stripLeakedGsdCodexSections`, which removes GSD's own managed/leaked
6235
+ // sections (the bare [agents] table GSD regenerates, legacy [agents.gsd-*], [[agents]])
6236
+ // while preserving genuine user TOML — so #2406's de-dup still holds AND user content survives.
6449
6237
  if (markerIndex !== -1) {
6450
6238
  let before = existing.substring(0, markerIndex).trimEnd();
6451
6239
  if (before) {
6452
6240
  // Strip any GSD-managed sections that leaked above the marker from previous installs
6453
6241
  before = stripLeakedGsdCodexSections(before).trimEnd();
6454
-
6455
- atomicWriteFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
6456
- } else {
6457
- atomicWriteFileSync(configPath, normalizedGsdBlock + eol);
6458
6242
  }
6243
+ // Capture and preserve genuine user content AFTER the GSD-managed region. The whole
6244
+ // post-marker region is passed through stripLeakedGsdCodexSections: GSD's own previously-
6245
+ // emitted [agents] table (regenerated above as normalizedGsdBlock) and any leaked sections
6246
+ // are removed, while user tables ([model], [mcp_servers.*], [profiles.*]) are kept. The
6247
+ // marker comment line itself (and the optional codex_hooks ownership line right under it)
6248
+ // is GSD-owned and is stripped from the trailing region so it is not duplicated alongside
6249
+ // the freshly regenerated block.
6250
+ const rawAfter = existing.substring(markerIndex);
6251
+ const markerStripped = rawAfter
6252
+ .replace(GSD_CODEX_MARKER, '')
6253
+ .replace(/^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
6254
+ const afterUser = stripLeakedGsdCodexSections(markerStripped).trim();
6255
+
6256
+ const parts = [];
6257
+ if (before) parts.push(before);
6258
+ parts.push(normalizedGsdBlock);
6259
+ if (afterUser) parts.push(afterUser);
6260
+ atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
6459
6261
  return;
6460
6262
  }
6461
6263
 
@@ -6756,43 +6558,13 @@ function stripGsdFromCopilotInstructions(content) {
6756
6558
  const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
6757
6559
  const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
6758
6560
 
6759
- /**
6760
- * The GSD instruction body shared by the Cline directory-form rules file and
6761
- * the cross-tool AGENTS.md block. Self-contained — references only the gsd-core
6762
- * engine layout, not the (separate) #782 Cline skills directory.
6763
- */
6764
- function buildClineRulesBody() {
6765
- return hooksSurface.buildClineRulesBody();
6766
- }
6767
-
6768
- /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
6769
- function buildClineAgentsMdBody() {
6770
- return hooksSurface.buildClineAgentsMdBody();
6771
- }
6772
-
6773
- /**
6774
- * The Cline PreToolUse hook script (issue #787).
6775
- *
6776
- * Cline invokes hooks as executable scripts named exactly after the event with
6777
- * no extension, passing the operation context as JSON on stdin and reading a
6778
- * JSON decision from stdout ({ cancel, errorMessage, contextModification }).
6779
- *
6780
- * This hook is a self-standing planning-artifact guard: it cancels write-class
6781
- * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
6782
- * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
6783
- * hook bug can never wedge the user. No dependency on the #782 skills work.
6784
- */
6785
- function buildClinePreToolUseHook() {
6786
- return hooksSurface.buildClinePreToolUseHook();
6787
- }
6788
-
6789
- /**
6790
- * Merge the GSD AGENTS.md block into an existing file (or create it), preserving
6791
- * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
6792
- */
6793
- function mergeGsdAgentsMd(filePath, gsdContent) {
6794
- return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
6795
- }
6561
+ // #2876: buildClineRulesBody, buildClineAgentsMdBody, buildClinePreToolUseHook,
6562
+ // and mergeGsdAgentsMd used to be re-bound here as one-line delegates to the
6563
+ // equivalent hooksSurface.* implementations. None had an install.js internal
6564
+ // caller or an export consumer (tests import all four directly from
6565
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6566
+ // dead code with no reachable body — removed rather than kept as unreachable
6567
+ // wrappers.
6796
6568
 
6797
6569
  /**
6798
6570
  * Strip the GSD block from AGENTS.md content. Returns null if the file became
@@ -6844,47 +6616,13 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
6844
6616
  //
6845
6617
  // References: https://cursor.com/docs/hooks
6846
6618
 
6847
- /**
6848
- * Build a managed Cursor hook entry for a given hook script path.
6849
- *
6850
- * @param {string} scriptPath - Absolute path to the hook script
6851
- * @returns {object} Cursor hook entry object
6852
- */
6853
- function buildCursorHookEntry(scriptPath) {
6854
- return hooksSurface.buildCursorHookEntry(scriptPath);
6855
- }
6856
-
6857
- /**
6858
- * Return true if a Cursor hook entry is GSD-managed.
6859
- * Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field.
6860
- *
6861
- * @param {object} entry - A hooks array element from hooks.json
6862
- * @returns {boolean}
6863
- */
6864
- function isManagedCursorHookEntry(entry) {
6865
- return hooksSurface.isManagedCursorHookEntry(entry);
6866
- }
6867
-
6868
- /**
6869
- * Reconcile the GSD-managed entries in a Cursor hooks.json file.
6870
- *
6871
- * Supports both known hooks.json shapes:
6872
- * 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } }
6873
- * 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object)
6874
- *
6875
- * Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then
6876
- * re-added if managedEntries is non-null/non-empty. User-owned entries are
6877
- * preserved. File is written atomically only when content changes.
6878
- *
6879
- * @param {string} hooksJsonPath - Absolute path to the hooks.json file
6880
- * @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries
6881
- * Map from event name to the new hook entry to register (or null to remove).
6882
- * Pass null for the whole param to remove all managed entries.
6883
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
6884
- */
6885
- function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
6886
- return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
6887
- }
6619
+ // #2876: buildCursorHookEntry, isManagedCursorHookEntry, and
6620
+ // reconcileCursorHooksJson used to be re-bound here as one-line delegates to
6621
+ // the equivalent hooksSurface.* implementations. None had an install.js
6622
+ // internal caller or an export consumer (tests import all three directly from
6623
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6624
+ // dead code with no reachable body — removed rather than kept as unreachable
6625
+ // wrappers.
6888
6626
 
6889
6627
  /**
6890
6628
  * #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
@@ -6944,23 +6682,13 @@ function removeWindsurfHooksJson(targetDir) {
6944
6682
  return hooksSurface.removeWindsurfHooksJson(targetDir);
6945
6683
  }
6946
6684
 
6947
- /**
6948
- * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
6949
- *
6950
- * Returns the verbatim JSON shape Copilot CLI expects:
6951
- * { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
6952
- *
6953
- * The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
6954
- * run inline (no external script file), so the config can never reference a
6955
- * hook script that the installer did not also install — it is self-contained
6956
- * by construction. The command is advisory-only (always exits 0) and orients
6957
- * the agent toward the project's GSD planning state at session start.
6958
- *
6959
- * @returns {object} Copilot hooks-configuration object
6960
- */
6961
- function buildCopilotHookConfig() {
6962
- return hooksSurface.buildCopilotHookConfig();
6963
- }
6685
+ // #2876: buildCopilotHookConfig used to be re-bound here as a one-line
6686
+ // delegate to hooksSurface.buildCopilotHookConfig. It had no install.js
6687
+ // internal caller or export consumer (tests import it directly from
6688
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired binding was
6689
+ // dead code with no reachable body — removed rather than kept as an
6690
+ // unreachable wrapper. writeCopilotHookConfig below is unaffected — it still
6691
+ // has an internal caller (finishInstall).
6964
6692
 
6965
6693
  /**
6966
6694
  * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
@@ -7051,7 +6779,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7051
6779
  const codexGsdPath = `${path.resolve(targetDir, 'gsd-core').replace(/\\/g, '/')}/`;
7052
6780
 
7053
6781
  for (const file of agentEntries) {
7054
- let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
6782
+ const agentTomlSourcePath = path.join(agentsSrc, file);
6783
+ let content = fs.readFileSync(agentTomlSourcePath, 'utf8');
6784
+ // #2995 (epic #1671 Phase 6.4): Codex embeds each agent's prompt into a
6785
+ // per-agent `.toml`, reading the source .md independently of the inline
6786
+ // agent loop — a separate emission path that must strip gsd:section
6787
+ // markers too, or a marked agent ships its markers inside the TOML.
6788
+ // Found by the exhaustive per-runtime emission sweep in
6789
+ // tests/agent-fragments-emission.install.test.cjs, not by call-graph
6790
+ // analysis, which is why that guard is behavioral rather than structural.
6791
+ content = composeWorkflow(content, { sourcePath: agentTomlSourcePath });
7055
6792
  // Replace full .claude/gsd-core prefix so path resolves to the Codex
7056
6793
  // GSD install before generic .claude → .codex conversion rewrites it.
7057
6794
  content = content.replace(/~\/\.claude\/gsd-core\//g, codexGsdPath);
@@ -7480,10 +7217,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7480
7217
 
7481
7218
  // convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
7482
7219
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
7483
- // Imported from installEngine above.
7220
+ // #2876 found no install.js internal caller for the latter two (tests import
7221
+ // them directly from gsd-core/bin/lib/install-engine.cjs) and retired the
7222
+ // destructured bindings above.
7484
7223
 
7485
7224
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
7486
- // Imported from installEngine above.
7225
+ // #2876 found no install.js internal caller (never had one either — it was
7226
+ // never part of this module's export surface) and retired the destructured
7227
+ // binding above.
7487
7228
  //
7488
7229
  // copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
7489
7230
  // src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
@@ -7585,13 +7326,13 @@ function writeHermesCategoryDescription(categoryDir) {
7585
7326
  * @param {boolean} isGlobal - Whether this is a global install
7586
7327
  */
7587
7328
 
7588
- // USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts,
7589
- // migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries,
7590
- // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir,
7591
- // _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts,
7592
- // installOpencodeFamilySkills, uninstallRuntimeArtifacts:
7593
- // ALL moved to src/install-engine.cts (ADR-1239 Phase B).
7594
- // Imported from installEngine above.
7329
+ // USER_OWNED_ARTIFACTS, migrateLegacyDevPreferencesToSkill, _snapshotDir,
7330
+ // installRuntimeArtifacts, installOpencodeFamilySkills, uninstallRuntimeArtifacts:
7331
+ // ALL moved to src/install-engine.cts (ADR-1239 Phase B). Imported from
7332
+ // installEngine above. _copyStaged, _removeGsdEntries,
7333
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _restoreDir, and
7334
+ // _removeHermesBareStemDirs moved there too but #2876 found no install.js
7335
+ // internal caller for any of them and retired their destructured bindings.
7595
7336
 
7596
7337
  // ---------------------------------------------------------------------------
7597
7338
  // Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
@@ -7668,7 +7409,18 @@ const RUNTIME_CONTENT_DISPATCH = {
7668
7409
  return `/gsd-${commandName}`;
7669
7410
  });
7670
7411
  content = content.replace(/\.claude\/skills\//g, '.trae/skills/');
7671
- content = content.replace(/CLAUDE\.md/g, '.trae/rules/');
7412
+ // #2658: the full dot-claude-slash-prefixed instruction-file path must
7413
+ // be replaced before the bare instruction-filename fallback, or the
7414
+ // bare regex only rewrites that filename and leaves the prefix stale
7415
+ // in place, producing a malformed doubled-prefix path (see the longer
7416
+ // note in convertClaudeToTraeMarkdown above — the instruction filename
7417
+ // and either malformed shape are deliberately never spelled out
7418
+ // contiguously here either, for the same reason: this file ships
7419
+ // verbatim). Both forms target the same concrete file (never a bare
7420
+ // directory), matching the `.md` converter (convertClaudeToTraeMarkdown)
7421
+ // so js/cjs and md content agree on one canonical path.
7422
+ content = content.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
7423
+ content = content.replace(/CLAUDE\.md/g, '.trae/rules/rules.md');
7672
7424
  content = content.replace(/\bClaude Code\b/g, 'Trae');
7673
7425
  return content;
7674
7426
  },
@@ -7777,6 +7529,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7777
7529
  const srcPath = path.join(srcDir, entry.name);
7778
7530
  const destPath = path.join(destDir, entry.name);
7779
7531
 
7532
+ // #3333: srcPath was enumerated by readdirSync above, but a filesystem is not
7533
+ // transactional — the file it named can vanish between listing and this read
7534
+ // (a concurrent process, or another test in this suite writing/cleaning up a
7535
+ // fixture inside this same real directory). Treat "gone by the time we get
7536
+ // here" as benign and skip it, never a fatal crash of the whole install.
7537
+ if (!entry.isDirectory() && !fs.existsSync(srcPath)) {
7538
+ continue;
7539
+ }
7540
+
7780
7541
  if (entry.isDirectory()) {
7781
7542
  copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
7782
7543
  } else if (entry.name.endsWith('.md')) {
@@ -7786,6 +7547,42 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7786
7547
  // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
7787
7548
  // Skip generic replacement for Copilot/Antigravity — their converters handle all paths
7788
7549
  let content = fs.readFileSync(srcPath, 'utf8');
7550
+
7551
+ // #2930 (epic #1671 Phase 3): strip `<!-- gsd:section -->` markers
7552
+ // BEFORE any per-runtime rewrite so a `.claude/` -> `.windsurf/` regex
7553
+ // (or any other converter below) never reaches inside a marker
7554
+ // attribute and corrupts it. composeWorkflow is a no-op (byte-identical
7555
+ // return) for the 88+ workflows and every non-workflow .md that carries
7556
+ // no markers, and for a malformed marker it throws loudly naming
7557
+ // srcPath — never emit a half-composed workflow.
7558
+ //
7559
+ // Scoped to gsd-core/workflows/ ONLY (two independent reviewers,
7560
+ // chore/2930): copyWithPathReplacement is the emit path for every .md
7561
+ // under gsd-core/, skills/, and commands/ (see the three call sites),
7562
+ // not just workflows. A doc that merely DOCUMENTS the marker syntax
7563
+ // with an unfenced example (docs/reference/workflow-fragments.md is
7564
+ // the live instance of this class, though not under the install tree
7565
+ // today) would otherwise get silently mis-parsed as a real marker and
7566
+ // that line lossily dropped — a file class issue #2930 never scoped
7567
+ // to. Path is normalized UNCONDITIONALLY (backslash paths arrive on
7568
+ // Linux too — CONTEXT.md path-separator rule) and checked as a
7569
+ // path-segment match so the recursive descent (srcPath may be several
7570
+ // directory levels below gsd-core/workflows/) is still caught.
7571
+ //
7572
+ // #3072: the scoping predicate itself now lives in ONE place —
7573
+ // shouldCompose (src/mcp-catalog.cts, imported above) — rather than
7574
+ // being re-declared inline here. The MCP served catalog calls the
7575
+ // SAME function to decide what it composes vs serves verbatim, so this
7576
+ // install path and the catalog can never independently drift on what
7577
+ // gets composed (ADR-1671:309, DEFECT.GENERATIVE-FIX; the parity gate
7578
+ // is tests/mcp-catalog-parity.test.cjs). shouldCompose normalizes with
7579
+ // the identical unconditional `.replace(/\\/g, '/')` internally, so
7580
+ // this call is behavior-preserving byte-for-behavior with the regex it
7581
+ // replaces.
7582
+ if (shouldCompose(srcPath)) {
7583
+ content = composeWorkflow(content, { sourcePath: srcPath });
7584
+ }
7585
+
7789
7586
  if (!dispatch.mdSkipGenericRewrite) {
7790
7587
  const globalClaudeRegex = /~\/\.claude\//g;
7791
7588
  const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
@@ -7793,8 +7590,20 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7793
7590
  content = content.replace(globalClaudeRegex, pathPrefix);
7794
7591
  content = content.replace(globalClaudeHomeRegex, pathPrefix);
7795
7592
  content = content.replace(localClaudeRegex, `./${dirName}/`);
7796
- content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7797
- content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7593
+ // #3544 review (Finding 1 fallout): guarded with the SAME
7594
+ // negative-lookahead convention already used at ~:2859-2860 below
7595
+ // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
7596
+ // is satisfied by ANY non-word character, including '-' — so for a
7597
+ // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
7598
+ // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
7599
+ // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
7600
+ // and re-appended the full prefix, corrupting every emitted path to
7601
+ // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
7602
+ // default '.claude' (self-replace with an identical string), which
7603
+ // is why this went undetected until a non-default config-dir name
7604
+ // was exercised.
7605
+ content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7606
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7798
7607
  content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7799
7608
  content = content.replace(/~\/\.qwen\//g, pathPrefix);
7800
7609
  content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
@@ -7802,6 +7611,17 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7802
7611
  content = content.replace(/~\/\.hermes\//g, pathPrefix);
7803
7612
  content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
7804
7613
  content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
7614
+ // #3544: restore @-file-reference lines to the tilde form Claude Code
7615
+ // actually expands — the SAME correction #3133 already applies to
7616
+ // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
7617
+ // restoreClaudeGlobalAtRefTilde's doc comment in
7618
+ // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree
7619
+ // emit path, which never had it: every @~/.claude/gsd-core/… include
7620
+ // in a global install's workflows/references tree silently resolved
7621
+ // to nothing (54 includes across 22 files on a live install).
7622
+ if (runtime === 'claude') {
7623
+ content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
7624
+ }
7805
7625
  }
7806
7626
  content = processAttribution(content, getCommitAttribution(runtime));
7807
7627
 
@@ -8080,6 +7900,23 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8080
7900
 
8081
7901
  let removedCount = 0;
8082
7902
 
7903
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
7904
+ // artifact orphaned by a PRIOR uninstall run that died between staging and
7905
+ // its own restore/discard, BEFORE this run's own preserve steps (sites 2,
7906
+ // 3, 5 below) stage anything new. Uninstall's own gsd-core/ removal and
7907
+ // legacy-commands cleanup are exactly as crash-exposed as install's —
7908
+ // without this, an orphan from a crashed uninstall is recoverable only if
7909
+ // the user later re-installs.
7910
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging root
7911
+ // itself cannot be resolved — skip this recovery pass rather than throw
7912
+ // out of uninstall() before it does anything.
7913
+ {
7914
+ const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
7915
+ if (_uninstallEntryStagingRoot !== null) {
7916
+ recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir);
7917
+ }
7918
+ }
7919
+
8083
7920
  // Remove profile marker so a clean reinstall defaults to full surface.
8084
7921
  try {
8085
7922
  fs.unlinkSync(path.join(targetDir, '.gsd-profile'));
@@ -8087,7 +7924,16 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8087
7924
  } catch {}
8088
7925
 
8089
7926
  // 1. Remove GSD commands/skills (layout-driven)
8090
- const scope = isGlobal ? 'global' : 'local';
7927
+ // #2870: scope id resolved ONCE here and reused below (was two independent
7928
+ // isGlobal-derived re-derivations). Routed through the Install Scope
7929
+ // Module (src/install-scope.cts) when the capability registry is
7930
+ // available; degrades to the plain id on failure (unknown/non-installable
7931
+ // runtime, broken bundle) so this function's scope-id uses — which never
7932
+ // depended on registry availability before this migration — keep working
7933
+ // exactly as they did pre-migration.
7934
+ const _uninstallScopeId = isGlobal ? 'global' : 'local';
7935
+ const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
7936
+ const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
8091
7937
  // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
8092
7938
  // Fail-open to the engine directly if the composed-registry adapter can't load.
8093
7939
  const _uninstallAdapter = _runtimeAdapter(runtime);
@@ -8168,11 +8014,12 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8168
8014
 
8169
8015
  // 1a-kimi. Non-layout Kimi side-effect (#2095 EoS/kimi Upgrade 1): kimi's
8170
8016
  // native config.toml lives outside targetDir entirely (resolveKimiHooksTomlDir
8171
- // resolves ~/.kimi, a sibling of targetDir's ~/.config/agents), so its
8017
+ // resolves ~/.kimi for kimi and ~/.kimi-code for kimi-code (#2755), a sibling
8018
+ // of targetDir's ~/.config/agents), so its
8172
8019
  // cleanup can't be driven by anything under targetDir the way every other
8173
8020
  // hook surface above is.
8174
8021
  if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
8175
- const kimiHooksRoot = resolveKimiHooksTomlDir();
8022
+ const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
8176
8023
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8177
8024
  const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8178
8025
  if (kimiHooksCleanup.changed) {
@@ -8219,23 +8066,24 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8219
8066
  }
8220
8067
  }
8221
8068
 
8069
+ // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8070
+ // before the emptiness check below, or the dir would never prune.
8071
+ if (removeCommonJsMarker(kimiHooksDir)) {
8072
+ removedCount++;
8073
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8074
+ }
8075
+
8222
8076
  try {
8223
8077
  if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8224
8078
  } catch (_) { /* not empty — leave it */ }
8225
8079
  }
8226
8080
 
8227
- const kimiPkgJsonPath = path.join(kimiHooksRoot, 'package.json');
8228
- if (fs.existsSync(kimiPkgJsonPath)) {
8229
- try {
8230
- const content = fs.readFileSync(kimiPkgJsonPath, 'utf8').trim();
8231
- if (content === '{"type":"commonjs"}') {
8232
- fs.unlinkSync(kimiPkgJsonPath);
8233
- removedCount++;
8234
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot}`);
8235
- }
8236
- } catch (e) {
8237
- // Ignore read errors
8238
- }
8081
+ // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8082
+ // used to write it. Exact content match — a user's own package.json in
8083
+ // kimi's native config home is never touched.
8084
+ if (removeCommonJsMarker(kimiHooksRoot)) {
8085
+ removedCount++;
8086
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8239
8087
  }
8240
8088
  }
8241
8089
 
@@ -8412,18 +8260,42 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8412
8260
  // Preserve user-owned dev-preferences.md if present (#1423 parity).
8413
8261
  const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
8414
8262
  if (fs.existsSync(legacyGsdCommandsDir)) {
8415
- const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md');
8416
- const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null;
8417
- fs.rmSync(legacyGsdCommandsDir, { recursive: true });
8418
- removedCount++;
8419
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8420
- if (savedDevPrefs) {
8421
- try {
8422
- fs.mkdirSync(legacyGsdCommandsDir, { recursive: true });
8423
- fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs);
8424
- console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
8425
- } catch (err) {
8426
- console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
8263
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
8264
+ // #1874-F19 "site 7" — found by sweeping bin/install.js for the
8265
+ // read-then-wipe-then-write PATTERN, not for preserveUserArtifacts'
8266
+ // callers; this uninstall-path block open-coded the same round-trip).
8267
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8268
+ // root cannot be resolved — skip this legacy-cleanup block entirely
8269
+ // (leave the stale dir in place) rather than wipe without a durable
8270
+ // backup for dev-preferences.md.
8271
+ const _legacyGsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8272
+ if (_legacyGsdCommandsStagingRoot !== null) {
8273
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdCommandsDir, ['dev-preferences.md'], _legacyGsdCommandsStagingRoot);
8274
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8275
+ // EMPTY dev-preferences.md was (and still is) silently not restored.
8276
+ const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
8277
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
8278
+ : null;
8279
+ fs.rmSync(legacyGsdCommandsDir, { recursive: true });
8280
+ removedCount++;
8281
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8282
+ if (savedDevPrefs) {
8283
+ try {
8284
+ restoreStagedUserArtifacts(legacyGsdCommandsDir, stagedDevPrefs);
8285
+ discardStagedUserArtifacts(stagedDevPrefs);
8286
+ console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
8287
+ } catch (err) {
8288
+ console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
8289
+ }
8290
+ } else {
8291
+ // #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and
8292
+ // still is) never restored — the original truthy-content check is
8293
+ // preserved byte-for-byte above — but the staged batch was never
8294
+ // discarded either, leaking a <configDir>/.gsd-staging/ record
8295
+ // forever and re-materializing the just-deleted file on a future
8296
+ // install's orphan-recovery pass. Discard unconditionally when there
8297
+ // is nothing to restore.
8298
+ discardStagedUserArtifacts(stagedDevPrefs);
8427
8299
  }
8428
8300
  }
8429
8301
  }
@@ -8441,21 +8313,77 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8441
8313
  // so this is a best-effort guard.
8442
8314
  const legacyDir = path.join(targetDir, 'commands', 'gsd');
8443
8315
  if (fs.existsSync(legacyDir)) {
8444
- const savedLegacyArtifacts = preserveUserArtifacts(legacyDir, ['dev-preferences.md']);
8445
- fs.rmSync(legacyDir, { recursive: true });
8446
- removedCount++;
8447
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8448
- const _uninstallScope = isGlobal ? 'global' : 'local';
8449
- if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) {
8450
- // Compute the actual path written so the log line is accurate per-runtime
8451
- const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
8452
- const _sk = _layout.kinds.find((k) => k.kind === 'skills');
8453
- const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
8454
- const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
8455
- console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
8456
- } else {
8457
- // Migration failed or already exists — restore to legacy location so user content is not lost
8458
- restoreUserArtifacts(legacyDir, savedLegacyArtifacts);
8316
+ // #2875 (#1874-F19): staged DURABLY to disk before the wipe below,
8317
+ // instead of an in-memory Map only — a crash between the wipe and the
8318
+ // restore-on-failure branch below now survives via
8319
+ // recoverOrphanedUserArtifacts on the next run.
8320
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8321
+ // root cannot be resolved — skip this legacy-migration block entirely
8322
+ // (leave the stale dir in place) rather than wipe without a durable
8323
+ // backup.
8324
+ const stagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8325
+ if (stagingRoot !== null) {
8326
+ const stagedLegacyArtifacts = stageUserArtifacts(legacyDir, ['dev-preferences.md'], stagingRoot);
8327
+ fs.rmSync(legacyDir, { recursive: true });
8328
+ removedCount++;
8329
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8330
+ const _uninstallScope = scope;
8331
+ // migrateLegacyDevPreferencesToSkill's Map<string,string> contract is
8332
+ // unchanged — read the staged content back from disk (not an in-memory
8333
+ // value held across the wipe above).
8334
+ //
8335
+ // #2875 defect fix (readFileSync following a staged symlink) — matches
8336
+ // install-engine.cts's _runLegacyInstallMigrations call site 1 exactly:
8337
+ // readFileSync ALWAYS follows a symlink, so a staged artifact that is
8338
+ // itself a symlink (user-artifact-staging.cts's "Symlink safety": a
8339
+ // symlinked user artifact is recreated AS a symlink in the staging
8340
+ // tree, never copied by content) would have its REFERENT's bytes read
8341
+ // here and land in SKILL.md. Excluded from migration below and
8342
+ // restored to its original location unchanged instead.
8343
+ // #2875 defect fix (regression closed — was previously unguarded and
8344
+ // BRICKED uninstall, the very command that should recover from this):
8345
+ // legacyDir was already removed above, so stagedLegacyArtifacts is
8346
+ // the only surviving copy. migrateLegacyDevPreferencesToSkill
8347
+ // correctly THROWS when it finds a planted/dangling symlink at the
8348
+ // skill-file leaf (security fix); a raw `fs.lstatSync` in the loop
8349
+ // below can also throw on a TOCTOU-vanished staged file. Either one,
8350
+ // left unguarded, propagated straight out of uninstall, aborting it
8351
+ // WITHOUT ever reaching the restore-or-discard branch below — the
8352
+ // staged batch was orphaned on disk and every retry hit the same
8353
+ // throw again. Degrade identically to every other #2875 staging step
8354
+ // in this function: catch, warn once, and treat the batch as
8355
+ // unmigrated so the restore branch below always fires.
8356
+ let _legacyMigrated = false;
8357
+ let migratableLegacyNames = [];
8358
+ try {
8359
+ const savedLegacyArtifacts = new Map();
8360
+ for (const name of stagedLegacyArtifacts.names) {
8361
+ const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name);
8362
+ if (fs.lstatSync(stagedPath).isSymbolicLink()) continue;
8363
+ savedLegacyArtifacts.set(name, fs.readFileSync(stagedPath, 'utf8'));
8364
+ migratableLegacyNames.push(name);
8365
+ }
8366
+ _legacyMigrated = migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope);
8367
+ } catch (err) {
8368
+ console.warn(` ${yellow}!${reset} dev-preferences.md migration skipped (${err.message}) — restoring the legacy copy instead.`);
8369
+ _legacyMigrated = false;
8370
+ migratableLegacyNames = [];
8371
+ }
8372
+ if (_legacyMigrated && migratableLegacyNames.length === stagedLegacyArtifacts.names.length) {
8373
+ // Compute the actual path written so the log line is accurate per-runtime
8374
+ const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
8375
+ const _sk = _layout.kinds.find((k) => k.kind === 'skills');
8376
+ const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
8377
+ const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
8378
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
8379
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8380
+ } else {
8381
+ // Migration failed, already exists, or a symlinked name was excluded
8382
+ // above — restore the WHOLE batch to the legacy location so no user
8383
+ // content is silently lost.
8384
+ restoreStagedUserArtifacts(legacyDir, stagedLegacyArtifacts);
8385
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8386
+ }
8459
8387
  }
8460
8388
  }
8461
8389
  }
@@ -8463,22 +8391,49 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8463
8391
  // 2. Remove gsd-core directory
8464
8392
  const gsdDir = path.join(targetDir, 'gsd-core');
8465
8393
  if (fs.existsSync(gsdDir)) {
8466
- // Preserve user-generated files before wipe (#1423)
8467
- const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
8468
- const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
8469
-
8470
- fs.rmSync(gsdDir, { recursive: true });
8471
- removedCount++;
8472
- console.log(` ${green}✓${reset} Removed gsd-core/`);
8394
+ // Stage user-generated files DURABLY to disk before wipe (#1423; #2875 /
8395
+ // #1874-F19 "site 5" — this block open-coded its own preserve/restore
8396
+ // instead of calling preserveUserArtifacts, which is why it was missed
8397
+ // by the original symbol-search measurement).
8398
+ // #2875 defect fix: this IS the core uninstall step (removing gsd-core/)
8399
+ // — unlike the optional legacy-cleanup blocks above, uninstall must
8400
+ // still be able to proceed and actually remove gsd-core/ even when the
8401
+ // staging root cannot be resolved. Degrade by skipping ONLY the
8402
+ // USER-PROFILE.md preserve/restore wrapper (warn), never the removal
8403
+ // itself.
8404
+ const _gsdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8405
+ if (_gsdDirStagingRoot === null) {
8406
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`);
8407
+ fs.rmSync(gsdDir, { recursive: true });
8408
+ removedCount++;
8409
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8410
+ } else {
8411
+ const stagedProfile = stageUserArtifacts(gsdDir, USER_OWNED_ARTIFACTS, _gsdDirStagingRoot);
8412
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8413
+ // EMPTY USER-PROFILE.md was (and still is) silently not restored —
8414
+ // matching prior behavior byte-for-byte rather than widening scope.
8415
+ const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md')
8416
+ ? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8')
8417
+ : null;
8418
+
8419
+ fs.rmSync(gsdDir, { recursive: true });
8420
+ removedCount++;
8421
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8473
8422
 
8474
- // Restore user-generated files
8475
- if (preservedProfile) {
8476
- try {
8477
- fs.mkdirSync(gsdDir, { recursive: true });
8478
- fs.writeFileSync(userProfilePath, preservedProfile);
8479
- console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
8480
- } catch (err) {
8481
- console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
8423
+ // Restore user-generated files
8424
+ if (preservedProfile) {
8425
+ try {
8426
+ restoreStagedUserArtifacts(gsdDir, stagedProfile);
8427
+ discardStagedUserArtifacts(stagedProfile);
8428
+ console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
8429
+ } catch (err) {
8430
+ console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
8431
+ }
8432
+ } else {
8433
+ // #2875 defect fix: same empty-file orphan leak as the legacy
8434
+ // commands/gsd/ site above — discard the staging batch regardless of
8435
+ // whether the staged content was truthy.
8436
+ discardStagedUserArtifacts(stagedProfile);
8482
8437
  }
8483
8438
  }
8484
8439
  }
@@ -8501,7 +8456,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8501
8456
  }
8502
8457
 
8503
8458
  // 4. Remove GSD hooks
8504
- const hooksDir = path.join(targetDir, 'hooks');
8459
+ // #3023: mirror the install site's descriptor-driven bundle dir name.
8460
+ const hooksDir = path.join(targetDir, resolveSharedHooksDirName(runtime));
8505
8461
  if (fs.existsSync(hooksDir)) {
8506
8462
  let hookCount = 0;
8507
8463
  for (const hook of GSD_UNINSTALL_HOOKS) {
@@ -8543,13 +8499,18 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8543
8499
  }
8544
8500
  }
8545
8501
 
8546
- // #2717: remove the CommonJS marker GSD wrote into hooks/ for runtimes that
8547
- // stage .js hooks via dedicated paths (cursor/windsurf/codex) — but ONLY if
8548
- // it still carries GSD's exact content (a user-authored package.json is
8549
- // never deleted). Safe no-op for runtimes whose marker lives at the config
8550
- // root (the shared-bundle path) or that never received one.
8502
+ // Retire the CommonJS marker staged into hooks/. hooks/ is shared space and
8503
+ // is deliberately never rmdir'd here, so the marker must be removed
8504
+ // explicitly or it would be left behind. Removed ONLY when it still carries
8505
+ // GSD's exact content — a user-authored package.json is never deleted.
8506
+ //
8507
+ // #2717 reaches the runtimes that stage .js hooks via dedicated paths
8508
+ // (cursor/windsurf/codex); #2544 reaches the shared-bundle runtimes, whose
8509
+ // marker this PR moves out of the config root and into hooks/. Both land in
8510
+ // the same directory, so one guarded call covers both.
8551
8511
  try {
8552
8512
  if (hooksSurface.removeCommonJsMarkerIfGsdOwned(hooksDir)) {
8513
+ removedCount++;
8553
8514
  console.log(` ${green}✓${reset} Removed GSD hooks/package.json (CommonJS marker)`);
8554
8515
  }
8555
8516
  } catch { /* best-effort */ }
@@ -8564,12 +8525,38 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8564
8525
  if (_np) {
8565
8526
  const pluginsDir = path.join(targetDir, _np.dir);
8566
8527
  const pluginPath = path.join(pluginsDir, _np.file);
8528
+ // Tracks whether GSD actually removed anything from pluginsDir. The rmdir
8529
+ // below is gated on it: pruning a directory GSD never wrote to is the same
8530
+ // "don't touch territory GSD didn't fill" violation this issue is about,
8531
+ // just inverted — a user-created but empty plugin/ or extensions/ dir is
8532
+ // theirs, and an uninstall that never removed anything has no business
8533
+ // deleting it.
8534
+ let removedFromPluginsDir = false;
8567
8535
  if (fs.existsSync(pluginPath)) {
8568
8536
  try {
8569
8537
  fs.unlinkSync(pluginPath);
8570
8538
  removedCount++;
8539
+ removedFromPluginsDir = true;
8571
8540
  console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
8572
8541
  } catch (_) { /* best-effort */ }
8542
+ }
8543
+ // #2544: the adapter's CommonJS marker sits beside it. Cleaned up OUTSIDE
8544
+ // the adapter-exists guard above — a partial install (or a hand-deleted
8545
+ // adapter) would otherwise strand GSD's marker forever and keep the dir
8546
+ // from ever pruning. Conditioned on the adapter being GONE, though: if the
8547
+ // unlink above failed, pulling the marker out from under a still-present
8548
+ // CommonJS adapter would leave it unloadable. The exact content match
8549
+ // still leaves any user-authored package.json in place.
8550
+ if (!fs.existsSync(pluginPath) && removeCommonJsMarker(pluginsDir)) {
8551
+ removedCount++;
8552
+ removedFromPluginsDir = true;
8553
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${_np.dir}/`);
8554
+ }
8555
+ // Only prune a dir GSD emptied. Pre-fix this rmdir sat inside the
8556
+ // adapter-exists guard, so it could never fire on a dir GSD had not
8557
+ // written to; hoisting it out to catch the marker-only case must not
8558
+ // silently widen it to "any empty plugin dir".
8559
+ if (removedFromPluginsDir) {
8573
8560
  try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
8574
8561
  }
8575
8562
  }
@@ -8579,13 +8566,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8579
8566
  // Any file NOT in this set is user-owned and must survive uninstall.
8580
8567
  // After removing GSD files, attempt to rmdir — if the directory is still
8581
8568
  // non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
8582
- const GSD_CHANGESET_FILES = [
8583
- 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
8584
- 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
8585
- 'README.md', // documentation only — not user-authored
8586
- ];
8587
- const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs'];
8588
-
8569
+ // GSD_CHANGESET_FILES / GSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
8570
+ // tests can assert their parity against the real directory contents.
8589
8571
  const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
8590
8572
  if (fs.existsSync(changesetUninstallDir)) {
8591
8573
  let removedChangeset = 0;
@@ -8630,19 +8612,14 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8630
8612
  }
8631
8613
 
8632
8614
  // 5. Remove GSD package.json (CommonJS mode marker)
8633
- const pkgJsonPath = path.join(targetDir, 'package.json');
8634
- if (fs.existsSync(pkgJsonPath)) {
8635
- try {
8636
- const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
8637
- // Only remove if it's our minimal CommonJS marker
8638
- if (content === '{"type":"commonjs"}') {
8639
- fs.unlinkSync(pkgJsonPath);
8640
- removedCount++;
8641
- console.log(` ${green}✓${reset} Removed GSD package.json`);
8642
- }
8643
- } catch (e) {
8644
- // Ignore read errors
8645
- }
8615
+ // Since #2544 the marker is staged into hooks/ (and the nativePlugin dir,
8616
+ // handled at 4z above) rather than at targetDir. The targetDir removal is
8617
+ // retained to retire the marker written by pre-#2544 installs — same exact
8618
+ // content match as before, so a user-authored package.json is still never
8619
+ // touched.
8620
+ if (removeCommonJsMarker(targetDir)) {
8621
+ removedCount++;
8622
+ console.log(` ${green}✓${reset} Removed GSD package.json (pre-#2544 config-root marker)`);
8646
8623
  }
8647
8624
 
8648
8625
  // 6. Clean up settings.json (remove GSD hooks and statusline)
@@ -9467,23 +9444,41 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9467
9444
  // so the manifest records what's actually on disk. _resolveSkillsRootDir already
9468
9445
  // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
9469
9446
  // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
9470
- const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
9447
+ // #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the
9448
+ // scope RECORDED in the manifest are one value, resolved once. Two reads of
9449
+ // `options.scope` could drift; one cannot.
9450
+ const resolvedScope = options.scope === 'local' ? 'local' : 'global';
9451
+ const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
9471
9452
  const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
9472
9453
  const agentsDir = path.join(configDir, 'agents');
9473
9454
  const manifest = {
9455
+ // Schema version of this DOCUMENT (#2872) — distinct from `version`
9456
+ // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1)
9457
+ // manifest, which readInstallManifest still reads without error and
9458
+ // without requiring a reinstall. Read from the Installer Migration
9459
+ // Module rather than repeated as a second literal: the writer here and
9460
+ // the reader's normalizeManifestVersion are two surfaces over one
9461
+ // constant, and this repo's "generative fix divergence" class is exactly
9462
+ // two such literals drifting apart.
9463
+ manifestVersion: MANIFEST_SCHEMA_VERSION,
9474
9464
  version: pkg.version,
9475
9465
  timestamp: new Date().toISOString(),
9476
9466
  mode: options.mode === 'minimal' ? 'minimal' : 'full',
9467
+ // Recorded so an Installed Surface Resolver can answer "which surfaces
9468
+ // are installed, at which scopes, for which runtimes" without re-deriving
9469
+ // it from the directory it happened to be found in (#2872).
9470
+ runtime,
9471
+ scope: resolvedScope,
9477
9472
  files: {},
9478
9473
  };
9479
9474
 
9480
9475
  const gsdHashes = generateManifest(gsdDir);
9481
9476
  for (const [rel, hash] of Object.entries(gsdHashes)) {
9482
- // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are preserved
9483
- // across reinstalls by preserveUserArtifacts and must NOT be hashed into
9484
- // the manifest — otherwise saveLocalPatches() would flag every refresh
9485
- // as a "local patch" (bug #2771). Single source of truth:
9486
- // USER_OWNED_ARTIFACTS at top of file.
9477
+ // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged
9478
+ // durably and restored across reinstalls (user-artifact-staging.cts,
9479
+ // #2875) and must NOT be hashed into the manifest — otherwise
9480
+ // saveLocalPatches() would flag every refresh as a "local patch"
9481
+ // (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file.
9487
9482
  if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
9488
9483
  manifest.files['gsd-core/' + rel] = hash;
9489
9484
  }
@@ -9574,7 +9569,10 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9574
9569
  // #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
9575
9570
  // skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
9576
9571
  if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
9577
- const hooksDir = path.join(configDir, 'hooks');
9572
+ // #3023: manifest keys must track the bundle wherever the descriptor put it,
9573
+ // or uninstall/saveLocalPatches silently orphan the tree.
9574
+ const sharedHooksDirName = resolveSharedHooksDirName(runtime);
9575
+ const hooksDir = path.join(configDir, sharedHooksDirName);
9578
9576
  if (fs.existsSync(hooksDir)) {
9579
9577
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
9580
9578
  // scripts/build-hooks.js) rather than a prefix/extension regex, so the
@@ -9586,7 +9584,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9586
9584
  for (const hook of INSTALLED_HOOK_FILES) {
9587
9585
  const hookPath = path.join(hooksDir, hook);
9588
9586
  if (fs.existsSync(hookPath)) {
9589
- manifest.files['hooks/' + hook] = fileHash(hookPath);
9587
+ manifest.files[sharedHooksDirName + '/' + hook] = fileHash(hookPath);
9590
9588
  }
9591
9589
  }
9592
9590
  // Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
@@ -9595,7 +9593,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9595
9593
  if (fs.existsSync(hooksLibDir)) {
9596
9594
  for (const file of fs.readdirSync(hooksLibDir)) {
9597
9595
  if (GSD_HOOK_LIB_FILES.includes(file)) {
9598
- manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
9596
+ manifest.files[sharedHooksDirName + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
9599
9597
  }
9600
9598
  }
9601
9599
  }
@@ -9968,21 +9966,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9968
9966
  // below were removed, leaving isKimi unused in this function (the kimi
9969
9967
  // local-install-deferred branch above already reads
9970
9968
  // _hostBehaviors(runtime).localInstallDeferred instead of this flag).
9971
- // #2096: isAntigravity dropped — antigravity is in
9972
- // _DESCRIPTOR_AGENTS_RUNTIMES below, so its two legacy-agent-loop branches
9973
- // (the path-rewrite skip and the converter dispatch) were unreachable dead
9974
- // code; both were removed rather than re-gated on hostBehaviors.
9975
- // #2098: isCodebuddy dropped — codebuddy is also in
9976
- // _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch
9977
- // (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
9969
+ // #2096: isAntigravity dropped — antigravity's agents were already
9970
+ // descriptor-driven (installRuntimeArtifacts), so its two legacy-agent-loop
9971
+ // branches (the path-rewrite skip and the converter dispatch) were
9972
+ // unreachable dead code; both were removed rather than re-gated on
9973
+ // hostBehaviors. (#2875 Part 2 later deleted that legacy loop and its
9974
+ // `_DESCRIPTOR_AGENTS_RUNTIMES` gate entirely — EVERY runtime is now
9975
+ // descriptor-driven for agents, not just this subset.)
9976
+ // #2098: isCodebuddy dropped — codebuddy's agents were likewise already
9977
+ // descriptor-driven, so its legacy converter-dispatch branch (the
9978
+ // `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
9978
9979
  // unreachable dead code and was removed rather than re-gated.
9979
- // #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES
9980
- // below, so its three legacy-agent-loop branches (the path-rewrite skip,
9981
- // the converter dispatch, and the .agent.md destName ternary) were
9982
- // unreachable dead code and were removed rather than re-gated; the
9983
- // .agent.md suffix now lives on hostBehaviors.agentFileExtension in
9984
- // src/install-engine.cts, and the skipSharedHooksInstall check above no
9985
- // longer needs `&& !isCopilot`.
9980
+ // #2099: isCopilot dropped — copilot's agents were likewise already
9981
+ // descriptor-driven, so its three legacy-agent-loop branches (the
9982
+ // path-rewrite skip, the converter dispatch, and the .agent.md destName
9983
+ // ternary) were unreachable dead code and were removed rather than
9984
+ // re-gated; the .agent.md suffix now lives on
9985
+ // hostBehaviors.agentFileExtension in src/install-engine.cts, and the
9986
+ // skipSharedHooksInstall check above no longer needs `&& !isCopilot`.
9986
9987
  // #2100: isWindsurf dropped — its four former isWindsurf-gated branches
9987
9988
  // (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the
9988
9989
  // workflow-verification report, and the shared-hooks-install exclusion) are
@@ -9990,14 +9991,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9990
9991
  // hostBehaviors.installsCommandBodiesForWorkflowDelegation,
9991
9992
  // hostBehaviors.verificationStyle === 'windsurf-workflows', and
9992
9993
  // hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop
9993
- // converter arm was likewise unreachable dead code (windsurf is in
9994
- // _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above.
9994
+ // converter arm was likewise unreachable dead code (windsurf's agents were
9995
+ // already descriptor-driven) and was removed above.
9995
9996
  // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall.
9996
9997
  const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
9997
9998
  const plan = resolveInstallPlan(runtime);
9998
9999
  const dirName = getDirName(runtime);
9999
10000
  const src = path.join(__dirname, '..');
10000
10001
 
10002
+ // #3241 — the Codex resolver-model-omitted notice dedupes "at most once", but
10003
+ // scoped per install() call rather than per process — each install() run gets
10004
+ // its own fresh window so a second install (e.g. a second runtime, or a test
10005
+ // re-running install()) can warn again if the same condition recurs.
10006
+ _codexResolverModelOmittedWarned = false;
10007
+
10001
10008
  if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
10002
10009
  console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
10003
10010
  console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
@@ -10015,6 +10022,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10015
10022
  };
10016
10023
  }
10017
10024
 
10025
+ // #2870: scope id resolved ONCE here and reused at every use below (was 10
10026
+ // independent isGlobal-derived re-derivations). Placed AFTER the kimi
10027
+ // local-deferred early return above so that return path does no extra
10028
+ // work. Routed through the Install Scope Module (src/install-scope.cts)
10029
+ // when the capability registry is available; degrades to the plain id on
10030
+ // failure (unknown/non-installable runtime, broken bundle) so this
10031
+ // function's plain scope-id uses — which never depended on registry
10032
+ // availability before this migration — keep working exactly as they did
10033
+ // pre-migration. `_installScope` (the full resolved value, not just the
10034
+ // id) additionally backs the settingsFileByScope routing below.
10035
+ const _installScopeId = isGlobal ? 'global' : 'local';
10036
+ const _installScope = _resolveScopeSafe(_installScopeId, runtime);
10037
+
10018
10038
  // Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
10019
10039
  // Defined early so it is visible to both the main and Codex code paths.
10020
10040
  // `allowlist` (when non-empty) restricts copying to the named top-level entries,
@@ -10060,6 +10080,26 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10060
10080
  ? process.cwd()
10061
10081
  : path.join(process.cwd(), dirName);
10062
10082
 
10083
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
10084
+ // artifact orphaned by a PRIOR install run that died between staging and
10085
+ // its own restore/discard, BEFORE this run's own preserve step stages
10086
+ // anything new. This is the production entry point every install() call
10087
+ // reaches — the only place this phase's durability fix is complete rather
10088
+ // than merely callable (40-design.md "The inertness trap this design must
10089
+ // avoid" / #1879-F15). Runs for every runtime, ahead of both the
10090
+ // layout-driven path's _runLegacyInstallMigrations (site 1, inside
10091
+ // installRuntimeArtifacts) and this function's own mainline gsd-core copy
10092
+ // (site 4, below).
10093
+ // #2875 defect fix: DEGRADE, never abort install, when the staging root
10094
+ // itself cannot be resolved — skip this recovery pass rather than throw
10095
+ // out of install() before it does anything.
10096
+ {
10097
+ const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10098
+ if (_installEntryStagingRoot !== null) {
10099
+ recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir);
10100
+ }
10101
+ }
10102
+
10063
10103
  const locationLabel = isGlobal
10064
10104
  ? targetDir.replace(os.homedir(), '~')
10065
10105
  : targetDir.replace(process.cwd(), '.');
@@ -10212,7 +10252,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10212
10252
  const codexPreInstallAgentContents = new Map();
10213
10253
  let codexPreInstallVersionBytes = null;
10214
10254
  if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
10215
- const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10255
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10216
10256
  if (fs.existsSync(_preSkillsDir)) {
10217
10257
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
10218
10258
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -10268,7 +10308,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10268
10308
  const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
10269
10309
  rollbackInstallerMigrations();
10270
10310
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
10271
- const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10311
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10272
10312
  for (const skillName of codexPreInstallSkillNames) {
10273
10313
  const skillDirPath = path.join(_earlySkillsDir, skillName);
10274
10314
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -10366,7 +10406,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10366
10406
  installerMigrationResult = runInstallerMigrations({
10367
10407
  configDir: targetDir,
10368
10408
  runtime,
10369
- scope: isGlobal ? 'global' : 'local',
10409
+ scope: _installScopeId,
10370
10410
  migrations: options.installerMigrations,
10371
10411
  baselineScan: true,
10372
10412
  });
@@ -10451,6 +10491,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10451
10491
  // (copyWithPathReplacement + stale-skills cleanup).
10452
10492
  const _isSkillsRuntime = (() => {
10453
10493
  if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
10494
+ // #2875 Part 2 defect fix: a runtime whose LOCAL commands are embedded in a
10495
+ // rules file rather than materialized as files (hostBehaviors.localCommandsViaRules
10496
+ // — cline is the only declarant, capabilities/cline/capability.json) must not
10497
+ // flip into this skills/commands-reporting branch merely because its local
10498
+ // artifactLayout now also declares an `agents` kind (#2875 Part 2 cline-local
10499
+ // agents regression fix). That branch's own verification reporting expects a
10500
+ // skills/ or commands/ directory this runtime never writes locally and would
10501
+ // spuriously fail; the `localCommandsViaRules` branch below (unchanged
10502
+ // messaging) and the unconditional agents-materialization block further down
10503
+ // (installAgentsKindStandalone) already cover this runtime/scope correctly.
10504
+ if (!isGlobal && _hostBehaviors(runtime).localCommandsViaRules) return false;
10454
10505
  const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
10455
10506
  const layout = cap && cap.runtime && cap.runtime.artifactLayout;
10456
10507
  if (!layout) return false;
@@ -10499,7 +10550,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10499
10550
 
10500
10551
  if (_isSkillsRuntime) {
10501
10552
  // Layout-driven install for skills-based runtimes (full and minimal modes)
10502
- const scope = isGlobal ? 'global' : 'local';
10553
+ const scope = _installScopeId;
10503
10554
  // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
10504
10555
  // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
10505
10556
  // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
@@ -10655,8 +10706,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10655
10706
  }
10656
10707
  }
10657
10708
 
10658
- // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands).
10659
- // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089).
10709
+ // Descriptor-driven commands/ output report (currently CodeBuddy).
10710
+ // Cursor retired this parallel surface in #2644 because its skills are
10711
+ // already slash-menu entries as well as model-invocable context.
10660
10712
  if (_hostBehaviors(runtime).reportCommandsDir) {
10661
10713
  const commandsDir = path.join(targetDir, 'commands');
10662
10714
  if (fs.existsSync(commandsDir)) {
@@ -10734,15 +10786,34 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10734
10786
  // that used the namespaced layout (wrote bare-name files under commands/gsd/).
10735
10787
  const legacyGsdDir = path.join(commandsDir, 'gsd');
10736
10788
  if (fs.existsSync(legacyGsdDir)) {
10737
- // Preserve user-owned dev-preferences.md before wiping
10738
- const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md');
10739
- const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
10740
- fs.rmSync(legacyGsdDir, { recursive: true });
10741
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
10742
- if (preservedDevPrefs) {
10743
- // Migrate dev-preferences to the new flat form
10744
- fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs);
10745
- console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
10789
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
10790
+ // #1874-F19 "site 6" — this Claude commands-install path open-coded
10791
+ // its own preserve/restore instead of calling preserveUserArtifacts,
10792
+ // found by sweeping for the read-then-wipe-then-write PATTERN rather
10793
+ // than for that helper's callers).
10794
+ // #2875 defect fix: DEGRADE, never abort install, when the staging
10795
+ // root cannot be resolved — skip this legacy-migration block entirely
10796
+ // (leave the stale dir in place) rather than wipe without a durable
10797
+ // backup.
10798
+ const _legacyGsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10799
+ if (_legacyGsdStagingRoot !== null) {
10800
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdDir, ['dev-preferences.md'], _legacyGsdStagingRoot);
10801
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
10802
+ // EMPTY dev-preferences.md was (and still is) silently not migrated —
10803
+ // matching prior behavior byte-for-byte rather than widening scope.
10804
+ const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
10805
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
10806
+ : null;
10807
+ fs.rmSync(legacyGsdDir, { recursive: true });
10808
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
10809
+ if (preservedDevPrefs) {
10810
+ // Migrate dev-preferences to the new flat form — a RENAME on
10811
+ // restore (staged as 'dev-preferences.md', restored as
10812
+ // 'gsd-dev-preferences.md'), not a round-trip.
10813
+ restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } });
10814
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
10815
+ }
10816
+ discardStagedUserArtifacts(stagedDevPrefs);
10746
10817
  }
10747
10818
  }
10748
10819
 
@@ -10773,12 +10844,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10773
10844
  }
10774
10845
 
10775
10846
  // Copy gsd-core skill with path replacement
10776
- // Preserve user-generated files before the wipe-and-copy so they survive re-install
10847
+ // Stage user-generated files DURABLY to disk before the wipe-and-copy so
10848
+ // they survive re-install even if the process dies mid-copy (#2875 /
10849
+ // #1874-F19) — copyWithPathReplacement wipes and recursively re-copies the
10850
+ // entire gsd-core/ tree, the single longest operation in the install, on
10851
+ // the path every user takes (40-design.md "Site 4 is far worse...").
10777
10852
  const skillSrc = path.join(src, 'gsd-core');
10778
10853
  const skillDest = path.join(targetDir, 'gsd-core');
10779
- const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
10780
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10781
- restoreUserArtifacts(skillDest, savedGsdArtifacts);
10854
+ // #2875 defect fix: this IS the mainline install step (installing
10855
+ // gsd-core/ itself) — unlike the optional legacy-cleanup blocks above,
10856
+ // install must still be able to proceed and actually write gsd-core/ even
10857
+ // when the staging root cannot be resolved. Degrade by skipping ONLY the
10858
+ // USER_OWNED_ARTIFACTS preserve/restore wrapper around the copy (warn),
10859
+ // never the copy itself.
10860
+ const _gsdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10861
+ if (_gsdArtifactsStagingRoot === null) {
10862
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/${USER_OWNED_ARTIFACTS.join(', gsd-core/')} preservation (staging unavailable) — it will be lost if present.`);
10863
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10864
+ } else {
10865
+ const stagedGsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _gsdArtifactsStagingRoot);
10866
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10867
+ restoreStagedUserArtifacts(skillDest, stagedGsdArtifacts);
10868
+ discardStagedUserArtifacts(stagedGsdArtifacts);
10869
+ }
10782
10870
  if (verifyInstalled(skillDest, 'gsd-core')) {
10783
10871
  console.log(` ${green}✓${reset} Installed workflow assets`);
10784
10872
  } else {
@@ -10840,214 +10928,89 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10840
10928
  }
10841
10929
  }
10842
10930
 
10843
- // Copy agents to agents directory.
10844
- // Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded
10845
- // into the runtime's Agent tool schema, costing ~6k tokens per turn even
10846
- // when no GSD workflow is active. See open-gsd/gsd-core#2762.
10847
- // Note: agentsSrc is declared as let before the enclosing try block so it
10848
- // is accessible by installCodexConfig() in the Codex config section below.
10849
- agentsSrc = _stageAgents(path.join(src, 'agents'));
10850
- const agentsDest = path.join(targetDir, 'agents');
10851
-
10852
- // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent
10853
- // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path
10854
- // applies path-rewrite + attribution + converter + normalize via
10855
- // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in
10856
- // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE
10857
- // (by installRuntimeArtifacts at line 8912), which also performs its own
10858
- // stale-file prune pass. The inline stale-removal + inline loop both skip them.
10859
- // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
10860
- // #1575: copilot and antigravity cut over — copilot gets .agent.md filename
10861
- // rename via _copyStaged(runtime); antigravity uses scope-aware converter.
10862
- // #2092 Phase B Upgrade 1: qwen cut over — native .qwen/agents/*.md subagent
10863
- // projection via convertClaudeAgentToQwenAgent. Without this exclusion the
10864
- // legacy inline loop below deletes+re-copies qwen's agents RAW (bypassing the
10865
- // new converter entirely, since qwen has no dedicated branch in the inline
10866
- // loop's if/else-if chain — it would silently fall through to the generic
10867
- // brandingRewrites-only branch).
10868
- // cline remains excluded: rules-only local branch + local/global complication
10869
- // that the descriptor-driven path does not handle correctly.
10870
- const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen', 'kimi']);
10871
-
10872
- // Always remove stale gsd-* agents first so re-installing with
10873
- // `--minimal` actually shrinks a previously-full install.
10874
- // For Codex this also covers per-agent `.toml` files alongside the `.md`
10875
- // sources so a full → minimal switch doesn't leave stale registrations.
10876
- // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes) and
10877
- // for pluginOnlyInstall runtimes (pi, ADR-1239 / #2102 Stage 1 — no agents/
10878
- // dir is ever written for them, see the leading branch below).
10879
- if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && !_hostBehaviors(runtime).pluginOnlyInstall && fs.existsSync(agentsDest)) {
10880
- for (const file of fs.readdirSync(agentsDest)) {
10881
- if (
10882
- file.startsWith('gsd-') &&
10883
- (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
10884
- ) {
10885
- fs.unlinkSync(path.join(agentsDest, file));
10886
- }
10887
- }
10888
- }
10889
-
10931
+ // Agents directory materialization.
10932
+ // #2875 Part 2 (the agents-bypass closure): EVERY runtime is now
10933
+ // descriptor-driven for agents — installRuntimeArtifacts (called earlier in
10934
+ // this function, the `_isSkillsRuntime` branch above) already wrote
10935
+ // agents/ for any runtime whose capability.json declares an `agents` kind,
10936
+ // via convertedAgentsKind/agentsKind (generic layout loop) or
10937
+ // installAgentsKindStandalone (OpenCode/Kilo's combinedFamilyInstall
10938
+ // branch, called from within installOpencodeFamilyArtifacts) — both reuse
10939
+ // the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite →
10940
+ // attribution → converter → frontmatter extensions → normalize) the
10941
+ // inline loop this replaces used to hand-roll, and both prune stale gsd-*
10942
+ // entries via their own _removeGsdEntries pass BEFORE copying (broader
10943
+ // than this loop's old extension-gated stale check — see
10944
+ // runtime-artifact-layout.cts's convertedAgentsKind doc comment).
10945
+ // Minimal-mode agent filtering is handled the SAME way it already was for
10946
+ // the ten runtimes cut over before this change: via resolvedProfile.agents
10947
+ // at staging time, not a separate branch here.
10948
+ //
10949
+ // `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path, and
10950
+ // pi) never reach that loop at all — installAgentsKindStandalone is called
10951
+ // here explicitly to cover them (install-engine.cts's own doc comment
10952
+ // explains why; a regression here was caught by the install-tree golden
10953
+ // fixture, tests/fixtures/install-tree/claude-local.json). It is a no-op
10954
+ // for pi: its capability.json declares an EMPTY artifactLayout for both
10955
+ // scopes (programmatic dispatch, no named-dispatch subagent toolkit, no
10956
+ // host-read markdown surface), so the resolved layout has no `agents` kind
10957
+ // to stage and the function returns `null` without writing anything.
10890
10958
  if (_hostBehaviors(runtime).pluginOnlyInstall) {
10891
- // pi (ADR-1239 / #2102 Stage 1): programmatic dispatch has no named-dispatch
10892
- // subagent toolkit (dispatch.subagentToolkit: "undocumented", no Agent-tool
10893
- // equivalent) and no host-read markdown surface — skip writing agents/ entirely.
10894
10959
  console.log(` ${green}✓${reset} pi: no subagent files (programmatic dispatch, no named-dispatch toolkit)`);
10895
- } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
10896
- // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
10897
- // via its own prune pass. No further action needed.
10960
+ } else if (_isSkillsRuntime) {
10898
10961
  console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
10899
- } else if (isMinimalMode(_effectiveInstallMode)) {
10900
- // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
10901
- // Without stripping them here, a full → minimal reinstall would leave the
10902
- // runtime advertising the old full agent surface even though the agent
10903
- // files are gone. Reuse the same helper that powers `--uninstall`.
10904
- if (_hostBehaviors(runtime).tomlConfigInstall) {
10905
- const codexConfigPath = path.join(targetDir, 'config.toml');
10906
- if (fs.existsSync(codexConfigPath)) {
10907
- const existing = fs.readFileSync(codexConfigPath, 'utf8');
10908
- const cleaned = stripGsdFromCodexConfig(existing);
10909
- if (cleaned === null) {
10910
- fs.unlinkSync(codexConfigPath);
10911
- } else if (cleaned !== existing) {
10912
- fs.writeFileSync(codexConfigPath, cleaned);
10913
- }
10962
+ } else {
10963
+ const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry);
10964
+ if (_standaloneAgentsResult) {
10965
+ // #2875 defect fix: installAgentsKindStandalone now returns `null`
10966
+ // (rather than a truthy result pointing at an empty destDir) whenever a
10967
+ // restricted (non-'*') resolvedProfile — --minimal being the common
10968
+ // case — legitimately stages ZERO agents, matching the pre-#2875-Part-2
10969
+ // inline loop's behavior of never creating agentsDest under --minimal
10970
+ // at all (see the deleted `isMinimalMode` branch). `destDir` is
10971
+ // therefore guaranteed non-empty whenever we reach this branch, so a
10972
+ // real staging failure still fails loudly via verifyInstalled below.
10973
+ if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) {
10974
+ console.log(` ${green}✓${reset} Installed agents`);
10975
+ } else {
10976
+ failures.push('agents');
10914
10977
  }
10915
- }
10916
- console.log(` ${dim}↳${reset} Skipping agents (minimal install — run \`gsd update\` without \`--minimal\` to add full surface)`);
10917
- } else if (fs.existsSync(agentsSrc)) {
10918
- fs.mkdirSync(agentsDest, { recursive: true });
10919
-
10920
- // Copy new agents
10921
- const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
10922
- for (const entry of agentEntries) {
10923
- if (entry.isFile() && entry.name.endsWith('.md')) {
10924
- let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
10925
- // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo
10926
- const dirRegex = /~\/\.claude\//g;
10927
- const homeDirRegex = /\$HOME\/\.claude\//g;
10928
- const bareDirRegex = /~\/\.claude\b/g;
10929
- const bareHomeDirRegex = /\$HOME\/\.claude\b/g;
10930
- const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
10931
- // #2096: `&& !isAntigravity` dropped — antigravity is in
10932
- // _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already
10933
- // unreachable for it; the path-rewrite skip for antigravity now lives
10934
- // in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite).
10935
- // #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in
10936
- // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole
10937
- // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
10938
- // isCopilot was therefore always false here, making the guard a no-op.
10939
- content = content.replace(dirRegex, pathPrefix);
10940
- content = content.replace(homeDirRegex, pathPrefix);
10941
- content = content.replace(bareDirRegex, normalizedPathPrefix);
10942
- content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
10943
- content = processAttribution(content, getCommitAttribution(runtime));
10944
- // Convert frontmatter for runtime compatibility (agents need different handling)
10945
- if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
10946
- // Resolve per-agent model for OpenCode agents.
10947
- // Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
10948
- // model_overrides (#2256): explicit per-agent override, highest precedence.
10949
- // model_profile_overrides (#2794): tier-based runtime resolver, same parity as Codex.
10950
- const _ocAgentName = entry.name.replace(/\.md$/, '');
10951
- const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir);
10952
- let _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null;
10953
- if (!_ocModelOverride) {
10954
- // Fall back to tier-based resolution via model_profile_overrides.opencode.<tier>.
10955
- const _ocRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
10956
- if (_ocRuntimeResolver) {
10957
- const _ocEntry = _ocRuntimeResolver.resolve(_ocAgentName);
10958
- if (_ocEntry?.model) {
10959
- _ocModelOverride = _ocEntry.model;
10960
- }
10961
- }
10962
- }
10963
- content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
10964
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
10965
- // Resolve per-agent model for Kilo agents (#2093 UPGRADE 2; Kilo is an
10966
- // OpenCode fork with the same static-frontmatter model constraint).
10967
- // Precedence: model_overrides[agent] > model_profile_overrides.kilo.<tier> > omit.
10968
- // model_overrides (#2256): explicit per-agent override, highest precedence.
10969
- // model_profile_overrides (#2794): tier-based runtime resolver, same parity as OpenCode.
10970
- const _kiloAgentName = entry.name.replace(/\.md$/, '');
10971
- const _kiloModelOverrides = readGsdEffectiveModelOverrides(targetDir);
10972
- let _kiloModelOverride = _kiloModelOverrides?.[_kiloAgentName] || null;
10973
- if (!_kiloModelOverride) {
10974
- // Fall back to tier-based resolution via model_profile_overrides.kilo.<tier>.
10975
- const _kiloRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
10976
- if (_kiloRuntimeResolver) {
10977
- const _kiloEntry = _kiloRuntimeResolver.resolve(_kiloAgentName);
10978
- if (_kiloEntry?.model) {
10979
- _kiloModelOverride = _kiloEntry.model;
10980
- }
10981
- }
10982
- }
10983
- content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride });
10984
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
10985
- content = convertClaudeAgentToCodexAgent(content);
10986
- // #2099: `else if (isCopilot)` arm dropped — copilot is unreachable
10987
- // here (see the isCopilot-guard-drop comment above); its content
10988
- // conversion is applied pre-staging via the descriptor's
10989
- // artifactLayout.converter (runtime-artifact-layout.cts), independent
10990
- // of this legacy loop.
10991
- // #2100: `else if (isWindsurf)` arm dropped — windsurf is ALSO in
10992
- // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9575 above), so this whole
10993
- // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
10994
- // isWindsurf was therefore always false here, making the arm dead.
10995
- // Its content conversion is applied pre-staging via the descriptor's
10996
- // artifactLayout.converter (convertClaudeAgentToWindsurfAgent),
10997
- // independent of this legacy loop.
10998
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
10999
- // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
11000
- // hostBehaviors.frontmatterDialect === 'cline'.
11001
- content = convertClaudeAgentToClineAgent(content);
11002
- } else if (_hostBehaviors(runtime).brandingRewrites) {
11003
- // Descriptor-driven (ADR-1239 / #2092): folded from separate
11004
- // `isQwen` / hermes-hardcoded branches into a single read of
11005
- // runtime.hostBehaviors.brandingRewrites (qwen -> QWEN.md/Qwen
11006
- // Code/.qwen/, hermes -> HERMES.md/Hermes Agent/.hermes/).
11007
- const _b = _hostBehaviors(runtime).brandingRewrites;
11008
- content = content.replace(/CLAUDE\.md/g, _b['CLAUDE.md']);
11009
- content = content.replace(/\bClaude Code\b/g, _b['Claude Code']);
11010
- content = content.replace(/\.claude\//g, _b['.claude/']);
11011
- }
11012
- // #443 — Inject `effort:` into the Claude .md frontmatter ONLY.
11013
- // OpenCode/Qwen/Hermes also produce .md files but break on
11014
- // unknown frontmatter keys (the repo bans skills:/permissionMode: for
11015
- // the same reason — see tests/agent-frontmatter.test.cjs).
11016
- // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
11017
- // Injection is per-runtime at install time because the canonical source
11018
- // agents/*.md must stay runtime-safe (no effort: key in source).
11019
- if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) {
11020
- const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
11021
- const _agentName = entry.name.replace(/\.md$/, '');
11022
- const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
11023
- const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value;
11024
- content = injectEffortFrontmatter(content, _renderedEffort);
11025
- const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
11026
- if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
11027
- }
11028
- // #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
11029
- // to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
11030
- // runtimes (claude / qwen / hermes). Self-converting and
11031
- // colon-canonical runtimes are skipped by the predicate — see
11032
- // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
11033
- // SKILL.md-body fix shipped via #3629.
11034
- content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
11035
- // #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is
11036
- // unreachable here (see the isCopilot-guard-drop comment above), so
11037
- // the ternary always evaluated to entry.name in practice; its
11038
- // .agent.md suffix is applied by the descriptor-driven fold in
11039
- // src/install-engine.cts (hostBehaviors.agentFileExtension).
11040
- const destName = entry.name;
11041
- fs.writeFileSync(path.join(agentsDest, destName), content);
11042
- }
11043
- }
11044
- if (verifyInstalled(agentsDest, 'agents')) {
11045
- console.log(` ${green}✓${reset} Installed agents`);
10978
+ } else if (_resolvedProfile.skills !== '*') {
10979
+ console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`gsd update\` with a broader profile to add them)`);
11046
10980
  } else {
11047
- failures.push('agents');
10981
+ console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`);
10982
+ }
10983
+ }
10984
+
10985
+ // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections —
10986
+ // NOT agents-directory materialization (design doc "Deliberately not in
10987
+ // scope"), so this stays independent of the agents/ write above. Without
10988
+ // stripping these on a full → minimal reinstall, the runtime would keep
10989
+ // advertising the old full agent surface even though the descriptor-driven
10990
+ // write above already skipped writing the .md files for a minimal-tier
10991
+ // resolvedProfile. Reuse the same helper that powers `--uninstall`.
10992
+ if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) {
10993
+ const codexConfigPath = path.join(targetDir, 'config.toml');
10994
+ if (fs.existsSync(codexConfigPath)) {
10995
+ const existing = fs.readFileSync(codexConfigPath, 'utf8');
10996
+ const cleaned = stripGsdFromCodexConfig(existing);
10997
+ if (cleaned === null) {
10998
+ fs.unlinkSync(codexConfigPath);
10999
+ } else if (cleaned !== existing) {
11000
+ fs.writeFileSync(codexConfigPath, cleaned);
11001
+ }
11048
11002
  }
11049
11003
  }
11050
11004
 
11005
+ // agentsSrc is declared as `let` before the enclosing try block (not const)
11006
+ // so it is accessible by installCodexConfig() in the Codex config section
11007
+ // below — that function reads RAW source agents/*.md (not the
11008
+ // descriptor-staged output above) to build Codex's per-agent config.toml
11009
+ // sidecar files, a separate writer this migration deliberately does not
11010
+ // touch (design doc: "Codex's config.toml [agents.gsd-*] strip... is not
11011
+ // agents-directory materialization").
11012
+ agentsSrc = _stageAgents(path.join(src, 'agents'));
11013
+
11051
11014
  // Copy CHANGELOG.md
11052
11015
  const changelogSrc = path.join(src, 'CHANGELOG.md');
11053
11016
  const changelogDest = path.join(targetDir, 'gsd-core', 'CHANGELOG.md');
@@ -11106,22 +11069,30 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11106
11069
  // a safe no-op when the dir is already present.
11107
11070
  fs.mkdirSync(destRootDir, { recursive: true });
11108
11071
 
11109
- // Write package.json to force CommonJS mode for GSD scripts
11110
- // Prevents "require is not defined" errors when project has "type": "module"
11111
- // Node.js walks up looking for package.json - this stops inheritance from project
11112
- const pkgJsonDest = path.join(destRootDir, 'package.json');
11113
- fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
11114
- console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
11072
+ // #3023: the bundle's directory NAME is descriptor-driven — a host that
11073
+ // reserves `hooks/` (pi) must be able to opt out. Resolved once here so the
11074
+ // stage / lib / marker sites can never disagree about where the bundle is.
11075
+ const sharedHooksDirName = resolveSharedHooksDirName(runtime);
11076
+
11077
+ // #2544: the CommonJS marker is NOT written here (destRootDir is the
11078
+ // runtime's shared config root — user-writable territory on OpenCode and
11079
+ // Kilo, where it is the documented place to declare local-plugin npm
11080
+ // dependencies). It is written into hooks/ below, the directory GSD
11081
+ // creates and fills with its own .js scripts, once that directory exists.
11115
11082
 
11116
11083
  let hooksOk = true;
11084
+ // #2544: true once GSD has actually written into destRootDir/hooks/, which
11085
+ // is what licenses the CommonJS marker below.
11086
+ let stagedHooks = false;
11117
11087
 
11118
11088
  // Copy hooks from dist/ (bundled with dependencies)
11119
11089
  // Template paths for the target runtime (replaces '.claude' with correct config dir)
11120
11090
  const hooksSrc = path.join(src, 'hooks', 'dist');
11121
11091
  if (fs.existsSync(hooksSrc)) {
11122
- const hooksDest = path.join(destRootDir, 'hooks');
11092
+ const hooksDest = path.join(destRootDir, sharedHooksDirName);
11123
11093
  fs.mkdirSync(hooksDest, { recursive: true });
11124
11094
  const hookEntries = fs.readdirSync(hooksSrc);
11095
+ if (hookEntries.some((e) => fs.statSync(path.join(hooksSrc, e)).isFile())) stagedHooks = true;
11125
11096
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
11126
11097
  for (const entry of hookEntries) {
11127
11098
  const srcFile = path.join(hooksSrc, entry);
@@ -11184,7 +11155,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11184
11155
  }
11185
11156
  }
11186
11157
  if (verifyInstalled(hooksDest, 'hooks')) {
11187
- console.log(` ${green}✓${reset} Installed hooks (bundled)`);
11158
+ console.log(` ${green}✓${reset} Installed ${sharedHooksDirName} (bundled)`);
11188
11159
  // Warn if expected community .sh hooks are missing (non-fatal)
11189
11160
  const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
11190
11161
  for (const sh of expectedShHooks) {
@@ -11213,10 +11184,48 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11213
11184
  // below; this helper itself only checks source presence.)
11214
11185
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
11215
11186
  if (fs.existsSync(hooksLibSrc)) {
11216
- const hooksLibDest = path.join(destRootDir, 'hooks', 'lib');
11187
+ const hooksLibDest = path.join(destRootDir, sharedHooksDirName, 'lib');
11217
11188
  fs.mkdirSync(hooksLibDest, { recursive: true });
11218
11189
  copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
11219
- console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
11190
+ if (GSD_HOOK_LIB_FILES.some((f) => fs.existsSync(path.join(hooksLibDest, f)))) stagedHooks = true;
11191
+ console.log(` ${green}✓${reset} Installed ${sharedHooksDirName}/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
11192
+ }
11193
+
11194
+ // #2544: pin the staged hook scripts to CommonJS from inside hooks/ — the
11195
+ // directory GSD just created and filled — instead of from destRootDir.
11196
+ // Scoping the marker to GSD's own directory keeps `require` working in
11197
+ // hooks/*.js and hooks/lib/*.js under any ambient "type": "module", while
11198
+ // leaving the shared config root untouched.
11199
+ //
11200
+ // Gated on `stagedHooks`, NOT on the directory merely existing: hooks/ is
11201
+ // shared space, so an existence check would drop a GSD marker into a
11202
+ // hooks/ directory the user created and GSD never wrote to — the same
11203
+ // write-into-someone-else's-territory this issue is about. And never
11204
+ // written over a package.json GSD does not own.
11205
+ //
11206
+ // ALSO gated on `hooksOk`: `stagedHooks` is computed from the SOURCE
11207
+ // listing before the copy loop, so it stays true when the copies land but
11208
+ // `verifyInstalled` then fails. Marking a hooks/ GSD did not successfully
11209
+ // populate as CommonJS claims an ownership the install did not earn — the
11210
+ // two flags answer different questions ("did we intend to fill it" vs "is
11211
+ // it actually filled"), and the marker needs both.
11212
+ const hooksMarkerDir = path.join(destRootDir, sharedHooksDirName);
11213
+ if (stagedHooks && hooksOk) {
11214
+ switch (ensureCommonJsMarker(hooksMarkerDir)) {
11215
+ case 'written':
11216
+ console.log(` ${green}✓${reset} Wrote ${sharedHooksDirName}/package.json (CommonJS mode)`);
11217
+ break;
11218
+ case 'preserved-foreign':
11219
+ console.warn(` ${yellow}⚠${reset} Left existing ${sharedHooksDirName}/package.json untouched (not GSD's marker) — GSD hooks may not resolve as CommonJS`);
11220
+ break;
11221
+ case 'failed':
11222
+ // Best-effort: a read-only or full config dir must not abort the
11223
+ // install with a raw stack trace. The hooks themselves are staged.
11224
+ console.warn(` ${yellow}⚠${reset} Could not write ${sharedHooksDirName}/package.json (CommonJS mode) — install continued; GSD hooks may not resolve as CommonJS`);
11225
+ break;
11226
+ default:
11227
+ break;
11228
+ }
11220
11229
  }
11221
11230
 
11222
11231
  return hooksOk;
@@ -11377,12 +11386,35 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11377
11386
  }
11378
11387
 
11379
11388
  // Write file manifest for future modification detection
11380
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11389
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11381
11390
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
11382
11391
 
11383
11392
  // Report any backed-up local patches
11384
11393
  reportLocalPatches(targetDir, runtime);
11385
11394
 
11395
+ // #2873: cross-scope shadow report. Fires ONCE per install (this is the
11396
+ // only writeManifest call site that gets it — the other four sites are
11397
+ // sub-writes within a single install, not separate installs). A shadowed
11398
+ // install is a warning, never a failure (ADR-2866 Consequences), so this
11399
+ // never touches `failures` or `process.exit`, and the whole block is
11400
+ // wrapped in a try/catch that swallows everything: a report failure must
11401
+ // never fail an otherwise-successful install (design row C5). No options
11402
+ // are injected into buildShadowReport — this is the production call shape,
11403
+ // resolving the real machine via os.homedir()/process.cwd() defaults
11404
+ // inside the resolver.
11405
+ try {
11406
+ const shadowReport = buildShadowReport(runtime);
11407
+ const shadowLines = renderShadowReport(shadowReport);
11408
+ if (shadowLines.length > 0) {
11409
+ console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
11410
+ for (const line of shadowLines.slice(1)) {
11411
+ console.warn(` ${dim}${line}${reset}`);
11412
+ }
11413
+ }
11414
+ } catch (_shadowReportErr) {
11415
+ // Never fail an install over a reporting concern — see comment above.
11416
+ }
11417
+
11386
11418
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
11387
11419
  if (!_hostBehaviors(runtime).ownsClaudePaths) {
11388
11420
  const leakedPaths = [];
@@ -11528,7 +11560,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11528
11560
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
11529
11561
  // are restored even when they are absent from disk at rollback time (#3245 CR).
11530
11562
  // • Dirs that did not pre-exist: remove entirely.
11531
- const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
11563
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
11532
11564
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
11533
11565
  for (const skillName of codexPreInstallSkillNames) {
11534
11566
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -11643,7 +11675,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11643
11675
  // Re-write the manifest now that .toml agent files exist on disk.
11644
11676
  // The initial writeManifest call (before Codex config generation) could
11645
11677
  // not include agents/gsd-*.toml because those files did not yet exist.
11646
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11678
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11647
11679
  } else {
11648
11680
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
11649
11681
  }
@@ -11667,6 +11699,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11667
11699
  const codexHooksDest = path.join(targetDir, 'hooks');
11668
11700
  fs.mkdirSync(codexHooksDest, { recursive: true });
11669
11701
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
11702
+ // #2544: track whether anything was actually staged. hooks/dist existing
11703
+ // is not the same as an allowlisted file landing in it — see the marker
11704
+ // gate below.
11705
+ let codexStagedHooks = false;
11670
11706
  for (const entry of fs.readdirSync(codexHooksSrc)) {
11671
11707
  if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
11672
11708
  const srcFile = path.join(codexHooksSrc, entry);
@@ -11700,6 +11736,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11700
11736
  fs.copyFileSync(srcFile, destFile);
11701
11737
  try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
11702
11738
  }
11739
+ codexStagedHooks = true;
11703
11740
  }
11704
11741
  console.log(` ${green}✓${reset} Installed hooks (Codex)`);
11705
11742
  // #2717: write the CommonJS marker into hooks/ alongside the staged .js
@@ -11710,7 +11747,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11710
11747
  // gsd-context-monitor.js as ESM and their require() calls fail silently.
11711
11748
  // Reuses the same helper the Cursor/Windsurf writers call so the marker
11712
11749
  // content + user-file-preservation contract is identical everywhere.
11713
- if (hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
11750
+ //
11751
+ // #2544: gated on codexStagedHooks, mirroring installSharedHooksBundle's
11752
+ // `stagedHooks`. The enclosing guard only proves hooks/dist EXISTS; if it
11753
+ // holds none of CODEX_HOOKS_TO_COPY, this block mkdirs hooks/ and stages
11754
+ // nothing, and an ungated marker would claim a directory GSD did not fill.
11755
+ if (codexStagedHooks && hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
11714
11756
  console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
11715
11757
  }
11716
11758
  }
@@ -11921,7 +11963,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11921
11963
  // manifest-tracked (verified) — uninstall removes them explicitly via
11922
11964
  // removeCursorHooksJson + its script list, and reconcile is idempotent.
11923
11965
  // The re-run is retained for parity with the settings.json install path.
11924
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11966
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11925
11967
  persistActiveProfileMarker();
11926
11968
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11927
11969
  }
@@ -11931,7 +11973,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11931
11973
  // hooks needed. Kimi is also artifact-only for its INSTALL surface (skills +
11932
11974
  // kimi-agents, no settings.json) but #2095 Upgrade 1 gives it its own
11933
11975
  // independent hooksSurface: kimi's native config.toml [[hooks]] array, which
11934
- // lives outside targetDir entirely (resolveKimiHooksTomlDir resolves ~/.kimi,
11976
+ // lives outside targetDir entirely (resolveKimiHooksTomlDir resolves the
11977
+ // per-runtime root — ~/.kimi for kimi, ~/.kimi-code for kimi-code, #2755 —
11935
11978
  // a sibling of targetDir's ~/.config/agents) — hence writing it here, inside
11936
11979
  // this early-return, rather than requiring installSurface to change.
11937
11980
  //
@@ -11952,7 +11995,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11952
11995
  // ~/.kimi/hooks/<script> rather than a script that doesn't exist under
11953
11996
  // targetDir/hooks (which kimi no longer receives).
11954
11997
  if (plan.hooksSurface === 'kimi-hooks-toml' && isGlobal) {
11955
- const kimiHooksRoot = resolveKimiHooksTomlDir();
11998
+ const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
11956
11999
  // Note: the `failures` array's hard-fail gate (`if (failures.length > 0)
11957
12000
  // process.exit(1)`) runs earlier in this function, before this
11958
12001
  // profile-marker-only branch is ever reached — pushing to it here would
@@ -11961,6 +12004,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11961
12004
  if (!installSharedHooksBundle(kimiHooksRoot)) {
11962
12005
  console.warn(` ${yellow}⚠${reset} Kimi hook bundle did not verify at ${path.join(kimiHooksRoot, 'hooks')} — GSD lifecycle hooks may be incomplete`);
11963
12006
  }
12007
+ // #2544: retire the pre-fix marker at kimi's root. installSharedHooksBundle
12008
+ // used to write {"type":"commonjs"} at destRootDir itself; it now writes it
12009
+ // under destRootDir/hooks/, so on an upgrade the old root file is stale and
12010
+ // would keep ~/.kimi pinned to CommonJS.
12011
+ //
12012
+ // Done HERE rather than in installer-migration 007 (which retires the same
12013
+ // stale marker for every other runtime) because kimi's hook root is
12014
+ // the per-runtime Kimi root — resolved by resolveKimiHooksTomlDir, OUTSIDE the configDir.
12015
+ // Migration relPaths are structurally confined to configDir, so the
12016
+ // framework cannot address this path at all. Same exact-content predicate
12017
+ // either way, so a user-authored ~/.kimi/package.json is never touched.
12018
+ if (removeCommonJsMarker(kimiHooksRoot)) {
12019
+ console.log(` ${green}✓${reset} Removed stale package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
12020
+ }
11964
12021
  const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
11965
12022
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
11966
12023
  const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
@@ -11993,7 +12050,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11993
12050
  // explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
11994
12051
  // is idempotent on repeated installs, so manifest tracking isn't needed
11995
12052
  // for correctness here.
11996
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12053
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11997
12054
  }
11998
12055
 
11999
12056
  persistActiveProfileMarker();
@@ -12007,7 +12064,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12007
12064
  writeClineArtifacts(targetDir, isGlobal);
12008
12065
  // Re-run the manifest pass: these artifacts are written *after* the earlier
12009
12066
  // writeManifest() call, so a second pass is needed to hash-track them.
12010
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12067
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12011
12068
  persistActiveProfileMarker();
12012
12069
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12013
12070
  }
@@ -12022,10 +12079,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12022
12079
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
12023
12080
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
12024
12081
  // settings.json. Global installs and all other runtimes continue to use settings.json.
12082
+ // #2870: the CURRENT scope's settings filename is sourced from the Install
12083
+ // Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
12084
+ // instead of indexing _scopedSettings by hand. _scopedSettings itself is
12085
+ // retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
12086
+ // already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
12087
+ // above) when the registry fails to load, whereas resolveScope's registry
12088
+ // lookup throws in that same scenario (_installScope is null when it did).
12089
+ // Falling back to _scopedSettings[_installScopeId] there — and keeping the
12090
+ // non-local-claude branch's expression untouched — means this is
12091
+ // byte-identical to the pre-migration computation in every case, including
12092
+ // the broken-registry fail-safe floor.
12025
12093
  const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
12026
- const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
12094
+ const _currentScopeSettingsFile = _installScope
12095
+ ? _installScope.settingsFile
12096
+ : (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
12097
+ const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
12027
12098
  const settingsFileName = isLocalClaude
12028
- ? _scopedSettings.local
12099
+ ? _currentScopeSettingsFile
12029
12100
  : ((_scopedSettings && _scopedSettings.global) || 'settings.json');
12030
12101
  // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
12031
12102
  // must resolve under targetDir (this path also drives a recursive mkdirSync).
@@ -13073,14 +13144,26 @@ function maybeSuggestPathExport(globalBin, homeDir) {
13073
13144
 
13074
13145
  console.log('');
13075
13146
  console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
13076
- console.log(` Add it with one of:`);
13077
13147
  const projected = projectPersistentPathExportActions({
13078
13148
  targetDir: globalBin,
13079
13149
  platform: process.platform,
13080
13150
  });
13081
- for (const action of projected.shellActions) {
13082
- const labelPrefix = action.label ? `${action.label}: ` : '';
13083
- console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
13151
+ if (projected.reason === PATH_ACTION_REASON.WIN32_RESERVED_QUOTE) {
13152
+ // #3118 review MINOR: a win32 targetDir containing `"` makes
13153
+ // projectPathActionProjection return [] (no command can quote it safely
13154
+ // on Windows) — printing the "Add it with one of:" header with nothing
13155
+ // under it is a silent dead-end. Name the cause instead.
13156
+ console.log(` No command can be suggested: the path contains a ${cyan}"${reset} character, which cannot appear in a Windows path.`);
13157
+ } else if (projected.shellActions.length === 0) {
13158
+ // #3118: no target directory to talk about (reason === NO_TARGET_DIR, or
13159
+ // no reason at all) — there is nothing to print beyond the "not on your
13160
+ // PATH" line above.
13161
+ } else {
13162
+ console.log(` Add it with one of:`);
13163
+ for (const action of projected.shellActions) {
13164
+ const labelPrefix = action.label ? `${action.label}: ` : '';
13165
+ console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
13166
+ }
13084
13167
  }
13085
13168
  console.log('');
13086
13169
  }
@@ -13291,14 +13374,12 @@ module.exports = {
13291
13374
  shouldNormalizeHyphenNamespaceInAgentBody,
13292
13375
  normalizeAgentBodyForRuntime,
13293
13376
  yamlIdentifier,
13294
- computePathPrefix,
13295
- applyRuntimeContentRewritesInPlace,
13296
13377
  getCodexSkillAdapterHeader,
13297
13378
  convertClaudeCommandToCursorSkill,
13298
- convertClaudeCommandToCursorCommand,
13299
13379
  convertClaudeAgentToCursorAgent,
13300
13380
  convertClaudeAgentToCodexAgent,
13301
13381
  generateCodexAgentToml,
13382
+ _resetCodexWarningDedupeForTests,
13302
13383
  cleanupCodexSkillMetadataSidecars,
13303
13384
  cleanupWindsurfLegacyDevinSkills,
13304
13385
  cleanupMovedSkillsOldLocation,
@@ -13306,7 +13387,6 @@ module.exports = {
13306
13387
  _resolveSkillsRootDir,
13307
13388
  codexBareAgentsHasOnlyKnownScalars,
13308
13389
  extractCodexUserAgentsScalars,
13309
- spliceCodexAgentsScalars,
13310
13390
  CODEX_EXTENDED_HOOK_EVENTS,
13311
13391
  generateCodexConfigBlock,
13312
13392
  stripGsdFromCodexConfig,
@@ -13317,19 +13397,19 @@ module.exports = {
13317
13397
  validateCodexConfigSchema,
13318
13398
  mergeCodexConfig,
13319
13399
  installCodexConfig,
13320
- readGsdRuntimeProfileResolver,
13321
- readGsdEffectiveModelOverrides,
13322
- readGsdEffectiveEffortConfig,
13323
- resolveInstallTimeEffort,
13324
- injectEffortFrontmatter,
13325
- get _GSD_EFFORT_MANIFEST_TIER_DEFAULTS() { return _getGsdEffortCatalog().EFFORT_MANIFEST_TIER_DEFAULTS; },
13326
- get _GSD_EFFORT_MANIFEST_DEFAULT() { return _getGsdEffortCatalog().EFFORT_MANIFEST_DEFAULT; },
13327
13400
  install,
13328
13401
  installAllRuntimes,
13329
13402
  uninstall,
13330
13403
  // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
13331
13404
  _resolveHostBehaviors,
13332
13405
  FALLBACK_HOST_BEHAVIORS,
13406
+ // #3023 — shared hook bundle directory name, descriptor-driven
13407
+ SHARED_HOOKS_DIR_DEFAULT,
13408
+ resolveSharedHooksDirName,
13409
+ // #3184 — uninstall-side GSD-managed file enumerations, exported for
13410
+ // parity assertions against the wholesale-copy source directories
13411
+ GSD_CHANGESET_FILES,
13412
+ GSD_SCRIPTS_LIB_FILES,
13333
13413
  convertSlashCommandsToCodexSkillMentions,
13334
13414
  convertClaudeCommandToCodexSkill,
13335
13415
  convertClaudeCommandToKimiSkill,
@@ -13338,8 +13418,6 @@ module.exports = {
13338
13418
  buildKimiAgentArtifacts,
13339
13419
  convertClaudeToOpencodeFrontmatter,
13340
13420
  convertClaudeToKiloFrontmatter,
13341
- convertClaudeCommandToOpencodeSkill,
13342
- convertClaudeCommandToKiloSkill,
13343
13421
  configureOpencodePermissions,
13344
13422
  neutralizeAgentReferences,
13345
13423
  // #768 — Claude Code permissions pre-population
@@ -13349,7 +13427,6 @@ module.exports = {
13349
13427
  GSD_CLAUDE_DENY_PERMISSIONS,
13350
13428
  GSD_CODEX_MARKER,
13351
13429
  CODEX_AGENT_SANDBOX,
13352
- getDirName,
13353
13430
  getGlobalDir,
13354
13431
  getConfigDirFromHome,
13355
13432
  resolveKiloConfigPath,
@@ -13371,20 +13448,11 @@ module.exports = {
13371
13448
  mergeCopilotInstructions,
13372
13449
  stripGsdFromCopilotInstructions,
13373
13450
  GSD_COPILOT_HOOK_FILE,
13374
- buildCopilotHookConfig,
13375
- writeCopilotHookConfig,
13376
13451
  convertClaudeToAntigravityContent,
13377
13452
  convertClaudeCommandToAntigravitySkill,
13378
13453
  convertClaudeAgentToAntigravityAgent,
13379
13454
  convertClaudeCommandToClaudeSkill,
13380
13455
  skillFrontmatterName,
13381
- convertClaudeToWindsurfMarkdown,
13382
- convertClaudeCommandToWindsurfSkill,
13383
- convertClaudeCommandToWindsurfWorkflow,
13384
- convertClaudeAgentToWindsurfAgent,
13385
- convertClaudeToAugmentMarkdown,
13386
- convertClaudeCommandToAugmentSkill,
13387
- convertClaudeAgentToAugmentAgent,
13388
13456
  convertClaudeToTraeMarkdown,
13389
13457
  convertClaudeCommandToTraeSkill,
13390
13458
  convertClaudeAgentToTraeAgent,
@@ -13395,8 +13463,6 @@ module.exports = {
13395
13463
  convertClaudeToCliineMarkdown,
13396
13464
  convertClaudeCommandToClineSkill,
13397
13465
  convertClaudeAgentToClineAgent,
13398
- // #2284(b) — cross-cutting branding protected-region helper
13399
- applyClaudeCodeBrandSwap,
13400
13466
  // #2284 — Hermes named-dispatch → delegate_task projection
13401
13467
  convertClaudeToHermesMarkdown,
13402
13468
  projectNamedDispatchToStructuralDelegate,
@@ -13406,30 +13472,11 @@ module.exports = {
13406
13472
  maskStringLiterals,
13407
13473
  findDispatchCallSpans,
13408
13474
  _assertProjectionComplete,
13409
- _normalizeDispatchCallSpan,
13410
- buildClineRulesBody,
13411
- buildClineAgentsMdBody,
13412
- buildClinePreToolUseHook,
13413
- writeClineArtifacts,
13414
- mergeGsdAgentsMd,
13415
13475
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
13416
13476
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
13417
- GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
13418
- GSD_CURSOR_STOP_HOOK_SCRIPT,
13419
- GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
13420
- GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
13421
- GSD_CURSOR_HOOK_SCRIPTS,
13422
13477
  GSD_CURSOR_HOOK_MARKER,
13423
- buildCursorHookEntry,
13424
- isManagedCursorHookEntry,
13425
- reconcileCursorHooksJson,
13426
- writeCursorHooksJson,
13427
- removeCursorHooksJson,
13428
13478
  GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
13429
13479
  GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
13430
- GSD_WINDSURF_HOOK_SCRIPTS,
13431
- writeWindsurfHooksJson,
13432
- removeWindsurfHooksJson,
13433
13480
  stripGsdFromAgentsMd,
13434
13481
  GSD_AGENTS_MD_MARKER,
13435
13482
  GSD_AGENTS_MD_CLOSE_MARKER,
@@ -13437,11 +13484,9 @@ module.exports = {
13437
13484
  saveLocalPatches,
13438
13485
  reportLocalPatches,
13439
13486
  validateHookFields,
13440
- preserveUserArtifacts,
13441
- restoreUserArtifacts,
13442
- migrateLegacyDevPreferencesToSkill,
13443
13487
  populatePristineDir,
13444
- USER_OWNED_ARTIFACTS,
13488
+ _resolveUserArtifactStagingRoot,
13489
+ _tryResolveUserArtifactStagingRoot,
13445
13490
  finishInstall,
13446
13491
  homePathCoveredByRc,
13447
13492
  homePathCoveredByFishConfig,
@@ -13456,34 +13501,11 @@ module.exports = {
13456
13501
  buildUpdateBannerPromptText,
13457
13502
  parseUpdateBannerInput,
13458
13503
  buildUpdateBannerHookEntry,
13459
- buildHookCommand,
13460
- normalizeNodePath,
13461
- resolveNodeRunner,
13462
- referencesHook,
13463
- applySettingsJsonHooks,
13464
- rewriteLegacyManagedNodeHookCommands,
13465
- buildCodexHookBlock,
13466
- rewriteLegacyCodexHookBlock,
13467
- buildCodexHookWindowsShimIR,
13468
- ensureCodexHooksJsonSessionStart,
13469
- ensureCodexHooksJsonEvent,
13470
- removeCodexHooksJsonEvent,
13471
- reconcileCodexHooksJsonEvent,
13472
- readGsdCommandNames,
13473
- installRuntimeArtifacts,
13474
- installOpencodeFamilySkills,
13475
- uninstallRuntimeArtifacts,
13476
13504
  parseConfigDirFromArgs,
13477
13505
  cleanupLegacyGsdCc,
13478
- _applyRuntimeRewrites,
13479
13506
  // #1191 — exported so tests exercise the REAL readSettings, not a replica
13480
13507
  readSettings,
13481
13508
  stripJsonComments,
13482
- // Compatibility relays retained after auditing the former broad
13483
- // runtimeArtifactConversion spread (#1559).
13484
- processAttribution,
13485
- applyRuntimeContentRewritesForCommandsInPlace,
13486
- _copyStaged,
13487
13509
  copyWithPathReplacement,
13488
13510
  };
13489
13511
 
@@ -13511,7 +13533,19 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
13511
13533
  console.error('Usage: node install.js --skills-root <runtime>');
13512
13534
  process.exit(1);
13513
13535
  }
13514
- const skillsRoot = getGlobalSkillsBase(runtimeArg);
13536
+ // #3024: validate the runtime id against the shipped capability registry
13537
+ // BEFORE resolving anything. getGlobalSkillsBase's bare `runtimes[runtime]`
13538
+ // lookup falls through the prototype chain to claude's skills root for an
13539
+ // unregistered/hostile id (`__proto__`, `constructor`, `prototype`, …)
13540
+ // instead of failing loudly. isRegisteredRuntimeId is the SAME validator
13541
+ // gsd-tools' `routeSkillsRoot` calls, so this entry point and the shipped
13542
+ // `gsd-tools query skills-root` entry point can never diverge on which
13543
+ // runtime ids they accept.
13544
+ if (!isRegisteredRuntimeId(runtimeArg)) {
13545
+ console.error(`Unknown runtime "${runtimeArg}" — must be a registered runtime id`);
13546
+ process.exit(1);
13547
+ }
13548
+ const skillsRoot = getGlobalSkillsBase(runtimeArg.trim());
13515
13549
  if (skillsRoot === null) {
13516
13550
  console.error(`${runtimeArg} does not use a skills directory`);
13517
13551
  process.exit(1);