@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
package/bin/install.js CHANGED
@@ -36,10 +36,19 @@ const {
36
36
  resolveKimiHooksTomlDir,
37
37
  isRegisteredRuntimeId,
38
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');
44
+ const { isTestHomeGuardRefusal } = require('../gsd-core/bin/lib/real-home-guard.cjs');
39
45
  // getDirName (runtime -> local config dir name) is relocated out of this
40
46
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
41
47
  // conversion module's rewrite engine can consume it without importing
42
- // bin/install.js. Re-exported below for back-compat consumers/tests.
48
+ // bin/install.js. Imported here for install.js's own internal call sites
49
+ // (getConfigDirFromHome and the runtime-content-rewrite loops below) — #2876
50
+ // retired the re-export; tests now import getDirName directly from
51
+ // gsd-core/bin/lib/runtime-name-policy.cjs.
43
52
  const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
44
53
  const {
45
54
  applyWorktreeBaseRef,
@@ -54,6 +63,11 @@ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs'
54
63
  // MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
55
64
  const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
56
65
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
66
+ const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
67
+ // #2873: cross-scope shadow detection — reports (never fails) when a
68
+ // GSD-owned scope shadows another on this machine (design doc:
69
+ // .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md).
70
+ const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
57
71
  // #2544: the CommonJS marker's single source of truth. classifyMarker() backs
58
72
  // BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
59
73
  // so the write side can no longer clobber a package.json the remove side would
@@ -66,9 +80,14 @@ const { ensureCommonJsMarker, removeCommonJsMarker } = require('../gsd-core/bin/
66
80
  const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
67
81
  const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
68
82
 
69
- // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
70
- // bin/install.js re-exports everything from hooksSurface so existing callers
71
- // (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
83
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated
84
+ // module. install.js used to re-export the whole hooksSurface surface so
85
+ // existing callers (require('../bin/install.js').writeCursorHooksJson etc.)
86
+ // kept working — #2876 found zero production/test consumers of any of those
87
+ // re-exports (tests import runtime-hooks-surface.cjs directly) and retired
88
+ // them from module.exports. install.js still requires hooksSurface below for
89
+ // its own internal call sites (writeCursorHooksJson, writeClineArtifacts,
90
+ // resolveNodeRunner, applySettingsJsonHooks, etc.).
72
91
  const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
73
92
 
74
93
  /**
@@ -296,13 +315,26 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
296
315
  // subagentStart → gsd-cursor-subagent-start.js (subagent context injection)
297
316
  // subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder)
298
317
  // Cursor docs: https://cursor.com/docs/hooks
299
- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
300
- const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
301
- const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
302
- const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
303
- const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
304
- const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
305
- // All GSD-managed Cursor hook scripts (used by uninstall cleanup).
318
+ //
319
+ // These script-name/marker constants used to be independently re-declared
320
+ // here with their own string literals — a second, unlinked copy of exactly
321
+ // the values runtime-hooks-surface.cts also defines for its own internal use
322
+ // (buildCursorHookEntry, writeCursorHooksJson, etc.). #2876's code review
323
+ // found tests reading the constant from install.js's copy while calling
324
+ // functions built from hooksSurface's copy, with nothing guarding the two
325
+ // staying equal — the same unlinked-duplicate-value hazard the ADR-1508
326
+ // dedup elsewhere in this file exists to prevent. Fixed at the root: these
327
+ // are now bare references to hooksSurface's own exports, so there is exactly
328
+ // one literal definition of each value, full stop.
329
+ const GSD_CURSOR_SESSION_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SESSION_HOOK_SCRIPT;
330
+ const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_POST_TOOL_HOOK_SCRIPT;
331
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT;
332
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_STOP_HOOK_SCRIPT;
333
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT;
334
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT;
335
+ // All GSD-managed Cursor hook scripts (used by uninstall cleanup). Not
336
+ // independently defined in hooksSurface — built here from the bare
337
+ // references above, so it can never drift from them either.
306
338
  const GSD_CURSOR_HOOK_SCRIPTS = [
307
339
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
308
340
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
@@ -312,7 +344,7 @@ const GSD_CURSOR_HOOK_SCRIPTS = [
312
344
  GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
313
345
  ];
314
346
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
315
- const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
347
+ const GSD_CURSOR_HOOK_MARKER = hooksSurface.GSD_CURSOR_HOOK_MARKER;
316
348
 
317
349
  // #2100 Stage 2 — Windsurf/Cascade lifecycle hook constants.
318
350
  // Windsurf/Cascade reads hook configs from <project-root>/.windsurf/hooks.json
@@ -328,15 +360,17 @@ const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
328
360
  // have no Windsurf counterpart and are deliberately NOT ported.
329
361
  // Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
330
362
  // https://docs.devin.ai/desktop/cascade/hooks
331
- const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
332
- const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
363
+ //
364
+ // Same #2876 fix as the Cursor block above: bare references to hooksSurface's
365
+ // own exports instead of a second, unlinked literal copy.
366
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT;
367
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT;
333
368
  // All GSD-managed Windsurf hook scripts (used by uninstall cleanup).
334
- const GSD_WINDSURF_HOOK_SCRIPTS = [
335
- GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
336
- GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
337
- ];
369
+ // hooksSurface independently defines the same array — bare reference here so
370
+ // the two can never drift.
371
+ const GSD_WINDSURF_HOOK_SCRIPTS = hooksSurface.GSD_WINDSURF_HOOK_SCRIPTS;
338
372
 
339
- // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
373
+ // GSD-managed files under hooks/lib/ (helpers required by gsd-*.js hooks).
340
374
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
341
375
  // cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
342
376
  // are staged individually by writeCursorHooksJson (Cursor sets
@@ -344,7 +378,10 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
344
378
  // copy below) — that function stages this helper alongside them. Listing it
345
379
  // here keeps uninstall and the manifest managing it for every OTHER runtime
346
380
  // that does receive hooks/lib.
347
- const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
381
+ // injection-patterns.js (#3504) is required by gsd-prompt-guard.js and
382
+ // gsd-read-injection-scanner.js — the shared prompt-injection pattern list the
383
+ // two guards require so their copies cannot drift.
384
+ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
348
385
 
349
386
  /**
350
387
  * Directory name GSD stages its shared hook bundle under, inside a runtime's
@@ -361,6 +398,21 @@ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-wor
361
398
  */
362
399
  const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
363
400
 
401
+ // #3184 — GSD-managed file enumerations for scripts/changeset/ and scripts/lib/
402
+ // uninstall. The install-side copy of both directories is wholesale ("copy every
403
+ // file present"), so these enumerations MUST be kept in parity with the real
404
+ // directory contents or an added file ships on install and then orphans on
405
+ // uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
406
+ // Hoisted to module scope (and exported below) so tests/install.test.cjs can
407
+ // assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
408
+ // without source-grepping this file.
409
+ const GSD_CHANGESET_FILES = [
410
+ 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
411
+ 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
412
+ 'README.md', // documentation only — not user-authored
413
+ ];
414
+ const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs', 'exit-code-registry.cjs', 'ndjson-reporter.cjs', 'ci-job-timing.cjs'];
415
+
364
416
  /**
365
417
  * Resolve a runtime's shared-hooks directory name from its descriptor.
366
418
  *
@@ -408,19 +460,32 @@ function resolveSharedHooksDirName(runtime) {
408
460
  return name;
409
461
  }
410
462
 
411
- const CODEX_AGENT_SANDBOX = {
412
- 'gsd-executor': 'workspace-write',
413
- 'gsd-planner': 'workspace-write',
414
- 'gsd-phase-researcher': 'workspace-write',
415
- 'gsd-project-researcher': 'workspace-write',
416
- 'gsd-research-synthesizer': 'workspace-write',
417
- 'gsd-verifier': 'workspace-write',
418
- 'gsd-codebase-mapper': 'workspace-write',
419
- 'gsd-roadmapper': 'workspace-write',
420
- 'gsd-debugger': 'workspace-write',
421
- 'gsd-plan-checker': 'read-only',
422
- 'gsd-integration-checker': 'read-only',
423
- };
463
+ // #3897 rung 3 — sandbox_mode derivation, the hold list, and the hold-roster
464
+ // validator now live in `src/codex-agent-toml.cts` (compiled to
465
+ // `gsd-core/bin/lib/codex-agent-toml.cjs`), NOT here. This module used to be
466
+ // the sole owner, and `agent-install-check.cts`'s `checkCodexSandboxPosture`
467
+ // lazily `require()`d THIS FILE to reach `deriveCodexSandboxMode` — but
468
+ // requiring `bin/install.js` runs its whole top-level script, including the
469
+ // CLI's ASCII banner print to stdout, which corrupted every stdout-JSON
470
+ // caller downstream of that posture check (`gsd-tools validate agents`).
471
+ // `codex-agent-toml.cjs` is a genuine leaf (no top-level side effects), so
472
+ // both this file and `agent-install-check.cts` import the derivation from
473
+ // there — ONE owner, no second predicate. See that module's header for the
474
+ // full rationale, and CAUSE B (below, `installCodexConfig`) for the removal
475
+ // of `validateCodexSandboxHolds`'s call from the install runtime path.
476
+ const {
477
+ CODEX_SANDBOX_HOLDS,
478
+ deriveCodexSandboxMode,
479
+ validateCodexSandboxHolds,
480
+ // #3897 list-form parse fix, Fix 3 (generative-fix-divergence): this
481
+ // file's own `generateCodexAgentToml` used to pull `tools:` via its
482
+ // private `extractFrontmatterField` (single-line only) instead of this
483
+ // shared reader — the two sandbox-feeding paths (this emitter and
484
+ // `agent-install-check.cts`'s `checkCodexSandboxPosture`) silently
485
+ // disagreed on YAML block-list `tools:` form. Both now route through this
486
+ // ONE extractor.
487
+ extractToolsValue,
488
+ } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'codex-agent-toml.cjs'));
424
489
 
425
490
  // Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
426
491
  // Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
@@ -450,14 +515,13 @@ const pkg = require('../package.json');
450
515
  // of cwd, but keeping the require at the top makes the dependency explicit and
451
516
  // surfaces resolution failures at process start instead of at first install call.
452
517
  const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
453
- const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
454
518
  const {
455
519
  RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
520
+ isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel,
456
521
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
457
- const {
458
- resolveTierEntry: gsdResolveTierEntry,
459
- CLAUDE_AGENT_ALIASES,
460
- } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
522
+ // #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
523
+ // install-model-override-resolver.cjs's readGsdRuntimeProfileResolver
524
+ // (required below) — this installer no longer needs its own bindings.
461
525
 
462
526
  // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
463
527
  // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile
@@ -530,6 +594,17 @@ try {
530
594
  // hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
531
595
  // The live descriptor (capabilities/claude/capability.json) remains the source of
532
596
  // truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
597
+ //
598
+ // #2870: NOT routed through the Install Scope Module (resolveScope,
599
+ // src/install-scope.cts) despite that module owning per-scope settings-file
600
+ // resolution elsewhere in this file. resolveScope's own descriptor lookup
601
+ // goes through the SAME capability registry require this floor exists to
602
+ // survive the failure of (see getRegistry() in install-scope.cts) — so on
603
+ // exactly the "registry failed to load" path this constant is for,
604
+ // resolveScope would throw too. Routing through it here would trade a
605
+ // graceful, documented degrade for a crash in the one case this floor was
606
+ // added to prevent. This hardcoded literal is the correct, honest answer,
607
+ // not an un-migrated leftover.
533
608
  const FALLBACK_HOST_BEHAVIORS = Object.freeze({
534
609
  claude: Object.freeze({
535
610
  settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
@@ -591,6 +666,84 @@ function _hostIntegrationDispatch(runtime) {
591
666
  return dispatch || {};
592
667
  }
593
668
 
669
+ /**
670
+ * #2870: shared install()/uninstall() scope resolution. Routes `id` through
671
+ * the Install Scope Module (src/install-scope.cts) and degrades to `null` on
672
+ * failure (unknown/non-installable runtime, broken registry bundle) instead
673
+ * of throwing, so each call site's own plain-id fallback keeps working
674
+ * exactly as it did before this migration. Both call sites previously carried
675
+ * their own copy of this try/catch; this is the one shared copy.
676
+ */
677
+ function _resolveScopeSafe(id, runtime) {
678
+ try {
679
+ return resolveScope({ id, runtime });
680
+ } catch (_) {
681
+ return null;
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Resolve a layout kind's on-disk destination directory. Shared by the
687
+ * skills-root resolution above and the #3664 warning below so the
688
+ * home-override + destSubpath join exists once (#3659-class re-encoding guard).
689
+ */
690
+ function _kindDestDir(layout, kindName, targetDir) {
691
+ const kind = layout.kinds.find((k) => k.kind === kindName);
692
+ if (!kind) return null;
693
+ return path.join(kind.home || targetDir, kind.destSubpath);
694
+ }
695
+
696
+ /**
697
+ * #3738: scope-aware, layout-resolving wrapper over _kindDestDir for callers
698
+ * that have (runtime, configDir, scope) rather than a resolved Layout — the
699
+ * writeManifest agents surface being the first. Never throws: a runtime whose
700
+ * layout cannot be resolved (unknown id, descriptor error) keeps the caller's
701
+ * own fallback rather than losing the manifest.
702
+ */
703
+ function _kindDestDirSafe(runtime, configDir, scope, kindName) {
704
+ try {
705
+ return _kindDestDir(resolveRuntimeArtifactLayout(runtime, configDir, scope), kindName, configDir);
706
+ } catch {
707
+ return null;
708
+ }
709
+ }
710
+
711
+ /**
712
+ * #3664 — warn (never refuse) when `--config-dir` points the install at a
713
+ * directory whose agent destination already holds FOREIGN (non-GSD) agent
714
+ * files — the fingerprint of another harness's config home (e.g. ~/.junie,
715
+ * ~/.factory) or a hand-curated agents dir. GSD emits the selected runtime's
716
+ * artifacts verbatim: tool IDs (`Skill`) and MCP grants (`mcp__server__tool`)
717
+ * that are inert or invalid in a foreign harness surface only at dispatch
718
+ * time, months later. Warn-and-proceed is the issue-sanctioned option (b):
719
+ * a fresh custom dir (the documented brand-specific-dir use), a gsd-only dir
720
+ * (updates, the --all shared dir — including kimi's root `gsd.md`, which is
721
+ * GSD-owned despite the bare `gsd` stem), and the no-flag default-home path
722
+ * (users keep personal agents in ~/.claude/agents) all stay silent. Degrades
723
+ * silently on any resolution failure — the warn path never blocks install.
724
+ */
725
+ function warnIfForeignAgentDest(runtime, targetDir, scope, explicitConfigDir) {
726
+ if (explicitConfigDir !== true) return;
727
+ try {
728
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
729
+ // kimi's global agents kind is `kimi-agents` (#2095 EoS), every other
730
+ // runtime's is `agents`.
731
+ const agentsDir = _kindDestDir(layout, 'agents', targetDir)
732
+ || _kindDestDir(layout, 'kimi-agents', targetDir);
733
+ if (!agentsDir) return;
734
+ if (!fs.existsSync(agentsDir) || !fs.statSync(agentsDir).isDirectory()) return;
735
+ const foreign = fs
736
+ .readdirSync(agentsDir)
737
+ .filter((f) => f.endsWith('.md') && !f.startsWith('gsd-') && f !== 'gsd.md');
738
+ if (foreign.length === 0) return;
739
+ console.log(
740
+ ` ${yellow}⚠${reset} ${bold}${targetDir}${reset} already contains ${foreign.length} non-GSD agent file(s) — this may be another harness's config home. GSD emits artifacts shaped for ${runtime}: tool IDs and MCP grants may be inert or invalid for whatever harness reads this directory (#3664).`
741
+ );
742
+ } catch (_) {
743
+ /* never block install on the warning path */
744
+ }
745
+ }
746
+
594
747
  /**
595
748
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
596
749
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -601,8 +754,8 @@ function _hostIntegrationDispatch(runtime) {
601
754
  function _resolveSkillsRootDir(runtime, targetDir, scope) {
602
755
  try {
603
756
  const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
604
- const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
605
- if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath);
757
+ const skillsDir = _kindDestDir(layout, 'skills', targetDir);
758
+ if (skillsDir) return skillsDir;
606
759
  } catch (_e) { /* fall through to the configDir default */ }
607
760
  return path.join(targetDir, 'skills');
608
761
  }
@@ -623,8 +776,10 @@ function _runtimeAdapter(runtime) {
623
776
  }
624
777
  }
625
778
  const {
779
+ acquireInstallMigrationLock,
626
780
  applyInstallerMigrationPlan,
627
781
  discoverInstallerMigrations,
782
+ MANIFEST_SCHEMA_VERSION,
628
783
  runInstallerMigrations,
629
784
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
630
785
  const {
@@ -653,29 +808,78 @@ const {
653
808
  // getCommitAttribution STAYS here (impure install-time config I/O); it is injected
654
809
  // into the engine functions via the resolveAttribution parameter at each call site.
655
810
  const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
811
+ // #2876: _copyStaged, convertClaudeCommandToOpencodeSkill, and
812
+ // convertClaudeCommandToKiloSkill used to be destructured here too — all
813
+ // three had no install.js internal caller and no export consumer (tests
814
+ // import all three directly from gsd-core/bin/lib/install-engine.cjs), so
815
+ // the retired bindings were dead code. applyOpencodeFamilyPathPrefix,
816
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup,
817
+ // _removeGsdEntries, _restoreDir, and _removeHermesBareStemDirs were the
818
+ // same — unused local bindings that were never part of this module's export
819
+ // surface either — found and retired in the same sweep.
656
820
  const {
657
821
  installRuntimeArtifacts,
658
822
  uninstallRuntimeArtifacts,
659
823
  installOpencodeFamilySkills,
824
+ installAgentsKindStandalone,
660
825
  _installNativePluginIfDeclared,
661
- _copyStaged,
662
826
  hasExistingSymlinkBetween,
663
827
  isSymlinkedDestOptIn,
664
- preserveUserArtifacts,
665
- restoreUserArtifacts,
666
828
  migrateLegacyDevPreferencesToSkill,
667
- applyOpencodeFamilyPathPrefix,
668
- convertClaudeCommandToOpencodeSkill,
669
- convertClaudeCommandToKiloSkill,
670
829
  USER_OWNED_ARTIFACTS,
671
- _runLegacyInstallMigrations,
672
- _runLegacyUninstallCleanup,
673
- _removeGsdEntries,
674
830
  _snapshotDir,
675
- _restoreDir,
676
- _removeHermesBareStemDirs,
677
831
  } = installEngine;
678
832
 
833
+ // #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
834
+ // across the preserve -> wipe -> restore window (#1874-F19). See
835
+ // src/user-artifact-staging.cts's module doc.
836
+ const {
837
+ stageUserArtifacts,
838
+ restoreStagedUserArtifacts,
839
+ discardStagedUserArtifacts,
840
+ recoverOrphanedUserArtifacts,
841
+ } = require(path.join(_gsdLibDir, 'user-artifact-staging.cjs'));
842
+
843
+ /**
844
+ * Resolve the durable staging root for `configDir`, confined via
845
+ * `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween`
846
+ * (test-matrix E1/E4) — mirrors install-engine.cts's
847
+ * `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's
848
+ * two call sites (uninstall's legacy-migration block, install's mainline
849
+ * gsd-core copy) are not inside that module.
850
+ */
851
+ function _resolveUserArtifactStagingRoot(configDir) {
852
+ const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.gsd-staging', 'user-artifacts'));
853
+ if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
854
+ throw new Error(
855
+ `_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.`,
856
+ );
857
+ }
858
+ return stagingRoot;
859
+ }
860
+
861
+ /**
862
+ * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors
863
+ * install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local
864
+ * here for the same reason the throwing version above is: bin/install.js's
865
+ * own call sites are not inside that module). A hostile/broken
866
+ * `.gsd-staging` path (or a symlinked configDir itself) must never brick
867
+ * `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot`
868
+ * was called UNGUARDED as the first statement of both, so
869
+ * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands, including
870
+ * uninstall, the remedy for the first problem. Returns `null` (never throws),
871
+ * logging one warning; every call site MUST treat `null` as "skip the
872
+ * staging-dependent step for this run".
873
+ */
874
+ function _tryResolveUserArtifactStagingRoot(configDir) {
875
+ try {
876
+ return _resolveUserArtifactStagingRoot(configDir);
877
+ } catch (err) {
878
+ console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
879
+ return null;
880
+ }
881
+ }
882
+
679
883
  // Parse args
680
884
  const args = process.argv.slice(2);
681
885
  const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -685,6 +889,17 @@ const hasSkillsRoot = args.includes('--skills-root');
685
889
  const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
686
890
  const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
687
891
  const hasDryRun = args.includes('--dry-run');
892
+ // #3031: opt-in reclaim of the GSD artifacts a PRE-#2755 `--kimi-code` install
893
+ // orphaned in Kimi CLI's `~/.kimi`. Opt-in and not automatic because the stale
894
+ // block is BYTE-IDENTICAL to a legitimate Kimi CLI one — both runtimes render
895
+ // the same bytes for the same root, since the command paths derive from the
896
+ // hooks root and not from the runtime — so no inspection can tell "litter GSD
897
+ // wrote for kimi-code" from "Kimi CLI's working hooks". Cleaning unasked would
898
+ // break #2755's own acceptance criterion ("Uninstalling GSD hooks for one
899
+ // runtime does not touch or remove the other runtime's hooks") for anyone with
900
+ // both products installed. The user, who knows which products they run, is the
901
+ // only party that can decide — so they ask for it explicitly.
902
+ const hasReclaimKimiLegacy = args.includes('--reclaim-kimi-legacy');
688
903
  // --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
689
904
  const _profileArgRaw = (() => {
690
905
  for (const arg of args) {
@@ -784,6 +999,21 @@ function disambiguateKimiVariant(runtimes) {
784
999
  return notices;
785
1000
  }
786
1001
 
1002
+ // #3031: `--reclaim-kimi-legacy` only ever acts inside the kimi-code GLOBAL
1003
+ // install branch. Say so when it cannot act, rather than exiting 0 having
1004
+ // silently done nothing: the user asked for a cleanup, and silence is
1005
+ // indistinguishable from "it ran and found nothing". Not a hard error — it
1006
+ // stays composable with `--all`, where it is legitimately inert for the other
1007
+ // seventeen runtimes.
1008
+ if (hasReclaimKimiLegacy && !selectedRuntimes.includes('kimi-code')) {
1009
+ console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — it applies only to a --kimi-code install; nothing in ~/.kimi was touched.`);
1010
+ } else if (hasReclaimKimiLegacy && hasLocal) {
1011
+ // Scope, checked HERE rather than inside install(): kimi-code declares
1012
+ // hostBehaviors.localInstallDeferred, so install() returns early long before
1013
+ // the kimi-hooks-toml branch — a warning placed there would be unreachable.
1014
+ console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — the legacy root is a global location; re-run with --global to reclaim it.`);
1015
+ }
1016
+
787
1017
  if (selectedRuntimes.includes('kimi') || selectedRuntimes.includes('kimi-code')) {
788
1018
  const kimiNotices = disambiguateKimiVariant(selectedRuntimes);
789
1019
  for (const notice of kimiNotices) {
@@ -861,7 +1091,8 @@ Then re-run: npx ${pkg.name}@latest
861
1091
  }
862
1092
 
863
1093
  // getDirName (runtime -> local config dir name) now lives in
864
- // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported + re-exported.
1094
+ // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported above for
1095
+ // install.js's own internal use only — #2876 retired the re-export.
865
1096
 
866
1097
  /**
867
1098
  * Get the config directory path relative to home directory for a runtime
@@ -947,6 +1178,14 @@ function parseConfigDirFromArgs(argsArray) {
947
1178
  return null;
948
1179
  }
949
1180
 
1181
+ // Parse --no-legacy-cleanup (#3799) — skip the legacy get-shit-done-cc scan
1182
+ // entirely. Some users run gsd-core alongside a live legacy install on
1183
+ // purpose; the scan is best-effbelt cleanup, never load-bearing for the
1184
+ // install itself.
1185
+ function parseNoLegacyCleanupArg(args = process.argv) {
1186
+ return args.includes('--no-legacy-cleanup');
1187
+ }
1188
+
950
1189
  // Parse --config-dir argument
951
1190
  function parseConfigDirArg() {
952
1191
  const result = parseConfigDirFromArgs(args);
@@ -977,23 +1216,30 @@ if (hasUninstall) {
977
1216
 
978
1217
  // Show help if requested
979
1218
  if (hasHelp) {
980
- 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`);
1219
+ 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}--kimi-code${reset} Install for Kimi Code 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}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\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 and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\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 Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\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 Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`);
981
1220
  process.exit(0);
982
1221
  }
983
1222
 
984
1223
  // computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix
985
- // (ADR-1508 / #1511 Phase 2 — single owner). The const binding above (~line 638)
986
- // re-exports it here for call sites and module.exports.
1224
+ // (ADR-1508 / #1511 Phase 2 — single owner). The const binding above re-binds
1225
+ // it here for install.js's own internal call sites only — #2876 retired the
1226
+ // module.exports entry (zero consumers found; tests import
1227
+ // runtimeArtifactConversion._computePathPrefix directly).
987
1228
  // Original doc: Compute the path prefix used for `@file` references in installed
988
1229
  // command/skill markdown. For global installs under $HOME uses $HOME/... form;
989
1230
  // OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux).
990
1231
 
991
- // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
992
- // now owned by the runtime-hooks-surface module. Import them here so
993
- // install.js callers continue to work and so there is a single implementation
994
- // of these helpers.
995
- const normalizeNodePath = hooksSurface.normalizeNodePath;
1232
+ // resolveNodeRunner, resolveBashRunner, referencesHook are now owned by the
1233
+ // runtime-hooks-surface module. Import them here so install.js callers
1234
+ // continue to work and so there is a single implementation of these helpers.
1235
+ // (normalizeNodePath was re-bound here too until #2876 found bin/install.js
1236
+ // had no internal caller and no export consumer for it — hooksSurface owns
1237
+ // the single implementation now, used internally by resolveNodeRunner there.)
996
1238
  const resolveNodeRunner = hooksSurface.resolveNodeRunner;
1239
+ // #3662: the runtime-resolving runner token for managed JS hooks — the baked
1240
+ // absolute node path tried FIRST, then `command -v node`, then well-known
1241
+ // layouts, resolved by the shell at hook-fire time instead of bake time.
1242
+ const buildNodeRunnerChainToken = hooksSurface.buildNodeRunnerChainToken;
997
1243
  const resolveBashRunner = hooksSurface.resolveBashRunner;
998
1244
  // referencesHook: pure predicate over hook entry objects, shared between
999
1245
  // install() and finishInstall() (ADR-857 phase 5f-1b).
@@ -1010,38 +1256,27 @@ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
1010
1256
  // callers continue to work and there is a single implementation. (All call
1011
1257
  // sites are below this line, so the const binding has no TDZ hazard.)
1012
1258
  const processAttribution = runtimeArtifactConversion.processAttribution;
1013
- // computePathPrefix / applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace:
1014
- // Single implementations now live in runtimeArtifactConversion (ADR-1508 / #1511 Phase 2).
1015
- // Re-bound here so install.js call sites and exports continue to work unchanged.
1016
- // Local bodies replaced by breadcrumb comments at their original locations.
1017
- // All call sites are below this line → no TDZ hazard.
1018
- const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
1019
- const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
1020
- const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
1021
- // #1675 (ADR-1508): the augment converter family is single-sourced in the
1022
- // conversion module. install.js re-binds (does not re-define) these so there
1023
- // is exactly one body — the generative-drift hazard the dedup removes. The two
1024
- // private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
1025
- // live only in the conversion module now; they are no longer duplicated here.
1259
+ // computePathPrefix: implementation lives in runtimeArtifactConversion
1260
+ // (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call
1261
+ // sites continue to work. #2876 retired the sibling
1262
+ // applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace
1263
+ // re-bindings that used to sit alongside it, and the entire #1675 Augment
1264
+ // converter family re-binding (convertClaudeToAugmentMarkdown /
1265
+ // convertClaudeCommandToAugmentSkill / convertClaudeAgentToAugmentAgent) that
1266
+ // used to follow — bin/install.js had no internal caller for any of them (the
1267
+ // descriptor pipeline in runtimeArtifactConversion calls them directly).
1026
1268
  // (All call sites are below this line → no TDZ hazard.)
1027
- const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
1028
- const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
1029
- const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
1269
+ const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
1030
1270
  // #2931 (ADR-1508): the windsurf converter family is single-sourced in the
1031
- // conversion module, same pattern as the #1675 Augment dedup above. install.js
1032
- // re-binds (does not re-define) these so there is exactly one body — the
1033
- // generative-drift hazard the dedup removes. The two private helpers
1034
- // (getWindsurfSkillAdapterHeader, convertSlashCommandsToWindsurfSkillMentions)
1035
- // live only in the conversion module now; they are no longer duplicated here.
1036
- // The reference-identity parity guard lives in
1037
- // tests/install-runtime-artifacts.test.cjs (single-owner reference-identity
1038
- // guard describe block), not tests/enh-1511-rewrite-engine-relocation.test.cjs
1039
- // as the Augment comment above stated — that reference was stale.
1271
+ // conversion module. install.js re-binds (does not re-define) the one member
1272
+ // it still calls internally so there is exactly one body — the
1273
+ // generative-drift hazard the dedup removes. #2876 retired the sibling
1274
+ // convertClaudeCommandToWindsurfSkill / convertClaudeCommandToWindsurfWorkflow /
1275
+ // convertClaudeAgentToWindsurfAgent re-bindings — bin/install.js had no
1276
+ // internal caller for any of them (the descriptor pipeline in
1277
+ // runtimeArtifactConversion calls them directly).
1040
1278
  // (All call sites are below this line → no TDZ hazard.)
1041
1279
  const convertClaudeToWindsurfMarkdown = runtimeArtifactConversion.convertClaudeToWindsurfMarkdown;
1042
- const convertClaudeCommandToWindsurfSkill = runtimeArtifactConversion.convertClaudeCommandToWindsurfSkill;
1043
- const convertClaudeCommandToWindsurfWorkflow = runtimeArtifactConversion.convertClaudeCommandToWindsurfWorkflow;
1044
- const convertClaudeAgentToWindsurfAgent = runtimeArtifactConversion.convertClaudeAgentToWindsurfAgent;
1045
1280
  // #2931 (ADR-1508): single-sourced in the conversion module — was a second,
1046
1281
  // unlinked verbatim copy here (used by the local Cursor/Trae/CodeBuddy/Cline
1047
1282
  // converters below), the exact drift class this PR exists to reduce. Verified
@@ -1057,116 +1292,14 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
1057
1292
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
1058
1293
  }
1059
1294
 
1060
- /**
1061
- * Build the GSD-managed Codex SessionStart hook block for config.toml.
1062
- *
1063
- * Issue #3017: the previous shape inlined `command = "node ${path}"` which
1064
- * fails under GUI/minimal-PATH runtimes where bare `node` doesn't resolve
1065
- * (same failure mode as #2979 → fixed for settings.json by #3002, this
1066
- * helper closes the gap for Codex's TOML hook surface).
1067
- *
1068
- * Returns null when `absoluteRunner` is null so callers can warn-and-skip
1069
- * registration — emitting a broken bare-node hook is strictly worse than
1070
- * not registering one (the user can re-run install once node is on PATH).
1071
- *
1072
- * @param {string} targetDir - Resolved absolute Codex config dir (e.g. ~/.codex).
1073
- * @param {{ absoluteRunner: string|null, eol?: string }} opts
1074
- * absoluteRunner: result of resolveNodeRunner() — a JSON-stringified
1075
- * absolute node path with forward slashes (e.g. `"/usr/local/bin/node"`),
1076
- * or null when process.execPath was unavailable.
1077
- * eol: line ending to emit ('\n' or '\r\n') — caller passes
1078
- * detectLineEnding(configContent) so existing CRLF files stay CRLF.
1079
- * Defaults to '\n'.
1080
- * @returns {string|null} The toml block to append, or null on missing runner.
1081
- */
1082
- function buildCodexHookBlock(targetDir, opts) {
1083
- return hooksSurface.buildCodexHookBlock(targetDir, opts);
1084
- }
1085
-
1086
- /**
1087
- * Rewrite legacy bare-`node` managed-hook command lines in a Codex
1088
- * config.toml string to use the absolute Node runner. Mirror of
1089
- * rewriteLegacyManagedNodeHookCommands but for the toml surface (#3017).
1090
- *
1091
- * Only rewrites entries whose script basename matches CODEX_MANAGED_HOOK_BASENAMES
1092
- * (basename equality, not substring containment) — user-authored bare-node
1093
- * hooks pointing at scripts outside the managed allowlist are left alone.
1094
- *
1095
- * @param {string} content - Current config.toml contents.
1096
- * @param {string|null} absoluteRunner - Result of resolveNodeRunner().
1097
- * @returns {{ content: string, changed: boolean }}
1098
- */
1099
- function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
1100
- return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
1101
- }
1102
-
1103
- /**
1104
- * Generic reconcile helper: ensure hooks.json contains exactly one managed GSD
1105
- * hook entry for `eventName`, while preserving all user-owned entries.
1106
- *
1107
- * Supports both known hooks.json shapes:
1108
- * 1) { "<EventName>": [...] }
1109
- * 2) { "hooks": { "<EventName>": [...] } }
1110
- *
1111
- * @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.codex).
1112
- * @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop').
1113
- * @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts
1114
- * managedCommand: POSIX hook command string to register, or null to remove.
1115
- * commandWindows: Windows .cmd shim path to emit as `commandWindows` field
1116
- * (#772). When provided, Codex uses this path on Windows and `managedCommand`
1117
- * on POSIX without needing per-platform config regeneration.
1118
- * matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write').
1119
- * timeout: optional timeout in seconds.
1120
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
1121
- */
1122
- function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1123
- return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
1124
- }
1125
-
1126
- /**
1127
- * Reconcile the GSD-managed SessionStart hook entry in hooks.json.
1128
- * Delegates to the generic reconcileCodexHooksJsonEvent helper.
1129
- *
1130
- * @param {string} targetDir
1131
- * @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts
1132
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
1133
- */
1134
- function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1135
- return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
1136
- }
1137
-
1138
- /**
1139
- * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
1140
- *
1141
- * On Windows, Codex runs hook commands from a PowerShell/cmd execution
1142
- * environment. The previous command format was:
1143
- *
1144
- * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
1145
- *
1146
- * This caused `bash.exe: bash.exe: cannot execute binary file` because
1147
- * Codex's hook dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe
1148
- * (a Windows PE binary) via execvp(), which fails with ENOEXEC on Windows PE
1149
- * binaries that the MSYS layer doesn't know how to fork-exec natively.
1150
- *
1151
- * Fix: write a .cmd shim (using the same CRLF .cmd shim pattern) whose
1152
- * content is `@ECHO OFF / @SETLOCAL / @"node.exe" "script.js" %*`.
1153
- * cmd.exe executes
1154
- * .cmd natively via CreateProcess — no POSIX exec layer, no MSYS shebang
1155
- * walk, no PE binary fork-exec failure.
1156
- *
1157
- * Returns the typed IR `{ invocation, cmdPath, hookCommand, render }` so
1158
- * callers can assert on the structured shape (CONTRIBUTING.md L558–L565
1159
- * IR-first discipline). Returns null when absoluteRunnerToken is null so
1160
- * callers can warn-and-skip instead of writing a broken hook.
1161
- *
1162
- * @param {string} scriptAbsPath - Absolute path to the .js hook script.
1163
- * @param {string|null} absoluteRunnerToken - JSON-quoted absolute node path
1164
- * (result of resolveNodeRunner()), e.g. `"C:/Program Files/nodejs/node.exe"`.
1165
- * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
1166
- */
1167
- function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1168
- return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
1169
- }
1295
+ // #2876: reconcileManagedShellHookCommands, buildCodexHookBlock,
1296
+ // rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, and
1297
+ // reconcileCodexHooksJsonSessionStart used to be re-bound here as one-line
1298
+ // delegates to the equivalent hooksSurface.* implementations. None had an
1299
+ // install.js internal caller or an export consumer (tests import all five
1300
+ // directly from gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired
1301
+ // bindings were dead code with no reachable body — removed rather than kept
1302
+ // as unreachable wrappers.
1170
1303
 
1171
1304
  /**
1172
1305
  * Ensure Codex hooks.json contains exactly one managed SessionStart
@@ -1359,281 +1492,44 @@ function readSettings(settingsPath) {
1359
1492
  }
1360
1493
 
1361
1494
  /**
1362
- * Write settings.json with proper formatting
1363
- */
1364
- function writeSettings(settingsPath, settings) {
1365
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1366
- }
1367
-
1368
- /**
1369
- * Read model_overrides from ~/.gsd/defaults.json at install time.
1370
- * Returns an object mapping agent names to model IDs, or null if the file
1371
- * doesn't exist or has no model_overrides entry.
1372
- * Used by Codex TOML and OpenCode agent file generators to embed per-agent
1373
- * model assignments so that model_overrides is respected on non-Claude runtimes (#2256).
1374
- */
1375
- function readGsdGlobalModelOverrides(options = {}) {
1376
- try {
1377
- const home = options.homedir ? options.homedir() : os.homedir();
1378
- const defaultsPath = path.join(home, '.gsd', 'defaults.json');
1379
- if (!fs.existsSync(defaultsPath)) return null;
1380
- const raw = fs.readFileSync(defaultsPath, 'utf-8');
1381
- const parsed = JSON.parse(raw);
1382
- const overrides = parsed.model_overrides;
1383
- if (!overrides || typeof overrides !== 'object') return null;
1384
- return overrides;
1385
- } catch {
1386
- return null;
1387
- }
1388
- }
1389
-
1390
- /**
1391
- * Effective per-agent model_overrides for the Codex / OpenCode install paths.
1392
- *
1393
- * Merges `~/.gsd/defaults.json` (global) with per-project
1394
- * `<project>/.planning/config.json`. Per-project keys win on conflict so a
1395
- * user can tune a single agent's model in one repo without re-setting the
1396
- * global defaults for every other repo. Non-conflicting keys from both
1397
- * sources are preserved.
1398
- *
1399
- * This is the fix for #2256: both adapters previously read only the global
1400
- * file, so a per-project `model_overrides` (the common case the reporter
1401
- * described — a per-project override for `gsd-codebase-mapper` in
1402
- * `.planning/config.json`) was silently dropped and child agents inherited
1403
- * the session default.
1404
- *
1405
- * `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for
1406
- * a global install, or `<project>/.codex` for a local install). We walk up
1407
- * from there looking for `.planning/` so both cases resolve the correct
1408
- * project root. When `targetDir` is null/undefined only the global file is
1409
- * consulted (matches prior behavior for code paths that have no project
1410
- * context).
1411
- *
1412
- * Returns a plain `{ agentName: modelId }` object, or `null` when neither
1413
- * source defines `model_overrides`.
1414
- */
1415
- function readGsdEffectiveModelOverrides(targetDir = null, options = {}) {
1416
- const global = readGsdGlobalModelOverrides(options);
1417
-
1418
- let projectOverrides = null;
1419
- if (targetDir) {
1420
- let probeDir = path.resolve(targetDir);
1421
- for (let depth = 0; depth < 8; depth += 1) {
1422
- const candidate = path.join(probeDir, '.planning', 'config.json');
1423
- if (fs.existsSync(candidate)) {
1424
- try {
1425
- const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
1426
- if (parsed && typeof parsed === 'object' && parsed.model_overrides
1427
- && typeof parsed.model_overrides === 'object') {
1428
- projectOverrides = parsed.model_overrides;
1429
- }
1430
- } catch {
1431
- // Malformed config.json — fall back to global; readGsdRuntimeProfileResolver
1432
- // surfaces a parse warning via _readGsdConfigFile already.
1433
- }
1434
- break;
1435
- }
1436
- const parent = path.dirname(probeDir);
1437
- if (parent === probeDir) break;
1438
- probeDir = parent;
1439
- }
1440
- }
1441
-
1442
- if (!global && !projectOverrides) return null;
1443
- // Per-project wins on conflict; preserve non-conflicting global keys.
1444
- return { ...(global || {}), ...(projectOverrides || {}) };
1445
- }
1446
-
1447
- /**
1448
- * #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
1449
- * file in a newline-agnostic way (LF and CRLF source files are both handled).
1450
- *
1451
- * The function:
1452
- * - Detects the file's EOL (CRLF if the first `---` line ends with \r\n,
1453
- * otherwise LF).
1454
- * - Skips injection if an `effort:` key already exists in the frontmatter
1455
- * (idempotent).
1456
- * - Inserts `effort: <value>` immediately before the closing `---` delimiter,
1457
- * using the same EOL as the surrounding frontmatter so the output file
1458
- * stays EOL-consistent.
1459
- * - Returns the original content unchanged when no YAML frontmatter is found.
1460
- *
1461
- * @param {string} content Raw file content (may have LF or CRLF endings).
1462
- * @param {string} effortValue Rendered effort string, e.g. "xhigh".
1463
- * @returns {string} Updated content with `effort:` injected, or the
1464
- * original content when no frontmatter is found.
1465
- */
1466
- function injectEffortFrontmatter(content, effortValue) {
1467
- // Detect the dominant EOL from the first line (the opening `---`).
1468
- // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1469
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1470
-
1471
- // Build a frontmatter-matching regex that tolerates an optional \r before
1472
- // each \n, so we handle both LF and CRLF files without needing to normalise
1473
- // the whole content.
1474
- //
1475
- // Breakdown:
1476
- // ^---\r?\n — opening delimiter (with optional \r)
1477
- // ([\s\S]*?) — frontmatter body (non-greedy)
1478
- // ^---\r?$ — closing delimiter line (optional \r, $ before \n in
1479
- // multiline mode)
1480
- // (\r?\n|$) — newline after closing --- (or end of string)
1481
- //
1482
- // The `m` flag makes ^ / $ match at every line boundary.
1483
- const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1484
- const match = fmRe.exec(content);
1485
- if (!match) return content; // no YAML frontmatter — leave unchanged
1486
-
1487
- // Idempotency guard: don't insert a second effort: line.
1488
- const fmBody = match[1]; // content between the two `---` lines
1489
- if (/^effort:/m.test(fmBody)) return content;
1490
-
1491
- // Locate the exact position of the closing `---` line so we can insert
1492
- // before it using a simple string splice (avoids re-running the regex and
1493
- // avoids any edge-cases with $ matching \r differently per engine).
1494
- const closeIdx = match.index + 4 + fmBody.length; // 4 = len("---\n") (opening)
1495
- // Actually compute based on the full match start + captured group length:
1496
- // match[0] = full frontmatter block; match.index = start of that block.
1497
- // The closing `---` starts at: match.index + ("---" + eol).length + fmBody.length
1498
- const openLen = 3 + eol.length; // "---" + eol
1499
- const closingStart = match.index + openLen + fmBody.length;
1500
-
1501
- const before = content.slice(0, closingStart);
1502
- const after = content.slice(closingStart);
1503
- return `${before}effort: ${effortValue}${eol}${after}`;
1504
- }
1505
-
1506
- /**
1507
- * #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
1508
- * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
1509
- * inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
1510
- * which break on unknown frontmatter keys.
1511
- */
1512
- function injectDisallowedToolsFrontmatter(content, disallowedValue) {
1513
- // Detect the dominant EOL from the first line (the opening `---`).
1514
- // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1515
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1516
-
1517
- // Build a frontmatter-matching regex that tolerates an optional \r before
1518
- // each \n, so we handle both LF and CRLF files without needing to normalise
1519
- // the whole content.
1520
- const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1521
- const match = fmRe.exec(content);
1522
- if (!match) return content; // no YAML frontmatter — leave unchanged
1523
-
1524
- // Idempotency guard: don't insert a second disallowedTools: line.
1525
- const fmBody = match[1]; // content between the two `---` lines
1526
- if (/^disallowedTools:/m.test(fmBody)) return content;
1527
-
1528
- // Locate the exact position of the closing `---` line so we can insert
1529
- // before it using a simple string splice.
1530
- const openLen = 3 + eol.length; // "---" + eol
1531
- const closingStart = match.index + openLen + fmBody.length;
1532
-
1533
- const before = content.slice(0, closingStart);
1534
- const after = content.slice(closingStart);
1535
- return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
1536
- }
1537
-
1538
- // #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
1539
- // Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
1540
- // output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
1541
- // excluded (it legitimately uses Write AND Edit to create/patch test files).
1542
- const READONLY_AGENT_DISALLOWED_TOOLS = {
1543
- 'gsd-plan-checker': 'Write, Edit, MultiEdit',
1544
- 'gsd-integration-checker': 'Write, Edit, MultiEdit',
1545
- 'gsd-ui-checker': 'Write, Edit, MultiEdit',
1546
- 'gsd-verifier': 'Edit, MultiEdit',
1547
- 'gsd-doc-verifier': 'Edit, MultiEdit',
1548
- 'gsd-eval-auditor': 'Edit, MultiEdit',
1549
- 'gsd-ui-auditor': 'Edit, MultiEdit',
1550
- };
1551
-
1552
- /**
1553
- * #2517 — Build a runtime-aware tier resolver for the install path.
1495
+ * Write settings.json with proper formatting.
1554
1496
  *
1555
- * Probes BOTH per-project `<targetDir>/.planning/config.json` AND
1556
- * `~/.gsd/defaults.json`, with per-project keys winning over global. This
1557
- * matches `loadConfig`'s precedence and is the only way the PR's headline claim
1558
- * — "set runtime in .planning/config.json and the Codex TOML emit picks it up"
1559
- * — actually holds end-to-end (review finding #1).
1497
+ * Atomic (temp+rename) because hosts discard the ENTIRE settings file on any
1498
+ * parse failure, so a truncated write costs the user every hook, permission,
1499
+ * and statusline they have — not just GSD's entries. This is the sole writer
1500
+ * of that surface for six runtimes.
1560
1501
  *
1561
- * `targetDir` should be the consuming runtime's install root — install code
1562
- * passes `path.dirname(<runtime root>)` so `.planning/config.json` resolves
1563
- * relative to the user's project. When `targetDir` is null/undefined, only the
1564
- * global defaults are consulted.
1565
- *
1566
- * Returns null if no `runtime` is configured (preserves prior behavior — only
1567
- * model_overrides is embedded, no tier/reasoning-effort inference). Returns
1568
- * null when `model_profile` is `inherit` so the literal alias passes through
1569
- * unchanged.
1570
- *
1571
- * Returns { runtime, resolve(agentName) -> { model, reasoning_effort? } | null }
1502
+ * `atomicWriteFileSync` is declared further down this file; it is dereferenced
1503
+ * at call time, after module evaluation, so the ordering is safe.
1572
1504
  */
1573
- function readGsdRuntimeProfileResolver(targetDir = null) {
1574
- const homeDefaults = _readGsdConfigFile(
1575
- path.join(os.homedir(), '.gsd', 'defaults.json'),
1576
- '~/.gsd/defaults.json'
1577
- );
1578
-
1579
- // Per-project config probe. Resolve the project root by walking up from
1580
- // targetDir until we hit a `.planning/` directory; this covers both the
1581
- // common case (caller passes the project root) and the case where caller
1582
- // passes a nested install dir like `<root>/.codex/`.
1583
- let projectConfig = null;
1584
- if (targetDir) {
1585
- let probeDir = path.resolve(targetDir);
1586
- for (let depth = 0; depth < 8; depth += 1) {
1587
- const candidate = path.join(probeDir, '.planning', 'config.json');
1588
- if (fs.existsSync(candidate)) {
1589
- projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
1590
- break;
1591
- }
1592
- const parent = path.dirname(probeDir);
1593
- if (parent === probeDir) break;
1594
- probeDir = parent;
1595
- }
1596
- }
1597
-
1598
- // Per-project wins. Only fall back to ~/.gsd/defaults.json when the project
1599
- // didn't set the field. Field-level merge (not whole-object replace) so a
1600
- // user can keep `runtime` global while overriding only `model_profile` per
1601
- // project, and vice versa.
1602
- const merged = {
1603
- runtime:
1604
- (projectConfig && projectConfig.runtime) ||
1605
- (homeDefaults && homeDefaults.runtime) ||
1606
- null,
1607
- model_profile:
1608
- (projectConfig && projectConfig.model_profile) ||
1609
- (homeDefaults && homeDefaults.model_profile) ||
1610
- 'balanced',
1611
- model_profile_overrides:
1612
- (projectConfig && projectConfig.model_profile_overrides) ||
1613
- (homeDefaults && homeDefaults.model_profile_overrides) ||
1614
- null,
1615
- };
1616
-
1617
- if (!merged.runtime) return null;
1618
-
1619
- const profile = String(merged.model_profile).toLowerCase();
1620
- if (profile === 'inherit') return null;
1621
-
1622
- return {
1623
- runtime: merged.runtime,
1624
- resolve(agentName) {
1625
- const agentModels = GSD_MODEL_PROFILES[agentName];
1626
- if (!agentModels) return null;
1627
- const tier = agentModels[profile] || agentModels.balanced;
1628
- if (!tier) return null;
1629
- return gsdResolveTierEntry({
1630
- runtime: merged.runtime,
1631
- tier,
1632
- overrides: merged.model_profile_overrides,
1633
- });
1634
- },
1635
- };
1636
- }
1505
+ function writeSettings(settingsPath, settings) {
1506
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
1507
+ }
1508
+
1509
+ // #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides /
1510
+ // readGsdEffectiveModelOverrides / readGsdRuntimeProfileResolver, plus the
1511
+ // shared resolveAgentModelOverride precedence chain) was extracted into the
1512
+ // shipped gsd-core/bin/lib/install-model-override-resolver.cjs, mirroring
1513
+ // install-effort-resolver.cjs's existing #2071 precedent, so the descriptor-
1514
+ // driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind)
1515
+ // and this installer resolve model_overrides / model_profile_overrides
1516
+ // through the SAME code — a single source of truth for the precedence chain
1517
+ // the inline agent loop below used to duplicate across ~24 lines per runtime
1518
+ // (kilo, opencode). See install-model-override-resolver.cts's module doc.
1519
+ const {
1520
+ readGsdEffectiveModelOverrides,
1521
+ readGsdRuntimeProfileResolver,
1522
+ resolveAgentModelOverride,
1523
+ } = require(path.join(_gsdLibDir, 'install-model-override-resolver.cjs'));
1524
+
1525
+ // #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion
1526
+ // (single source of truth with the descriptor pipeline's
1527
+ // applyAgentFrontmatterExtensions step, which now also owns disallowedTools
1528
+ // injection + the read-only agent deny-list internally — see its module doc
1529
+ // in src/runtime-artifact-conversion.cts). injectEffortFrontmatter used to be
1530
+ // re-bound here purely to stay on this module's export surface; #2876 found
1531
+ // no install.js internal caller either (tests import it directly from
1532
+ // gsd-core/bin/lib/runtime-artifact-conversion.cjs) and retired the binding.
1637
1533
 
1638
1534
  // Cache for attribution settings (populated once per runtime during install)
1639
1535
  const attributionCache = new Map();
@@ -1922,10 +1818,6 @@ function buildKiloAgentPermissionBlock(claudeTools) {
1922
1818
  return lines;
1923
1819
  }
1924
1820
 
1925
- function escapeRegExp(value) {
1926
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1927
- }
1928
-
1929
1821
  function replaceRelativePathReference(content, fromPath, toPath) {
1930
1822
  const escapedPath = escapeRegExp(fromPath);
1931
1823
  return content.replace(
@@ -2046,12 +1938,6 @@ function skillFrontmatterName(skillDirName) {
2046
1938
  return skillDirName;
2047
1939
  }
2048
1940
 
2049
- function normalizeClaudeSkillEffort(effort) {
2050
- // #3039: `max` is rejected by Anthropic models when extended thinking is disabled.
2051
- if (effort === 'xhigh' || effort === 'max') return 'high';
2052
- return effort;
2053
- }
2054
-
2055
1941
  /**
2056
1942
  * Qwen Code skills accept an optional numeric `priority` frontmatter field.
2057
1943
  * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -2108,10 +1994,11 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2108
1994
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2109
1995
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2110
1996
  const agent = extractFrontmatterField(frontmatter, 'agent');
2111
- // #769: preserve context: and effort: from source command files so they
2112
- // are emitted into the installed SKILL.md frontmatter unchanged.
1997
+ // #769: preserve context: from source command files so it is emitted into
1998
+ // the installed SKILL.md frontmatter unchanged. (#3151: effort: is no longer
1999
+ // emitted into skill frontmatter — a static effort value invalidates the
2000
+ // caller's prompt cache at both scope boundaries.)
2113
2001
  const context = extractFrontmatterField(frontmatter, 'context');
2114
- const effort = extractFrontmatterField(frontmatter, 'effort');
2115
2002
 
2116
2003
  // Preserve allowed-tools as YAML multiline list (Claude native format)
2117
2004
  const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
@@ -2145,12 +2032,13 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2145
2032
  }
2146
2033
  if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
2147
2034
  if (agent) fm += `agent: ${agent}\n`;
2148
- // #769: emit context: and effort: when present so the runtime can honour
2149
- // them natively (context: fork = isolated subagent window; effort: =
2150
- // token-budget tier). Fields are Claude-specific; unknown frontmatter
2151
- // fields are silently ignored by other runtimes (backward-compatible).
2035
+ // #769: emit context: when present so the runtime can honour it natively
2036
+ // (context: fork = isolated subagent window). Claude-specific; unknown
2037
+ // frontmatter fields are silently ignored by other runtimes (backward-compatible).
2038
+ // (#3151: effort: is intentionally NOT emitted into skill frontmatter — a
2039
+ // static effort value changes output_config.effort on invocation and
2040
+ // invalidates the caller's prompt cache at both scope boundaries.)
2152
2041
  if (context) fm += `context: ${context}\n`;
2153
- if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
2154
2042
  if (toolsBlock) fm += toolsBlock;
2155
2043
  fm += '---';
2156
2044
 
@@ -2488,7 +2376,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2488
2376
  /**
2489
2377
  * Apply Antigravity-specific content conversion — path replacement + command name conversion.
2490
2378
  * Path mappings depend on install mode:
2491
- * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2379
+ * Global: ~/.claude/skills/ → ~/.gemini/config/skills/ (#3738),
2380
+ * ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2492
2381
  * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
2493
2382
  * Applied to ALL Antigravity content (skills, agents, engine files).
2494
2383
  * @param {string} content - Source content to convert
@@ -2497,6 +2386,19 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2497
2386
  function convertClaudeToAntigravityContent(content, isGlobal = false) {
2498
2387
  let c = content;
2499
2388
  if (isGlobal) {
2389
+ // #3738: global skills install under ~/.gemini/config/skills (the dir AGY
2390
+ // scans for global discovery), so skills-path references must divert there
2391
+ // — BEFORE the configHome rewrite below, which is correct for gsd-core
2392
+ // runtime-file references (settings, workflows, VERSION) but wrong for the
2393
+ // skills dir itself. Mirrors src/runtime-artifact-conversion.cts (ADR-1508
2394
+ // keeps bin/install.js hand-authored; the two copies must stay in sync).
2395
+ c = c.replace(/\$HOME\/\.claude\/skills\//g, '$HOME/.gemini/config/skills/');
2396
+ c = c.replace(/~\/\.claude\/skills\//g, '~/.gemini/config/skills/');
2397
+ // Bare skills form (no trailing slash) — must also precede the generic
2398
+ // slash rule, which would otherwise divert it to the retired configHome
2399
+ // path ($HOME/.gemini/antigravity/skills).
2400
+ c = c.replace(/\$HOME\/\.claude\/skills\b/g, '$HOME/.gemini/config/skills');
2401
+ c = c.replace(/~\/\.claude\/skills\b/g, '~/.gemini/config/skills');
2500
2402
  c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/');
2501
2403
  c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/');
2502
2404
  // Bare form (no trailing slash) — must come after slash form to avoid double-replace
@@ -3964,7 +3866,7 @@ Typed mapping (agent_type-capable schema only):
3964
3866
  to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty,
3965
3867
  inherited, or unsupported values; do not invent one-off effort literals in
3966
3868
  workflow prose.
3967
- - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
3869
+ - \`fork_context: false\` by default — GSD agents load their own context via \`<required_reading>\` blocks
3968
3870
  - \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task
3969
3871
  - \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement)
3970
3872
  - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
@@ -4059,15 +3961,19 @@ purpose: ${toSingleLine(description)}
4059
3961
  /**
4060
3962
  * #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
4061
3963
  * Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
4062
- * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES, imported from
4063
- * src/model-resolver.cts so it can't diverge); (b) any Claude model id in any provider
4064
- * namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*` (the forms the
4065
- * catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via the runtime-
4066
- * resolver path). No OpenAI/Codex model id contains "claude", so a case-insensitive
4067
- * substring test is a safe, exhaustive guard for (b). Codex/ChatGPT rejects all of these.
3964
+ * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES); (b) any Claude model
3965
+ * id in any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
3966
+ * (the forms the catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via
3967
+ * the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
3968
+ * case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
3969
+ * rejects all of these.
3970
+ *
3971
+ * #3241 — thin delegation to the shared predicate on src/model-catalog.cts (moved there
3972
+ * so it can't diverge across Codex-posture surfaces); kept as a local name because it
3973
+ * reads better at the call sites below.
4068
3974
  */
4069
3975
  function _isAnthropicFlavoredModel(model) {
4070
- return typeof model === 'string' && (CLAUDE_AGENT_ALIASES.has(model) || model.toLowerCase().includes('claude'));
3976
+ return gsdIsAnthropicFlavoredModel(model);
4071
3977
  }
4072
3978
 
4073
3979
  // #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
@@ -4086,6 +3992,42 @@ function _warnCodexModelOverrideDropped(agentName, value) {
4086
3992
  );
4087
3993
  }
4088
3994
 
3995
+ // #3241 — one-time per-install deprecation notice: the automatic runtime-resolver
3996
+ // per-tier Codex model embed was removed (D1/D5, ADR-2313 passive-posture epic). When
3997
+ // the resolver *would have* supplied a model and nothing else ends up pinned, this
3998
+ // notice points the user at model_overrides as the explicit-pin replacement. Dedupes
3999
+ // with a module-level boolean (mirrors _codexModelOverrideDroppedWarned's Set above)
4000
+ // so a multi-agent install — every Codex agent hits this condition simultaneously —
4001
+ // emits exactly one line, not one per agent. Reset once per install() call (see
4002
+ // install()) so the "at most once" window is per-install, not per-process. That
4003
+ // reset lives ONLY inside install() (~:10116) — generateCodexAgentToml is also
4004
+ // exported standalone (~:13460), and a caller invoking it directly/repeatedly
4005
+ // outside install() gets process-lifetime dedupe instead of per-install. No
4006
+ // current test depends on the standalone caller's dedupe window.
4007
+ let _codexResolverModelOmittedWarned = false;
4008
+ function _warnCodexResolverModelOmitted() {
4009
+ if (_codexResolverModelOmittedWarned) return;
4010
+ _codexResolverModelOmittedWarned = true;
4011
+ process.stderr.write(
4012
+ 'gsd: notice — Codex agents no longer auto-pin a per-tier model from the runtime ' +
4013
+ 'resolver; set model_overrides for an agent if you want a specific Codex model ' +
4014
+ 'instead of the session model.\n',
4015
+ );
4016
+ }
4017
+
4018
+ // Test seam only — bin/install.js deliberately keeps per-install warning/notice
4019
+ // dedupe in module scope (both the _codexModelOverrideDroppedWarned Set above and
4020
+ // the _codexResolverModelOmittedWarned boolean; install() resets the latter at
4021
+ // ~:10116). A unit test that drives generateCodexAgentToml() directly, without
4022
+ // going through install(), has no other way to reset either store between
4023
+ // assertions without busting the require.cache (which breaks module-instance
4024
+ // sharing with the rest of the suite). This is the single sanctioned way for a
4025
+ // unit test to clear both dedupe stores — exported so tests can call it instead.
4026
+ function _resetCodexWarningDedupeForTests() {
4027
+ _codexModelOverrideDroppedWarned.clear();
4028
+ _codexResolverModelOmittedWarned = false;
4029
+ }
4030
+
4089
4031
  /**
4090
4032
  * Generate a per-agent .toml config file for Codex.
4091
4033
  * Sets required agent metadata, sandbox_mode, and developer_instructions
@@ -4098,10 +4040,39 @@ function _warnCodexModelOverrideDropped(agentName, value) {
4098
4040
  * @param {object|null} effortCfg — #443: merged effort config from readGsdEffectiveEffortConfig
4099
4041
  */
4100
4042
  function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
4101
- const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
4102
4043
  const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
4103
4044
  const frontmatterText = frontmatter || '';
4045
+ // #3897 list-form parse fix, Fix 3: `toolsRaw` MUST come from the same
4046
+ // shared `extractToolsValue` reader `checkCodexSandboxPosture` uses, not
4047
+ // this file's own `extractFrontmatterField` — the two used to disagree on
4048
+ // YAML block-list `tools:` form (`extractFrontmatterField`'s single-line
4049
+ // regex read only the first list item), which is exactly the generative-
4050
+ // fix-divergence shape CLAUDE.md warns about for two paths feeding one
4051
+ // derivation. `extractToolsValue` does its own `---`-delimited frontmatter
4052
+ // scan of the full `agentContent`, so it is not re-derived from
4053
+ // `frontmatterText` here.
4054
+ const toolsRaw = extractToolsValue(agentContent) ?? '';
4104
4055
  const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
4056
+ // #3897 rung 3 — derived from the role's own tool contract (HALT.md option
4057
+ // 2). The former hand-maintained CODEX_AGENT_SANDBOX map is deleted (ADR-3473
4058
+ // §8.3): it was fully redundant with this derivation, zero disagreements
4059
+ // across all 11 entries. Never a silent `|| 'read-only'` fallback either.
4060
+ // Derivation itself lives in codex-agent-toml.cjs, which does no frontmatter
4061
+ // parsing of its own (no third copy of that extraction) — it takes the
4062
+ // already-resolved `tools:` value, extracted via the shared reader above.
4063
+ //
4064
+ // #3897 security review F1 (blocker): pass BOTH candidate identities —
4065
+ // `agentName` (the caller's filename-stem identity) AND `resolvedName`
4066
+ // (the frontmatter `name:` this function's OWN emitted `name = ...` line
4067
+ // uses, and what `installCodexConfig`'s caller keys the output PATH on) —
4068
+ // never just one. Deciding the sandbox for `agentName` alone and applying
4069
+ // it to an artifact that a DIFFERENT identity (`resolvedName`) names is
4070
+ // exactly how a held role's own `.toml` could end up `workspace-write`
4071
+ // (rename the source file, or plant a sibling whose `name:` collides with
4072
+ // a held role). `deriveCodexSandboxMode` takes the most restrictive result
4073
+ // across every candidate — see its doc and `isSandboxHeld` in
4074
+ // codex-agent-toml.cjs.
4075
+ const sandboxMode = deriveCodexSandboxMode([agentName, resolvedName], toolsRaw);
4105
4076
  const resolvedDescription = toSingleLine(
4106
4077
  extractFrontmatterField(frontmatterText, 'description') || `GSD agent ${resolvedName}`
4107
4078
  );
@@ -4118,38 +4089,58 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4118
4089
  // Embed model override when configured in ~/.gsd/defaults.json so that
4119
4090
  // model_overrides is respected on Codex (which uses static TOML, not inline
4120
4091
  // Task() model parameters). See #2256.
4121
- // Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
4122
4092
  // #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
4123
4093
  // passive/session-only model host (ADR-1239): GSD cannot reliably route per-agent
4124
4094
  // tiers, and a bare GSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
4125
4095
  // id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
4126
4096
  // using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
4127
4097
  // model pin from model_overrides; omit anything Anthropic-flavored so the agent
4128
- // inherits the always-available session model. (Removing the runtime-resolver
4129
- // per-tier embedding below is the ADR-2310 passive-posture epic.)
4098
+ // inherits the always-available session model.
4099
+ // #3241 (D1/D5) — the runtime-aware tier-resolver auto-embed that used to fall
4100
+ // through here when model_overrides had nothing was removed: Codex is passive by
4101
+ // default now, and only an explicit model_overrides pin survives. See the
4102
+ // deprecation-notice block below for the population that used to get a pin from
4103
+ // the resolver and no longer does.
4130
4104
  const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
4131
4105
  let pinnedModel = null;
4132
4106
  if (rawModelOverride) {
4133
- if (typeof rawModelOverride === 'string' && rawModelOverride && !_isAnthropicFlavoredModel(rawModelOverride)) {
4134
- pinnedModel = rawModelOverride; // explicit real-Codex model pin → embed verbatim (#2256)
4107
+ // Trim before the truthiness test (#3241 defect fix): a whitespace-only value
4108
+ // (e.g. ' ') is a truthy JS string but not a model id — it must not be
4109
+ // embedded verbatim (`model = " "`, a live pre-fix defect) or routed to
4110
+ // _warnCodexModelOverrideDropped, whose "is not a valid Codex model
4111
+ // (Anthropic alias/id)" text would misdescribe a blank config field. It is
4112
+ // silently dropped, matching how '' already behaves (no pin, no warning).
4113
+ const trimmedOverride = typeof rawModelOverride === 'string' ? rawModelOverride.trim() : rawModelOverride;
4114
+ if (typeof trimmedOverride === 'string' && trimmedOverride && !_isAnthropicFlavoredModel(trimmedOverride)) {
4115
+ pinnedModel = trimmedOverride; // explicit real-Codex model pin → embed verbatim (#2256)
4116
+ } else if (typeof rawModelOverride === 'string' && trimmedOverride === '') {
4117
+ // whitespace-only override — no pin, no warning (#3241).
4135
4118
  } else {
4136
4119
  _warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
4137
4120
  }
4138
4121
  }
4139
- if (!pinnedModel && runtimeResolver) {
4140
- // #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
4141
- // from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
4142
- // (Superseded on the default path by the ADR-2310 passive-posture epic.)
4143
- const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
4144
- if (entry?.model) pinnedModel = entry.model;
4145
- }
4146
4122
  // #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
4147
- // .toml, even from the runtime-resolver path (e.g. a defaults.json runtime that
4148
- // does not match the codex install target).
4123
+ // .toml, even one that reached here through some other path than the override
4124
+ // check above.
4149
4125
  if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
4150
4126
  _warnCodexModelOverrideDropped(resolvedName, pinnedModel);
4151
4127
  pinnedModel = null;
4152
4128
  }
4129
+ // #3241 — one-time deprecation notice: if nothing ends up pinned but the
4130
+ // runtime resolver would have supplied a per-tier model that would actually
4131
+ // have been EMBEDDED (the population that loses a pin now that the
4132
+ // auto-embed above is gone), point the user at model_overrides. The would-be
4133
+ // model must also clear the #2310 Anthropic-flavored gate above — if it
4134
+ // wouldn't have survived that gate, the user never had that pin pre-Phase-1
4135
+ // either, and the notice would be false. Never fires when the resolver is
4136
+ // null, resolves to nothing, resolves to an Anthropic-flavored model, or an
4137
+ // explicit real-Codex pin survived — in all of those cases nothing was lost.
4138
+ if (!pinnedModel && runtimeResolver) {
4139
+ const wouldHavePinned = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
4140
+ if (wouldHavePinned?.model && !_isAnthropicFlavoredModel(wouldHavePinned.model)) {
4141
+ _warnCodexResolverModelOmitted();
4142
+ }
4143
+ }
4153
4144
  let hasPinnedModel = false;
4154
4145
  if (pinnedModel) {
4155
4146
  lines.push(`model = ${JSON.stringify(pinnedModel)}`);
@@ -4161,16 +4152,26 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4161
4152
  // #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
4162
4153
  // as the Claude .md effort injection (resolveInstallTimeEffort), so both runtimes read
4163
4154
  // from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
4164
- // config source. Codex does not support 'max' → clamped to 'xhigh' by
4165
- // gsdRenderEffortForRuntime('codex', ...).
4155
+ // config source. #3007 — Codex advertises supported_reasoning_levels per model, so the
4156
+ // pinned model id is passed through and the value is resolved against that model's own
4157
+ // set: 'max' now passes, 'minimal' clamps up to 'low', and 'ultra' is refused (no key
4158
+ // emitted) rather than clamped to a fabricated level.
4166
4159
  // #838 — Do not pin effort when Codex is intentionally inheriting the parent
4167
4160
  // chat model. A TOML with no `model` but a static `model_reasoning_effort`
4168
4161
  // creates confusing partial routing: model follows the Codex UI while effort
4169
4162
  // follows GSD. Keep those knobs coupled unless GSD also pins the model.
4170
4163
  if (hasPinnedModel) {
4171
4164
  const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
4172
- const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
4173
- lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4165
+ // #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
4166
+ // own effort default. Never write the literal.
4167
+ if (_universalEffortCodex !== 'inherit') {
4168
+ const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex, pinnedModel).value;
4169
+ // #3007 — 'ultra' is rejected by the model's supported_reasoning_levels and
4170
+ // renders as null. Omit the key entirely rather than write a literal `null`.
4171
+ if (_renderedEffortCodex !== null) {
4172
+ lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4173
+ }
4174
+ }
4174
4175
  }
4175
4176
 
4176
4177
  // #774 — Emit service_tier and model_verbosity for light-tier agents.
@@ -6377,6 +6378,31 @@ const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
6377
6378
  * All writes go through atomicWriteFileSync so a mid-write failure leaves
6378
6379
  * the original config.toml untouched (#2760 fix 4).
6379
6380
  */
6381
+ /**
6382
+ * Split TOML content into its leading TOP-LEVEL key lines and everything from
6383
+ * the first table header onward (#3610).
6384
+ *
6385
+ * Top-level keys were file-scoped before a merge. The regenerated GSD block
6386
+ * opens with a table header (`[agents]`, #2088/ADR-1239 upgrade 2), so placing
6387
+ * that block ABOVE surviving top-level keys re-scopes them into `[agents]` —
6388
+ * `validateCodexConfigSchema` then correctly rejects the merged file and the
6389
+ * install aborts. Hoisting the keys above the block preserves their scope.
6390
+ *
6391
+ * Table headers inside multiline strings do not start the "rest" region (the
6392
+ * record parser already excludes them via startsInMultilineString).
6393
+ */
6394
+ function splitTopLevelKeys(content) {
6395
+ for (const record of getTomlLineRecords(content)) {
6396
+ if (record.tableHeader && !record.startsInMultilineString) {
6397
+ return {
6398
+ topLevel: content.slice(0, record.start).trim(),
6399
+ rest: content.slice(record.start).trim(),
6400
+ };
6401
+ }
6402
+ }
6403
+ return { topLevel: content.trim(), rest: '' };
6404
+ }
6405
+
6380
6406
  function mergeCodexConfig(configPath, gsdBlock) {
6381
6407
  // Case 1: No config.toml — create fresh
6382
6408
  if (!fs.existsSync(configPath)) {
@@ -6424,10 +6450,25 @@ function mergeCodexConfig(configPath, gsdBlock) {
6424
6450
  .replace(/^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
6425
6451
  const afterUser = stripLeakedGsdCodexSections(markerStripped).trim();
6426
6452
 
6453
+ // #3610: top-level keys that survived BELOW the marker were file-scoped
6454
+ // before this merge; the regenerated block opens with the `[agents]` table
6455
+ // header, so they must be hoisted to FILE scope or TOML re-scopes them
6456
+ // into a table. File scope means BEFORE the first table header of the
6457
+ // pre-marker region too — appending them after a pre-marker table (the
6458
+ // default real-world layout: user tables precede the marker) would merely
6459
+ // capture them into THAT table instead of [agents], and the schema
6460
+ // validator is blind to non-agents tables.
6461
+ const beforeSplit = before ? splitTopLevelKeys(before) : { topLevel: '', rest: '' };
6462
+ const { topLevel: afterTopLevel, rest: afterTables } = splitTopLevelKeys(afterUser);
6463
+
6427
6464
  const parts = [];
6428
- if (before) parts.push(before);
6465
+ const topParts = [];
6466
+ if (beforeSplit.topLevel) topParts.push(beforeSplit.topLevel);
6467
+ if (afterTopLevel) topParts.push(afterTopLevel);
6468
+ if (topParts.length > 0) parts.push(topParts.join(eol + eol));
6469
+ if (beforeSplit.rest) parts.push(beforeSplit.rest);
6429
6470
  parts.push(normalizedGsdBlock);
6430
- if (afterUser) parts.push(afterUser);
6471
+ if (afterTables) parts.push(afterTables);
6431
6472
  atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
6432
6473
  return;
6433
6474
  }
@@ -6729,43 +6770,13 @@ function stripGsdFromCopilotInstructions(content) {
6729
6770
  const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
6730
6771
  const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
6731
6772
 
6732
- /**
6733
- * The GSD instruction body shared by the Cline directory-form rules file and
6734
- * the cross-tool AGENTS.md block. Self-contained — references only the gsd-core
6735
- * engine layout, not the (separate) #782 Cline skills directory.
6736
- */
6737
- function buildClineRulesBody() {
6738
- return hooksSurface.buildClineRulesBody();
6739
- }
6740
-
6741
- /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
6742
- function buildClineAgentsMdBody() {
6743
- return hooksSurface.buildClineAgentsMdBody();
6744
- }
6745
-
6746
- /**
6747
- * The Cline PreToolUse hook script (issue #787).
6748
- *
6749
- * Cline invokes hooks as executable scripts named exactly after the event with
6750
- * no extension, passing the operation context as JSON on stdin and reading a
6751
- * JSON decision from stdout ({ cancel, errorMessage, contextModification }).
6752
- *
6753
- * This hook is a self-standing planning-artifact guard: it cancels write-class
6754
- * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
6755
- * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
6756
- * hook bug can never wedge the user. No dependency on the #782 skills work.
6757
- */
6758
- function buildClinePreToolUseHook() {
6759
- return hooksSurface.buildClinePreToolUseHook();
6760
- }
6761
-
6762
- /**
6763
- * Merge the GSD AGENTS.md block into an existing file (or create it), preserving
6764
- * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
6765
- */
6766
- function mergeGsdAgentsMd(filePath, gsdContent) {
6767
- return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
6768
- }
6773
+ // #2876: buildClineRulesBody, buildClineAgentsMdBody, buildClinePreToolUseHook,
6774
+ // and mergeGsdAgentsMd used to be re-bound here as one-line delegates to the
6775
+ // equivalent hooksSurface.* implementations. None had an install.js internal
6776
+ // caller or an export consumer (tests import all four directly from
6777
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6778
+ // dead code with no reachable body — removed rather than kept as unreachable
6779
+ // wrappers.
6769
6780
 
6770
6781
  /**
6771
6782
  * Strip the GSD block from AGENTS.md content. Returns null if the file became
@@ -6817,47 +6828,13 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
6817
6828
  //
6818
6829
  // References: https://cursor.com/docs/hooks
6819
6830
 
6820
- /**
6821
- * Build a managed Cursor hook entry for a given hook script path.
6822
- *
6823
- * @param {string} scriptPath - Absolute path to the hook script
6824
- * @returns {object} Cursor hook entry object
6825
- */
6826
- function buildCursorHookEntry(scriptPath) {
6827
- return hooksSurface.buildCursorHookEntry(scriptPath);
6828
- }
6829
-
6830
- /**
6831
- * Return true if a Cursor hook entry is GSD-managed.
6832
- * Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field.
6833
- *
6834
- * @param {object} entry - A hooks array element from hooks.json
6835
- * @returns {boolean}
6836
- */
6837
- function isManagedCursorHookEntry(entry) {
6838
- return hooksSurface.isManagedCursorHookEntry(entry);
6839
- }
6840
-
6841
- /**
6842
- * Reconcile the GSD-managed entries in a Cursor hooks.json file.
6843
- *
6844
- * Supports both known hooks.json shapes:
6845
- * 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } }
6846
- * 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object)
6847
- *
6848
- * Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then
6849
- * re-added if managedEntries is non-null/non-empty. User-owned entries are
6850
- * preserved. File is written atomically only when content changes.
6851
- *
6852
- * @param {string} hooksJsonPath - Absolute path to the hooks.json file
6853
- * @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries
6854
- * Map from event name to the new hook entry to register (or null to remove).
6855
- * Pass null for the whole param to remove all managed entries.
6856
- * @returns {{ changed: boolean, wrote: boolean, path: string }}
6857
- */
6858
- function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
6859
- return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
6860
- }
6831
+ // #2876: buildCursorHookEntry, isManagedCursorHookEntry, and
6832
+ // reconcileCursorHooksJson used to be re-bound here as one-line delegates to
6833
+ // the equivalent hooksSurface.* implementations. None had an install.js
6834
+ // internal caller or an export consumer (tests import all three directly from
6835
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6836
+ // dead code with no reachable body — removed rather than kept as unreachable
6837
+ // wrappers.
6861
6838
 
6862
6839
  /**
6863
6840
  * #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
@@ -6917,23 +6894,13 @@ function removeWindsurfHooksJson(targetDir) {
6917
6894
  return hooksSurface.removeWindsurfHooksJson(targetDir);
6918
6895
  }
6919
6896
 
6920
- /**
6921
- * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
6922
- *
6923
- * Returns the verbatim JSON shape Copilot CLI expects:
6924
- * { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
6925
- *
6926
- * The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
6927
- * run inline (no external script file), so the config can never reference a
6928
- * hook script that the installer did not also install — it is self-contained
6929
- * by construction. The command is advisory-only (always exits 0) and orients
6930
- * the agent toward the project's GSD planning state at session start.
6931
- *
6932
- * @returns {object} Copilot hooks-configuration object
6933
- */
6934
- function buildCopilotHookConfig() {
6935
- return hooksSurface.buildCopilotHookConfig();
6936
- }
6897
+ // #2876: buildCopilotHookConfig used to be re-bound here as a one-line
6898
+ // delegate to hooksSurface.buildCopilotHookConfig. It had no install.js
6899
+ // internal caller or export consumer (tests import it directly from
6900
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired binding was
6901
+ // dead code with no reachable body — removed rather than kept as an
6902
+ // unreachable wrapper. writeCopilotHookConfig below is unaffected — it still
6903
+ // has an internal caller (finishInstall).
6937
6904
 
6938
6905
  /**
6939
6906
  * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
@@ -6971,29 +6938,50 @@ function writeNonClaudeDefaults(runtime) {
6971
6938
  if (_hostBehaviors(runtime).nativeModelAliases || process.env.GSD_TEST_MODE) return;
6972
6939
  const gsdDir = path.join(os.homedir(), '.gsd');
6973
6940
  const defaultsPath = path.join(gsdDir, 'defaults.json');
6941
+ let releaseLock = null;
6974
6942
  try {
6975
6943
  fs.mkdirSync(gsdDir, { recursive: true });
6944
+ // defaults.json is machine-global — every runtime and project on the box
6945
+ // reads it. Serialize the read-modify-write so two concurrent installs
6946
+ // cannot lose each other's key, and apply both mutations in ONE atomic
6947
+ // write so a crash cannot leave the file truncated (the read path swallows
6948
+ // parse errors and treats a corrupt file as absent, which would silently
6949
+ // degrade model resolution everywhere until repaired by hand).
6950
+ releaseLock = acquireInstallMigrationLock(gsdDir);
6976
6951
  let defaults = {};
6977
6952
  try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
6978
6953
  if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
6979
6954
  defaults = {};
6980
6955
  }
6956
+ const applied = [];
6981
6957
  // Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
6982
6958
  const existing = defaults.resolve_model_ids;
6983
6959
  const shouldDefaultToOmit = existing !== true && existing !== 'omit';
6984
6960
  if (shouldDefaultToOmit) {
6985
6961
  defaults.resolve_model_ids = 'omit';
6986
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6987
- console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
6962
+ applied.push(`Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
6988
6963
  }
6989
6964
  // #2395: persist runtime for non-Claude runtimes.
6990
6965
  if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
6991
6966
  defaults.runtime = runtime;
6992
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6993
- console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
6967
+ applied.push(`Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
6968
+ }
6969
+ if (applied.length > 0) {
6970
+ atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
6971
+ for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
6994
6972
  }
6995
6973
  } catch (e) {
6996
6974
  console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
6975
+ } finally {
6976
+ if (releaseLock) {
6977
+ try {
6978
+ releaseLock();
6979
+ } catch (releaseError) {
6980
+ // A leaked lock blocks the next install, so surface it rather than
6981
+ // swallowing; the stale-lock reaper clears it once this pid exits.
6982
+ console.log(` ${yellow}⚠${reset} Could not release the ~/.gsd install lock: ${releaseError.message}`);
6983
+ }
6984
+ }
6997
6985
  }
6998
6986
  }
6999
6987
 
@@ -7017,6 +7005,36 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7017
7005
  }
7018
7006
  fs.mkdirSync(agentsTomlDir, { recursive: true });
7019
7007
 
7008
+ // #3897 rung 3 (CAUSE B fix) — validateCodexSandboxHolds is deliberately
7009
+ // NOT called here. The "no stale holds" invariant is a REPO invariant about
7010
+ // the canonical roster in `agents/`, not a property of whatever directory
7011
+ // an install happens to read from: a partial or synthetic `agentsSrc` (a
7012
+ // test fixture, a `--config-dir` subset) legitimately contains only a few
7013
+ // agents, and a held role simply absent from THIS source dir must be
7014
+ // inert, not fatal. Throwing here also masked unrelated failures further
7015
+ // down this same loop (e.g. the name-injection/path-escape guard on
7016
+ // `agentTomlPath` below), since this check ran first and unconditionally.
7017
+ // The invariant is still enforced — as a test over the real `agents/`
7018
+ // roster (tests/codex-config.test.cjs T24/T25) — just never on this
7019
+ // runtime path. See src/codex-agent-toml.cts's `validateCodexSandboxHolds`
7020
+ // docblock for the full rationale.
7021
+ //
7022
+ // #3897 security review F2 (assessed post-F1-fix, verified by execution —
7023
+ // not asserted): before F1's fix, the "runtime detector removed" gap here
7024
+ // was real — a rename (case a) or a sibling `name:` clobber (case b) could
7025
+ // reach this loop and land a held role's `.toml` at `workspace-write` with
7026
+ // nothing here to catch it. After F1 (`deriveCodexSandboxMode` now decides
7027
+ // over BOTH the filename stem and the resolved frontmatter `name:`, most
7028
+ // restrictive wins), both cases were re-run end-to-end through this exact
7029
+ // function and the EMITTED ARTIFACT for both is `read-only` — the
7030
+ // dangerous condition no longer produces a wrong artifact, it produces the
7031
+ // SAFE one. A detector guarding a now-fail-safe condition is not
7032
+ // load-bearing, and restoring a throw here would re-break the legitimate
7033
+ // partial-source-dir case CAUSE B removed it for (see above). Regression
7034
+ // coverage for both cases lives in `tests/codex-config.test.cjs` (F1(a)
7035
+ // rename / F1(b) sibling-clobber rows), asserted on the emitted `.toml`'s
7036
+ // `sandbox_mode`, not on the derivation's return value.
7037
+
7020
7038
  const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
7021
7039
  const agents = [];
7022
7040
 
@@ -7045,7 +7063,20 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7045
7063
  // CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
7046
7064
  content = convertClaudeToCodexMarkdown(content);
7047
7065
  const { frontmatter } = extractFrontmatterAndBody(content);
7048
- const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
7066
+ // #3897 security review F1 (blocker, post-merge): this loop used to key
7067
+ // the sandbox/hold decision off ONLY the filename stem while the emitted
7068
+ // `.toml`'s OUTPUT PATH below is keyed off `name` (frontmatter-derived,
7069
+ // attacker-editable) — so a renamed source file, or a sibling file whose
7070
+ // `name:` collides with a held role, could make the decided identity and
7071
+ // the landed artifact disagree, widening a held role's own file to
7072
+ // `workspace-write`. `generateCodexAgentToml` (below) now derives
7073
+ // `sandbox_mode` over BOTH the filename stem it is given AND the
7074
+ // frontmatter `name:` it resolves internally, taking the most
7075
+ // restrictive result — this loop no longer needs to choose one identity
7076
+ // for that call; see `deriveCodexSandboxMode`'s doc in
7077
+ // `codex-agent-toml.cts` for the resolution.
7078
+ const fileStem = file.replace(/\.md$/, '');
7079
+ const name = extractFrontmatterField(frontmatter, 'name') || fileStem;
7049
7080
  const description = extractFrontmatterField(frontmatter, 'description') || '';
7050
7081
 
7051
7082
  agents.push({ name, description: toSingleLine(description) });
@@ -7067,7 +7098,10 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7067
7098
  // #443 — pass unified effort config so model_reasoning_effort in the .toml
7068
7099
  // follows the same config-driven precedence as the Claude .md effort key.
7069
7100
  const effortCfg = readGsdEffectiveEffortConfig(targetDir);
7070
- const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
7101
+ // Pass `fileStem`; `generateCodexAgentToml` itself additionally resolves
7102
+ // and folds in the frontmatter `name:` for the sandbox decision (F1
7103
+ // above) — this call site does not need to pass `name` explicitly.
7104
+ const tomlContent = generateCodexAgentToml(fileStem, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
7071
7105
  // Confine the per-agent write to the agents/ dir itself: a crafted agent
7072
7106
  // `name` containing path separators must not escape agents/ (which would let
7073
7107
  // it clobber config.toml or write elsewhere under the configHome).
@@ -7462,10 +7496,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7462
7496
 
7463
7497
  // convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
7464
7498
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
7465
- // Imported from installEngine above.
7499
+ // #2876 found no install.js internal caller for the latter two (tests import
7500
+ // them directly from gsd-core/bin/lib/install-engine.cjs) and retired the
7501
+ // destructured bindings above.
7466
7502
 
7467
7503
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
7468
- // Imported from installEngine above.
7504
+ // #2876 found no install.js internal caller (never had one either — it was
7505
+ // never part of this module's export surface) and retired the destructured
7506
+ // binding above.
7469
7507
  //
7470
7508
  // copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
7471
7509
  // src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
@@ -7567,13 +7605,13 @@ function writeHermesCategoryDescription(categoryDir) {
7567
7605
  * @param {boolean} isGlobal - Whether this is a global install
7568
7606
  */
7569
7607
 
7570
- // USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts,
7571
- // migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries,
7572
- // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir,
7573
- // _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts,
7574
- // installOpencodeFamilySkills, uninstallRuntimeArtifacts:
7575
- // ALL moved to src/install-engine.cts (ADR-1239 Phase B).
7576
- // Imported from installEngine above.
7608
+ // USER_OWNED_ARTIFACTS, migrateLegacyDevPreferencesToSkill, _snapshotDir,
7609
+ // installRuntimeArtifacts, installOpencodeFamilySkills, uninstallRuntimeArtifacts:
7610
+ // ALL moved to src/install-engine.cts (ADR-1239 Phase B). Imported from
7611
+ // installEngine above. _copyStaged, _removeGsdEntries,
7612
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _restoreDir, and
7613
+ // _removeHermesBareStemDirs moved there too but #2876 found no install.js
7614
+ // internal caller for any of them and retired their destructured bindings.
7577
7615
 
7578
7616
  // ---------------------------------------------------------------------------
7579
7617
  // Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
@@ -7770,6 +7808,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7770
7808
  const srcPath = path.join(srcDir, entry.name);
7771
7809
  const destPath = path.join(destDir, entry.name);
7772
7810
 
7811
+ // #3333: srcPath was enumerated by readdirSync above, but a filesystem is not
7812
+ // transactional — the file it named can vanish between listing and this read
7813
+ // (a concurrent process, or another test in this suite writing/cleaning up a
7814
+ // fixture inside this same real directory). Treat "gone by the time we get
7815
+ // here" as benign and skip it, never a fatal crash of the whole install.
7816
+ if (!entry.isDirectory() && !fs.existsSync(srcPath)) {
7817
+ continue;
7818
+ }
7819
+
7773
7820
  if (entry.isDirectory()) {
7774
7821
  copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
7775
7822
  } else if (entry.name.endsWith('.md')) {
@@ -7822,8 +7869,20 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7822
7869
  content = content.replace(globalClaudeRegex, pathPrefix);
7823
7870
  content = content.replace(globalClaudeHomeRegex, pathPrefix);
7824
7871
  content = content.replace(localClaudeRegex, `./${dirName}/`);
7825
- content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7826
- content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7872
+ // #3544 review (Finding 1 fallout): guarded with the SAME
7873
+ // negative-lookahead convention already used at ~:2859-2860 below
7874
+ // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
7875
+ // is satisfied by ANY non-word character, including '-' — so for a
7876
+ // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
7877
+ // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
7878
+ // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
7879
+ // and re-appended the full prefix, corrupting every emitted path to
7880
+ // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
7881
+ // default '.claude' (self-replace with an identical string), which
7882
+ // is why this went undetected until a non-default config-dir name
7883
+ // was exercised.
7884
+ content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7885
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7827
7886
  content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7828
7887
  content = content.replace(/~\/\.qwen\//g, pathPrefix);
7829
7888
  content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
@@ -7831,6 +7890,17 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7831
7890
  content = content.replace(/~\/\.hermes\//g, pathPrefix);
7832
7891
  content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
7833
7892
  content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
7893
+ // #3544: restore @-file-reference lines to the tilde form Claude Code
7894
+ // actually expands — the SAME correction #3133 already applies to
7895
+ // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
7896
+ // restoreClaudeGlobalAtRefTilde's doc comment in
7897
+ // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree
7898
+ // emit path, which never had it: every @~/.claude/gsd-core/… include
7899
+ // in a global install's workflows/references tree silently resolved
7900
+ // to nothing (54 includes across 22 files on a live install).
7901
+ if (runtime === 'claude') {
7902
+ content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
7903
+ }
7834
7904
  }
7835
7905
  content = processAttribution(content, getCommitAttribution(runtime));
7836
7906
 
@@ -8031,6 +8101,133 @@ function validateHookFields(settings) {
8031
8101
  */
8032
8102
  const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd'];
8033
8103
 
8104
+ /**
8105
+ * Whether two paths denote the SAME directory — used to stop a reclaim from
8106
+ * deleting the very root the current install just wrote (#3031).
8107
+ *
8108
+ * A plain `path.resolve` comparison is not enough here, because both roots come
8109
+ * from user-controlled env vars (`KIMI_SHARE_DIR`, `KIMI_CODE_HOME`) and two
8110
+ * different strings routinely name one directory:
8111
+ * - case-insensitive filesystems (macOS, Windows): `~/Kimi` vs `~/kimi`
8112
+ * - symlinks / bind mounts: `~/link-to-kimi` vs the real target
8113
+ * Getting this wrong is not cosmetic — it is the difference between skipping a
8114
+ * reclaim and deleting a live install's own hooks.
8115
+ *
8116
+ * Strategy, cheapest-first: string equality after `resolve`, then identity by
8117
+ * `dev`+`ino` (definitive when both exist and the platform reports them), then
8118
+ * `realpath` string equality (resolves symlinks AND canonicalizes case). Any
8119
+ * rung answering "same" wins; a path that does not exist cannot be the root we
8120
+ * just wrote, so a failed stat simply falls through.
8121
+ *
8122
+ * @returns {boolean} true only when both paths are proven to be one directory.
8123
+ */
8124
+ function isSameDirectory(a, b) {
8125
+ if (path.resolve(a) === path.resolve(b)) return true;
8126
+ try {
8127
+ const sa = fs.statSync(a);
8128
+ const sb = fs.statSync(b);
8129
+ // `ino` is 0 on some Windows filesystems; only trust a positive match.
8130
+ if (sa.ino && sb.ino && sa.dev === sb.dev && sa.ino === sb.ino) return true;
8131
+ } catch (_) { /* one side missing — fall through to realpath */ }
8132
+ try {
8133
+ return fs.realpathSync.native(a) === fs.realpathSync.native(b);
8134
+ } catch (_) {
8135
+ return false;
8136
+ }
8137
+ }
8138
+
8139
+ /**
8140
+ * Remove every GSD-owned artifact from a Kimi hooks root (`~/.kimi` for kimi,
8141
+ * `~/.kimi-code` for kimi-code — resolveKimiHooksTomlDir, #2755): the managed
8142
+ * `[[hooks]]` block in the native config.toml, the hook scripts, hooks/lib/,
8143
+ * and the CommonJS marker at both its current (hooks/) and pre-#2544 (root)
8144
+ * locations.
8145
+ *
8146
+ * This root is Kimi's own native config home — SHARED space that may hold the
8147
+ * user's real config.toml, providers and their own scripts — so only exact
8148
+ * GSD-owned filenames are removed and directories are pruned only when that
8149
+ * removal leaves them empty.
8150
+ *
8151
+ * TWO callers, deliberately one implementation (#3031). `uninstall()` calls it
8152
+ * for the runtime being uninstalled; the opt-in `--reclaim-kimi-legacy` path in
8153
+ * `install()` calls it for the LEGACY `~/.kimi` root a pre-#2755 `--kimi-code`
8154
+ * install orphaned. Duplicating this sequence for the second caller would be
8155
+ * exactly the generative-divergence hazard the repo bans — the reclaim must
8156
+ * remove precisely what a real uninstall removes, forever, by construction.
8157
+ *
8158
+ * @param {string} kimiHooksRoot - Absolute path to the Kimi hooks root.
8159
+ * @returns {number} count of removal steps performed (0 when nothing matched).
8160
+ */
8161
+ function reclaimKimiHooksRoot(kimiHooksRoot) {
8162
+ let steps = 0;
8163
+ const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8164
+ const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8165
+ if (kimiHooksCleanup.changed) {
8166
+ steps++;
8167
+ console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
8168
+ }
8169
+
8170
+ // Kimi's shared hook scripts + CommonJS package.json marker are installed
8171
+ // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
8172
+ // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
8173
+ // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
8174
+ // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
8175
+ // may hold the user's real config.toml/providers), so only the exact
8176
+ // GSD-owned filenames are removed, and directories are pruned only if left
8177
+ // empty by that removal.
8178
+ const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
8179
+ if (fs.existsSync(kimiHooksDir)) {
8180
+ let kimiHookCount = 0;
8181
+ for (const hook of GSD_UNINSTALL_HOOKS) {
8182
+ const hookPath = path.join(kimiHooksDir, hook);
8183
+ if (fs.existsSync(hookPath)) {
8184
+ fs.unlinkSync(hookPath);
8185
+ kimiHookCount++;
8186
+ }
8187
+ }
8188
+ if (kimiHookCount > 0) {
8189
+ steps++;
8190
+ console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
8191
+ }
8192
+
8193
+ const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
8194
+ if (fs.existsSync(kimiHooksLibDir)) {
8195
+ let removedKimiLibFiles = 0;
8196
+ for (const file of GSD_HOOK_LIB_FILES) {
8197
+ try {
8198
+ fs.unlinkSync(path.join(kimiHooksLibDir, file));
8199
+ removedKimiLibFiles++;
8200
+ } catch (_) { /* best-effort */ }
8201
+ }
8202
+ try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
8203
+ if (removedKimiLibFiles > 0) {
8204
+ steps++;
8205
+ console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
8206
+ }
8207
+ }
8208
+
8209
+ // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8210
+ // before the emptiness check below, or the dir would never prune.
8211
+ if (removeCommonJsMarker(kimiHooksDir)) {
8212
+ steps++;
8213
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8214
+ }
8215
+
8216
+ try {
8217
+ if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8218
+ } catch (_) { /* not empty — leave it */ }
8219
+ }
8220
+
8221
+ // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8222
+ // used to write it. Exact content match — a user's own package.json in
8223
+ // kimi's native config home is never touched.
8224
+ if (removeCommonJsMarker(kimiHooksRoot)) {
8225
+ steps++;
8226
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8227
+ }
8228
+ return steps;
8229
+ }
8230
+
8034
8231
  /**
8035
8232
  * Uninstall GSD from the specified directory for a specific runtime
8036
8233
  * Removes only GSD-specific files/directories, preserves user content
@@ -8109,6 +8306,23 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8109
8306
 
8110
8307
  let removedCount = 0;
8111
8308
 
8309
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
8310
+ // artifact orphaned by a PRIOR uninstall run that died between staging and
8311
+ // its own restore/discard, BEFORE this run's own preserve steps (sites 2,
8312
+ // 3, 5 below) stage anything new. Uninstall's own gsd-core/ removal and
8313
+ // legacy-commands cleanup are exactly as crash-exposed as install's —
8314
+ // without this, an orphan from a crashed uninstall is recoverable only if
8315
+ // the user later re-installs.
8316
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging root
8317
+ // itself cannot be resolved — skip this recovery pass rather than throw
8318
+ // out of uninstall() before it does anything.
8319
+ {
8320
+ const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8321
+ if (_uninstallEntryStagingRoot !== null) {
8322
+ recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir);
8323
+ }
8324
+ }
8325
+
8112
8326
  // Remove profile marker so a clean reinstall defaults to full surface.
8113
8327
  try {
8114
8328
  fs.unlinkSync(path.join(targetDir, '.gsd-profile'));
@@ -8116,7 +8330,16 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8116
8330
  } catch {}
8117
8331
 
8118
8332
  // 1. Remove GSD commands/skills (layout-driven)
8119
- const scope = isGlobal ? 'global' : 'local';
8333
+ // #2870: scope id resolved ONCE here and reused below (was two independent
8334
+ // isGlobal-derived re-derivations). Routed through the Install Scope
8335
+ // Module (src/install-scope.cts) when the capability registry is
8336
+ // available; degrades to the plain id on failure (unknown/non-installable
8337
+ // runtime, broken bundle) so this function's scope-id uses — which never
8338
+ // depended on registry availability before this migration — keep working
8339
+ // exactly as they did pre-migration.
8340
+ const _uninstallScopeId = isGlobal ? 'global' : 'local';
8341
+ const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
8342
+ const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
8120
8343
  // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
8121
8344
  // Fail-open to the engine directly if the composed-registry adapter can't load.
8122
8345
  const _uninstallAdapter = _runtimeAdapter(runtime);
@@ -8202,72 +8425,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8202
8425
  // cleanup can't be driven by anything under targetDir the way every other
8203
8426
  // hook surface above is.
8204
8427
  if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
8205
- const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
8206
- const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8207
- const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8208
- if (kimiHooksCleanup.changed) {
8209
- removedCount++;
8210
- console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
8211
- }
8212
-
8213
- // Kimi's shared hook scripts + CommonJS package.json marker are installed
8214
- // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
8215
- // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
8216
- // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
8217
- // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
8218
- // may hold the user's real config.toml/providers), so only the exact
8219
- // GSD-owned filenames are removed, and directories are pruned only if left
8220
- // empty by that removal.
8221
- const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
8222
- if (fs.existsSync(kimiHooksDir)) {
8223
- let kimiHookCount = 0;
8224
- for (const hook of GSD_UNINSTALL_HOOKS) {
8225
- const hookPath = path.join(kimiHooksDir, hook);
8226
- if (fs.existsSync(hookPath)) {
8227
- fs.unlinkSync(hookPath);
8228
- kimiHookCount++;
8229
- }
8230
- }
8231
- if (kimiHookCount > 0) {
8232
- removedCount++;
8233
- console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
8234
- }
8235
-
8236
- const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
8237
- if (fs.existsSync(kimiHooksLibDir)) {
8238
- let removedKimiLibFiles = 0;
8239
- for (const file of GSD_HOOK_LIB_FILES) {
8240
- try {
8241
- fs.unlinkSync(path.join(kimiHooksLibDir, file));
8242
- removedKimiLibFiles++;
8243
- } catch (_) { /* best-effort */ }
8244
- }
8245
- try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
8246
- if (removedKimiLibFiles > 0) {
8247
- removedCount++;
8248
- console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
8249
- }
8250
- }
8251
-
8252
- // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8253
- // before the emptiness check below, or the dir would never prune.
8254
- if (removeCommonJsMarker(kimiHooksDir)) {
8255
- removedCount++;
8256
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8257
- }
8258
-
8259
- try {
8260
- if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8261
- } catch (_) { /* not empty — leave it */ }
8262
- }
8263
-
8264
- // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8265
- // used to write it. Exact content match — a user's own package.json in
8266
- // kimi's native config home is never touched.
8267
- if (removeCommonJsMarker(kimiHooksRoot)) {
8268
- removedCount++;
8269
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8270
- }
8428
+ removedCount += reclaimKimiHooksRoot(resolveKimiHooksTomlDir({ runtime }));
8271
8429
  }
8272
8430
 
8273
8431
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
@@ -8443,18 +8601,42 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8443
8601
  // Preserve user-owned dev-preferences.md if present (#1423 parity).
8444
8602
  const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
8445
8603
  if (fs.existsSync(legacyGsdCommandsDir)) {
8446
- const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md');
8447
- const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null;
8448
- fs.rmSync(legacyGsdCommandsDir, { recursive: true });
8449
- removedCount++;
8450
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8451
- if (savedDevPrefs) {
8452
- try {
8453
- fs.mkdirSync(legacyGsdCommandsDir, { recursive: true });
8454
- fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs);
8455
- console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
8456
- } catch (err) {
8457
- console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
8604
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
8605
+ // #1874-F19 "site 7" — found by sweeping bin/install.js for the
8606
+ // read-then-wipe-then-write PATTERN, not for preserveUserArtifacts'
8607
+ // callers; this uninstall-path block open-coded the same round-trip).
8608
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8609
+ // root cannot be resolved — skip this legacy-cleanup block entirely
8610
+ // (leave the stale dir in place) rather than wipe without a durable
8611
+ // backup for dev-preferences.md.
8612
+ const _legacyGsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8613
+ if (_legacyGsdCommandsStagingRoot !== null) {
8614
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdCommandsDir, ['dev-preferences.md'], _legacyGsdCommandsStagingRoot);
8615
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8616
+ // EMPTY dev-preferences.md was (and still is) silently not restored.
8617
+ const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
8618
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
8619
+ : null;
8620
+ fs.rmSync(legacyGsdCommandsDir, { recursive: true });
8621
+ removedCount++;
8622
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8623
+ if (savedDevPrefs) {
8624
+ try {
8625
+ restoreStagedUserArtifacts(legacyGsdCommandsDir, stagedDevPrefs);
8626
+ discardStagedUserArtifacts(stagedDevPrefs);
8627
+ console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
8628
+ } catch (err) {
8629
+ console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
8630
+ }
8631
+ } else {
8632
+ // #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and
8633
+ // still is) never restored — the original truthy-content check is
8634
+ // preserved byte-for-byte above — but the staged batch was never
8635
+ // discarded either, leaking a <configDir>/.gsd-staging/ record
8636
+ // forever and re-materializing the just-deleted file on a future
8637
+ // install's orphan-recovery pass. Discard unconditionally when there
8638
+ // is nothing to restore.
8639
+ discardStagedUserArtifacts(stagedDevPrefs);
8458
8640
  }
8459
8641
  }
8460
8642
  }
@@ -8472,21 +8654,77 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8472
8654
  // so this is a best-effort guard.
8473
8655
  const legacyDir = path.join(targetDir, 'commands', 'gsd');
8474
8656
  if (fs.existsSync(legacyDir)) {
8475
- const savedLegacyArtifacts = preserveUserArtifacts(legacyDir, ['dev-preferences.md']);
8476
- fs.rmSync(legacyDir, { recursive: true });
8477
- removedCount++;
8478
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8479
- const _uninstallScope = isGlobal ? 'global' : 'local';
8480
- if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) {
8481
- // Compute the actual path written so the log line is accurate per-runtime
8482
- const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
8483
- const _sk = _layout.kinds.find((k) => k.kind === 'skills');
8484
- const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
8485
- const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
8486
- console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
8487
- } else {
8488
- // Migration failed or already exists — restore to legacy location so user content is not lost
8489
- restoreUserArtifacts(legacyDir, savedLegacyArtifacts);
8657
+ // #2875 (#1874-F19): staged DURABLY to disk before the wipe below,
8658
+ // instead of an in-memory Map only — a crash between the wipe and the
8659
+ // restore-on-failure branch below now survives via
8660
+ // recoverOrphanedUserArtifacts on the next run.
8661
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8662
+ // root cannot be resolved — skip this legacy-migration block entirely
8663
+ // (leave the stale dir in place) rather than wipe without a durable
8664
+ // backup.
8665
+ const stagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8666
+ if (stagingRoot !== null) {
8667
+ const stagedLegacyArtifacts = stageUserArtifacts(legacyDir, ['dev-preferences.md'], stagingRoot);
8668
+ fs.rmSync(legacyDir, { recursive: true });
8669
+ removedCount++;
8670
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8671
+ const _uninstallScope = scope;
8672
+ // migrateLegacyDevPreferencesToSkill's Map<string,string> contract is
8673
+ // unchanged — read the staged content back from disk (not an in-memory
8674
+ // value held across the wipe above).
8675
+ //
8676
+ // #2875 defect fix (readFileSync following a staged symlink) — matches
8677
+ // install-engine.cts's _runLegacyInstallMigrations call site 1 exactly:
8678
+ // readFileSync ALWAYS follows a symlink, so a staged artifact that is
8679
+ // itself a symlink (user-artifact-staging.cts's "Symlink safety": a
8680
+ // symlinked user artifact is recreated AS a symlink in the staging
8681
+ // tree, never copied by content) would have its REFERENT's bytes read
8682
+ // here and land in SKILL.md. Excluded from migration below and
8683
+ // restored to its original location unchanged instead.
8684
+ // #2875 defect fix (regression closed — was previously unguarded and
8685
+ // BRICKED uninstall, the very command that should recover from this):
8686
+ // legacyDir was already removed above, so stagedLegacyArtifacts is
8687
+ // the only surviving copy. migrateLegacyDevPreferencesToSkill
8688
+ // correctly THROWS when it finds a planted/dangling symlink at the
8689
+ // skill-file leaf (security fix); a raw `fs.lstatSync` in the loop
8690
+ // below can also throw on a TOCTOU-vanished staged file. Either one,
8691
+ // left unguarded, propagated straight out of uninstall, aborting it
8692
+ // WITHOUT ever reaching the restore-or-discard branch below — the
8693
+ // staged batch was orphaned on disk and every retry hit the same
8694
+ // throw again. Degrade identically to every other #2875 staging step
8695
+ // in this function: catch, warn once, and treat the batch as
8696
+ // unmigrated so the restore branch below always fires.
8697
+ let _legacyMigrated = false;
8698
+ let migratableLegacyNames = [];
8699
+ try {
8700
+ const savedLegacyArtifacts = new Map();
8701
+ for (const name of stagedLegacyArtifacts.names) {
8702
+ const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name);
8703
+ if (fs.lstatSync(stagedPath).isSymbolicLink()) continue;
8704
+ savedLegacyArtifacts.set(name, fs.readFileSync(stagedPath, 'utf8'));
8705
+ migratableLegacyNames.push(name);
8706
+ }
8707
+ _legacyMigrated = migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope);
8708
+ } catch (err) {
8709
+ console.warn(` ${yellow}!${reset} dev-preferences.md migration skipped (${err.message}) — restoring the legacy copy instead.`);
8710
+ _legacyMigrated = false;
8711
+ migratableLegacyNames = [];
8712
+ }
8713
+ if (_legacyMigrated && migratableLegacyNames.length === stagedLegacyArtifacts.names.length) {
8714
+ // Compute the actual path written so the log line is accurate per-runtime
8715
+ const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
8716
+ const _sk = _layout.kinds.find((k) => k.kind === 'skills');
8717
+ const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
8718
+ const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
8719
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
8720
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8721
+ } else {
8722
+ // Migration failed, already exists, or a symlinked name was excluded
8723
+ // above — restore the WHOLE batch to the legacy location so no user
8724
+ // content is silently lost.
8725
+ restoreStagedUserArtifacts(legacyDir, stagedLegacyArtifacts);
8726
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8727
+ }
8490
8728
  }
8491
8729
  }
8492
8730
  }
@@ -8494,22 +8732,49 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8494
8732
  // 2. Remove gsd-core directory
8495
8733
  const gsdDir = path.join(targetDir, 'gsd-core');
8496
8734
  if (fs.existsSync(gsdDir)) {
8497
- // Preserve user-generated files before wipe (#1423)
8498
- const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
8499
- const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
8500
-
8501
- fs.rmSync(gsdDir, { recursive: true });
8502
- removedCount++;
8503
- console.log(` ${green}✓${reset} Removed gsd-core/`);
8735
+ // Stage user-generated files DURABLY to disk before wipe (#1423; #2875 /
8736
+ // #1874-F19 "site 5" — this block open-coded its own preserve/restore
8737
+ // instead of calling preserveUserArtifacts, which is why it was missed
8738
+ // by the original symbol-search measurement).
8739
+ // #2875 defect fix: this IS the core uninstall step (removing gsd-core/)
8740
+ // — unlike the optional legacy-cleanup blocks above, uninstall must
8741
+ // still be able to proceed and actually remove gsd-core/ even when the
8742
+ // staging root cannot be resolved. Degrade by skipping ONLY the
8743
+ // USER-PROFILE.md preserve/restore wrapper (warn), never the removal
8744
+ // itself.
8745
+ const _gsdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8746
+ if (_gsdDirStagingRoot === null) {
8747
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`);
8748
+ fs.rmSync(gsdDir, { recursive: true });
8749
+ removedCount++;
8750
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8751
+ } else {
8752
+ const stagedProfile = stageUserArtifacts(gsdDir, USER_OWNED_ARTIFACTS, _gsdDirStagingRoot);
8753
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8754
+ // EMPTY USER-PROFILE.md was (and still is) silently not restored —
8755
+ // matching prior behavior byte-for-byte rather than widening scope.
8756
+ const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md')
8757
+ ? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8')
8758
+ : null;
8759
+
8760
+ fs.rmSync(gsdDir, { recursive: true });
8761
+ removedCount++;
8762
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8504
8763
 
8505
- // Restore user-generated files
8506
- if (preservedProfile) {
8507
- try {
8508
- fs.mkdirSync(gsdDir, { recursive: true });
8509
- fs.writeFileSync(userProfilePath, preservedProfile);
8510
- console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
8511
- } catch (err) {
8512
- console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
8764
+ // Restore user-generated files
8765
+ if (preservedProfile) {
8766
+ try {
8767
+ restoreStagedUserArtifacts(gsdDir, stagedProfile);
8768
+ discardStagedUserArtifacts(stagedProfile);
8769
+ console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
8770
+ } catch (err) {
8771
+ console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
8772
+ }
8773
+ } else {
8774
+ // #2875 defect fix: same empty-file orphan leak as the legacy
8775
+ // commands/gsd/ site above — discard the staging batch regardless of
8776
+ // whether the staged content was truthy.
8777
+ discardStagedUserArtifacts(stagedProfile);
8513
8778
  }
8514
8779
  }
8515
8780
  }
@@ -8642,13 +8907,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8642
8907
  // Any file NOT in this set is user-owned and must survive uninstall.
8643
8908
  // After removing GSD files, attempt to rmdir — if the directory is still
8644
8909
  // non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
8645
- const GSD_CHANGESET_FILES = [
8646
- 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
8647
- 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
8648
- 'README.md', // documentation only — not user-authored
8649
- ];
8650
- const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs'];
8651
-
8910
+ // GSD_CHANGESET_FILES / GSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
8911
+ // tests can assert their parity against the real directory contents.
8652
8912
  const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
8653
8913
  if (fs.existsSync(changesetUninstallDir)) {
8654
8914
  let removedChangeset = 0;
@@ -9525,23 +9785,48 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9525
9785
  // so the manifest records what's actually on disk. _resolveSkillsRootDir already
9526
9786
  // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
9527
9787
  // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
9528
- const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
9788
+ // #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the
9789
+ // scope RECORDED in the manifest are one value, resolved once. Two reads of
9790
+ // `options.scope` could drift; one cannot.
9791
+ const resolvedScope = options.scope === 'local' ? 'local' : 'global';
9792
+ const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
9529
9793
  const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
9530
- const agentsDir = path.join(configDir, 'agents');
9794
+ // #3738: resolve the ACTUAL agents-install dir honoring an agents-kind `home`
9795
+ // override (antigravity global → $HOME/.gemini/config/agents), mirroring
9796
+ // _resolveSkillsRootDir for skills. Hardcoding configDir/agents left the
9797
+ // manifest blind to the whole agents surface the moment the override landed —
9798
+ // no drift detection, no patch backup. Falls back to <configDir>/agents.
9799
+ const agentsDir = _kindDestDirSafe(runtime, configDir, resolvedScope, 'agents')
9800
+ || _kindDestDirSafe(runtime, configDir, resolvedScope, 'kimi-agents')
9801
+ || path.join(configDir, 'agents');
9531
9802
  const manifest = {
9803
+ // Schema version of this DOCUMENT (#2872) — distinct from `version`
9804
+ // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1)
9805
+ // manifest, which readInstallManifest still reads without error and
9806
+ // without requiring a reinstall. Read from the Installer Migration
9807
+ // Module rather than repeated as a second literal: the writer here and
9808
+ // the reader's normalizeManifestVersion are two surfaces over one
9809
+ // constant, and this repo's "generative fix divergence" class is exactly
9810
+ // two such literals drifting apart.
9811
+ manifestVersion: MANIFEST_SCHEMA_VERSION,
9532
9812
  version: pkg.version,
9533
9813
  timestamp: new Date().toISOString(),
9534
9814
  mode: options.mode === 'minimal' ? 'minimal' : 'full',
9815
+ // Recorded so an Installed Surface Resolver can answer "which surfaces
9816
+ // are installed, at which scopes, for which runtimes" without re-deriving
9817
+ // it from the directory it happened to be found in (#2872).
9818
+ runtime,
9819
+ scope: resolvedScope,
9535
9820
  files: {},
9536
9821
  };
9537
9822
 
9538
9823
  const gsdHashes = generateManifest(gsdDir);
9539
9824
  for (const [rel, hash] of Object.entries(gsdHashes)) {
9540
- // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are preserved
9541
- // across reinstalls by preserveUserArtifacts and must NOT be hashed into
9542
- // the manifest — otherwise saveLocalPatches() would flag every refresh
9543
- // as a "local patch" (bug #2771). Single source of truth:
9544
- // USER_OWNED_ARTIFACTS at top of file.
9825
+ // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged
9826
+ // durably and restored across reinstalls (user-artifact-staging.cts,
9827
+ // #2875) and must NOT be hashed into the manifest — otherwise
9828
+ // saveLocalPatches() would flag every refresh as a "local patch"
9829
+ // (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file.
9545
9830
  if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
9546
9831
  manifest.files['gsd-core/' + rel] = hash;
9547
9832
  }
@@ -10029,21 +10314,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10029
10314
  // below were removed, leaving isKimi unused in this function (the kimi
10030
10315
  // local-install-deferred branch above already reads
10031
10316
  // _hostBehaviors(runtime).localInstallDeferred instead of this flag).
10032
- // #2096: isAntigravity dropped — antigravity is in
10033
- // _DESCRIPTOR_AGENTS_RUNTIMES below, so its two legacy-agent-loop branches
10034
- // (the path-rewrite skip and the converter dispatch) were unreachable dead
10035
- // code; both were removed rather than re-gated on hostBehaviors.
10036
- // #2098: isCodebuddy dropped — codebuddy is also in
10037
- // _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch
10038
- // (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
10317
+ // #2096: isAntigravity dropped — antigravity's agents were already
10318
+ // descriptor-driven (installRuntimeArtifacts), so its two legacy-agent-loop
10319
+ // branches (the path-rewrite skip and the converter dispatch) were
10320
+ // unreachable dead code; both were removed rather than re-gated on
10321
+ // hostBehaviors. (#2875 Part 2 later deleted that legacy loop and its
10322
+ // `_DESCRIPTOR_AGENTS_RUNTIMES` gate entirely — EVERY runtime is now
10323
+ // descriptor-driven for agents, not just this subset.)
10324
+ // #2098: isCodebuddy dropped — codebuddy's agents were likewise already
10325
+ // descriptor-driven, so its legacy converter-dispatch branch (the
10326
+ // `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
10039
10327
  // unreachable dead code and was removed rather than re-gated.
10040
- // #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES
10041
- // below, so its three legacy-agent-loop branches (the path-rewrite skip,
10042
- // the converter dispatch, and the .agent.md destName ternary) were
10043
- // unreachable dead code and were removed rather than re-gated; the
10044
- // .agent.md suffix now lives on hostBehaviors.agentFileExtension in
10045
- // src/install-engine.cts, and the skipSharedHooksInstall check above no
10046
- // longer needs `&& !isCopilot`.
10328
+ // #2099: isCopilot dropped — copilot's agents were likewise already
10329
+ // descriptor-driven, so its three legacy-agent-loop branches (the
10330
+ // path-rewrite skip, the converter dispatch, and the .agent.md destName
10331
+ // ternary) were unreachable dead code and were removed rather than
10332
+ // re-gated; the .agent.md suffix now lives on
10333
+ // hostBehaviors.agentFileExtension in src/install-engine.cts, and the
10334
+ // skipSharedHooksInstall check above no longer needs `&& !isCopilot`.
10047
10335
  // #2100: isWindsurf dropped — its four former isWindsurf-gated branches
10048
10336
  // (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the
10049
10337
  // workflow-verification report, and the shared-hooks-install exclusion) are
@@ -10051,14 +10339,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10051
10339
  // hostBehaviors.installsCommandBodiesForWorkflowDelegation,
10052
10340
  // hostBehaviors.verificationStyle === 'windsurf-workflows', and
10053
10341
  // hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop
10054
- // converter arm was likewise unreachable dead code (windsurf is in
10055
- // _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above.
10342
+ // converter arm was likewise unreachable dead code (windsurf's agents were
10343
+ // already descriptor-driven) and was removed above.
10056
10344
  // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall.
10057
10345
  const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
10058
10346
  const plan = resolveInstallPlan(runtime);
10059
10347
  const dirName = getDirName(runtime);
10060
10348
  const src = path.join(__dirname, '..');
10061
10349
 
10350
+ // #3241 — the Codex resolver-model-omitted notice dedupes "at most once", but
10351
+ // scoped per install() call rather than per process — each install() run gets
10352
+ // its own fresh window so a second install (e.g. a second runtime, or a test
10353
+ // re-running install()) can warn again if the same condition recurs.
10354
+ _codexResolverModelOmittedWarned = false;
10355
+
10062
10356
  if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
10063
10357
  console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
10064
10358
  console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
@@ -10076,6 +10370,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10076
10370
  };
10077
10371
  }
10078
10372
 
10373
+ // #2870: scope id resolved ONCE here and reused at every use below (was 10
10374
+ // independent isGlobal-derived re-derivations). Placed AFTER the kimi
10375
+ // local-deferred early return above so that return path does no extra
10376
+ // work. Routed through the Install Scope Module (src/install-scope.cts)
10377
+ // when the capability registry is available; degrades to the plain id on
10378
+ // failure (unknown/non-installable runtime, broken bundle) so this
10379
+ // function's plain scope-id uses — which never depended on registry
10380
+ // availability before this migration — keep working exactly as they did
10381
+ // pre-migration. `_installScope` (the full resolved value, not just the
10382
+ // id) additionally backs the settingsFileByScope routing below.
10383
+ const _installScopeId = isGlobal ? 'global' : 'local';
10384
+ const _installScope = _resolveScopeSafe(_installScopeId, runtime);
10385
+
10079
10386
  // Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
10080
10387
  // Defined early so it is visible to both the main and Codex code paths.
10081
10388
  // `allowlist` (when non-empty) restricts copying to the named top-level entries,
@@ -10121,6 +10428,32 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10121
10428
  ? process.cwd()
10122
10429
  : path.join(process.cwd(), dirName);
10123
10430
 
10431
+ // #3664: a --config-dir destination holding foreign agent files gets an
10432
+ // explicit install-time warning — never a silent Claude-shaped emit.
10433
+ if (isGlobal) {
10434
+ warnIfForeignAgentDest(runtime, targetDir, _installScopeId, Boolean(explicitConfigDir));
10435
+ }
10436
+
10437
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
10438
+ // artifact orphaned by a PRIOR install run that died between staging and
10439
+ // its own restore/discard, BEFORE this run's own preserve step stages
10440
+ // anything new. This is the production entry point every install() call
10441
+ // reaches — the only place this phase's durability fix is complete rather
10442
+ // than merely callable (40-design.md "The inertness trap this design must
10443
+ // avoid" / #1879-F15). Runs for every runtime, ahead of both the
10444
+ // layout-driven path's _runLegacyInstallMigrations (site 1, inside
10445
+ // installRuntimeArtifacts) and this function's own mainline gsd-core copy
10446
+ // (site 4, below).
10447
+ // #2875 defect fix: DEGRADE, never abort install, when the staging root
10448
+ // itself cannot be resolved — skip this recovery pass rather than throw
10449
+ // out of install() before it does anything.
10450
+ {
10451
+ const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10452
+ if (_installEntryStagingRoot !== null) {
10453
+ recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir);
10454
+ }
10455
+ }
10456
+
10124
10457
  const locationLabel = isGlobal
10125
10458
  ? targetDir.replace(os.homedir(), '~')
10126
10459
  : targetDir.replace(process.cwd(), '.');
@@ -10273,7 +10606,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10273
10606
  const codexPreInstallAgentContents = new Map();
10274
10607
  let codexPreInstallVersionBytes = null;
10275
10608
  if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
10276
- const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10609
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10277
10610
  if (fs.existsSync(_preSkillsDir)) {
10278
10611
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
10279
10612
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -10329,7 +10662,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10329
10662
  const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
10330
10663
  rollbackInstallerMigrations();
10331
10664
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
10332
- const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10665
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10333
10666
  for (const skillName of codexPreInstallSkillNames) {
10334
10667
  const skillDirPath = path.join(_earlySkillsDir, skillName);
10335
10668
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -10427,7 +10760,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10427
10760
  installerMigrationResult = runInstallerMigrations({
10428
10761
  configDir: targetDir,
10429
10762
  runtime,
10430
- scope: isGlobal ? 'global' : 'local',
10763
+ scope: _installScopeId,
10431
10764
  migrations: options.installerMigrations,
10432
10765
  baselineScan: true,
10433
10766
  });
@@ -10512,6 +10845,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10512
10845
  // (copyWithPathReplacement + stale-skills cleanup).
10513
10846
  const _isSkillsRuntime = (() => {
10514
10847
  if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
10848
+ // #2875 Part 2 defect fix: a runtime whose LOCAL commands are embedded in a
10849
+ // rules file rather than materialized as files (hostBehaviors.localCommandsViaRules
10850
+ // — cline is the only declarant, capabilities/cline/capability.json) must not
10851
+ // flip into this skills/commands-reporting branch merely because its local
10852
+ // artifactLayout now also declares an `agents` kind (#2875 Part 2 cline-local
10853
+ // agents regression fix). That branch's own verification reporting expects a
10854
+ // skills/ or commands/ directory this runtime never writes locally and would
10855
+ // spuriously fail; the `localCommandsViaRules` branch below (unchanged
10856
+ // messaging) and the unconditional agents-materialization block further down
10857
+ // (installAgentsKindStandalone) already cover this runtime/scope correctly.
10858
+ if (!isGlobal && _hostBehaviors(runtime).localCommandsViaRules) return false;
10515
10859
  const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
10516
10860
  const layout = cap && cap.runtime && cap.runtime.artifactLayout;
10517
10861
  if (!layout) return false;
@@ -10560,7 +10904,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10560
10904
 
10561
10905
  if (_isSkillsRuntime) {
10562
10906
  // Layout-driven install for skills-based runtimes (full and minimal modes)
10563
- const scope = isGlobal ? 'global' : 'local';
10907
+ const scope = _installScopeId;
10564
10908
  // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
10565
10909
  // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
10566
10910
  // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
@@ -10796,15 +11140,34 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10796
11140
  // that used the namespaced layout (wrote bare-name files under commands/gsd/).
10797
11141
  const legacyGsdDir = path.join(commandsDir, 'gsd');
10798
11142
  if (fs.existsSync(legacyGsdDir)) {
10799
- // Preserve user-owned dev-preferences.md before wiping
10800
- const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md');
10801
- const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
10802
- fs.rmSync(legacyGsdDir, { recursive: true });
10803
- console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
10804
- if (preservedDevPrefs) {
10805
- // Migrate dev-preferences to the new flat form
10806
- fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs);
10807
- console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
11143
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
11144
+ // #1874-F19 "site 6" — this Claude commands-install path open-coded
11145
+ // its own preserve/restore instead of calling preserveUserArtifacts,
11146
+ // found by sweeping for the read-then-wipe-then-write PATTERN rather
11147
+ // than for that helper's callers).
11148
+ // #2875 defect fix: DEGRADE, never abort install, when the staging
11149
+ // root cannot be resolved — skip this legacy-migration block entirely
11150
+ // (leave the stale dir in place) rather than wipe without a durable
11151
+ // backup.
11152
+ const _legacyGsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
11153
+ if (_legacyGsdStagingRoot !== null) {
11154
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdDir, ['dev-preferences.md'], _legacyGsdStagingRoot);
11155
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
11156
+ // EMPTY dev-preferences.md was (and still is) silently not migrated —
11157
+ // matching prior behavior byte-for-byte rather than widening scope.
11158
+ const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
11159
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
11160
+ : null;
11161
+ fs.rmSync(legacyGsdDir, { recursive: true });
11162
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
11163
+ if (preservedDevPrefs) {
11164
+ // Migrate dev-preferences to the new flat form — a RENAME on
11165
+ // restore (staged as 'dev-preferences.md', restored as
11166
+ // 'gsd-dev-preferences.md'), not a round-trip.
11167
+ restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } });
11168
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
11169
+ }
11170
+ discardStagedUserArtifacts(stagedDevPrefs);
10808
11171
  }
10809
11172
  }
10810
11173
 
@@ -10835,12 +11198,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10835
11198
  }
10836
11199
 
10837
11200
  // Copy gsd-core skill with path replacement
10838
- // Preserve user-generated files before the wipe-and-copy so they survive re-install
11201
+ // Stage user-generated files DURABLY to disk before the wipe-and-copy so
11202
+ // they survive re-install even if the process dies mid-copy (#2875 /
11203
+ // #1874-F19) — copyWithPathReplacement wipes and recursively re-copies the
11204
+ // entire gsd-core/ tree, the single longest operation in the install, on
11205
+ // the path every user takes (40-design.md "Site 4 is far worse...").
10839
11206
  const skillSrc = path.join(src, 'gsd-core');
10840
11207
  const skillDest = path.join(targetDir, 'gsd-core');
10841
- const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
10842
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10843
- restoreUserArtifacts(skillDest, savedGsdArtifacts);
11208
+ // #2875 defect fix: this IS the mainline install step (installing
11209
+ // gsd-core/ itself) — unlike the optional legacy-cleanup blocks above,
11210
+ // install must still be able to proceed and actually write gsd-core/ even
11211
+ // when the staging root cannot be resolved. Degrade by skipping ONLY the
11212
+ // USER_OWNED_ARTIFACTS preserve/restore wrapper around the copy (warn),
11213
+ // never the copy itself.
11214
+ const _gsdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
11215
+ if (_gsdArtifactsStagingRoot === null) {
11216
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/${USER_OWNED_ARTIFACTS.join(', gsd-core/')} preservation (staging unavailable) — it will be lost if present.`);
11217
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
11218
+ } else {
11219
+ const stagedGsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _gsdArtifactsStagingRoot);
11220
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
11221
+ restoreStagedUserArtifacts(skillDest, stagedGsdArtifacts);
11222
+ discardStagedUserArtifacts(stagedGsdArtifacts);
11223
+ }
10844
11224
  if (verifyInstalled(skillDest, 'gsd-core')) {
10845
11225
  console.log(` ${green}✓${reset} Installed workflow assets`);
10846
11226
  } else {
@@ -10902,219 +11282,89 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10902
11282
  }
10903
11283
  }
10904
11284
 
10905
- // Copy agents to agents directory.
10906
- // Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded
10907
- // into the runtime's Agent tool schema, costing ~6k tokens per turn even
10908
- // when no GSD workflow is active. See open-gsd/gsd-core#2762.
10909
- // Note: agentsSrc is declared as let before the enclosing try block so it
10910
- // is accessible by installCodexConfig() in the Codex config section below.
10911
- agentsSrc = _stageAgents(path.join(src, 'agents'));
10912
- const agentsDest = path.join(targetDir, 'agents');
10913
-
10914
- // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent
10915
- // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path
10916
- // applies path-rewrite + attribution + converter + normalize via
10917
- // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in
10918
- // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE
10919
- // (by installRuntimeArtifacts at line 8912), which also performs its own
10920
- // stale-file prune pass. The inline stale-removal + inline loop both skip them.
10921
- // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
10922
- // #1575: copilot and antigravity cut over — copilot gets .agent.md filename
10923
- // rename via _copyStaged(runtime); antigravity uses scope-aware converter.
10924
- // #2092 Phase B Upgrade 1: qwen cut over — native .qwen/agents/*.md subagent
10925
- // projection via convertClaudeAgentToQwenAgent. Without this exclusion the
10926
- // legacy inline loop below deletes+re-copies qwen's agents RAW (bypassing the
10927
- // new converter entirely, since qwen has no dedicated branch in the inline
10928
- // loop's if/else-if chain — it would silently fall through to the generic
10929
- // brandingRewrites-only branch).
10930
- // cline remains excluded: rules-only local branch + local/global complication
10931
- // that the descriptor-driven path does not handle correctly.
10932
- const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen', 'kimi']);
10933
-
10934
- // Always remove stale gsd-* agents first so re-installing with
10935
- // `--minimal` actually shrinks a previously-full install.
10936
- // For Codex this also covers per-agent `.toml` files alongside the `.md`
10937
- // sources so a full → minimal switch doesn't leave stale registrations.
10938
- // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes) and
10939
- // for pluginOnlyInstall runtimes (pi, ADR-1239 / #2102 Stage 1 — no agents/
10940
- // dir is ever written for them, see the leading branch below).
10941
- if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && !_hostBehaviors(runtime).pluginOnlyInstall && fs.existsSync(agentsDest)) {
10942
- for (const file of fs.readdirSync(agentsDest)) {
10943
- if (
10944
- file.startsWith('gsd-') &&
10945
- (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
10946
- ) {
10947
- fs.unlinkSync(path.join(agentsDest, file));
10948
- }
10949
- }
10950
- }
10951
-
11285
+ // Agents directory materialization.
11286
+ // #2875 Part 2 (the agents-bypass closure): EVERY runtime is now
11287
+ // descriptor-driven for agents — installRuntimeArtifacts (called earlier in
11288
+ // this function, the `_isSkillsRuntime` branch above) already wrote
11289
+ // agents/ for any runtime whose capability.json declares an `agents` kind,
11290
+ // via convertedAgentsKind/agentsKind (generic layout loop) or
11291
+ // installAgentsKindStandalone (OpenCode/Kilo's combinedFamilyInstall
11292
+ // branch, called from within installOpencodeFamilyArtifacts) — both reuse
11293
+ // the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite →
11294
+ // attribution → converter → frontmatter extensions → normalize) the
11295
+ // inline loop this replaces used to hand-roll, and both prune stale gsd-*
11296
+ // entries via their own _removeGsdEntries pass BEFORE copying (broader
11297
+ // than this loop's old extension-gated stale check — see
11298
+ // runtime-artifact-layout.cts's convertedAgentsKind doc comment).
11299
+ // Minimal-mode agent filtering is handled the SAME way it already was for
11300
+ // the ten runtimes cut over before this change: via resolvedProfile.agents
11301
+ // at staging time, not a separate branch here.
11302
+ //
11303
+ // `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path, and
11304
+ // pi) never reach that loop at all — installAgentsKindStandalone is called
11305
+ // here explicitly to cover them (install-engine.cts's own doc comment
11306
+ // explains why; a regression here was caught by the install-tree golden
11307
+ // fixture, tests/fixtures/install-tree/claude-local.json). It is a no-op
11308
+ // for pi: its capability.json declares an EMPTY artifactLayout for both
11309
+ // scopes (programmatic dispatch, no named-dispatch subagent toolkit, no
11310
+ // host-read markdown surface), so the resolved layout has no `agents` kind
11311
+ // to stage and the function returns `null` without writing anything.
10952
11312
  if (_hostBehaviors(runtime).pluginOnlyInstall) {
10953
- // pi (ADR-1239 / #2102 Stage 1): programmatic dispatch has no named-dispatch
10954
- // subagent toolkit (dispatch.subagentToolkit: "undocumented", no Agent-tool
10955
- // equivalent) and no host-read markdown surface — skip writing agents/ entirely.
10956
11313
  console.log(` ${green}✓${reset} pi: no subagent files (programmatic dispatch, no named-dispatch toolkit)`);
10957
- } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
10958
- // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
10959
- // via its own prune pass. No further action needed.
11314
+ } else if (_isSkillsRuntime) {
10960
11315
  console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
10961
- } else if (isMinimalMode(_effectiveInstallMode)) {
10962
- // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
10963
- // Without stripping them here, a full → minimal reinstall would leave the
10964
- // runtime advertising the old full agent surface even though the agent
10965
- // files are gone. Reuse the same helper that powers `--uninstall`.
10966
- if (_hostBehaviors(runtime).tomlConfigInstall) {
10967
- const codexConfigPath = path.join(targetDir, 'config.toml');
10968
- if (fs.existsSync(codexConfigPath)) {
10969
- const existing = fs.readFileSync(codexConfigPath, 'utf8');
10970
- const cleaned = stripGsdFromCodexConfig(existing);
10971
- if (cleaned === null) {
10972
- fs.unlinkSync(codexConfigPath);
10973
- } else if (cleaned !== existing) {
10974
- fs.writeFileSync(codexConfigPath, cleaned);
10975
- }
11316
+ } else {
11317
+ const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry);
11318
+ if (_standaloneAgentsResult) {
11319
+ // #2875 defect fix: installAgentsKindStandalone now returns `null`
11320
+ // (rather than a truthy result pointing at an empty destDir) whenever a
11321
+ // restricted (non-'*') resolvedProfile — --minimal being the common
11322
+ // case — legitimately stages ZERO agents, matching the pre-#2875-Part-2
11323
+ // inline loop's behavior of never creating agentsDest under --minimal
11324
+ // at all (see the deleted `isMinimalMode` branch). `destDir` is
11325
+ // therefore guaranteed non-empty whenever we reach this branch, so a
11326
+ // real staging failure still fails loudly via verifyInstalled below.
11327
+ if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) {
11328
+ console.log(` ${green}✓${reset} Installed agents`);
11329
+ } else {
11330
+ failures.push('agents');
10976
11331
  }
10977
- }
10978
- console.log(` ${dim}↳${reset} Skipping agents (minimal install — run \`gsd update\` without \`--minimal\` to add full surface)`);
10979
- } else if (fs.existsSync(agentsSrc)) {
10980
- fs.mkdirSync(agentsDest, { recursive: true });
10981
-
10982
- // Copy new agents
10983
- const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
10984
- for (const entry of agentEntries) {
10985
- if (entry.isFile() && entry.name.endsWith('.md')) {
10986
- const agentSourcePath = path.join(agentsSrc, entry.name);
10987
- let content = fs.readFileSync(agentSourcePath, 'utf8');
10988
- // #2995 (epic #1671 Phase 6.4): strip `<!-- gsd:section -->` markers BEFORE
10989
- // the path-rewrite regexes below, so a rewrite can never reach inside a
10990
- // marker attribute. No-op (byte-identical) for an unmarked agent.
10991
- content = composeWorkflow(content, { sourcePath: agentSourcePath });
10992
- // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo
10993
- const dirRegex = /~\/\.claude\//g;
10994
- const homeDirRegex = /\$HOME\/\.claude\//g;
10995
- const bareDirRegex = /~\/\.claude\b/g;
10996
- const bareHomeDirRegex = /\$HOME\/\.claude\b/g;
10997
- const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
10998
- // #2096: `&& !isAntigravity` dropped — antigravity is in
10999
- // _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already
11000
- // unreachable for it; the path-rewrite skip for antigravity now lives
11001
- // in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite).
11002
- // #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in
11003
- // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole
11004
- // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
11005
- // isCopilot was therefore always false here, making the guard a no-op.
11006
- content = content.replace(dirRegex, pathPrefix);
11007
- content = content.replace(homeDirRegex, pathPrefix);
11008
- content = content.replace(bareDirRegex, normalizedPathPrefix);
11009
- content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
11010
- content = processAttribution(content, getCommitAttribution(runtime));
11011
- // Convert frontmatter for runtime compatibility (agents need different handling)
11012
- if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
11013
- // Resolve per-agent model for OpenCode agents.
11014
- // Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
11015
- // model_overrides (#2256): explicit per-agent override, highest precedence.
11016
- // model_profile_overrides (#2794): tier-based runtime resolver, same parity as Codex.
11017
- const _ocAgentName = entry.name.replace(/\.md$/, '');
11018
- const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir);
11019
- let _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null;
11020
- if (!_ocModelOverride) {
11021
- // Fall back to tier-based resolution via model_profile_overrides.opencode.<tier>.
11022
- const _ocRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
11023
- if (_ocRuntimeResolver) {
11024
- const _ocEntry = _ocRuntimeResolver.resolve(_ocAgentName);
11025
- if (_ocEntry?.model) {
11026
- _ocModelOverride = _ocEntry.model;
11027
- }
11028
- }
11029
- }
11030
- content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
11031
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
11032
- // Resolve per-agent model for Kilo agents (#2093 UPGRADE 2; Kilo is an
11033
- // OpenCode fork with the same static-frontmatter model constraint).
11034
- // Precedence: model_overrides[agent] > model_profile_overrides.kilo.<tier> > omit.
11035
- // model_overrides (#2256): explicit per-agent override, highest precedence.
11036
- // model_profile_overrides (#2794): tier-based runtime resolver, same parity as OpenCode.
11037
- const _kiloAgentName = entry.name.replace(/\.md$/, '');
11038
- const _kiloModelOverrides = readGsdEffectiveModelOverrides(targetDir);
11039
- let _kiloModelOverride = _kiloModelOverrides?.[_kiloAgentName] || null;
11040
- if (!_kiloModelOverride) {
11041
- // Fall back to tier-based resolution via model_profile_overrides.kilo.<tier>.
11042
- const _kiloRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
11043
- if (_kiloRuntimeResolver) {
11044
- const _kiloEntry = _kiloRuntimeResolver.resolve(_kiloAgentName);
11045
- if (_kiloEntry?.model) {
11046
- _kiloModelOverride = _kiloEntry.model;
11047
- }
11048
- }
11049
- }
11050
- content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride });
11051
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
11052
- content = convertClaudeAgentToCodexAgent(content);
11053
- // #2099: `else if (isCopilot)` arm dropped — copilot is unreachable
11054
- // here (see the isCopilot-guard-drop comment above); its content
11055
- // conversion is applied pre-staging via the descriptor's
11056
- // artifactLayout.converter (runtime-artifact-layout.cts), independent
11057
- // of this legacy loop.
11058
- // #2100: `else if (isWindsurf)` arm dropped — windsurf is ALSO in
11059
- // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9575 above), so this whole
11060
- // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
11061
- // isWindsurf was therefore always false here, making the arm dead.
11062
- // Its content conversion is applied pre-staging via the descriptor's
11063
- // artifactLayout.converter (convertClaudeAgentToWindsurfAgent),
11064
- // independent of this legacy loop.
11065
- } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
11066
- // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
11067
- // hostBehaviors.frontmatterDialect === 'cline'.
11068
- content = convertClaudeAgentToClineAgent(content);
11069
- } else if (_hostBehaviors(runtime).brandingRewrites) {
11070
- // Descriptor-driven (ADR-1239 / #2092): folded from separate
11071
- // `isQwen` / hermes-hardcoded branches into a single read of
11072
- // runtime.hostBehaviors.brandingRewrites (qwen -> QWEN.md/Qwen
11073
- // Code/.qwen/, hermes -> HERMES.md/Hermes Agent/.hermes/).
11074
- const _b = _hostBehaviors(runtime).brandingRewrites;
11075
- content = content.replace(/CLAUDE\.md/g, _b['CLAUDE.md']);
11076
- content = content.replace(/\bClaude Code\b/g, _b['Claude Code']);
11077
- content = content.replace(/\.claude\//g, _b['.claude/']);
11078
- }
11079
- // #443 — Inject `effort:` into the Claude .md frontmatter ONLY.
11080
- // OpenCode/Qwen/Hermes also produce .md files but break on
11081
- // unknown frontmatter keys (the repo bans skills:/permissionMode: for
11082
- // the same reason — see tests/agent-frontmatter.test.cjs).
11083
- // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
11084
- // Injection is per-runtime at install time because the canonical source
11085
- // agents/*.md must stay runtime-safe (no effort: key in source).
11086
- if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) {
11087
- const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
11088
- const _agentName = entry.name.replace(/\.md$/, '');
11089
- const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
11090
- const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value;
11091
- content = injectEffortFrontmatter(content, _renderedEffort);
11092
- const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
11093
- if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
11094
- }
11095
- // #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
11096
- // to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
11097
- // runtimes (claude / qwen / hermes). Self-converting and
11098
- // colon-canonical runtimes are skipped by the predicate — see
11099
- // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
11100
- // SKILL.md-body fix shipped via #3629.
11101
- content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
11102
- // #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is
11103
- // unreachable here (see the isCopilot-guard-drop comment above), so
11104
- // the ternary always evaluated to entry.name in practice; its
11105
- // .agent.md suffix is applied by the descriptor-driven fold in
11106
- // src/install-engine.cts (hostBehaviors.agentFileExtension).
11107
- const destName = entry.name;
11108
- fs.writeFileSync(path.join(agentsDest, destName), content);
11109
- }
11110
- }
11111
- if (verifyInstalled(agentsDest, 'agents')) {
11112
- console.log(` ${green}✓${reset} Installed agents`);
11332
+ } else if (_resolvedProfile.skills !== '*') {
11333
+ console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`gsd update\` with a broader profile to add them)`);
11113
11334
  } else {
11114
- failures.push('agents');
11335
+ console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`);
11336
+ }
11337
+ }
11338
+
11339
+ // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections —
11340
+ // NOT agents-directory materialization (design doc "Deliberately not in
11341
+ // scope"), so this stays independent of the agents/ write above. Without
11342
+ // stripping these on a full → minimal reinstall, the runtime would keep
11343
+ // advertising the old full agent surface even though the descriptor-driven
11344
+ // write above already skipped writing the .md files for a minimal-tier
11345
+ // resolvedProfile. Reuse the same helper that powers `--uninstall`.
11346
+ if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) {
11347
+ const codexConfigPath = path.join(targetDir, 'config.toml');
11348
+ if (fs.existsSync(codexConfigPath)) {
11349
+ const existing = fs.readFileSync(codexConfigPath, 'utf8');
11350
+ const cleaned = stripGsdFromCodexConfig(existing);
11351
+ if (cleaned === null) {
11352
+ fs.unlinkSync(codexConfigPath);
11353
+ } else if (cleaned !== existing) {
11354
+ fs.writeFileSync(codexConfigPath, cleaned);
11355
+ }
11115
11356
  }
11116
11357
  }
11117
11358
 
11359
+ // agentsSrc is declared as `let` before the enclosing try block (not const)
11360
+ // so it is accessible by installCodexConfig() in the Codex config section
11361
+ // below — that function reads RAW source agents/*.md (not the
11362
+ // descriptor-staged output above) to build Codex's per-agent config.toml
11363
+ // sidecar files, a separate writer this migration deliberately does not
11364
+ // touch (design doc: "Codex's config.toml [agents.gsd-*] strip... is not
11365
+ // agents-directory materialization").
11366
+ agentsSrc = _stageAgents(path.join(src, 'agents'));
11367
+
11118
11368
  // Copy CHANGELOG.md
11119
11369
  const changelogSrc = path.join(src, 'CHANGELOG.md');
11120
11370
  const changelogDest = path.join(targetDir, 'gsd-core', 'CHANGELOG.md');
@@ -11478,10 +11728,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11478
11728
  // abort a successful install — log a warning and continue.
11479
11729
  // install() is never reached in --dry-run mode (the early-exit at the CLI
11480
11730
  // dispatch handles preview), so cleanup here always applies for real.
11481
- try {
11482
- cleanupLegacyGsdCc({ dryRun: false });
11483
- } catch (cleanupErr) {
11484
- console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
11731
+ //
11732
+ // #3799: when --config-dir redirected the install, the scan is SCOPED to
11733
+ // that destination ([targetDir]) — the default home's live install must
11734
+ // never be planned for removal from a sandboxed install. --no-legacy-cleanup
11735
+ // skips the scan entirely.
11736
+ const skipNoLegacyCleanup = parseNoLegacyCleanupArg();
11737
+ const legacyCleanupScope = (explicitConfigDir !== null && isGlobal)
11738
+ ? [targetDir]
11739
+ : undefined;
11740
+ if (!skipNoLegacyCleanup) {
11741
+ try {
11742
+ cleanupLegacyGsdCc({ dryRun: false, ...(legacyCleanupScope ? { configDirs: legacyCleanupScope } : {}) });
11743
+ } catch (cleanupErr) {
11744
+ console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
11745
+ }
11485
11746
  }
11486
11747
 
11487
11748
  if (failures.length > 0) {
@@ -11490,12 +11751,35 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11490
11751
  }
11491
11752
 
11492
11753
  // Write file manifest for future modification detection
11493
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11754
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11494
11755
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
11495
11756
 
11496
11757
  // Report any backed-up local patches
11497
11758
  reportLocalPatches(targetDir, runtime);
11498
11759
 
11760
+ // #2873: cross-scope shadow report. Fires ONCE per install (this is the
11761
+ // only writeManifest call site that gets it — the other four sites are
11762
+ // sub-writes within a single install, not separate installs). A shadowed
11763
+ // install is a warning, never a failure (ADR-2866 Consequences), so this
11764
+ // never touches `failures` or `process.exit`, and the whole block is
11765
+ // wrapped in a try/catch that swallows everything: a report failure must
11766
+ // never fail an otherwise-successful install (design row C5). No options
11767
+ // are injected into buildShadowReport — this is the production call shape,
11768
+ // resolving the real machine via os.homedir()/process.cwd() defaults
11769
+ // inside the resolver.
11770
+ try {
11771
+ const shadowReport = buildShadowReport(runtime);
11772
+ const shadowLines = renderShadowReport(shadowReport);
11773
+ if (shadowLines.length > 0) {
11774
+ console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
11775
+ for (const line of shadowLines.slice(1)) {
11776
+ console.warn(` ${dim}${line}${reset}`);
11777
+ }
11778
+ }
11779
+ } catch (_shadowReportErr) {
11780
+ // Never fail an install over a reporting concern — see comment above.
11781
+ }
11782
+
11499
11783
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
11500
11784
  if (!_hostBehaviors(runtime).ownsClaudePaths) {
11501
11785
  const leakedPaths = [];
@@ -11561,7 +11845,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11561
11845
  // #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
11562
11846
  // agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
11563
11847
  // rollback so the caller is never left in a partially-installed state.
11564
- rollbackInstallerMigrations();
11848
+ // (The second, identical rollbackInstallerMigrations() that used to sit here was
11849
+ // a duplicate of the line above, not a second phase — removed in #3725 review.)
11850
+ // #3712 — the test-home guard refuses before any LAYOUT-DRIVEN write, so no
11851
+ // gsd-* directory in the skills root has been touched and there is nothing
11852
+ // there to undo. (Legacy install migrations DO run first; that is why the
11853
+ // rollbackInstallerMigrations() calls above still execute, and why the one
11854
+ // migration that can reach a `home` override carries its own assertion.)
11855
+ // Running the codex rollback anyway would delete and recreate every
11856
+ // snapshotted gsd-* directory in the resolved skills root, which for an
11857
+ // un-sandboxed codex install IS the real ~/.agents/skills: the guard's own
11858
+ // refusal would provoke the mutation it exists to prevent. This is the only
11859
+ // _codexPreConfigRollback() call site, and applySurface/uninstall cannot
11860
+ // reach it. Every other error still rolls back. Found by review, not by CI.
11861
+ if (isTestHomeGuardRefusal(_earlyInstallErr)) throw _earlyInstallErr;
11565
11862
  if (_codexPreConfigRollback) {
11566
11863
  _codexPreConfigRollback();
11567
11864
  }
@@ -11641,7 +11938,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11641
11938
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
11642
11939
  // are restored even when they are absent from disk at rollback time (#3245 CR).
11643
11940
  // • Dirs that did not pre-exist: remove entirely.
11644
- const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
11941
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
11645
11942
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
11646
11943
  for (const skillName of codexPreInstallSkillNames) {
11647
11944
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -11756,7 +12053,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11756
12053
  // Re-write the manifest now that .toml agent files exist on disk.
11757
12054
  // The initial writeManifest call (before Codex config generation) could
11758
12055
  // not include agents/gsd-*.toml because those files did not yet exist.
11759
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12056
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11760
12057
  } else {
11761
12058
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
11762
12059
  }
@@ -12044,7 +12341,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12044
12341
  // manifest-tracked (verified) — uninstall removes them explicitly via
12045
12342
  // removeCursorHooksJson + its script list, and reconcile is idempotent.
12046
12343
  // The re-run is retained for parity with the settings.json install path.
12047
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12344
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12048
12345
  persistActiveProfileMarker();
12049
12346
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12050
12347
  }
@@ -12105,6 +12402,44 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12105
12402
  if (kimiHooksResult.changed) {
12106
12403
  console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
12107
12404
  }
12405
+
12406
+ // #3031: opt-in reclaim of the pre-#2755 legacy root. Runs LAST in this
12407
+ // branch so the kimi-code install above is already complete and durable —
12408
+ // a reclaim can only ever remove, never leave the install half-written.
12409
+ //
12410
+ // Gated on `runtime === 'kimi-code'`: a `--kimi` install resolves this
12411
+ // very same `~/.kimi` as its own hooks root, so reclaiming there would
12412
+ // delete the hooks it just wrote. The flag is silently inert for kimi
12413
+ // rather than an error — `--all` passes every runtime through this branch,
12414
+ // and one opt-in flag must not fail an otherwise valid multi-runtime run.
12415
+ //
12416
+ // ALSO gated on kimi NOT being installed by this same invocation. The
12417
+ // flag asserts "I only use Kimi Code"; `--all`, or an explicit `--kimi
12418
+ // --kimi-code`, falsifies that outright. Both orderings put `kimi` BEFORE
12419
+ // `kimi-code` (selectRuntimesFromArgs), so without this guard the run
12420
+ // installs Kimi CLI's hooks and then deletes them moments later — the run
12421
+ // reports success and the user is left with the very breakage the opt-in
12422
+ // exists to prevent. Verified reproducible before this guard existed.
12423
+ const kimiInstalledThisRun = selectedRuntimes.includes('kimi');
12424
+ if (hasReclaimKimiLegacy && runtime === 'kimi-code' && kimiInstalledThisRun) {
12425
+ console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: this run also installs --kimi, so ${resolveKimiHooksTomlDir({ runtime: 'kimi' })} is a live Kimi CLI install`);
12426
+ } else if (hasReclaimKimiLegacy && runtime === 'kimi-code') {
12427
+ const legacyKimiRoot = resolveKimiHooksTomlDir({ runtime: 'kimi' });
12428
+ // Both roots honor their own env override (KIMI_SHARE_DIR /
12429
+ // KIMI_CODE_HOME). A user who points both at ONE directory collapses
12430
+ // "the legacy root" onto "the root this install just wrote", and an
12431
+ // unguarded reclaim would delete its own output. isSameDirectory compares
12432
+ // the DIRECTORIES, not the strings — case-insensitive filesystems and
12433
+ // symlinked aliases both name one dir with two spellings.
12434
+ if (isSameDirectory(legacyKimiRoot, kimiHooksRoot)) {
12435
+ console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: ${legacyKimiRoot} is this install's own hooks root`);
12436
+ } else {
12437
+ const reclaimed = reclaimKimiHooksRoot(legacyKimiRoot);
12438
+ console.log(reclaimed > 0
12439
+ ? ` ${green}✓${reset} Reclaimed ${reclaimed} orphaned GSD artifact group(s) from ${legacyKimiRoot} (pre-#2755)`
12440
+ : ` ${dim}•${reset} No orphaned GSD artifacts found in ${legacyKimiRoot}`);
12441
+ }
12442
+ }
12108
12443
  }
12109
12444
 
12110
12445
  // ADR-1239 / #2100 Stage 2: Windsurf's own independent hooksSurface —
@@ -12131,7 +12466,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12131
12466
  // explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
12132
12467
  // is idempotent on repeated installs, so manifest tracking isn't needed
12133
12468
  // for correctness here.
12134
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12469
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12135
12470
  }
12136
12471
 
12137
12472
  persistActiveProfileMarker();
@@ -12145,7 +12480,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12145
12480
  writeClineArtifacts(targetDir, isGlobal);
12146
12481
  // Re-run the manifest pass: these artifacts are written *after* the earlier
12147
12482
  // writeManifest() call, so a second pass is needed to hash-track them.
12148
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12483
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12149
12484
  persistActiveProfileMarker();
12150
12485
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12151
12486
  }
@@ -12160,10 +12495,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12160
12495
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
12161
12496
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
12162
12497
  // settings.json. Global installs and all other runtimes continue to use settings.json.
12498
+ // #2870: the CURRENT scope's settings filename is sourced from the Install
12499
+ // Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
12500
+ // instead of indexing _scopedSettings by hand. _scopedSettings itself is
12501
+ // retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
12502
+ // already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
12503
+ // above) when the registry fails to load, whereas resolveScope's registry
12504
+ // lookup throws in that same scenario (_installScope is null when it did).
12505
+ // Falling back to _scopedSettings[_installScopeId] there — and keeping the
12506
+ // non-local-claude branch's expression untouched — means this is
12507
+ // byte-identical to the pre-migration computation in every case, including
12508
+ // the broken-registry fail-safe floor.
12163
12509
  const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
12164
- const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
12510
+ const _currentScopeSettingsFile = _installScope
12511
+ ? _installScope.settingsFile
12512
+ : (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
12513
+ const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
12165
12514
  const settingsFileName = isLocalClaude
12166
- ? _scopedSettings.local
12515
+ ? _currentScopeSettingsFile
12167
12516
  : ((_scopedSettings && _scopedSettings.global) || 'settings.json');
12168
12517
  // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
12169
12518
  // must resolve under targetDir (this path also drives a recursive mkdirSync).
@@ -12184,9 +12533,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12184
12533
  );
12185
12534
  const hasGsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
12186
12535
  isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
12187
- if (hasGsdHooks || hasGsdStatusline) {
12536
+ const needsMigration = hasGsdHooks || hasGsdStatusline;
12537
+ // readSettings returns null ONLY for an unparseable file — its documented
12538
+ // "preserve existing, don't touch" signal. Stand the WHOLE migration down
12539
+ // in that case: skipping just the local merge while still stripping the
12540
+ // shared file below would destroy the GSD entries outright instead of
12541
+ // relocating them. Leaving both files untouched lets the migration retry
12542
+ // once the user repairs the local file.
12543
+ const localRaw = needsMigration ? readSettings(settingsPath) : null;
12544
+ if (needsMigration && localRaw === null) {
12545
+ console.log(' ' + yellow + 'i' + reset + ' Skipping #338 migration — ' + settingsFileName +
12546
+ ' could not be parsed. Your existing settings are preserved.');
12547
+ } else if (needsMigration) {
12188
12548
  // Merge GSD entries into settings.local.json
12189
- const localRaw = readSettings(settingsPath) || {};
12190
12549
  if (hasGsdStatusline && !localRaw.statusLine) {
12191
12550
  localRaw.statusLine = sharedRaw.statusLine;
12192
12551
  }
@@ -12246,16 +12605,22 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12246
12605
  if (rawSettings === null) {
12247
12606
  console.log(' ' + yellow + 'i' + reset + ' Skipping settings.local.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.');
12248
12607
  persistActiveProfileMarker();
12249
- return;
12608
+ // Callers index this result by `runtime` (installAllRuntimes' statusline
12609
+ // lookup), so every early exit must return the full shape — a bare return
12610
+ // crashes the install rather than skipping one file.
12611
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12250
12612
  }
12251
12613
  const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
12252
- // #3002 CR: rewrite legacy `node .../gsd-*.js` command strings carried over
12253
- // from pre-#2979 installs to use the absolute node binary path. Without this,
12254
- // existing managed hook entries stay bare-`node`-prefixed across reinstalls
12255
- // and remain broken under GUI/minimal-PATH runtimes.
12256
- const settingsRunner = resolveNodeRunner();
12614
+ // #3002 CR / #3662: rewrite legacy `node .../gsd-*.js` command strings (pre-
12615
+ // #2979 installs) AND entries baked with another environment's absolute node
12616
+ // path onto the runtime-resolving runner. Without this, existing managed
12617
+ // hook entries stay bare-`node`-prefixed or foreign-absolute across
12618
+ // reinstalls and remain broken under GUI/minimal-PATH runtimes and shared
12619
+ // config roots — the #3662 mixed state where no environment can run all
12620
+ // hooks.
12621
+ const settingsRunner = buildNodeRunnerChainToken();
12257
12622
  if (settingsRunner && rewriteLegacyManagedNodeHookCommands(settings, settingsRunner, { platform: process.platform, runtime })) {
12258
- console.log(` ${green}✓${reset} Rewrote legacy bare-node managed-hook commands to absolute path (#2979)`);
12623
+ console.log(` ${green}✓${reset} Rewrote legacy managed-hook commands to the runtime-resolving node runner (#2979/#3662)`);
12259
12624
  }
12260
12625
  // Local installs anchor hook paths so they resolve regardless of cwd (#1906).
12261
12626
  // Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
@@ -12266,12 +12631,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12266
12631
  // check inside projectLocalHookPrefix.
12267
12632
  const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
12268
12633
  const hookOpts = { portableHooks: hasPortableHooks, runtime };
12269
- // #2979: local-install hook commands also use the absolute node path so
12270
- // GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
12271
- // host launches the runtime with a stripped PATH (Finder/Antigravity/etc).
12272
- const localNodeRunner = resolveNodeRunner();
12634
+ // #2979: local-install hook commands also use a runner GUI/minimal-PATH
12635
+ // runtimes can resolve. Bare `node` fails when the host launches the
12636
+ // runtime with a stripped PATH (Finder/Antigravity/etc) — #3662 replaces
12637
+ // the baked absolute path with the runtime-resolving chain (baked path
12638
+ // first, so the minimal-PATH guarantee is unchanged).
12639
+ const localNodeRunner = buildNodeRunnerChainToken();
12273
12640
  const localBashRunner = resolveBashRunner({ platform: process.platform });
12274
- // If we cannot resolve an absolute node path AND this is a local install,
12641
+ // If we cannot resolve a node runner AND this is a local install,
12275
12642
  // skip managed-hook registration. Returning null from buildHookCommand on
12276
12643
  // global installs has the same effect. Better to skip than to emit a bare
12277
12644
  // `node` command that recreates the #2979 failure.
@@ -13276,31 +13643,49 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
13276
13643
  * @param {object} [opts.logger=console] - injectable logger
13277
13644
  * @returns {{ plan: {path:string,reason:string}[], result: object }}
13278
13645
  */
13279
- function cleanupLegacyGsdCc({ homeDir = os.homedir(), dryRun = false, logger = console } = {}) {
13646
+ function cleanupLegacyGsdCc({ homeDir = os.homedir(), configDirs = null, dryRun = false, logger = console } = {}) {
13280
13647
  // Build de-duplicated list of candidate config dirs to scan.
13281
13648
  // Only scan under homeDir — never cwd — to prevent accidental deletion of
13282
13649
  // the user's active-project hooks when the installer is invoked from a
13283
13650
  // project directory that has .claude/hooks or similar subdirs.
13651
+ // #3799: an explicit configDirs override (install() passes [targetDir]
13652
+ // whenever --config-dir redirected the destination) scopes the WHOLE scan
13653
+ // to that dir — the default-home scan must never plan removals of a live
13654
+ // install that lives outside the destination the user chose.
13284
13655
  const seen = new Set();
13285
- const configDirs = [];
13286
- for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
13287
- const candidate = path.join(homeDir, name);
13288
- if (!seen.has(candidate) && fs.existsSync(candidate)) {
13289
- seen.add(candidate);
13290
- configDirs.push(candidate);
13656
+ const scanDirs = [];
13657
+ if (Array.isArray(configDirs) && configDirs.length > 0) {
13658
+ for (const candidate of configDirs) {
13659
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
13660
+ seen.add(candidate);
13661
+ scanDirs.push(candidate);
13662
+ }
13663
+ }
13664
+ } else {
13665
+ for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
13666
+ const candidate = path.join(homeDir, name);
13667
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
13668
+ seen.add(candidate);
13669
+ scanDirs.push(candidate);
13670
+ }
13291
13671
  }
13292
13672
  }
13293
13673
 
13294
13674
  // planLegacyCleanup scans each configDir and already includes the legacy
13295
13675
  // shared cache (gsd-update-check.json) as a plan entry.
13296
- const plan = planLegacyCleanup(configDirs, { homeDir });
13676
+ const plan = planLegacyCleanup(scanDirs, { homeDir, ...(Array.isArray(configDirs) && configDirs.length > 0 ? { configDirs } : {}) });
13297
13677
 
13298
13678
  // Apply the plan (dryRun honors the flag).
13299
13679
  const result = applyLegacyCleanup(plan, { dryRun, logger });
13300
13680
 
13301
13681
  // Also clear / preview the per-package cache so next session re-evaluates
13302
13682
  // hook versions (replaces the former inline unlinkSync on line ~9104).
13303
- const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName);
13683
+ // #3799: under a configDirs override the cache is read/cleared under the
13684
+ // SCOPE root, never the default home — same invariant as the scan itself.
13685
+ const perPkgCacheRoot = (Array.isArray(configDirs) && configDirs.length > 0)
13686
+ ? configDirs[0]
13687
+ : homeDir;
13688
+ const perPkgCacheFile = path.join(perPkgCacheRoot, '.cache', 'gsd', updateCacheFileName);
13304
13689
  if (dryRun) {
13305
13690
  logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
13306
13691
  } else {
@@ -13419,7 +13804,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
13419
13804
  });
13420
13805
  };
13421
13806
 
13422
- if (primaryStatuslineResult) {
13807
+ // `settings` is null on every early exit (unparseable file, skipped runtime),
13808
+ // and handleStatusline dereferences it — an install that declined to touch a
13809
+ // settings file has no statusline to prompt about, so fall through.
13810
+ if (primaryStatuslineResult && primaryStatuslineResult.settings) {
13423
13811
  handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
13424
13812
  } else if (canInstallBanner) {
13425
13813
  // No statusline-capable runtime, but at least one runtime can host the
@@ -13439,15 +13827,16 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
13439
13827
  module.exports = {
13440
13828
  // #3677 — hyphen-namespace normalization seam for agent bodies
13441
13829
  shouldNormalizeHyphenNamespaceInAgentBody,
13830
+ // #3664: --config-dir foreign-agent-destination warning (warn-and-proceed)
13831
+ warnIfForeignAgentDest,
13442
13832
  normalizeAgentBodyForRuntime,
13443
13833
  yamlIdentifier,
13444
- computePathPrefix,
13445
- applyRuntimeContentRewritesInPlace,
13446
13834
  getCodexSkillAdapterHeader,
13447
13835
  convertClaudeCommandToCursorSkill,
13448
13836
  convertClaudeAgentToCursorAgent,
13449
13837
  convertClaudeAgentToCodexAgent,
13450
13838
  generateCodexAgentToml,
13839
+ _resetCodexWarningDedupeForTests,
13451
13840
  cleanupCodexSkillMetadataSidecars,
13452
13841
  cleanupWindsurfLegacyDevinSkills,
13453
13842
  cleanupMovedSkillsOldLocation,
@@ -13455,7 +13844,6 @@ module.exports = {
13455
13844
  _resolveSkillsRootDir,
13456
13845
  codexBareAgentsHasOnlyKnownScalars,
13457
13846
  extractCodexUserAgentsScalars,
13458
- spliceCodexAgentsScalars,
13459
13847
  CODEX_EXTENDED_HOOK_EVENTS,
13460
13848
  generateCodexConfigBlock,
13461
13849
  stripGsdFromCodexConfig,
@@ -13466,13 +13854,6 @@ module.exports = {
13466
13854
  validateCodexConfigSchema,
13467
13855
  mergeCodexConfig,
13468
13856
  installCodexConfig,
13469
- readGsdRuntimeProfileResolver,
13470
- readGsdEffectiveModelOverrides,
13471
- readGsdEffectiveEffortConfig,
13472
- resolveInstallTimeEffort,
13473
- injectEffortFrontmatter,
13474
- get _GSD_EFFORT_MANIFEST_TIER_DEFAULTS() { return _getGsdEffortCatalog().EFFORT_MANIFEST_TIER_DEFAULTS; },
13475
- get _GSD_EFFORT_MANIFEST_DEFAULT() { return _getGsdEffortCatalog().EFFORT_MANIFEST_DEFAULT; },
13476
13857
  install,
13477
13858
  installAllRuntimes,
13478
13859
  uninstall,
@@ -13482,6 +13863,10 @@ module.exports = {
13482
13863
  // #3023 — shared hook bundle directory name, descriptor-driven
13483
13864
  SHARED_HOOKS_DIR_DEFAULT,
13484
13865
  resolveSharedHooksDirName,
13866
+ // #3184 — uninstall-side GSD-managed file enumerations, exported for
13867
+ // parity assertions against the wholesale-copy source directories
13868
+ GSD_CHANGESET_FILES,
13869
+ GSD_SCRIPTS_LIB_FILES,
13485
13870
  convertSlashCommandsToCodexSkillMentions,
13486
13871
  convertClaudeCommandToCodexSkill,
13487
13872
  convertClaudeCommandToKimiSkill,
@@ -13490,8 +13875,6 @@ module.exports = {
13490
13875
  buildKimiAgentArtifacts,
13491
13876
  convertClaudeToOpencodeFrontmatter,
13492
13877
  convertClaudeToKiloFrontmatter,
13493
- convertClaudeCommandToOpencodeSkill,
13494
- convertClaudeCommandToKiloSkill,
13495
13878
  configureOpencodePermissions,
13496
13879
  neutralizeAgentReferences,
13497
13880
  // #768 — Claude Code permissions pre-population
@@ -13500,8 +13883,10 @@ module.exports = {
13500
13883
  GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
13501
13884
  GSD_CLAUDE_DENY_PERMISSIONS,
13502
13885
  GSD_CODEX_MARKER,
13503
- CODEX_AGENT_SANDBOX,
13504
- getDirName,
13886
+ // #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
13887
+ CODEX_SANDBOX_HOLDS,
13888
+ deriveCodexSandboxMode,
13889
+ validateCodexSandboxHolds,
13505
13890
  getGlobalDir,
13506
13891
  getConfigDirFromHome,
13507
13892
  resolveKiloConfigPath,
@@ -13523,20 +13908,11 @@ module.exports = {
13523
13908
  mergeCopilotInstructions,
13524
13909
  stripGsdFromCopilotInstructions,
13525
13910
  GSD_COPILOT_HOOK_FILE,
13526
- buildCopilotHookConfig,
13527
- writeCopilotHookConfig,
13528
13911
  convertClaudeToAntigravityContent,
13529
13912
  convertClaudeCommandToAntigravitySkill,
13530
13913
  convertClaudeAgentToAntigravityAgent,
13531
13914
  convertClaudeCommandToClaudeSkill,
13532
13915
  skillFrontmatterName,
13533
- convertClaudeToWindsurfMarkdown,
13534
- convertClaudeCommandToWindsurfSkill,
13535
- convertClaudeCommandToWindsurfWorkflow,
13536
- convertClaudeAgentToWindsurfAgent,
13537
- convertClaudeToAugmentMarkdown,
13538
- convertClaudeCommandToAugmentSkill,
13539
- convertClaudeAgentToAugmentAgent,
13540
13916
  convertClaudeToTraeMarkdown,
13541
13917
  convertClaudeCommandToTraeSkill,
13542
13918
  convertClaudeAgentToTraeAgent,
@@ -13547,8 +13923,6 @@ module.exports = {
13547
13923
  convertClaudeToCliineMarkdown,
13548
13924
  convertClaudeCommandToClineSkill,
13549
13925
  convertClaudeAgentToClineAgent,
13550
- // #2284(b) — cross-cutting branding protected-region helper
13551
- applyClaudeCodeBrandSwap,
13552
13926
  // #2284 — Hermes named-dispatch → delegate_task projection
13553
13927
  convertClaudeToHermesMarkdown,
13554
13928
  projectNamedDispatchToStructuralDelegate,
@@ -13558,30 +13932,11 @@ module.exports = {
13558
13932
  maskStringLiterals,
13559
13933
  findDispatchCallSpans,
13560
13934
  _assertProjectionComplete,
13561
- _normalizeDispatchCallSpan,
13562
- buildClineRulesBody,
13563
- buildClineAgentsMdBody,
13564
- buildClinePreToolUseHook,
13565
- writeClineArtifacts,
13566
- mergeGsdAgentsMd,
13567
13935
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
13568
13936
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
13569
- GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
13570
- GSD_CURSOR_STOP_HOOK_SCRIPT,
13571
- GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
13572
- GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
13573
- GSD_CURSOR_HOOK_SCRIPTS,
13574
13937
  GSD_CURSOR_HOOK_MARKER,
13575
- buildCursorHookEntry,
13576
- isManagedCursorHookEntry,
13577
- reconcileCursorHooksJson,
13578
- writeCursorHooksJson,
13579
- removeCursorHooksJson,
13580
13938
  GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
13581
13939
  GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
13582
- GSD_WINDSURF_HOOK_SCRIPTS,
13583
- writeWindsurfHooksJson,
13584
- removeWindsurfHooksJson,
13585
13940
  stripGsdFromAgentsMd,
13586
13941
  GSD_AGENTS_MD_MARKER,
13587
13942
  GSD_AGENTS_MD_CLOSE_MARKER,
@@ -13589,11 +13944,9 @@ module.exports = {
13589
13944
  saveLocalPatches,
13590
13945
  reportLocalPatches,
13591
13946
  validateHookFields,
13592
- preserveUserArtifacts,
13593
- restoreUserArtifacts,
13594
- migrateLegacyDevPreferencesToSkill,
13595
13947
  populatePristineDir,
13596
- USER_OWNED_ARTIFACTS,
13948
+ _resolveUserArtifactStagingRoot,
13949
+ _tryResolveUserArtifactStagingRoot,
13597
13950
  finishInstall,
13598
13951
  homePathCoveredByRc,
13599
13952
  homePathCoveredByFishConfig,
@@ -13608,34 +13961,13 @@ module.exports = {
13608
13961
  buildUpdateBannerPromptText,
13609
13962
  parseUpdateBannerInput,
13610
13963
  buildUpdateBannerHookEntry,
13611
- buildHookCommand,
13612
- normalizeNodePath,
13613
- resolveNodeRunner,
13614
- referencesHook,
13615
- applySettingsJsonHooks,
13616
- rewriteLegacyManagedNodeHookCommands,
13617
- buildCodexHookBlock,
13618
- rewriteLegacyCodexHookBlock,
13619
- buildCodexHookWindowsShimIR,
13620
- ensureCodexHooksJsonSessionStart,
13621
- ensureCodexHooksJsonEvent,
13622
- removeCodexHooksJsonEvent,
13623
- reconcileCodexHooksJsonEvent,
13624
- readGsdCommandNames,
13625
- installRuntimeArtifacts,
13626
- installOpencodeFamilySkills,
13627
- uninstallRuntimeArtifacts,
13628
13964
  parseConfigDirFromArgs,
13629
13965
  cleanupLegacyGsdCc,
13630
- _applyRuntimeRewrites,
13631
13966
  // #1191 — exported so tests exercise the REAL readSettings, not a replica
13632
13967
  readSettings,
13968
+ writeSettings,
13969
+ writeNonClaudeDefaults,
13633
13970
  stripJsonComments,
13634
- // Compatibility relays retained after auditing the former broad
13635
- // runtimeArtifactConversion spread (#1559).
13636
- processAttribution,
13637
- applyRuntimeContentRewritesForCommandsInPlace,
13638
- _copyStaged,
13639
13971
  copyWithPathReplacement,
13640
13972
  };
13641
13973
 
@@ -13649,10 +13981,23 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
13649
13981
  console.log('Dry run — no files will be modified.\n');
13650
13982
  // cleanupLegacyGsdCc with dryRun:true is the single source of truth for
13651
13983
  // both the legacy artifacts and the per-package cache path — no duplicate
13652
- // printing here.
13653
- const { plan } = cleanupLegacyGsdCc({ dryRun: true });
13654
- if (plan.length === 0) {
13655
- console.log(' (no legacy get-shit-done-cc artifacts found)');
13984
+ // printing here. #3799: the preview honors the SAME scope and skip the
13985
+ // real install would apply (--config-dir scopes; --no-legacy-cleanup
13986
+ // skips) — a preview that listed default-home paths a real install would
13987
+ // never touch misrepresents the run.
13988
+ if (parseNoLegacyCleanupArg()) {
13989
+ console.log(' (--no-legacy-cleanup — legacy scan skipped)');
13990
+ } else {
13991
+ const previewScope = (explicitConfigDir !== null)
13992
+ ? [getGlobalConfigDir(DEFAULT_RUNTIME, explicitConfigDir)]
13993
+ : undefined;
13994
+ const { plan } = cleanupLegacyGsdCc({
13995
+ dryRun: true,
13996
+ ...(previewScope ? { configDirs: previewScope } : {}),
13997
+ });
13998
+ if (plan.length === 0) {
13999
+ console.log(' (no legacy get-shit-done-cc artifacts found)');
14000
+ }
13656
14001
  }
13657
14002
  process.exit(0);
13658
14003
  } else if (hasSkillsRoot) {