@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
package/bin/install.js CHANGED
@@ -36,10 +36,18 @@ 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');
39
44
  // getDirName (runtime -> local config dir name) is relocated out of this
40
45
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
41
46
  // conversion module's rewrite engine can consume it without importing
42
- // bin/install.js. Re-exported below for back-compat consumers/tests.
47
+ // bin/install.js. Imported here for install.js's own internal call sites
48
+ // (getConfigDirFromHome and the runtime-content-rewrite loops below) — #2876
49
+ // retired the re-export; tests now import getDirName directly from
50
+ // gsd-core/bin/lib/runtime-name-policy.cjs.
43
51
  const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
44
52
  const {
45
53
  applyWorktreeBaseRef,
@@ -54,6 +62,11 @@ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs'
54
62
  // MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
55
63
  const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
56
64
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
65
+ const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
66
+ // #2873: cross-scope shadow detection — reports (never fails) when a
67
+ // GSD-owned scope shadows another on this machine (design doc:
68
+ // .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md).
69
+ const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
57
70
  // #2544: the CommonJS marker's single source of truth. classifyMarker() backs
58
71
  // BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
59
72
  // so the write side can no longer clobber a package.json the remove side would
@@ -66,9 +79,14 @@ const { ensureCommonJsMarker, removeCommonJsMarker } = require('../gsd-core/bin/
66
79
  const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
67
80
  const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
68
81
 
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.
82
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated
83
+ // module. install.js used to re-export the whole hooksSurface surface so
84
+ // existing callers (require('../bin/install.js').writeCursorHooksJson etc.)
85
+ // kept working — #2876 found zero production/test consumers of any of those
86
+ // re-exports (tests import runtime-hooks-surface.cjs directly) and retired
87
+ // them from module.exports. install.js still requires hooksSurface below for
88
+ // its own internal call sites (writeCursorHooksJson, writeClineArtifacts,
89
+ // resolveNodeRunner, applySettingsJsonHooks, etc.).
72
90
  const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
73
91
 
74
92
  /**
@@ -296,13 +314,26 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
296
314
  // subagentStart → gsd-cursor-subagent-start.js (subagent context injection)
297
315
  // subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder)
298
316
  // 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).
317
+ //
318
+ // These script-name/marker constants used to be independently re-declared
319
+ // here with their own string literals — a second, unlinked copy of exactly
320
+ // the values runtime-hooks-surface.cts also defines for its own internal use
321
+ // (buildCursorHookEntry, writeCursorHooksJson, etc.). #2876's code review
322
+ // found tests reading the constant from install.js's copy while calling
323
+ // functions built from hooksSurface's copy, with nothing guarding the two
324
+ // staying equal — the same unlinked-duplicate-value hazard the ADR-1508
325
+ // dedup elsewhere in this file exists to prevent. Fixed at the root: these
326
+ // are now bare references to hooksSurface's own exports, so there is exactly
327
+ // one literal definition of each value, full stop.
328
+ const GSD_CURSOR_SESSION_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SESSION_HOOK_SCRIPT;
329
+ const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_POST_TOOL_HOOK_SCRIPT;
330
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT;
331
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_STOP_HOOK_SCRIPT;
332
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT;
333
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = hooksSurface.GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT;
334
+ // All GSD-managed Cursor hook scripts (used by uninstall cleanup). Not
335
+ // independently defined in hooksSurface — built here from the bare
336
+ // references above, so it can never drift from them either.
306
337
  const GSD_CURSOR_HOOK_SCRIPTS = [
307
338
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
308
339
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
@@ -312,7 +343,7 @@ const GSD_CURSOR_HOOK_SCRIPTS = [
312
343
  GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
313
344
  ];
314
345
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
315
- const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
346
+ const GSD_CURSOR_HOOK_MARKER = hooksSurface.GSD_CURSOR_HOOK_MARKER;
316
347
 
317
348
  // #2100 Stage 2 — Windsurf/Cascade lifecycle hook constants.
318
349
  // Windsurf/Cascade reads hook configs from <project-root>/.windsurf/hooks.json
@@ -328,15 +359,17 @@ const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
328
359
  // have no Windsurf counterpart and are deliberately NOT ported.
329
360
  // Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
330
361
  // 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';
362
+ //
363
+ // Same #2876 fix as the Cursor block above: bare references to hooksSurface's
364
+ // own exports instead of a second, unlinked literal copy.
365
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT;
366
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = hooksSurface.GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT;
333
367
  // 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
- ];
368
+ // hooksSurface independently defines the same array — bare reference here so
369
+ // the two can never drift.
370
+ const GSD_WINDSURF_HOOK_SCRIPTS = hooksSurface.GSD_WINDSURF_HOOK_SCRIPTS;
338
371
 
339
- // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
372
+ // GSD-managed files under hooks/lib/ (helpers required by gsd-*.js hooks).
340
373
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
341
374
  // cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
342
375
  // are staged individually by writeCursorHooksJson (Cursor sets
@@ -344,7 +377,10 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
344
377
  // copy below) — that function stages this helper alongside them. Listing it
345
378
  // here keeps uninstall and the manifest managing it for every OTHER runtime
346
379
  // that does receive hooks/lib.
347
- const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
380
+ // injection-patterns.js (#3504) is required by gsd-prompt-guard.js and
381
+ // gsd-read-injection-scanner.js — the shared prompt-injection pattern list the
382
+ // two guards require so their copies cannot drift.
383
+ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
348
384
 
349
385
  /**
350
386
  * Directory name GSD stages its shared hook bundle under, inside a runtime's
@@ -361,6 +397,21 @@ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-wor
361
397
  */
362
398
  const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
363
399
 
400
+ // #3184 — GSD-managed file enumerations for scripts/changeset/ and scripts/lib/
401
+ // uninstall. The install-side copy of both directories is wholesale ("copy every
402
+ // file present"), so these enumerations MUST be kept in parity with the real
403
+ // directory contents or an added file ships on install and then orphans on
404
+ // uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
405
+ // Hoisted to module scope (and exported below) so tests/install.test.cjs can
406
+ // assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
407
+ // without source-grepping this file.
408
+ const GSD_CHANGESET_FILES = [
409
+ 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
410
+ 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
411
+ 'README.md', // documentation only — not user-authored
412
+ ];
413
+ const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs'];
414
+
364
415
  /**
365
416
  * Resolve a runtime's shared-hooks directory name from its descriptor.
366
417
  *
@@ -450,14 +501,13 @@ const pkg = require('../package.json');
450
501
  // of cwd, but keeping the require at the top makes the dependency explicit and
451
502
  // surfaces resolution failures at process start instead of at first install call.
452
503
  const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
453
- const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
454
504
  const {
455
505
  RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
506
+ isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel,
456
507
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
457
- const {
458
- resolveTierEntry: gsdResolveTierEntry,
459
- CLAUDE_AGENT_ALIASES,
460
- } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
508
+ // #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
509
+ // install-model-override-resolver.cjs's readGsdRuntimeProfileResolver
510
+ // (required below) — this installer no longer needs its own bindings.
461
511
 
462
512
  // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
463
513
  // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile
@@ -530,6 +580,17 @@ try {
530
580
  // hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
531
581
  // The live descriptor (capabilities/claude/capability.json) remains the source of
532
582
  // truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
583
+ //
584
+ // #2870: NOT routed through the Install Scope Module (resolveScope,
585
+ // src/install-scope.cts) despite that module owning per-scope settings-file
586
+ // resolution elsewhere in this file. resolveScope's own descriptor lookup
587
+ // goes through the SAME capability registry require this floor exists to
588
+ // survive the failure of (see getRegistry() in install-scope.cts) — so on
589
+ // exactly the "registry failed to load" path this constant is for,
590
+ // resolveScope would throw too. Routing through it here would trade a
591
+ // graceful, documented degrade for a crash in the one case this floor was
592
+ // added to prevent. This hardcoded literal is the correct, honest answer,
593
+ // not an un-migrated leftover.
533
594
  const FALLBACK_HOST_BEHAVIORS = Object.freeze({
534
595
  claude: Object.freeze({
535
596
  settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
@@ -591,6 +652,22 @@ function _hostIntegrationDispatch(runtime) {
591
652
  return dispatch || {};
592
653
  }
593
654
 
655
+ /**
656
+ * #2870: shared install()/uninstall() scope resolution. Routes `id` through
657
+ * the Install Scope Module (src/install-scope.cts) and degrades to `null` on
658
+ * failure (unknown/non-installable runtime, broken registry bundle) instead
659
+ * of throwing, so each call site's own plain-id fallback keeps working
660
+ * exactly as it did before this migration. Both call sites previously carried
661
+ * their own copy of this try/catch; this is the one shared copy.
662
+ */
663
+ function _resolveScopeSafe(id, runtime) {
664
+ try {
665
+ return resolveScope({ id, runtime });
666
+ } catch (_) {
667
+ return null;
668
+ }
669
+ }
670
+
594
671
  /**
595
672
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
596
673
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -625,6 +702,7 @@ function _runtimeAdapter(runtime) {
625
702
  const {
626
703
  applyInstallerMigrationPlan,
627
704
  discoverInstallerMigrations,
705
+ MANIFEST_SCHEMA_VERSION,
628
706
  runInstallerMigrations,
629
707
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
630
708
  const {
@@ -653,29 +731,78 @@ const {
653
731
  // getCommitAttribution STAYS here (impure install-time config I/O); it is injected
654
732
  // into the engine functions via the resolveAttribution parameter at each call site.
655
733
  const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
734
+ // #2876: _copyStaged, convertClaudeCommandToOpencodeSkill, and
735
+ // convertClaudeCommandToKiloSkill used to be destructured here too — all
736
+ // three had no install.js internal caller and no export consumer (tests
737
+ // import all three directly from gsd-core/bin/lib/install-engine.cjs), so
738
+ // the retired bindings were dead code. applyOpencodeFamilyPathPrefix,
739
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup,
740
+ // _removeGsdEntries, _restoreDir, and _removeHermesBareStemDirs were the
741
+ // same — unused local bindings that were never part of this module's export
742
+ // surface either — found and retired in the same sweep.
656
743
  const {
657
744
  installRuntimeArtifacts,
658
745
  uninstallRuntimeArtifacts,
659
746
  installOpencodeFamilySkills,
747
+ installAgentsKindStandalone,
660
748
  _installNativePluginIfDeclared,
661
- _copyStaged,
662
749
  hasExistingSymlinkBetween,
663
750
  isSymlinkedDestOptIn,
664
- preserveUserArtifacts,
665
- restoreUserArtifacts,
666
751
  migrateLegacyDevPreferencesToSkill,
667
- applyOpencodeFamilyPathPrefix,
668
- convertClaudeCommandToOpencodeSkill,
669
- convertClaudeCommandToKiloSkill,
670
752
  USER_OWNED_ARTIFACTS,
671
- _runLegacyInstallMigrations,
672
- _runLegacyUninstallCleanup,
673
- _removeGsdEntries,
674
753
  _snapshotDir,
675
- _restoreDir,
676
- _removeHermesBareStemDirs,
677
754
  } = installEngine;
678
755
 
756
+ // #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
757
+ // across the preserve -> wipe -> restore window (#1874-F19). See
758
+ // src/user-artifact-staging.cts's module doc.
759
+ const {
760
+ stageUserArtifacts,
761
+ restoreStagedUserArtifacts,
762
+ discardStagedUserArtifacts,
763
+ recoverOrphanedUserArtifacts,
764
+ } = require(path.join(_gsdLibDir, 'user-artifact-staging.cjs'));
765
+
766
+ /**
767
+ * Resolve the durable staging root for `configDir`, confined via
768
+ * `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween`
769
+ * (test-matrix E1/E4) — mirrors install-engine.cts's
770
+ * `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's
771
+ * two call sites (uninstall's legacy-migration block, install's mainline
772
+ * gsd-core copy) are not inside that module.
773
+ */
774
+ function _resolveUserArtifactStagingRoot(configDir) {
775
+ const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.gsd-staging', 'user-artifacts'));
776
+ if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
777
+ throw new Error(
778
+ `_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
779
+ );
780
+ }
781
+ return stagingRoot;
782
+ }
783
+
784
+ /**
785
+ * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors
786
+ * install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local
787
+ * here for the same reason the throwing version above is: bin/install.js's
788
+ * own call sites are not inside that module). A hostile/broken
789
+ * `.gsd-staging` path (or a symlinked configDir itself) must never brick
790
+ * `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot`
791
+ * was called UNGUARDED as the first statement of both, so
792
+ * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands, including
793
+ * uninstall, the remedy for the first problem. Returns `null` (never throws),
794
+ * logging one warning; every call site MUST treat `null` as "skip the
795
+ * staging-dependent step for this run".
796
+ */
797
+ function _tryResolveUserArtifactStagingRoot(configDir) {
798
+ try {
799
+ return _resolveUserArtifactStagingRoot(configDir);
800
+ } catch (err) {
801
+ console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
802
+ return null;
803
+ }
804
+ }
805
+
679
806
  // Parse args
680
807
  const args = process.argv.slice(2);
681
808
  const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -861,7 +988,8 @@ Then re-run: npx ${pkg.name}@latest
861
988
  }
862
989
 
863
990
  // getDirName (runtime -> local config dir name) now lives in
864
- // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported + re-exported.
991
+ // runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported above for
992
+ // install.js's own internal use only — #2876 retired the re-export.
865
993
 
866
994
  /**
867
995
  * Get the config directory path relative to home directory for a runtime
@@ -982,17 +1110,20 @@ if (hasHelp) {
982
1110
  }
983
1111
 
984
1112
  // 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.
1113
+ // (ADR-1508 / #1511 Phase 2 — single owner). The const binding above re-binds
1114
+ // it here for install.js's own internal call sites only — #2876 retired the
1115
+ // module.exports entry (zero consumers found; tests import
1116
+ // runtimeArtifactConversion._computePathPrefix directly).
987
1117
  // Original doc: Compute the path prefix used for `@file` references in installed
988
1118
  // command/skill markdown. For global installs under $HOME uses $HOME/... form;
989
1119
  // OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux).
990
1120
 
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;
1121
+ // resolveNodeRunner, resolveBashRunner, referencesHook are now owned by the
1122
+ // runtime-hooks-surface module. Import them here so install.js callers
1123
+ // continue to work and so there is a single implementation of these helpers.
1124
+ // (normalizeNodePath was re-bound here too until #2876 found bin/install.js
1125
+ // had no internal caller and no export consumer for it — hooksSurface owns
1126
+ // the single implementation now, used internally by resolveNodeRunner there.)
996
1127
  const resolveNodeRunner = hooksSurface.resolveNodeRunner;
997
1128
  const resolveBashRunner = hooksSurface.resolveBashRunner;
998
1129
  // referencesHook: pure predicate over hook entry objects, shared between
@@ -1010,38 +1141,27 @@ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
1010
1141
  // callers continue to work and there is a single implementation. (All call
1011
1142
  // sites are below this line, so the const binding has no TDZ hazard.)
1012
1143
  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.
1144
+ // computePathPrefix: implementation lives in runtimeArtifactConversion
1145
+ // (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call
1146
+ // sites continue to work. #2876 retired the sibling
1147
+ // applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace
1148
+ // re-bindings that used to sit alongside it, and the entire #1675 Augment
1149
+ // converter family re-binding (convertClaudeToAugmentMarkdown /
1150
+ // convertClaudeCommandToAugmentSkill / convertClaudeAgentToAugmentAgent) that
1151
+ // used to follow — bin/install.js had no internal caller for any of them (the
1152
+ // descriptor pipeline in runtimeArtifactConversion calls them directly).
1026
1153
  // (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;
1154
+ const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
1030
1155
  // #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.
1156
+ // conversion module. install.js re-binds (does not re-define) the one member
1157
+ // it still calls internally so there is exactly one body — the
1158
+ // generative-drift hazard the dedup removes. #2876 retired the sibling
1159
+ // convertClaudeCommandToWindsurfSkill / convertClaudeCommandToWindsurfWorkflow /
1160
+ // convertClaudeAgentToWindsurfAgent re-bindings — bin/install.js had no
1161
+ // internal caller for any of them (the descriptor pipeline in
1162
+ // runtimeArtifactConversion calls them directly).
1040
1163
  // (All call sites are below this line → no TDZ hazard.)
1041
1164
  const convertClaudeToWindsurfMarkdown = runtimeArtifactConversion.convertClaudeToWindsurfMarkdown;
1042
- const convertClaudeCommandToWindsurfSkill = runtimeArtifactConversion.convertClaudeCommandToWindsurfSkill;
1043
- const convertClaudeCommandToWindsurfWorkflow = runtimeArtifactConversion.convertClaudeCommandToWindsurfWorkflow;
1044
- const convertClaudeAgentToWindsurfAgent = runtimeArtifactConversion.convertClaudeAgentToWindsurfAgent;
1045
1165
  // #2931 (ADR-1508): single-sourced in the conversion module — was a second,
1046
1166
  // unlinked verbatim copy here (used by the local Cursor/Trae/CodeBuddy/Cline
1047
1167
  // converters below), the exact drift class this PR exists to reduce. Verified
@@ -1057,116 +1177,14 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
1057
1177
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
1058
1178
  }
1059
1179
 
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
- }
1180
+ // #2876: reconcileManagedShellHookCommands, buildCodexHookBlock,
1181
+ // rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, and
1182
+ // reconcileCodexHooksJsonSessionStart used to be re-bound here as one-line
1183
+ // delegates to the equivalent hooksSurface.* implementations. None had an
1184
+ // install.js internal caller or an export consumer (tests import all five
1185
+ // directly from gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired
1186
+ // bindings were dead code with no reachable body — removed rather than kept
1187
+ // as unreachable wrappers.
1170
1188
 
1171
1189
  /**
1172
1190
  * Ensure Codex hooks.json contains exactly one managed SessionStart
@@ -1365,275 +1383,30 @@ function writeSettings(settingsPath, settings) {
1365
1383
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1366
1384
  }
1367
1385
 
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.
1554
- *
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).
1560
- *
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 }
1572
- */
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
- }
1386
+ // #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides /
1387
+ // readGsdEffectiveModelOverrides / readGsdRuntimeProfileResolver, plus the
1388
+ // shared resolveAgentModelOverride precedence chain) was extracted into the
1389
+ // shipped gsd-core/bin/lib/install-model-override-resolver.cjs, mirroring
1390
+ // install-effort-resolver.cjs's existing #2071 precedent, so the descriptor-
1391
+ // driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind)
1392
+ // and this installer resolve model_overrides / model_profile_overrides
1393
+ // through the SAME code — a single source of truth for the precedence chain
1394
+ // the inline agent loop below used to duplicate across ~24 lines per runtime
1395
+ // (kilo, opencode). See install-model-override-resolver.cts's module doc.
1396
+ const {
1397
+ readGsdEffectiveModelOverrides,
1398
+ readGsdRuntimeProfileResolver,
1399
+ resolveAgentModelOverride,
1400
+ } = require(path.join(_gsdLibDir, 'install-model-override-resolver.cjs'));
1401
+
1402
+ // #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion
1403
+ // (single source of truth with the descriptor pipeline's
1404
+ // applyAgentFrontmatterExtensions step, which now also owns disallowedTools
1405
+ // injection + the read-only agent deny-list internally — see its module doc
1406
+ // in src/runtime-artifact-conversion.cts). injectEffortFrontmatter used to be
1407
+ // re-bound here purely to stay on this module's export surface; #2876 found
1408
+ // no install.js internal caller either (tests import it directly from
1409
+ // gsd-core/bin/lib/runtime-artifact-conversion.cjs) and retired the binding.
1637
1410
 
1638
1411
  // Cache for attribution settings (populated once per runtime during install)
1639
1412
  const attributionCache = new Map();
@@ -1922,10 +1695,6 @@ function buildKiloAgentPermissionBlock(claudeTools) {
1922
1695
  return lines;
1923
1696
  }
1924
1697
 
1925
- function escapeRegExp(value) {
1926
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1927
- }
1928
-
1929
1698
  function replaceRelativePathReference(content, fromPath, toPath) {
1930
1699
  const escapedPath = escapeRegExp(fromPath);
1931
1700
  return content.replace(
@@ -2046,12 +1815,6 @@ function skillFrontmatterName(skillDirName) {
2046
1815
  return skillDirName;
2047
1816
  }
2048
1817
 
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
1818
  /**
2056
1819
  * Qwen Code skills accept an optional numeric `priority` frontmatter field.
2057
1820
  * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -2108,10 +1871,11 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2108
1871
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2109
1872
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2110
1873
  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.
1874
+ // #769: preserve context: from source command files so it is emitted into
1875
+ // the installed SKILL.md frontmatter unchanged. (#3151: effort: is no longer
1876
+ // emitted into skill frontmatter — a static effort value invalidates the
1877
+ // caller's prompt cache at both scope boundaries.)
2113
1878
  const context = extractFrontmatterField(frontmatter, 'context');
2114
- const effort = extractFrontmatterField(frontmatter, 'effort');
2115
1879
 
2116
1880
  // Preserve allowed-tools as YAML multiline list (Claude native format)
2117
1881
  const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
@@ -2145,12 +1909,13 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2145
1909
  }
2146
1910
  if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
2147
1911
  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).
1912
+ // #769: emit context: when present so the runtime can honour it natively
1913
+ // (context: fork = isolated subagent window). Claude-specific; unknown
1914
+ // frontmatter fields are silently ignored by other runtimes (backward-compatible).
1915
+ // (#3151: effort: is intentionally NOT emitted into skill frontmatter — a
1916
+ // static effort value changes output_config.effort on invocation and
1917
+ // invalidates the caller's prompt cache at both scope boundaries.)
2152
1918
  if (context) fm += `context: ${context}\n`;
2153
- if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
2154
1919
  if (toolsBlock) fm += toolsBlock;
2155
1920
  fm += '---';
2156
1921
 
@@ -3964,7 +3729,7 @@ Typed mapping (agent_type-capable schema only):
3964
3729
  to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty,
3965
3730
  inherited, or unsupported values; do not invent one-off effort literals in
3966
3731
  workflow prose.
3967
- - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
3732
+ - \`fork_context: false\` by default — GSD agents load their own context via \`<required_reading>\` blocks
3968
3733
  - \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task
3969
3734
  - \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement)
3970
3735
  - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
@@ -4059,15 +3824,19 @@ purpose: ${toSingleLine(description)}
4059
3824
  /**
4060
3825
  * #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
4061
3826
  * 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.
3827
+ * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES); (b) any Claude model
3828
+ * id in any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
3829
+ * (the forms the catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via
3830
+ * the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
3831
+ * case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
3832
+ * rejects all of these.
3833
+ *
3834
+ * #3241 — thin delegation to the shared predicate on src/model-catalog.cts (moved there
3835
+ * so it can't diverge across Codex-posture surfaces); kept as a local name because it
3836
+ * reads better at the call sites below.
4068
3837
  */
4069
3838
  function _isAnthropicFlavoredModel(model) {
4070
- return typeof model === 'string' && (CLAUDE_AGENT_ALIASES.has(model) || model.toLowerCase().includes('claude'));
3839
+ return gsdIsAnthropicFlavoredModel(model);
4071
3840
  }
4072
3841
 
4073
3842
  // #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
@@ -4086,6 +3855,42 @@ function _warnCodexModelOverrideDropped(agentName, value) {
4086
3855
  );
4087
3856
  }
4088
3857
 
3858
+ // #3241 — one-time per-install deprecation notice: the automatic runtime-resolver
3859
+ // per-tier Codex model embed was removed (D1/D5, ADR-2313 passive-posture epic). When
3860
+ // the resolver *would have* supplied a model and nothing else ends up pinned, this
3861
+ // notice points the user at model_overrides as the explicit-pin replacement. Dedupes
3862
+ // with a module-level boolean (mirrors _codexModelOverrideDroppedWarned's Set above)
3863
+ // so a multi-agent install — every Codex agent hits this condition simultaneously —
3864
+ // emits exactly one line, not one per agent. Reset once per install() call (see
3865
+ // install()) so the "at most once" window is per-install, not per-process. That
3866
+ // reset lives ONLY inside install() (~:10116) — generateCodexAgentToml is also
3867
+ // exported standalone (~:13460), and a caller invoking it directly/repeatedly
3868
+ // outside install() gets process-lifetime dedupe instead of per-install. No
3869
+ // current test depends on the standalone caller's dedupe window.
3870
+ let _codexResolverModelOmittedWarned = false;
3871
+ function _warnCodexResolverModelOmitted() {
3872
+ if (_codexResolverModelOmittedWarned) return;
3873
+ _codexResolverModelOmittedWarned = true;
3874
+ process.stderr.write(
3875
+ 'gsd: notice — Codex agents no longer auto-pin a per-tier model from the runtime ' +
3876
+ 'resolver; set model_overrides for an agent if you want a specific Codex model ' +
3877
+ 'instead of the session model.\n',
3878
+ );
3879
+ }
3880
+
3881
+ // Test seam only — bin/install.js deliberately keeps per-install warning/notice
3882
+ // dedupe in module scope (both the _codexModelOverrideDroppedWarned Set above and
3883
+ // the _codexResolverModelOmittedWarned boolean; install() resets the latter at
3884
+ // ~:10116). A unit test that drives generateCodexAgentToml() directly, without
3885
+ // going through install(), has no other way to reset either store between
3886
+ // assertions without busting the require.cache (which breaks module-instance
3887
+ // sharing with the rest of the suite). This is the single sanctioned way for a
3888
+ // unit test to clear both dedupe stores — exported so tests can call it instead.
3889
+ function _resetCodexWarningDedupeForTests() {
3890
+ _codexModelOverrideDroppedWarned.clear();
3891
+ _codexResolverModelOmittedWarned = false;
3892
+ }
3893
+
4089
3894
  /**
4090
3895
  * Generate a per-agent .toml config file for Codex.
4091
3896
  * Sets required agent metadata, sandbox_mode, and developer_instructions
@@ -4118,38 +3923,58 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4118
3923
  // Embed model override when configured in ~/.gsd/defaults.json so that
4119
3924
  // model_overrides is respected on Codex (which uses static TOML, not inline
4120
3925
  // Task() model parameters). See #2256.
4121
- // Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
4122
3926
  // #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
4123
3927
  // passive/session-only model host (ADR-1239): GSD cannot reliably route per-agent
4124
3928
  // tiers, and a bare GSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
4125
3929
  // id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
4126
3930
  // using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
4127
3931
  // 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.)
3932
+ // inherits the always-available session model.
3933
+ // #3241 (D1/D5) — the runtime-aware tier-resolver auto-embed that used to fall
3934
+ // through here when model_overrides had nothing was removed: Codex is passive by
3935
+ // default now, and only an explicit model_overrides pin survives. See the
3936
+ // deprecation-notice block below for the population that used to get a pin from
3937
+ // the resolver and no longer does.
4130
3938
  const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
4131
3939
  let pinnedModel = null;
4132
3940
  if (rawModelOverride) {
4133
- if (typeof rawModelOverride === 'string' && rawModelOverride && !_isAnthropicFlavoredModel(rawModelOverride)) {
4134
- pinnedModel = rawModelOverride; // explicit real-Codex model pin → embed verbatim (#2256)
3941
+ // Trim before the truthiness test (#3241 defect fix): a whitespace-only value
3942
+ // (e.g. ' ') is a truthy JS string but not a model id — it must not be
3943
+ // embedded verbatim (`model = " "`, a live pre-fix defect) or routed to
3944
+ // _warnCodexModelOverrideDropped, whose "is not a valid Codex model
3945
+ // (Anthropic alias/id)" text would misdescribe a blank config field. It is
3946
+ // silently dropped, matching how '' already behaves (no pin, no warning).
3947
+ const trimmedOverride = typeof rawModelOverride === 'string' ? rawModelOverride.trim() : rawModelOverride;
3948
+ if (typeof trimmedOverride === 'string' && trimmedOverride && !_isAnthropicFlavoredModel(trimmedOverride)) {
3949
+ pinnedModel = trimmedOverride; // explicit real-Codex model pin → embed verbatim (#2256)
3950
+ } else if (typeof rawModelOverride === 'string' && trimmedOverride === '') {
3951
+ // whitespace-only override — no pin, no warning (#3241).
4135
3952
  } else {
4136
3953
  _warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
4137
3954
  }
4138
3955
  }
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
3956
  // #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).
3957
+ // .toml, even one that reached here through some other path than the override
3958
+ // check above.
4149
3959
  if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
4150
3960
  _warnCodexModelOverrideDropped(resolvedName, pinnedModel);
4151
3961
  pinnedModel = null;
4152
3962
  }
3963
+ // #3241 — one-time deprecation notice: if nothing ends up pinned but the
3964
+ // runtime resolver would have supplied a per-tier model that would actually
3965
+ // have been EMBEDDED (the population that loses a pin now that the
3966
+ // auto-embed above is gone), point the user at model_overrides. The would-be
3967
+ // model must also clear the #2310 Anthropic-flavored gate above — if it
3968
+ // wouldn't have survived that gate, the user never had that pin pre-Phase-1
3969
+ // either, and the notice would be false. Never fires when the resolver is
3970
+ // null, resolves to nothing, resolves to an Anthropic-flavored model, or an
3971
+ // explicit real-Codex pin survived — in all of those cases nothing was lost.
3972
+ if (!pinnedModel && runtimeResolver) {
3973
+ const wouldHavePinned = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
3974
+ if (wouldHavePinned?.model && !_isAnthropicFlavoredModel(wouldHavePinned.model)) {
3975
+ _warnCodexResolverModelOmitted();
3976
+ }
3977
+ }
4153
3978
  let hasPinnedModel = false;
4154
3979
  if (pinnedModel) {
4155
3980
  lines.push(`model = ${JSON.stringify(pinnedModel)}`);
@@ -4169,8 +3994,12 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4169
3994
  // follows GSD. Keep those knobs coupled unless GSD also pins the model.
4170
3995
  if (hasPinnedModel) {
4171
3996
  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)}`);
3997
+ // #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
3998
+ // own effort default. Never write the literal.
3999
+ if (_universalEffortCodex !== 'inherit') {
4000
+ const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
4001
+ lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4002
+ }
4174
4003
  }
4175
4004
 
4176
4005
  // #774 — Emit service_tier and model_verbosity for light-tier agents.
@@ -6729,43 +6558,13 @@ function stripGsdFromCopilotInstructions(content) {
6729
6558
  const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
6730
6559
  const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
6731
6560
 
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
- }
6561
+ // #2876: buildClineRulesBody, buildClineAgentsMdBody, buildClinePreToolUseHook,
6562
+ // and mergeGsdAgentsMd used to be re-bound here as one-line delegates to the
6563
+ // equivalent hooksSurface.* implementations. None had an install.js internal
6564
+ // caller or an export consumer (tests import all four directly from
6565
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6566
+ // dead code with no reachable body — removed rather than kept as unreachable
6567
+ // wrappers.
6769
6568
 
6770
6569
  /**
6771
6570
  * Strip the GSD block from AGENTS.md content. Returns null if the file became
@@ -6817,47 +6616,13 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
6817
6616
  //
6818
6617
  // References: https://cursor.com/docs/hooks
6819
6618
 
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
- }
6619
+ // #2876: buildCursorHookEntry, isManagedCursorHookEntry, and
6620
+ // reconcileCursorHooksJson used to be re-bound here as one-line delegates to
6621
+ // the equivalent hooksSurface.* implementations. None had an install.js
6622
+ // internal caller or an export consumer (tests import all three directly from
6623
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
6624
+ // dead code with no reachable body — removed rather than kept as unreachable
6625
+ // wrappers.
6861
6626
 
6862
6627
  /**
6863
6628
  * #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
@@ -6917,23 +6682,13 @@ function removeWindsurfHooksJson(targetDir) {
6917
6682
  return hooksSurface.removeWindsurfHooksJson(targetDir);
6918
6683
  }
6919
6684
 
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
- }
6685
+ // #2876: buildCopilotHookConfig used to be re-bound here as a one-line
6686
+ // delegate to hooksSurface.buildCopilotHookConfig. It had no install.js
6687
+ // internal caller or export consumer (tests import it directly from
6688
+ // gsd-core/bin/lib/runtime-hooks-surface.cjs), so the retired binding was
6689
+ // dead code with no reachable body — removed rather than kept as an
6690
+ // unreachable wrapper. writeCopilotHookConfig below is unaffected — it still
6691
+ // has an internal caller (finishInstall).
6937
6692
 
6938
6693
  /**
6939
6694
  * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
@@ -7462,10 +7217,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7462
7217
 
7463
7218
  // convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
7464
7219
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
7465
- // Imported from installEngine above.
7220
+ // #2876 found no install.js internal caller for the latter two (tests import
7221
+ // them directly from gsd-core/bin/lib/install-engine.cjs) and retired the
7222
+ // destructured bindings above.
7466
7223
 
7467
7224
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
7468
- // Imported from installEngine above.
7225
+ // #2876 found no install.js internal caller (never had one either — it was
7226
+ // never part of this module's export surface) and retired the destructured
7227
+ // binding above.
7469
7228
  //
7470
7229
  // copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
7471
7230
  // src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
@@ -7567,13 +7326,13 @@ function writeHermesCategoryDescription(categoryDir) {
7567
7326
  * @param {boolean} isGlobal - Whether this is a global install
7568
7327
  */
7569
7328
 
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.
7329
+ // USER_OWNED_ARTIFACTS, migrateLegacyDevPreferencesToSkill, _snapshotDir,
7330
+ // installRuntimeArtifacts, installOpencodeFamilySkills, uninstallRuntimeArtifacts:
7331
+ // ALL moved to src/install-engine.cts (ADR-1239 Phase B). Imported from
7332
+ // installEngine above. _copyStaged, _removeGsdEntries,
7333
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _restoreDir, and
7334
+ // _removeHermesBareStemDirs moved there too but #2876 found no install.js
7335
+ // internal caller for any of them and retired their destructured bindings.
7577
7336
 
7578
7337
  // ---------------------------------------------------------------------------
7579
7338
  // Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
@@ -7770,6 +7529,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7770
7529
  const srcPath = path.join(srcDir, entry.name);
7771
7530
  const destPath = path.join(destDir, entry.name);
7772
7531
 
7532
+ // #3333: srcPath was enumerated by readdirSync above, but a filesystem is not
7533
+ // transactional — the file it named can vanish between listing and this read
7534
+ // (a concurrent process, or another test in this suite writing/cleaning up a
7535
+ // fixture inside this same real directory). Treat "gone by the time we get
7536
+ // here" as benign and skip it, never a fatal crash of the whole install.
7537
+ if (!entry.isDirectory() && !fs.existsSync(srcPath)) {
7538
+ continue;
7539
+ }
7540
+
7773
7541
  if (entry.isDirectory()) {
7774
7542
  copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
7775
7543
  } else if (entry.name.endsWith('.md')) {
@@ -7822,8 +7590,20 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7822
7590
  content = content.replace(globalClaudeRegex, pathPrefix);
7823
7591
  content = content.replace(globalClaudeHomeRegex, pathPrefix);
7824
7592
  content = content.replace(localClaudeRegex, `./${dirName}/`);
7825
- content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7826
- content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7593
+ // #3544 review (Finding 1 fallout): guarded with the SAME
7594
+ // negative-lookahead convention already used at ~:2859-2860 below
7595
+ // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
7596
+ // is satisfied by ANY non-word character, including '-' — so for a
7597
+ // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
7598
+ // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
7599
+ // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
7600
+ // and re-appended the full prefix, corrupting every emitted path to
7601
+ // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
7602
+ // default '.claude' (self-replace with an identical string), which
7603
+ // is why this went undetected until a non-default config-dir name
7604
+ // was exercised.
7605
+ content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7606
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7827
7607
  content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7828
7608
  content = content.replace(/~\/\.qwen\//g, pathPrefix);
7829
7609
  content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
@@ -7831,6 +7611,17 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7831
7611
  content = content.replace(/~\/\.hermes\//g, pathPrefix);
7832
7612
  content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
7833
7613
  content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
7614
+ // #3544: restore @-file-reference lines to the tilde form Claude Code
7615
+ // actually expands — the SAME correction #3133 already applies to
7616
+ // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
7617
+ // restoreClaudeGlobalAtRefTilde's doc comment in
7618
+ // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree
7619
+ // emit path, which never had it: every @~/.claude/gsd-core/… include
7620
+ // in a global install's workflows/references tree silently resolved
7621
+ // to nothing (54 includes across 22 files on a live install).
7622
+ if (runtime === 'claude') {
7623
+ content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
7624
+ }
7834
7625
  }
7835
7626
  content = processAttribution(content, getCommitAttribution(runtime));
7836
7627
 
@@ -8109,6 +7900,23 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8109
7900
 
8110
7901
  let removedCount = 0;
8111
7902
 
7903
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
7904
+ // artifact orphaned by a PRIOR uninstall run that died between staging and
7905
+ // its own restore/discard, BEFORE this run's own preserve steps (sites 2,
7906
+ // 3, 5 below) stage anything new. Uninstall's own gsd-core/ removal and
7907
+ // legacy-commands cleanup are exactly as crash-exposed as install's —
7908
+ // without this, an orphan from a crashed uninstall is recoverable only if
7909
+ // the user later re-installs.
7910
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging root
7911
+ // itself cannot be resolved — skip this recovery pass rather than throw
7912
+ // out of uninstall() before it does anything.
7913
+ {
7914
+ const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
7915
+ if (_uninstallEntryStagingRoot !== null) {
7916
+ recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir);
7917
+ }
7918
+ }
7919
+
8112
7920
  // Remove profile marker so a clean reinstall defaults to full surface.
8113
7921
  try {
8114
7922
  fs.unlinkSync(path.join(targetDir, '.gsd-profile'));
@@ -8116,7 +7924,16 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8116
7924
  } catch {}
8117
7925
 
8118
7926
  // 1. Remove GSD commands/skills (layout-driven)
8119
- const scope = isGlobal ? 'global' : 'local';
7927
+ // #2870: scope id resolved ONCE here and reused below (was two independent
7928
+ // isGlobal-derived re-derivations). Routed through the Install Scope
7929
+ // Module (src/install-scope.cts) when the capability registry is
7930
+ // available; degrades to the plain id on failure (unknown/non-installable
7931
+ // runtime, broken bundle) so this function's scope-id uses — which never
7932
+ // depended on registry availability before this migration — keep working
7933
+ // exactly as they did pre-migration.
7934
+ const _uninstallScopeId = isGlobal ? 'global' : 'local';
7935
+ const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
7936
+ const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
8120
7937
  // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
8121
7938
  // Fail-open to the engine directly if the composed-registry adapter can't load.
8122
7939
  const _uninstallAdapter = _runtimeAdapter(runtime);
@@ -8443,18 +8260,42 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8443
8260
  // Preserve user-owned dev-preferences.md if present (#1423 parity).
8444
8261
  const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
8445
8262
  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}`);
8263
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
8264
+ // #1874-F19 "site 7" — found by sweeping bin/install.js for the
8265
+ // read-then-wipe-then-write PATTERN, not for preserveUserArtifacts'
8266
+ // callers; this uninstall-path block open-coded the same round-trip).
8267
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8268
+ // root cannot be resolved — skip this legacy-cleanup block entirely
8269
+ // (leave the stale dir in place) rather than wipe without a durable
8270
+ // backup for dev-preferences.md.
8271
+ const _legacyGsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8272
+ if (_legacyGsdCommandsStagingRoot !== null) {
8273
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdCommandsDir, ['dev-preferences.md'], _legacyGsdCommandsStagingRoot);
8274
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8275
+ // EMPTY dev-preferences.md was (and still is) silently not restored.
8276
+ const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
8277
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
8278
+ : null;
8279
+ fs.rmSync(legacyGsdCommandsDir, { recursive: true });
8280
+ removedCount++;
8281
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8282
+ if (savedDevPrefs) {
8283
+ try {
8284
+ restoreStagedUserArtifacts(legacyGsdCommandsDir, stagedDevPrefs);
8285
+ discardStagedUserArtifacts(stagedDevPrefs);
8286
+ console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
8287
+ } catch (err) {
8288
+ console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
8289
+ }
8290
+ } else {
8291
+ // #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and
8292
+ // still is) never restored — the original truthy-content check is
8293
+ // preserved byte-for-byte above — but the staged batch was never
8294
+ // discarded either, leaking a <configDir>/.gsd-staging/ record
8295
+ // forever and re-materializing the just-deleted file on a future
8296
+ // install's orphan-recovery pass. Discard unconditionally when there
8297
+ // is nothing to restore.
8298
+ discardStagedUserArtifacts(stagedDevPrefs);
8458
8299
  }
8459
8300
  }
8460
8301
  }
@@ -8472,21 +8313,77 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8472
8313
  // so this is a best-effort guard.
8473
8314
  const legacyDir = path.join(targetDir, 'commands', 'gsd');
8474
8315
  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);
8316
+ // #2875 (#1874-F19): staged DURABLY to disk before the wipe below,
8317
+ // instead of an in-memory Map only — a crash between the wipe and the
8318
+ // restore-on-failure branch below now survives via
8319
+ // recoverOrphanedUserArtifacts on the next run.
8320
+ // #2875 defect fix: DEGRADE, never abort uninstall, when the staging
8321
+ // root cannot be resolved — skip this legacy-migration block entirely
8322
+ // (leave the stale dir in place) rather than wipe without a durable
8323
+ // backup.
8324
+ const stagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8325
+ if (stagingRoot !== null) {
8326
+ const stagedLegacyArtifacts = stageUserArtifacts(legacyDir, ['dev-preferences.md'], stagingRoot);
8327
+ fs.rmSync(legacyDir, { recursive: true });
8328
+ removedCount++;
8329
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
8330
+ const _uninstallScope = scope;
8331
+ // migrateLegacyDevPreferencesToSkill's Map<string,string> contract is
8332
+ // unchanged — read the staged content back from disk (not an in-memory
8333
+ // value held across the wipe above).
8334
+ //
8335
+ // #2875 defect fix (readFileSync following a staged symlink) — matches
8336
+ // install-engine.cts's _runLegacyInstallMigrations call site 1 exactly:
8337
+ // readFileSync ALWAYS follows a symlink, so a staged artifact that is
8338
+ // itself a symlink (user-artifact-staging.cts's "Symlink safety": a
8339
+ // symlinked user artifact is recreated AS a symlink in the staging
8340
+ // tree, never copied by content) would have its REFERENT's bytes read
8341
+ // here and land in SKILL.md. Excluded from migration below and
8342
+ // restored to its original location unchanged instead.
8343
+ // #2875 defect fix (regression closed — was previously unguarded and
8344
+ // BRICKED uninstall, the very command that should recover from this):
8345
+ // legacyDir was already removed above, so stagedLegacyArtifacts is
8346
+ // the only surviving copy. migrateLegacyDevPreferencesToSkill
8347
+ // correctly THROWS when it finds a planted/dangling symlink at the
8348
+ // skill-file leaf (security fix); a raw `fs.lstatSync` in the loop
8349
+ // below can also throw on a TOCTOU-vanished staged file. Either one,
8350
+ // left unguarded, propagated straight out of uninstall, aborting it
8351
+ // WITHOUT ever reaching the restore-or-discard branch below — the
8352
+ // staged batch was orphaned on disk and every retry hit the same
8353
+ // throw again. Degrade identically to every other #2875 staging step
8354
+ // in this function: catch, warn once, and treat the batch as
8355
+ // unmigrated so the restore branch below always fires.
8356
+ let _legacyMigrated = false;
8357
+ let migratableLegacyNames = [];
8358
+ try {
8359
+ const savedLegacyArtifacts = new Map();
8360
+ for (const name of stagedLegacyArtifacts.names) {
8361
+ const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name);
8362
+ if (fs.lstatSync(stagedPath).isSymbolicLink()) continue;
8363
+ savedLegacyArtifacts.set(name, fs.readFileSync(stagedPath, 'utf8'));
8364
+ migratableLegacyNames.push(name);
8365
+ }
8366
+ _legacyMigrated = migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope);
8367
+ } catch (err) {
8368
+ console.warn(` ${yellow}!${reset} dev-preferences.md migration skipped (${err.message}) — restoring the legacy copy instead.`);
8369
+ _legacyMigrated = false;
8370
+ migratableLegacyNames = [];
8371
+ }
8372
+ if (_legacyMigrated && migratableLegacyNames.length === stagedLegacyArtifacts.names.length) {
8373
+ // Compute the actual path written so the log line is accurate per-runtime
8374
+ const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
8375
+ const _sk = _layout.kinds.find((k) => k.kind === 'skills');
8376
+ const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
8377
+ const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
8378
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
8379
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8380
+ } else {
8381
+ // Migration failed, already exists, or a symlinked name was excluded
8382
+ // above — restore the WHOLE batch to the legacy location so no user
8383
+ // content is silently lost.
8384
+ restoreStagedUserArtifacts(legacyDir, stagedLegacyArtifacts);
8385
+ discardStagedUserArtifacts(stagedLegacyArtifacts);
8386
+ }
8490
8387
  }
8491
8388
  }
8492
8389
  }
@@ -8494,22 +8391,49 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8494
8391
  // 2. Remove gsd-core directory
8495
8392
  const gsdDir = path.join(targetDir, 'gsd-core');
8496
8393
  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/`);
8394
+ // Stage user-generated files DURABLY to disk before wipe (#1423; #2875 /
8395
+ // #1874-F19 "site 5" — this block open-coded its own preserve/restore
8396
+ // instead of calling preserveUserArtifacts, which is why it was missed
8397
+ // by the original symbol-search measurement).
8398
+ // #2875 defect fix: this IS the core uninstall step (removing gsd-core/)
8399
+ // — unlike the optional legacy-cleanup blocks above, uninstall must
8400
+ // still be able to proceed and actually remove gsd-core/ even when the
8401
+ // staging root cannot be resolved. Degrade by skipping ONLY the
8402
+ // USER-PROFILE.md preserve/restore wrapper (warn), never the removal
8403
+ // itself.
8404
+ const _gsdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
8405
+ if (_gsdDirStagingRoot === null) {
8406
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`);
8407
+ fs.rmSync(gsdDir, { recursive: true });
8408
+ removedCount++;
8409
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8410
+ } else {
8411
+ const stagedProfile = stageUserArtifacts(gsdDir, USER_OWNED_ARTIFACTS, _gsdDirStagingRoot);
8412
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
8413
+ // EMPTY USER-PROFILE.md was (and still is) silently not restored —
8414
+ // matching prior behavior byte-for-byte rather than widening scope.
8415
+ const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md')
8416
+ ? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8')
8417
+ : null;
8418
+
8419
+ fs.rmSync(gsdDir, { recursive: true });
8420
+ removedCount++;
8421
+ console.log(` ${green}✓${reset} Removed gsd-core/`);
8504
8422
 
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}`);
8423
+ // Restore user-generated files
8424
+ if (preservedProfile) {
8425
+ try {
8426
+ restoreStagedUserArtifacts(gsdDir, stagedProfile);
8427
+ discardStagedUserArtifacts(stagedProfile);
8428
+ console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
8429
+ } catch (err) {
8430
+ console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
8431
+ }
8432
+ } else {
8433
+ // #2875 defect fix: same empty-file orphan leak as the legacy
8434
+ // commands/gsd/ site above — discard the staging batch regardless of
8435
+ // whether the staged content was truthy.
8436
+ discardStagedUserArtifacts(stagedProfile);
8513
8437
  }
8514
8438
  }
8515
8439
  }
@@ -8642,13 +8566,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8642
8566
  // Any file NOT in this set is user-owned and must survive uninstall.
8643
8567
  // After removing GSD files, attempt to rmdir — if the directory is still
8644
8568
  // 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
-
8569
+ // GSD_CHANGESET_FILES / GSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
8570
+ // tests can assert their parity against the real directory contents.
8652
8571
  const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
8653
8572
  if (fs.existsSync(changesetUninstallDir)) {
8654
8573
  let removedChangeset = 0;
@@ -9525,23 +9444,41 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9525
9444
  // so the manifest records what's actually on disk. _resolveSkillsRootDir already
9526
9445
  // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
9527
9446
  // 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');
9447
+ // #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the
9448
+ // scope RECORDED in the manifest are one value, resolved once. Two reads of
9449
+ // `options.scope` could drift; one cannot.
9450
+ const resolvedScope = options.scope === 'local' ? 'local' : 'global';
9451
+ const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
9529
9452
  const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
9530
9453
  const agentsDir = path.join(configDir, 'agents');
9531
9454
  const manifest = {
9455
+ // Schema version of this DOCUMENT (#2872) — distinct from `version`
9456
+ // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1)
9457
+ // manifest, which readInstallManifest still reads without error and
9458
+ // without requiring a reinstall. Read from the Installer Migration
9459
+ // Module rather than repeated as a second literal: the writer here and
9460
+ // the reader's normalizeManifestVersion are two surfaces over one
9461
+ // constant, and this repo's "generative fix divergence" class is exactly
9462
+ // two such literals drifting apart.
9463
+ manifestVersion: MANIFEST_SCHEMA_VERSION,
9532
9464
  version: pkg.version,
9533
9465
  timestamp: new Date().toISOString(),
9534
9466
  mode: options.mode === 'minimal' ? 'minimal' : 'full',
9467
+ // Recorded so an Installed Surface Resolver can answer "which surfaces
9468
+ // are installed, at which scopes, for which runtimes" without re-deriving
9469
+ // it from the directory it happened to be found in (#2872).
9470
+ runtime,
9471
+ scope: resolvedScope,
9535
9472
  files: {},
9536
9473
  };
9537
9474
 
9538
9475
  const gsdHashes = generateManifest(gsdDir);
9539
9476
  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.
9477
+ // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged
9478
+ // durably and restored across reinstalls (user-artifact-staging.cts,
9479
+ // #2875) and must NOT be hashed into the manifest — otherwise
9480
+ // saveLocalPatches() would flag every refresh as a "local patch"
9481
+ // (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file.
9545
9482
  if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
9546
9483
  manifest.files['gsd-core/' + rel] = hash;
9547
9484
  }
@@ -10029,21 +9966,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10029
9966
  // below were removed, leaving isKimi unused in this function (the kimi
10030
9967
  // local-install-deferred branch above already reads
10031
9968
  // _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
9969
+ // #2096: isAntigravity dropped — antigravity's agents were already
9970
+ // descriptor-driven (installRuntimeArtifacts), so its two legacy-agent-loop
9971
+ // branches (the path-rewrite skip and the converter dispatch) were
9972
+ // unreachable dead code; both were removed rather than re-gated on
9973
+ // hostBehaviors. (#2875 Part 2 later deleted that legacy loop and its
9974
+ // `_DESCRIPTOR_AGENTS_RUNTIMES` gate entirely — EVERY runtime is now
9975
+ // descriptor-driven for agents, not just this subset.)
9976
+ // #2098: isCodebuddy dropped — codebuddy's agents were likewise already
9977
+ // descriptor-driven, so its legacy converter-dispatch branch (the
9978
+ // `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
10039
9979
  // 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`.
9980
+ // #2099: isCopilot dropped — copilot's agents were likewise already
9981
+ // descriptor-driven, so its three legacy-agent-loop branches (the
9982
+ // path-rewrite skip, the converter dispatch, and the .agent.md destName
9983
+ // ternary) were unreachable dead code and were removed rather than
9984
+ // re-gated; the .agent.md suffix now lives on
9985
+ // hostBehaviors.agentFileExtension in src/install-engine.cts, and the
9986
+ // skipSharedHooksInstall check above no longer needs `&& !isCopilot`.
10047
9987
  // #2100: isWindsurf dropped — its four former isWindsurf-gated branches
10048
9988
  // (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the
10049
9989
  // workflow-verification report, and the shared-hooks-install exclusion) are
@@ -10051,14 +9991,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10051
9991
  // hostBehaviors.installsCommandBodiesForWorkflowDelegation,
10052
9992
  // hostBehaviors.verificationStyle === 'windsurf-workflows', and
10053
9993
  // 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.
9994
+ // converter arm was likewise unreachable dead code (windsurf's agents were
9995
+ // already descriptor-driven) and was removed above.
10056
9996
  // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall.
10057
9997
  const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
10058
9998
  const plan = resolveInstallPlan(runtime);
10059
9999
  const dirName = getDirName(runtime);
10060
10000
  const src = path.join(__dirname, '..');
10061
10001
 
10002
+ // #3241 — the Codex resolver-model-omitted notice dedupes "at most once", but
10003
+ // scoped per install() call rather than per process — each install() run gets
10004
+ // its own fresh window so a second install (e.g. a second runtime, or a test
10005
+ // re-running install()) can warn again if the same condition recurs.
10006
+ _codexResolverModelOmittedWarned = false;
10007
+
10062
10008
  if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
10063
10009
  console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
10064
10010
  console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
@@ -10076,6 +10022,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10076
10022
  };
10077
10023
  }
10078
10024
 
10025
+ // #2870: scope id resolved ONCE here and reused at every use below (was 10
10026
+ // independent isGlobal-derived re-derivations). Placed AFTER the kimi
10027
+ // local-deferred early return above so that return path does no extra
10028
+ // work. Routed through the Install Scope Module (src/install-scope.cts)
10029
+ // when the capability registry is available; degrades to the plain id on
10030
+ // failure (unknown/non-installable runtime, broken bundle) so this
10031
+ // function's plain scope-id uses — which never depended on registry
10032
+ // availability before this migration — keep working exactly as they did
10033
+ // pre-migration. `_installScope` (the full resolved value, not just the
10034
+ // id) additionally backs the settingsFileByScope routing below.
10035
+ const _installScopeId = isGlobal ? 'global' : 'local';
10036
+ const _installScope = _resolveScopeSafe(_installScopeId, runtime);
10037
+
10079
10038
  // Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
10080
10039
  // Defined early so it is visible to both the main and Codex code paths.
10081
10040
  // `allowlist` (when non-empty) restricts copying to the named top-level entries,
@@ -10121,6 +10080,26 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10121
10080
  ? process.cwd()
10122
10081
  : path.join(process.cwd(), dirName);
10123
10082
 
10083
+ // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
10084
+ // artifact orphaned by a PRIOR install run that died between staging and
10085
+ // its own restore/discard, BEFORE this run's own preserve step stages
10086
+ // anything new. This is the production entry point every install() call
10087
+ // reaches — the only place this phase's durability fix is complete rather
10088
+ // than merely callable (40-design.md "The inertness trap this design must
10089
+ // avoid" / #1879-F15). Runs for every runtime, ahead of both the
10090
+ // layout-driven path's _runLegacyInstallMigrations (site 1, inside
10091
+ // installRuntimeArtifacts) and this function's own mainline gsd-core copy
10092
+ // (site 4, below).
10093
+ // #2875 defect fix: DEGRADE, never abort install, when the staging root
10094
+ // itself cannot be resolved — skip this recovery pass rather than throw
10095
+ // out of install() before it does anything.
10096
+ {
10097
+ const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10098
+ if (_installEntryStagingRoot !== null) {
10099
+ recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir);
10100
+ }
10101
+ }
10102
+
10124
10103
  const locationLabel = isGlobal
10125
10104
  ? targetDir.replace(os.homedir(), '~')
10126
10105
  : targetDir.replace(process.cwd(), '.');
@@ -10273,7 +10252,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10273
10252
  const codexPreInstallAgentContents = new Map();
10274
10253
  let codexPreInstallVersionBytes = null;
10275
10254
  if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
10276
- const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10255
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10277
10256
  if (fs.existsSync(_preSkillsDir)) {
10278
10257
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
10279
10258
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -10329,7 +10308,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10329
10308
  const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
10330
10309
  rollbackInstallerMigrations();
10331
10310
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
10332
- const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
10311
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10333
10312
  for (const skillName of codexPreInstallSkillNames) {
10334
10313
  const skillDirPath = path.join(_earlySkillsDir, skillName);
10335
10314
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -10427,7 +10406,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10427
10406
  installerMigrationResult = runInstallerMigrations({
10428
10407
  configDir: targetDir,
10429
10408
  runtime,
10430
- scope: isGlobal ? 'global' : 'local',
10409
+ scope: _installScopeId,
10431
10410
  migrations: options.installerMigrations,
10432
10411
  baselineScan: true,
10433
10412
  });
@@ -10512,6 +10491,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10512
10491
  // (copyWithPathReplacement + stale-skills cleanup).
10513
10492
  const _isSkillsRuntime = (() => {
10514
10493
  if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
10494
+ // #2875 Part 2 defect fix: a runtime whose LOCAL commands are embedded in a
10495
+ // rules file rather than materialized as files (hostBehaviors.localCommandsViaRules
10496
+ // — cline is the only declarant, capabilities/cline/capability.json) must not
10497
+ // flip into this skills/commands-reporting branch merely because its local
10498
+ // artifactLayout now also declares an `agents` kind (#2875 Part 2 cline-local
10499
+ // agents regression fix). That branch's own verification reporting expects a
10500
+ // skills/ or commands/ directory this runtime never writes locally and would
10501
+ // spuriously fail; the `localCommandsViaRules` branch below (unchanged
10502
+ // messaging) and the unconditional agents-materialization block further down
10503
+ // (installAgentsKindStandalone) already cover this runtime/scope correctly.
10504
+ if (!isGlobal && _hostBehaviors(runtime).localCommandsViaRules) return false;
10515
10505
  const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
10516
10506
  const layout = cap && cap.runtime && cap.runtime.artifactLayout;
10517
10507
  if (!layout) return false;
@@ -10560,7 +10550,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10560
10550
 
10561
10551
  if (_isSkillsRuntime) {
10562
10552
  // Layout-driven install for skills-based runtimes (full and minimal modes)
10563
- const scope = isGlobal ? 'global' : 'local';
10553
+ const scope = _installScopeId;
10564
10554
  // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
10565
10555
  // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
10566
10556
  // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
@@ -10796,15 +10786,34 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10796
10786
  // that used the namespaced layout (wrote bare-name files under commands/gsd/).
10797
10787
  const legacyGsdDir = path.join(commandsDir, 'gsd');
10798
10788
  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`);
10789
+ // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
10790
+ // #1874-F19 "site 6" — this Claude commands-install path open-coded
10791
+ // its own preserve/restore instead of calling preserveUserArtifacts,
10792
+ // found by sweeping for the read-then-wipe-then-write PATTERN rather
10793
+ // than for that helper's callers).
10794
+ // #2875 defect fix: DEGRADE, never abort install, when the staging
10795
+ // root cannot be resolved — skip this legacy-migration block entirely
10796
+ // (leave the stale dir in place) rather than wipe without a durable
10797
+ // backup.
10798
+ const _legacyGsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10799
+ if (_legacyGsdStagingRoot !== null) {
10800
+ const stagedDevPrefs = stageUserArtifacts(legacyGsdDir, ['dev-preferences.md'], _legacyGsdStagingRoot);
10801
+ // Preserve the ORIGINAL truthy-content check exactly: an existing but
10802
+ // EMPTY dev-preferences.md was (and still is) silently not migrated —
10803
+ // matching prior behavior byte-for-byte rather than widening scope.
10804
+ const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
10805
+ ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
10806
+ : null;
10807
+ fs.rmSync(legacyGsdDir, { recursive: true });
10808
+ console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
10809
+ if (preservedDevPrefs) {
10810
+ // Migrate dev-preferences to the new flat form — a RENAME on
10811
+ // restore (staged as 'dev-preferences.md', restored as
10812
+ // 'gsd-dev-preferences.md'), not a round-trip.
10813
+ restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } });
10814
+ console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
10815
+ }
10816
+ discardStagedUserArtifacts(stagedDevPrefs);
10808
10817
  }
10809
10818
  }
10810
10819
 
@@ -10835,12 +10844,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10835
10844
  }
10836
10845
 
10837
10846
  // Copy gsd-core skill with path replacement
10838
- // Preserve user-generated files before the wipe-and-copy so they survive re-install
10847
+ // Stage user-generated files DURABLY to disk before the wipe-and-copy so
10848
+ // they survive re-install even if the process dies mid-copy (#2875 /
10849
+ // #1874-F19) — copyWithPathReplacement wipes and recursively re-copies the
10850
+ // entire gsd-core/ tree, the single longest operation in the install, on
10851
+ // the path every user takes (40-design.md "Site 4 is far worse...").
10839
10852
  const skillSrc = path.join(src, 'gsd-core');
10840
10853
  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);
10854
+ // #2875 defect fix: this IS the mainline install step (installing
10855
+ // gsd-core/ itself) — unlike the optional legacy-cleanup blocks above,
10856
+ // install must still be able to proceed and actually write gsd-core/ even
10857
+ // when the staging root cannot be resolved. Degrade by skipping ONLY the
10858
+ // USER_OWNED_ARTIFACTS preserve/restore wrapper around the copy (warn),
10859
+ // never the copy itself.
10860
+ const _gsdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10861
+ if (_gsdArtifactsStagingRoot === null) {
10862
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/${USER_OWNED_ARTIFACTS.join(', gsd-core/')} preservation (staging unavailable) — it will be lost if present.`);
10863
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10864
+ } else {
10865
+ const stagedGsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _gsdArtifactsStagingRoot);
10866
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10867
+ restoreStagedUserArtifacts(skillDest, stagedGsdArtifacts);
10868
+ discardStagedUserArtifacts(stagedGsdArtifacts);
10869
+ }
10844
10870
  if (verifyInstalled(skillDest, 'gsd-core')) {
10845
10871
  console.log(` ${green}✓${reset} Installed workflow assets`);
10846
10872
  } else {
@@ -10902,219 +10928,89 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10902
10928
  }
10903
10929
  }
10904
10930
 
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
-
10931
+ // Agents directory materialization.
10932
+ // #2875 Part 2 (the agents-bypass closure): EVERY runtime is now
10933
+ // descriptor-driven for agents — installRuntimeArtifacts (called earlier in
10934
+ // this function, the `_isSkillsRuntime` branch above) already wrote
10935
+ // agents/ for any runtime whose capability.json declares an `agents` kind,
10936
+ // via convertedAgentsKind/agentsKind (generic layout loop) or
10937
+ // installAgentsKindStandalone (OpenCode/Kilo's combinedFamilyInstall
10938
+ // branch, called from within installOpencodeFamilyArtifacts) — both reuse
10939
+ // the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite →
10940
+ // attribution → converter → frontmatter extensions → normalize) the
10941
+ // inline loop this replaces used to hand-roll, and both prune stale gsd-*
10942
+ // entries via their own _removeGsdEntries pass BEFORE copying (broader
10943
+ // than this loop's old extension-gated stale check — see
10944
+ // runtime-artifact-layout.cts's convertedAgentsKind doc comment).
10945
+ // Minimal-mode agent filtering is handled the SAME way it already was for
10946
+ // the ten runtimes cut over before this change: via resolvedProfile.agents
10947
+ // at staging time, not a separate branch here.
10948
+ //
10949
+ // `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path, and
10950
+ // pi) never reach that loop at all — installAgentsKindStandalone is called
10951
+ // here explicitly to cover them (install-engine.cts's own doc comment
10952
+ // explains why; a regression here was caught by the install-tree golden
10953
+ // fixture, tests/fixtures/install-tree/claude-local.json). It is a no-op
10954
+ // for pi: its capability.json declares an EMPTY artifactLayout for both
10955
+ // scopes (programmatic dispatch, no named-dispatch subagent toolkit, no
10956
+ // host-read markdown surface), so the resolved layout has no `agents` kind
10957
+ // to stage and the function returns `null` without writing anything.
10952
10958
  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
10959
  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.
10960
+ } else if (_isSkillsRuntime) {
10960
10961
  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
- }
10962
+ } else {
10963
+ const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry);
10964
+ if (_standaloneAgentsResult) {
10965
+ // #2875 defect fix: installAgentsKindStandalone now returns `null`
10966
+ // (rather than a truthy result pointing at an empty destDir) whenever a
10967
+ // restricted (non-'*') resolvedProfile — --minimal being the common
10968
+ // case — legitimately stages ZERO agents, matching the pre-#2875-Part-2
10969
+ // inline loop's behavior of never creating agentsDest under --minimal
10970
+ // at all (see the deleted `isMinimalMode` branch). `destDir` is
10971
+ // therefore guaranteed non-empty whenever we reach this branch, so a
10972
+ // real staging failure still fails loudly via verifyInstalled below.
10973
+ if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) {
10974
+ console.log(` ${green}✓${reset} Installed agents`);
10975
+ } else {
10976
+ failures.push('agents');
10976
10977
  }
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`);
10978
+ } else if (_resolvedProfile.skills !== '*') {
10979
+ console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`gsd update\` with a broader profile to add them)`);
11113
10980
  } else {
11114
- failures.push('agents');
10981
+ console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`);
11115
10982
  }
11116
10983
  }
11117
10984
 
10985
+ // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections —
10986
+ // NOT agents-directory materialization (design doc "Deliberately not in
10987
+ // scope"), so this stays independent of the agents/ write above. Without
10988
+ // stripping these on a full → minimal reinstall, the runtime would keep
10989
+ // advertising the old full agent surface even though the descriptor-driven
10990
+ // write above already skipped writing the .md files for a minimal-tier
10991
+ // resolvedProfile. Reuse the same helper that powers `--uninstall`.
10992
+ if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) {
10993
+ const codexConfigPath = path.join(targetDir, 'config.toml');
10994
+ if (fs.existsSync(codexConfigPath)) {
10995
+ const existing = fs.readFileSync(codexConfigPath, 'utf8');
10996
+ const cleaned = stripGsdFromCodexConfig(existing);
10997
+ if (cleaned === null) {
10998
+ fs.unlinkSync(codexConfigPath);
10999
+ } else if (cleaned !== existing) {
11000
+ fs.writeFileSync(codexConfigPath, cleaned);
11001
+ }
11002
+ }
11003
+ }
11004
+
11005
+ // agentsSrc is declared as `let` before the enclosing try block (not const)
11006
+ // so it is accessible by installCodexConfig() in the Codex config section
11007
+ // below — that function reads RAW source agents/*.md (not the
11008
+ // descriptor-staged output above) to build Codex's per-agent config.toml
11009
+ // sidecar files, a separate writer this migration deliberately does not
11010
+ // touch (design doc: "Codex's config.toml [agents.gsd-*] strip... is not
11011
+ // agents-directory materialization").
11012
+ agentsSrc = _stageAgents(path.join(src, 'agents'));
11013
+
11118
11014
  // Copy CHANGELOG.md
11119
11015
  const changelogSrc = path.join(src, 'CHANGELOG.md');
11120
11016
  const changelogDest = path.join(targetDir, 'gsd-core', 'CHANGELOG.md');
@@ -11490,12 +11386,35 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11490
11386
  }
11491
11387
 
11492
11388
  // Write file manifest for future modification detection
11493
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11389
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11494
11390
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
11495
11391
 
11496
11392
  // Report any backed-up local patches
11497
11393
  reportLocalPatches(targetDir, runtime);
11498
11394
 
11395
+ // #2873: cross-scope shadow report. Fires ONCE per install (this is the
11396
+ // only writeManifest call site that gets it — the other four sites are
11397
+ // sub-writes within a single install, not separate installs). A shadowed
11398
+ // install is a warning, never a failure (ADR-2866 Consequences), so this
11399
+ // never touches `failures` or `process.exit`, and the whole block is
11400
+ // wrapped in a try/catch that swallows everything: a report failure must
11401
+ // never fail an otherwise-successful install (design row C5). No options
11402
+ // are injected into buildShadowReport — this is the production call shape,
11403
+ // resolving the real machine via os.homedir()/process.cwd() defaults
11404
+ // inside the resolver.
11405
+ try {
11406
+ const shadowReport = buildShadowReport(runtime);
11407
+ const shadowLines = renderShadowReport(shadowReport);
11408
+ if (shadowLines.length > 0) {
11409
+ console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
11410
+ for (const line of shadowLines.slice(1)) {
11411
+ console.warn(` ${dim}${line}${reset}`);
11412
+ }
11413
+ }
11414
+ } catch (_shadowReportErr) {
11415
+ // Never fail an install over a reporting concern — see comment above.
11416
+ }
11417
+
11499
11418
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
11500
11419
  if (!_hostBehaviors(runtime).ownsClaudePaths) {
11501
11420
  const leakedPaths = [];
@@ -11641,7 +11560,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11641
11560
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
11642
11561
  // are restored even when they are absent from disk at rollback time (#3245 CR).
11643
11562
  // • Dirs that did not pre-exist: remove entirely.
11644
- const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
11563
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
11645
11564
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
11646
11565
  for (const skillName of codexPreInstallSkillNames) {
11647
11566
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -11756,7 +11675,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11756
11675
  // Re-write the manifest now that .toml agent files exist on disk.
11757
11676
  // The initial writeManifest call (before Codex config generation) could
11758
11677
  // not include agents/gsd-*.toml because those files did not yet exist.
11759
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11678
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
11760
11679
  } else {
11761
11680
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
11762
11681
  }
@@ -12044,7 +11963,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12044
11963
  // manifest-tracked (verified) — uninstall removes them explicitly via
12045
11964
  // removeCursorHooksJson + its script list, and reconcile is idempotent.
12046
11965
  // The re-run is retained for parity with the settings.json install path.
12047
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
11966
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12048
11967
  persistActiveProfileMarker();
12049
11968
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12050
11969
  }
@@ -12131,7 +12050,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12131
12050
  // explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
12132
12051
  // is idempotent on repeated installs, so manifest tracking isn't needed
12133
12052
  // for correctness here.
12134
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12053
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12135
12054
  }
12136
12055
 
12137
12056
  persistActiveProfileMarker();
@@ -12145,7 +12064,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12145
12064
  writeClineArtifacts(targetDir, isGlobal);
12146
12065
  // Re-run the manifest pass: these artifacts are written *after* the earlier
12147
12066
  // writeManifest() call, so a second pass is needed to hash-track them.
12148
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
12067
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12149
12068
  persistActiveProfileMarker();
12150
12069
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12151
12070
  }
@@ -12160,10 +12079,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12160
12079
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
12161
12080
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
12162
12081
  // settings.json. Global installs and all other runtimes continue to use settings.json.
12082
+ // #2870: the CURRENT scope's settings filename is sourced from the Install
12083
+ // Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
12084
+ // instead of indexing _scopedSettings by hand. _scopedSettings itself is
12085
+ // retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
12086
+ // already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
12087
+ // above) when the registry fails to load, whereas resolveScope's registry
12088
+ // lookup throws in that same scenario (_installScope is null when it did).
12089
+ // Falling back to _scopedSettings[_installScopeId] there — and keeping the
12090
+ // non-local-claude branch's expression untouched — means this is
12091
+ // byte-identical to the pre-migration computation in every case, including
12092
+ // the broken-registry fail-safe floor.
12163
12093
  const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
12164
- const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
12094
+ const _currentScopeSettingsFile = _installScope
12095
+ ? _installScope.settingsFile
12096
+ : (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
12097
+ const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
12165
12098
  const settingsFileName = isLocalClaude
12166
- ? _scopedSettings.local
12099
+ ? _currentScopeSettingsFile
12167
12100
  : ((_scopedSettings && _scopedSettings.global) || 'settings.json');
12168
12101
  // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
12169
12102
  // must resolve under targetDir (this path also drives a recursive mkdirSync).
@@ -13441,13 +13374,12 @@ module.exports = {
13441
13374
  shouldNormalizeHyphenNamespaceInAgentBody,
13442
13375
  normalizeAgentBodyForRuntime,
13443
13376
  yamlIdentifier,
13444
- computePathPrefix,
13445
- applyRuntimeContentRewritesInPlace,
13446
13377
  getCodexSkillAdapterHeader,
13447
13378
  convertClaudeCommandToCursorSkill,
13448
13379
  convertClaudeAgentToCursorAgent,
13449
13380
  convertClaudeAgentToCodexAgent,
13450
13381
  generateCodexAgentToml,
13382
+ _resetCodexWarningDedupeForTests,
13451
13383
  cleanupCodexSkillMetadataSidecars,
13452
13384
  cleanupWindsurfLegacyDevinSkills,
13453
13385
  cleanupMovedSkillsOldLocation,
@@ -13455,7 +13387,6 @@ module.exports = {
13455
13387
  _resolveSkillsRootDir,
13456
13388
  codexBareAgentsHasOnlyKnownScalars,
13457
13389
  extractCodexUserAgentsScalars,
13458
- spliceCodexAgentsScalars,
13459
13390
  CODEX_EXTENDED_HOOK_EVENTS,
13460
13391
  generateCodexConfigBlock,
13461
13392
  stripGsdFromCodexConfig,
@@ -13466,13 +13397,6 @@ module.exports = {
13466
13397
  validateCodexConfigSchema,
13467
13398
  mergeCodexConfig,
13468
13399
  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
13400
  install,
13477
13401
  installAllRuntimes,
13478
13402
  uninstall,
@@ -13482,6 +13406,10 @@ module.exports = {
13482
13406
  // #3023 — shared hook bundle directory name, descriptor-driven
13483
13407
  SHARED_HOOKS_DIR_DEFAULT,
13484
13408
  resolveSharedHooksDirName,
13409
+ // #3184 — uninstall-side GSD-managed file enumerations, exported for
13410
+ // parity assertions against the wholesale-copy source directories
13411
+ GSD_CHANGESET_FILES,
13412
+ GSD_SCRIPTS_LIB_FILES,
13485
13413
  convertSlashCommandsToCodexSkillMentions,
13486
13414
  convertClaudeCommandToCodexSkill,
13487
13415
  convertClaudeCommandToKimiSkill,
@@ -13490,8 +13418,6 @@ module.exports = {
13490
13418
  buildKimiAgentArtifacts,
13491
13419
  convertClaudeToOpencodeFrontmatter,
13492
13420
  convertClaudeToKiloFrontmatter,
13493
- convertClaudeCommandToOpencodeSkill,
13494
- convertClaudeCommandToKiloSkill,
13495
13421
  configureOpencodePermissions,
13496
13422
  neutralizeAgentReferences,
13497
13423
  // #768 — Claude Code permissions pre-population
@@ -13501,7 +13427,6 @@ module.exports = {
13501
13427
  GSD_CLAUDE_DENY_PERMISSIONS,
13502
13428
  GSD_CODEX_MARKER,
13503
13429
  CODEX_AGENT_SANDBOX,
13504
- getDirName,
13505
13430
  getGlobalDir,
13506
13431
  getConfigDirFromHome,
13507
13432
  resolveKiloConfigPath,
@@ -13523,20 +13448,11 @@ module.exports = {
13523
13448
  mergeCopilotInstructions,
13524
13449
  stripGsdFromCopilotInstructions,
13525
13450
  GSD_COPILOT_HOOK_FILE,
13526
- buildCopilotHookConfig,
13527
- writeCopilotHookConfig,
13528
13451
  convertClaudeToAntigravityContent,
13529
13452
  convertClaudeCommandToAntigravitySkill,
13530
13453
  convertClaudeAgentToAntigravityAgent,
13531
13454
  convertClaudeCommandToClaudeSkill,
13532
13455
  skillFrontmatterName,
13533
- convertClaudeToWindsurfMarkdown,
13534
- convertClaudeCommandToWindsurfSkill,
13535
- convertClaudeCommandToWindsurfWorkflow,
13536
- convertClaudeAgentToWindsurfAgent,
13537
- convertClaudeToAugmentMarkdown,
13538
- convertClaudeCommandToAugmentSkill,
13539
- convertClaudeAgentToAugmentAgent,
13540
13456
  convertClaudeToTraeMarkdown,
13541
13457
  convertClaudeCommandToTraeSkill,
13542
13458
  convertClaudeAgentToTraeAgent,
@@ -13547,8 +13463,6 @@ module.exports = {
13547
13463
  convertClaudeToCliineMarkdown,
13548
13464
  convertClaudeCommandToClineSkill,
13549
13465
  convertClaudeAgentToClineAgent,
13550
- // #2284(b) — cross-cutting branding protected-region helper
13551
- applyClaudeCodeBrandSwap,
13552
13466
  // #2284 — Hermes named-dispatch → delegate_task projection
13553
13467
  convertClaudeToHermesMarkdown,
13554
13468
  projectNamedDispatchToStructuralDelegate,
@@ -13558,30 +13472,11 @@ module.exports = {
13558
13472
  maskStringLiterals,
13559
13473
  findDispatchCallSpans,
13560
13474
  _assertProjectionComplete,
13561
- _normalizeDispatchCallSpan,
13562
- buildClineRulesBody,
13563
- buildClineAgentsMdBody,
13564
- buildClinePreToolUseHook,
13565
- writeClineArtifacts,
13566
- mergeGsdAgentsMd,
13567
13475
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
13568
13476
  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
13477
  GSD_CURSOR_HOOK_MARKER,
13575
- buildCursorHookEntry,
13576
- isManagedCursorHookEntry,
13577
- reconcileCursorHooksJson,
13578
- writeCursorHooksJson,
13579
- removeCursorHooksJson,
13580
13478
  GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
13581
13479
  GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
13582
- GSD_WINDSURF_HOOK_SCRIPTS,
13583
- writeWindsurfHooksJson,
13584
- removeWindsurfHooksJson,
13585
13480
  stripGsdFromAgentsMd,
13586
13481
  GSD_AGENTS_MD_MARKER,
13587
13482
  GSD_AGENTS_MD_CLOSE_MARKER,
@@ -13589,11 +13484,9 @@ module.exports = {
13589
13484
  saveLocalPatches,
13590
13485
  reportLocalPatches,
13591
13486
  validateHookFields,
13592
- preserveUserArtifacts,
13593
- restoreUserArtifacts,
13594
- migrateLegacyDevPreferencesToSkill,
13595
13487
  populatePristineDir,
13596
- USER_OWNED_ARTIFACTS,
13488
+ _resolveUserArtifactStagingRoot,
13489
+ _tryResolveUserArtifactStagingRoot,
13597
13490
  finishInstall,
13598
13491
  homePathCoveredByRc,
13599
13492
  homePathCoveredByFishConfig,
@@ -13608,34 +13501,11 @@ module.exports = {
13608
13501
  buildUpdateBannerPromptText,
13609
13502
  parseUpdateBannerInput,
13610
13503
  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
13504
  parseConfigDirFromArgs,
13629
13505
  cleanupLegacyGsdCc,
13630
- _applyRuntimeRewrites,
13631
13506
  // #1191 — exported so tests exercise the REAL readSettings, not a replica
13632
13507
  readSettings,
13633
13508
  stripJsonComments,
13634
- // Compatibility relays retained after auditing the former broad
13635
- // runtimeArtifactConversion spread (#1559).
13636
- processAttribution,
13637
- applyRuntimeContentRewritesForCommandsInPlace,
13638
- _copyStaged,
13639
13509
  copyWithPathReplacement,
13640
13510
  };
13641
13511