@opengsd/gsd-core 1.11.0 → 1.13.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 (498) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
package/bin/install.js CHANGED
@@ -41,6 +41,7 @@ const {
41
41
  // consentRequired, hostPrecedenceRank) instead of the id being re-derived
42
42
  // and re-interpreted at each call site. See src/install-scope.cts.
43
43
  const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
44
+ const { isTestHomeGuardRefusal } = require('../gsd-core/bin/lib/real-home-guard.cjs');
44
45
  // getDirName (runtime -> local config dir name) is relocated out of this
45
46
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
46
47
  // conversion module's rewrite engine can consume it without importing
@@ -182,10 +183,11 @@ function isCodexHooksFeatureKey(key) {
182
183
  return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
183
184
  }
184
185
 
185
- // #768 \u2014 Claude Code permissions.allow / permissions.deny entries.
186
+ // #768 \u2014 Claude Code permissions.allow entries.
186
187
  // Pre-populated during Claude installs to eliminate first-run approval friction
187
- // for gsd-core's own known-safe tool calls, and to add defense-in-depth deny
188
- // entries for common credential files.
188
+ // for gsd-core's own known-safe tool calls. (The defense-in-depth deny entries
189
+ // for credential files that #768 also wrote are retired \u2014 see
190
+ // GSD_CLAUDE_LEGACY_DENY_PERMISSIONS below.)
189
191
  //
190
192
  // Format: each string uses Claude Code's documented permission rule syntax \u2014
191
193
  // "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
@@ -203,7 +205,20 @@ const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
203
205
  'Read(STATE.md)',
204
206
  'Edit(STATE.md)',
205
207
  ]);
206
- const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
208
+ // #4221 \u2014 Retired deny rules. #768 wrote these three `Read()` deny rules
209
+ // into settings.json; Claude Code 2.1.259 hardened the Bash-side enforcement
210
+ // of Read() deny rules so that ANY such rule makes every
211
+ // `cd DIR && grep/cat relative-path` compound prompt for approval, even in
212
+ // `auto` permission mode \u2014 and GSD subagents emit hundreds of those per
213
+ // session. The same protection now ships as the managed PreToolUse hook
214
+ // hooks/gsd-secret-read-guard.js (a hook denial is not a permission rule and
215
+ // never arms that check). Unlike the #2278 allow-side migration, there is no
216
+ // surviving "current" deny list: the constant is RENAMED to its legacy role
217
+ // and only ever filtered, never added. Byte-equal strings only \u2014 a user's
218
+ // own hand-written identical rule is indistinguishable and is removed too
219
+ // (the install manifest never recorded permission strings, so a
220
+ // manifest-gated cleanup is not possible).
221
+ const GSD_CLAUDE_LEGACY_DENY_PERMISSIONS = Object.freeze([
207
222
  'Read(.env)',
208
223
  'Read(.env.*)',
209
224
  'Read(.secrets)',
@@ -235,6 +250,13 @@ const GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
235
250
  * so existing installs end up with the working `Edit(...)` forms instead of
236
251
  * both the dead legacy entry and its replacement sitting side by side.
237
252
  *
253
+ * Migration (#4221): the retired GSD_CLAUDE_LEGACY_DENY_PERMISSIONS entries
254
+ * are removed from permissions.deny (byte-equal only). Nothing is added to
255
+ * deny any more: an absent `deny` key is left absent (never created as an
256
+ * empty array), and a `deny` array emptied BY THIS FILTER is deleted so the
257
+ * retirement leaves no `"deny": []` residue; a user's pre-existing empty
258
+ * `deny: []` is untouched.
259
+ *
238
260
  * Defensive: if settings is not a plain object, returns immediately without
239
261
  * throwing. If permissions.allow / permissions.deny exist but are not arrays
240
262
  * (malformed settings), they are replaced with valid arrays.
@@ -251,7 +273,7 @@ function mergeClaudePermissions(settings) {
251
273
  if (!Array.isArray(settings.permissions.allow)) {
252
274
  settings.permissions.allow = [];
253
275
  }
254
- if (!Array.isArray(settings.permissions.deny)) {
276
+ if (settings.permissions.deny !== undefined && !Array.isArray(settings.permissions.deny)) {
255
277
  settings.permissions.deny = [];
256
278
  }
257
279
 
@@ -264,9 +286,13 @@ function mergeClaudePermissions(settings) {
264
286
  settings.permissions.allow.push(entry);
265
287
  }
266
288
  }
267
- for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
268
- if (!settings.permissions.deny.includes(entry)) {
269
- settings.permissions.deny.push(entry);
289
+ if (Array.isArray(settings.permissions.deny)) {
290
+ const before = settings.permissions.deny.length;
291
+ settings.permissions.deny = settings.permissions.deny.filter(
292
+ (e) => !GSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
293
+ );
294
+ if (settings.permissions.deny.length === 0 && before > 0) {
295
+ delete settings.permissions.deny;
270
296
  }
271
297
  }
272
298
  }
@@ -410,7 +436,7 @@ const GSD_CHANGESET_FILES = [
410
436
  'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
411
437
  'README.md', // documentation only — not user-authored
412
438
  ];
413
- const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs'];
439
+ const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs', 'exit-code-registry.cjs', 'ndjson-reporter.cjs', 'ci-job-timing.cjs', 'shellcheck-fetch.cjs'];
414
440
 
415
441
  /**
416
442
  * Resolve a runtime's shared-hooks directory name from its descriptor.
@@ -459,19 +485,32 @@ function resolveSharedHooksDirName(runtime) {
459
485
  return name;
460
486
  }
461
487
 
462
- const CODEX_AGENT_SANDBOX = {
463
- 'gsd-executor': 'workspace-write',
464
- 'gsd-planner': 'workspace-write',
465
- 'gsd-phase-researcher': 'workspace-write',
466
- 'gsd-project-researcher': 'workspace-write',
467
- 'gsd-research-synthesizer': 'workspace-write',
468
- 'gsd-verifier': 'workspace-write',
469
- 'gsd-codebase-mapper': 'workspace-write',
470
- 'gsd-roadmapper': 'workspace-write',
471
- 'gsd-debugger': 'workspace-write',
472
- 'gsd-plan-checker': 'read-only',
473
- 'gsd-integration-checker': 'read-only',
474
- };
488
+ // #3897 rung 3 — sandbox_mode derivation, the hold list, and the hold-roster
489
+ // validator now live in `src/codex-agent-toml.cts` (compiled to
490
+ // `gsd-core/bin/lib/codex-agent-toml.cjs`), NOT here. This module used to be
491
+ // the sole owner, and `agent-install-check.cts`'s `checkCodexSandboxPosture`
492
+ // lazily `require()`d THIS FILE to reach `deriveCodexSandboxMode` — but
493
+ // requiring `bin/install.js` runs its whole top-level script, including the
494
+ // CLI's ASCII banner print to stdout, which corrupted every stdout-JSON
495
+ // caller downstream of that posture check (`gsd-tools validate agents`).
496
+ // `codex-agent-toml.cjs` is a genuine leaf (no top-level side effects), so
497
+ // both this file and `agent-install-check.cts` import the derivation from
498
+ // there — ONE owner, no second predicate. See that module's header for the
499
+ // full rationale, and CAUSE B (below, `installCodexConfig`) for the removal
500
+ // of `validateCodexSandboxHolds`'s call from the install runtime path.
501
+ const {
502
+ CODEX_SANDBOX_HOLDS,
503
+ deriveCodexSandboxMode,
504
+ validateCodexSandboxHolds,
505
+ // #3897 list-form parse fix, Fix 3 (generative-fix-divergence): this
506
+ // file's own `generateCodexAgentToml` used to pull `tools:` via its
507
+ // private `extractFrontmatterField` (single-line only) instead of this
508
+ // shared reader — the two sandbox-feeding paths (this emitter and
509
+ // `agent-install-check.cts`'s `checkCodexSandboxPosture`) silently
510
+ // disagreed on YAML block-list `tools:` form. Both now route through this
511
+ // ONE extractor.
512
+ extractToolsValue,
513
+ } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'codex-agent-toml.cjs'));
475
514
 
476
515
  // Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
477
516
  // Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
@@ -668,6 +707,68 @@ function _resolveScopeSafe(id, runtime) {
668
707
  }
669
708
  }
670
709
 
710
+ /**
711
+ * Resolve a layout kind's on-disk destination directory. Shared by the
712
+ * skills-root resolution above and the #3664 warning below so the
713
+ * home-override + destSubpath join exists once (#3659-class re-encoding guard).
714
+ */
715
+ function _kindDestDir(layout, kindName, targetDir) {
716
+ const kind = layout.kinds.find((k) => k.kind === kindName);
717
+ if (!kind) return null;
718
+ return path.join(kind.home || targetDir, kind.destSubpath);
719
+ }
720
+
721
+ /**
722
+ * #3738: scope-aware, layout-resolving wrapper over _kindDestDir for callers
723
+ * that have (runtime, configDir, scope) rather than a resolved Layout — the
724
+ * writeManifest agents surface being the first. Never throws: a runtime whose
725
+ * layout cannot be resolved (unknown id, descriptor error) keeps the caller's
726
+ * own fallback rather than losing the manifest.
727
+ */
728
+ function _kindDestDirSafe(runtime, configDir, scope, kindName) {
729
+ try {
730
+ return _kindDestDir(resolveRuntimeArtifactLayout(runtime, configDir, scope), kindName, configDir);
731
+ } catch {
732
+ return null;
733
+ }
734
+ }
735
+
736
+ /**
737
+ * #3664 — warn (never refuse) when `--config-dir` points the install at a
738
+ * directory whose agent destination already holds FOREIGN (non-GSD) agent
739
+ * files — the fingerprint of another harness's config home (e.g. ~/.junie,
740
+ * ~/.factory) or a hand-curated agents dir. GSD emits the selected runtime's
741
+ * artifacts verbatim: tool IDs (`Skill`) and MCP grants (`mcp__server__tool`)
742
+ * that are inert or invalid in a foreign harness surface only at dispatch
743
+ * time, months later. Warn-and-proceed is the issue-sanctioned option (b):
744
+ * a fresh custom dir (the documented brand-specific-dir use), a gsd-only dir
745
+ * (updates, the --all shared dir — including kimi's root `gsd.md`, which is
746
+ * GSD-owned despite the bare `gsd` stem), and the no-flag default-home path
747
+ * (users keep personal agents in ~/.claude/agents) all stay silent. Degrades
748
+ * silently on any resolution failure — the warn path never blocks install.
749
+ */
750
+ function warnIfForeignAgentDest(runtime, targetDir, scope, explicitConfigDir) {
751
+ if (explicitConfigDir !== true) return;
752
+ try {
753
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
754
+ // kimi's global agents kind is `kimi-agents` (#2095 EoS), every other
755
+ // runtime's is `agents`.
756
+ const agentsDir = _kindDestDir(layout, 'agents', targetDir)
757
+ || _kindDestDir(layout, 'kimi-agents', targetDir);
758
+ if (!agentsDir) return;
759
+ if (!fs.existsSync(agentsDir) || !fs.statSync(agentsDir).isDirectory()) return;
760
+ const foreign = fs
761
+ .readdirSync(agentsDir)
762
+ .filter((f) => f.endsWith('.md') && !f.startsWith('gsd-') && f !== 'gsd.md');
763
+ if (foreign.length === 0) return;
764
+ console.log(
765
+ ` ${yellow}⚠${reset} ${bold}${targetDir}${reset} already contains ${foreign.length} non-GSD agent file(s) — this may be another harness's config home. GSD emits artifacts shaped for ${runtime}: tool IDs and MCP grants may be inert or invalid for whatever harness reads this directory (#3664).`
766
+ );
767
+ } catch (_) {
768
+ /* never block install on the warning path */
769
+ }
770
+ }
771
+
671
772
  /**
672
773
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
673
774
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -678,8 +779,8 @@ function _resolveScopeSafe(id, runtime) {
678
779
  function _resolveSkillsRootDir(runtime, targetDir, scope) {
679
780
  try {
680
781
  const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
681
- const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
682
- if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath);
782
+ const skillsDir = _kindDestDir(layout, 'skills', targetDir);
783
+ if (skillsDir) return skillsDir;
683
784
  } catch (_e) { /* fall through to the configDir default */ }
684
785
  return path.join(targetDir, 'skills');
685
786
  }
@@ -700,6 +801,7 @@ function _runtimeAdapter(runtime) {
700
801
  }
701
802
  }
702
803
  const {
804
+ acquireInstallMigrationLock,
703
805
  applyInstallerMigrationPlan,
704
806
  discoverInstallerMigrations,
705
807
  MANIFEST_SCHEMA_VERSION,
@@ -713,6 +815,10 @@ const {
713
815
  const {
714
816
  resolveRuntimeArtifactLayout,
715
817
  } = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
818
+ const {
819
+ readSurface,
820
+ resolveSurface,
821
+ } = require(path.join(_gsdLibDir, 'surface.cjs'));
716
822
  const {
717
823
  assertDestWithinConfigHome,
718
824
  createRuntimeArtifactInstallPlan,
@@ -812,6 +918,17 @@ const hasSkillsRoot = args.includes('--skills-root');
812
918
  const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
813
919
  const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
814
920
  const hasDryRun = args.includes('--dry-run');
921
+ // #3031: opt-in reclaim of the GSD artifacts a PRE-#2755 `--kimi-code` install
922
+ // orphaned in Kimi CLI's `~/.kimi`. Opt-in and not automatic because the stale
923
+ // block is BYTE-IDENTICAL to a legitimate Kimi CLI one — both runtimes render
924
+ // the same bytes for the same root, since the command paths derive from the
925
+ // hooks root and not from the runtime — so no inspection can tell "litter GSD
926
+ // wrote for kimi-code" from "Kimi CLI's working hooks". Cleaning unasked would
927
+ // break #2755's own acceptance criterion ("Uninstalling GSD hooks for one
928
+ // runtime does not touch or remove the other runtime's hooks") for anyone with
929
+ // both products installed. The user, who knows which products they run, is the
930
+ // only party that can decide — so they ask for it explicitly.
931
+ const hasReclaimKimiLegacy = args.includes('--reclaim-kimi-legacy');
815
932
  // --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
816
933
  const _profileArgRaw = (() => {
817
934
  for (const arg of args) {
@@ -911,6 +1028,21 @@ function disambiguateKimiVariant(runtimes) {
911
1028
  return notices;
912
1029
  }
913
1030
 
1031
+ // #3031: `--reclaim-kimi-legacy` only ever acts inside the kimi-code GLOBAL
1032
+ // install branch. Say so when it cannot act, rather than exiting 0 having
1033
+ // silently done nothing: the user asked for a cleanup, and silence is
1034
+ // indistinguishable from "it ran and found nothing". Not a hard error — it
1035
+ // stays composable with `--all`, where it is legitimately inert for the other
1036
+ // seventeen runtimes.
1037
+ if (hasReclaimKimiLegacy && !selectedRuntimes.includes('kimi-code')) {
1038
+ console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — it applies only to a --kimi-code install; nothing in ~/.kimi was touched.`);
1039
+ } else if (hasReclaimKimiLegacy && hasLocal) {
1040
+ // Scope, checked HERE rather than inside install(): kimi-code declares
1041
+ // hostBehaviors.localInstallDeferred, so install() returns early long before
1042
+ // the kimi-hooks-toml branch — a warning placed there would be unreachable.
1043
+ console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — the legacy root is a global location; re-run with --global to reclaim it.`);
1044
+ }
1045
+
914
1046
  if (selectedRuntimes.includes('kimi') || selectedRuntimes.includes('kimi-code')) {
915
1047
  const kimiNotices = disambiguateKimiVariant(selectedRuntimes);
916
1048
  for (const notice of kimiNotices) {
@@ -1075,6 +1207,14 @@ function parseConfigDirFromArgs(argsArray) {
1075
1207
  return null;
1076
1208
  }
1077
1209
 
1210
+ // Parse --no-legacy-cleanup (#3799) — skip the legacy get-shit-done-cc scan
1211
+ // entirely. Some users run gsd-core alongside a live legacy install on
1212
+ // purpose; the scan is best-effbelt cleanup, never load-bearing for the
1213
+ // install itself.
1214
+ function parseNoLegacyCleanupArg(args = process.argv) {
1215
+ return args.includes('--no-legacy-cleanup');
1216
+ }
1217
+
1078
1218
  // Parse --config-dir argument
1079
1219
  function parseConfigDirArg() {
1080
1220
  const result = parseConfigDirFromArgs(args);
@@ -1105,7 +1245,7 @@ if (hasUninstall) {
1105
1245
 
1106
1246
  // Show help if requested
1107
1247
  if (hasHelp) {
1108
- console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
1248
+ console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--kimi-code${reset} Install for Kimi Code only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`);
1109
1249
  process.exit(0);
1110
1250
  }
1111
1251
 
@@ -1125,6 +1265,10 @@ if (hasHelp) {
1125
1265
  // had no internal caller and no export consumer for it — hooksSurface owns
1126
1266
  // the single implementation now, used internally by resolveNodeRunner there.)
1127
1267
  const resolveNodeRunner = hooksSurface.resolveNodeRunner;
1268
+ // #3662: the runtime-resolving runner token for managed JS hooks — the baked
1269
+ // absolute node path tried FIRST, then `command -v node`, then well-known
1270
+ // layouts, resolved by the shell at hook-fire time instead of bake time.
1271
+ const buildNodeRunnerChainToken = hooksSurface.buildNodeRunnerChainToken;
1128
1272
  const resolveBashRunner = hooksSurface.resolveBashRunner;
1129
1273
  // referencesHook: pure predicate over hook entry objects, shared between
1130
1274
  // install() and finishInstall() (ADR-857 phase 5f-1b).
@@ -1377,10 +1521,18 @@ function readSettings(settingsPath) {
1377
1521
  }
1378
1522
 
1379
1523
  /**
1380
- * Write settings.json with proper formatting
1524
+ * Write settings.json with proper formatting.
1525
+ *
1526
+ * Atomic (temp+rename) because hosts discard the ENTIRE settings file on any
1527
+ * parse failure, so a truncated write costs the user every hook, permission,
1528
+ * and statusline they have — not just GSD's entries. This is the sole writer
1529
+ * of that surface for six runtimes.
1530
+ *
1531
+ * `atomicWriteFileSync` is declared further down this file; it is dereferenced
1532
+ * at call time, after module evaluation, so the ordering is safe.
1381
1533
  */
1382
1534
  function writeSettings(settingsPath, settings) {
1383
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1535
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
1384
1536
  }
1385
1537
 
1386
1538
  // #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides /
@@ -1673,8 +1825,20 @@ const kiloAgentPermissionOrder = [
1673
1825
  'lsp',
1674
1826
  ];
1675
1827
 
1828
+ const kiloMcpPermissionPattern = /^mcp__([A-Za-z0-9_-]+)__((?:[A-Za-z0-9_-]+)|\*)$/;
1829
+
1830
+ // Derives Kilo's native `{server}_{tool}` MCP permission key (kilo.ai/docs —
1831
+ // external, fixed format). Not injective: both capture groups allow `_`, so
1832
+ // e.g. `mcp__a_b__c` and `mcp__a__b_c` derive the same key. The `Set` below
1833
+ // resolves any such collision deterministically to first-seen-wins — see
1834
+ // src/runtime-artifact-conversion.cts's regression test. Mirrors that file's
1835
+ // convertClaudeToKiloPermissionTool exactly (#4032).
1676
1836
  function convertClaudeToKiloPermissionTool(claudeTool) {
1677
- return claudeToKiloAgentPermissions[claudeTool] || null;
1837
+ const builtinPermission = claudeToKiloAgentPermissions[claudeTool];
1838
+ if (builtinPermission) return builtinPermission;
1839
+
1840
+ const mcpPermission = kiloMcpPermissionPattern.exec(claudeTool);
1841
+ return mcpPermission ? `${mcpPermission[1]}_${mcpPermission[2]}` : null;
1678
1842
  }
1679
1843
 
1680
1844
  function buildKiloAgentPermissionBlock(claudeTools) {
@@ -1691,6 +1855,10 @@ function buildKiloAgentPermissionBlock(claudeTools) {
1691
1855
  for (const permission of kiloAgentPermissionOrder) {
1692
1856
  lines.push(` ${permission}: ${allowedPermissions.has(permission) ? 'allow' : 'deny'}`);
1693
1857
  }
1858
+ for (const permission of allowedPermissions) {
1859
+ if (kiloAgentPermissionOrder.includes(permission)) continue;
1860
+ lines.push(` ${permission}: allow`);
1861
+ }
1694
1862
 
1695
1863
  return lines;
1696
1864
  }
@@ -2253,7 +2421,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2253
2421
  /**
2254
2422
  * Apply Antigravity-specific content conversion — path replacement + command name conversion.
2255
2423
  * Path mappings depend on install mode:
2256
- * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2424
+ * Global: ~/.claude/skills/ → ~/.gemini/config/skills/ (#3738),
2425
+ * ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2257
2426
  * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
2258
2427
  * Applied to ALL Antigravity content (skills, agents, engine files).
2259
2428
  * @param {string} content - Source content to convert
@@ -2262,6 +2431,19 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2262
2431
  function convertClaudeToAntigravityContent(content, isGlobal = false) {
2263
2432
  let c = content;
2264
2433
  if (isGlobal) {
2434
+ // #3738: global skills install under ~/.gemini/config/skills (the dir AGY
2435
+ // scans for global discovery), so skills-path references must divert there
2436
+ // — BEFORE the configHome rewrite below, which is correct for gsd-core
2437
+ // runtime-file references (settings, workflows, VERSION) but wrong for the
2438
+ // skills dir itself. Mirrors src/runtime-artifact-conversion.cts (ADR-1508
2439
+ // keeps bin/install.js hand-authored; the two copies must stay in sync).
2440
+ c = c.replace(/\$HOME\/\.claude\/skills\//g, '$HOME/.gemini/config/skills/');
2441
+ c = c.replace(/~\/\.claude\/skills\//g, '~/.gemini/config/skills/');
2442
+ // Bare skills form (no trailing slash) — must also precede the generic
2443
+ // slash rule, which would otherwise divert it to the retired configHome
2444
+ // path ($HOME/.gemini/antigravity/skills).
2445
+ c = c.replace(/\$HOME\/\.claude\/skills\b/g, '$HOME/.gemini/config/skills');
2446
+ c = c.replace(/~\/\.claude\/skills\b/g, '~/.gemini/config/skills');
2265
2447
  c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/');
2266
2448
  c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/');
2267
2449
  // Bare form (no trailing slash) — must come after slash form to avoid double-replace
@@ -3712,26 +3894,37 @@ Execute mode fallback:
3712
3894
  ## C. Task() → spawn_agent Mapping
3713
3895
  GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
3714
3896
 
3715
- **Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas:
3716
- - **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available.
3717
- - **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session.
3897
+ **Schema detection (required first step):** Before spawning, inspect the \`spawn_agent\`
3898
+ tool's visible parameter schema (via \`tool_search\` or the tool list). Use the presence
3899
+ of \`agent_type\` only to choose typed dispatch versus the generic-agent workaround.
3900
+ Detect optional fields independently: \`model\`, \`reasoning_effort\`, \`task_name\`,
3901
+ \`fork_turns\`, and \`fork_context\` may be added or removed without \`agent_type\` changing.
3902
+ Never infer one field from a schema/version label or from the presence of another field.
3718
3903
 
3719
- Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active.
3904
+ - **agent_type-capable schema:** \`spawn_agent\` advertises \`agent_type\` — typed GSD agent dispatch is available.
3905
+ - **Generic schema:** \`spawn_agent\` does not advertise \`agent_type\` — typed GSD agent dispatch is unavailable in this session, even if other optional fields are present.
3720
3906
 
3721
3907
  Typed mapping (agent_type-capable schema only):
3722
3908
  - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
3723
3909
  - \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
3724
- - \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter;
3725
- GSD embeds the resolved per-agent model directly into each agent's \`.toml\`
3726
- at install time so \`model_overrides\` from \`.planning/config.json\` and
3727
- \`~/.gsd/defaults.json\` are honored automatically by Codex's agent router.
3728
- - Resolved \`reasoning_effort="low|medium|high|xhigh"\` (\`xhigh\` is a GSD/Codex tier, not a generic runtime enum) → pass \`reasoning_effort\`
3729
- to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty,
3730
- inherited, or unsupported values; do not invent one-off effort literals in
3731
- workflow prose.
3910
+ - \`Task(model="{resolved_model}")\` → pass \`model="{resolved_model}"\` when the
3911
+ visible \`spawn_agent\` schema advertises \`model\` and the resolved value is explicit.
3912
+ This is how \`model_profile\` tier routing, including \`adaptive\`, reaches the child agent.
3913
+ Omit \`model\` only when the schema does not advertise \`model\`, or when the value is
3914
+ missing, empty, or \`"inherit"\`; omission deliberately inherits the session/static agent
3915
+ configuration. Explicit \`model_overrides\` may also be embedded in agent \`.toml\` files,
3916
+ but ordinary profile-resolved models are not, so a TOML file is not a reason to discard
3917
+ an available inline value.
3918
+ - Before each typed spawn, obtain the paired effort for its role with
3919
+ \`gsd_run query resolve-model <subagent_type> --pick effort\` when the workflow has not
3920
+ already exposed it. The resolver's unified \`effort\` field maps to the Codex spawn argument
3921
+ \`reasoning_effort\`; do not look for a resolver field named \`reasoning_effort\`.
3922
+ Pass it when the visible \`spawn_agent\` schema advertises \`reasoning_effort\`. Omit the
3923
+ field when it is not advertised, or when the value is missing, empty, \`"inherit"\`, or
3924
+ unsupported; do not invent one-off effort literals in workflow prose.
3732
3925
  - \`fork_context: false\` by default — GSD agents load their own context via \`<required_reading>\` blocks
3733
- - \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task
3734
- - \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement)
3926
+ - \`task_name\` — when advertised, provide a descriptive name for each spawned task
3927
+ - \`fork_turns\` — when advertised, controls turn-forking depth; coexists with \`fork_context\` (not a replacement)
3735
3928
  - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
3736
3929
  but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
3737
3930
  \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
@@ -3903,10 +4096,39 @@ function _resetCodexWarningDedupeForTests() {
3903
4096
  * @param {object|null} effortCfg — #443: merged effort config from readGsdEffectiveEffortConfig
3904
4097
  */
3905
4098
  function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
3906
- const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
3907
4099
  const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
3908
4100
  const frontmatterText = frontmatter || '';
4101
+ // #3897 list-form parse fix, Fix 3: `toolsRaw` MUST come from the same
4102
+ // shared `extractToolsValue` reader `checkCodexSandboxPosture` uses, not
4103
+ // this file's own `extractFrontmatterField` — the two used to disagree on
4104
+ // YAML block-list `tools:` form (`extractFrontmatterField`'s single-line
4105
+ // regex read only the first list item), which is exactly the generative-
4106
+ // fix-divergence shape CLAUDE.md warns about for two paths feeding one
4107
+ // derivation. `extractToolsValue` does its own `---`-delimited frontmatter
4108
+ // scan of the full `agentContent`, so it is not re-derived from
4109
+ // `frontmatterText` here.
4110
+ const toolsRaw = extractToolsValue(agentContent) ?? '';
3909
4111
  const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
4112
+ // #3897 rung 3 — derived from the role's own tool contract (HALT.md option
4113
+ // 2). The former hand-maintained CODEX_AGENT_SANDBOX map is deleted (ADR-3473
4114
+ // §8.3): it was fully redundant with this derivation, zero disagreements
4115
+ // across all 11 entries. Never a silent `|| 'read-only'` fallback either.
4116
+ // Derivation itself lives in codex-agent-toml.cjs, which does no frontmatter
4117
+ // parsing of its own (no third copy of that extraction) — it takes the
4118
+ // already-resolved `tools:` value, extracted via the shared reader above.
4119
+ //
4120
+ // #3897 security review F1 (blocker): pass BOTH candidate identities —
4121
+ // `agentName` (the caller's filename-stem identity) AND `resolvedName`
4122
+ // (the frontmatter `name:` this function's OWN emitted `name = ...` line
4123
+ // uses, and what `installCodexConfig`'s caller keys the output PATH on) —
4124
+ // never just one. Deciding the sandbox for `agentName` alone and applying
4125
+ // it to an artifact that a DIFFERENT identity (`resolvedName`) names is
4126
+ // exactly how a held role's own `.toml` could end up `workspace-write`
4127
+ // (rename the source file, or plant a sibling whose `name:` collides with
4128
+ // a held role). `deriveCodexSandboxMode` takes the most restrictive result
4129
+ // across every candidate — see its doc and `isSandboxHeld` in
4130
+ // codex-agent-toml.cjs.
4131
+ const sandboxMode = deriveCodexSandboxMode([agentName, resolvedName], toolsRaw);
3910
4132
  const resolvedDescription = toSingleLine(
3911
4133
  extractFrontmatterField(frontmatterText, 'description') || `GSD agent ${resolvedName}`
3912
4134
  );
@@ -3986,8 +4208,10 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3986
4208
  // #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
3987
4209
  // as the Claude .md effort injection (resolveInstallTimeEffort), so both runtimes read
3988
4210
  // from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
3989
- // config source. Codex does not support 'max' → clamped to 'xhigh' by
3990
- // gsdRenderEffortForRuntime('codex', ...).
4211
+ // config source. #3007 — Codex advertises supported_reasoning_levels per model, so the
4212
+ // pinned model id is passed through and the value is resolved against that model's own
4213
+ // set: 'max' now passes, 'minimal' clamps up to 'low', and 'ultra' is refused (no key
4214
+ // emitted) rather than clamped to a fabricated level.
3991
4215
  // #838 — Do not pin effort when Codex is intentionally inheriting the parent
3992
4216
  // chat model. A TOML with no `model` but a static `model_reasoning_effort`
3993
4217
  // creates confusing partial routing: model follows the Codex UI while effort
@@ -3997,8 +4221,12 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3997
4221
  // #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
3998
4222
  // own effort default. Never write the literal.
3999
4223
  if (_universalEffortCodex !== 'inherit') {
4000
- const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
4001
- lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4224
+ const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex, pinnedModel).value;
4225
+ // #3007 — 'ultra' is rejected by the model's supported_reasoning_levels and
4226
+ // renders as null. Omit the key entirely rather than write a literal `null`.
4227
+ if (_renderedEffortCodex !== null) {
4228
+ lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
4229
+ }
4002
4230
  }
4003
4231
  }
4004
4232
 
@@ -6206,6 +6434,31 @@ const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
6206
6434
  * All writes go through atomicWriteFileSync so a mid-write failure leaves
6207
6435
  * the original config.toml untouched (#2760 fix 4).
6208
6436
  */
6437
+ /**
6438
+ * Split TOML content into its leading TOP-LEVEL key lines and everything from
6439
+ * the first table header onward (#3610).
6440
+ *
6441
+ * Top-level keys were file-scoped before a merge. The regenerated GSD block
6442
+ * opens with a table header (`[agents]`, #2088/ADR-1239 upgrade 2), so placing
6443
+ * that block ABOVE surviving top-level keys re-scopes them into `[agents]` —
6444
+ * `validateCodexConfigSchema` then correctly rejects the merged file and the
6445
+ * install aborts. Hoisting the keys above the block preserves their scope.
6446
+ *
6447
+ * Table headers inside multiline strings do not start the "rest" region (the
6448
+ * record parser already excludes them via startsInMultilineString).
6449
+ */
6450
+ function splitTopLevelKeys(content) {
6451
+ for (const record of getTomlLineRecords(content)) {
6452
+ if (record.tableHeader && !record.startsInMultilineString) {
6453
+ return {
6454
+ topLevel: content.slice(0, record.start).trim(),
6455
+ rest: content.slice(record.start).trim(),
6456
+ };
6457
+ }
6458
+ }
6459
+ return { topLevel: content.trim(), rest: '' };
6460
+ }
6461
+
6209
6462
  function mergeCodexConfig(configPath, gsdBlock) {
6210
6463
  // Case 1: No config.toml — create fresh
6211
6464
  if (!fs.existsSync(configPath)) {
@@ -6253,10 +6506,25 @@ function mergeCodexConfig(configPath, gsdBlock) {
6253
6506
  .replace(/^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
6254
6507
  const afterUser = stripLeakedGsdCodexSections(markerStripped).trim();
6255
6508
 
6509
+ // #3610: top-level keys that survived BELOW the marker were file-scoped
6510
+ // before this merge; the regenerated block opens with the `[agents]` table
6511
+ // header, so they must be hoisted to FILE scope or TOML re-scopes them
6512
+ // into a table. File scope means BEFORE the first table header of the
6513
+ // pre-marker region too — appending them after a pre-marker table (the
6514
+ // default real-world layout: user tables precede the marker) would merely
6515
+ // capture them into THAT table instead of [agents], and the schema
6516
+ // validator is blind to non-agents tables.
6517
+ const beforeSplit = before ? splitTopLevelKeys(before) : { topLevel: '', rest: '' };
6518
+ const { topLevel: afterTopLevel, rest: afterTables } = splitTopLevelKeys(afterUser);
6519
+
6256
6520
  const parts = [];
6257
- if (before) parts.push(before);
6521
+ const topParts = [];
6522
+ if (beforeSplit.topLevel) topParts.push(beforeSplit.topLevel);
6523
+ if (afterTopLevel) topParts.push(afterTopLevel);
6524
+ if (topParts.length > 0) parts.push(topParts.join(eol + eol));
6525
+ if (beforeSplit.rest) parts.push(beforeSplit.rest);
6258
6526
  parts.push(normalizedGsdBlock);
6259
- if (afterUser) parts.push(afterUser);
6527
+ if (afterTables) parts.push(afterTables);
6260
6528
  atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
6261
6529
  return;
6262
6530
  }
@@ -6726,29 +6994,50 @@ function writeNonClaudeDefaults(runtime) {
6726
6994
  if (_hostBehaviors(runtime).nativeModelAliases || process.env.GSD_TEST_MODE) return;
6727
6995
  const gsdDir = path.join(os.homedir(), '.gsd');
6728
6996
  const defaultsPath = path.join(gsdDir, 'defaults.json');
6997
+ let releaseLock = null;
6729
6998
  try {
6730
6999
  fs.mkdirSync(gsdDir, { recursive: true });
7000
+ // defaults.json is machine-global — every runtime and project on the box
7001
+ // reads it. Serialize the read-modify-write so two concurrent installs
7002
+ // cannot lose each other's key, and apply both mutations in ONE atomic
7003
+ // write so a crash cannot leave the file truncated (the read path swallows
7004
+ // parse errors and treats a corrupt file as absent, which would silently
7005
+ // degrade model resolution everywhere until repaired by hand).
7006
+ releaseLock = acquireInstallMigrationLock(gsdDir);
6731
7007
  let defaults = {};
6732
7008
  try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
6733
7009
  if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
6734
7010
  defaults = {};
6735
7011
  }
7012
+ const applied = [];
6736
7013
  // Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
6737
7014
  const existing = defaults.resolve_model_ids;
6738
7015
  const shouldDefaultToOmit = existing !== true && existing !== 'omit';
6739
7016
  if (shouldDefaultToOmit) {
6740
7017
  defaults.resolve_model_ids = 'omit';
6741
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6742
- console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
7018
+ applied.push(`Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
6743
7019
  }
6744
7020
  // #2395: persist runtime for non-Claude runtimes.
6745
7021
  if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
6746
7022
  defaults.runtime = runtime;
6747
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6748
- console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
7023
+ applied.push(`Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
7024
+ }
7025
+ if (applied.length > 0) {
7026
+ atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
7027
+ for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
6749
7028
  }
6750
7029
  } catch (e) {
6751
7030
  console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
7031
+ } finally {
7032
+ if (releaseLock) {
7033
+ try {
7034
+ releaseLock();
7035
+ } catch (releaseError) {
7036
+ // A leaked lock blocks the next install, so surface it rather than
7037
+ // swallowing; the stale-lock reaper clears it once this pid exits.
7038
+ console.log(` ${yellow}⚠${reset} Could not release the ~/.gsd install lock: ${releaseError.message}`);
7039
+ }
7040
+ }
6752
7041
  }
6753
7042
  }
6754
7043
 
@@ -6772,6 +7061,36 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
6772
7061
  }
6773
7062
  fs.mkdirSync(agentsTomlDir, { recursive: true });
6774
7063
 
7064
+ // #3897 rung 3 (CAUSE B fix) — validateCodexSandboxHolds is deliberately
7065
+ // NOT called here. The "no stale holds" invariant is a REPO invariant about
7066
+ // the canonical roster in `agents/`, not a property of whatever directory
7067
+ // an install happens to read from: a partial or synthetic `agentsSrc` (a
7068
+ // test fixture, a `--config-dir` subset) legitimately contains only a few
7069
+ // agents, and a held role simply absent from THIS source dir must be
7070
+ // inert, not fatal. Throwing here also masked unrelated failures further
7071
+ // down this same loop (e.g. the name-injection/path-escape guard on
7072
+ // `agentTomlPath` below), since this check ran first and unconditionally.
7073
+ // The invariant is still enforced — as a test over the real `agents/`
7074
+ // roster (tests/codex-config.test.cjs T24/T25) — just never on this
7075
+ // runtime path. See src/codex-agent-toml.cts's `validateCodexSandboxHolds`
7076
+ // docblock for the full rationale.
7077
+ //
7078
+ // #3897 security review F2 (assessed post-F1-fix, verified by execution —
7079
+ // not asserted): before F1's fix, the "runtime detector removed" gap here
7080
+ // was real — a rename (case a) or a sibling `name:` clobber (case b) could
7081
+ // reach this loop and land a held role's `.toml` at `workspace-write` with
7082
+ // nothing here to catch it. After F1 (`deriveCodexSandboxMode` now decides
7083
+ // over BOTH the filename stem and the resolved frontmatter `name:`, most
7084
+ // restrictive wins), both cases were re-run end-to-end through this exact
7085
+ // function and the EMITTED ARTIFACT for both is `read-only` — the
7086
+ // dangerous condition no longer produces a wrong artifact, it produces the
7087
+ // SAFE one. A detector guarding a now-fail-safe condition is not
7088
+ // load-bearing, and restoring a throw here would re-break the legitimate
7089
+ // partial-source-dir case CAUSE B removed it for (see above). Regression
7090
+ // coverage for both cases lives in `tests/codex-config.test.cjs` (F1(a)
7091
+ // rename / F1(b) sibling-clobber rows), asserted on the emitted `.toml`'s
7092
+ // `sandbox_mode`, not on the derivation's return value.
7093
+
6775
7094
  const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
6776
7095
  const agents = [];
6777
7096
 
@@ -6800,7 +7119,20 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
6800
7119
  // CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
6801
7120
  content = convertClaudeToCodexMarkdown(content);
6802
7121
  const { frontmatter } = extractFrontmatterAndBody(content);
6803
- const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
7122
+ // #3897 security review F1 (blocker, post-merge): this loop used to key
7123
+ // the sandbox/hold decision off ONLY the filename stem while the emitted
7124
+ // `.toml`'s OUTPUT PATH below is keyed off `name` (frontmatter-derived,
7125
+ // attacker-editable) — so a renamed source file, or a sibling file whose
7126
+ // `name:` collides with a held role, could make the decided identity and
7127
+ // the landed artifact disagree, widening a held role's own file to
7128
+ // `workspace-write`. `generateCodexAgentToml` (below) now derives
7129
+ // `sandbox_mode` over BOTH the filename stem it is given AND the
7130
+ // frontmatter `name:` it resolves internally, taking the most
7131
+ // restrictive result — this loop no longer needs to choose one identity
7132
+ // for that call; see `deriveCodexSandboxMode`'s doc in
7133
+ // `codex-agent-toml.cts` for the resolution.
7134
+ const fileStem = file.replace(/\.md$/, '');
7135
+ const name = extractFrontmatterField(frontmatter, 'name') || fileStem;
6804
7136
  const description = extractFrontmatterField(frontmatter, 'description') || '';
6805
7137
 
6806
7138
  agents.push({ name, description: toSingleLine(description) });
@@ -6822,7 +7154,10 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
6822
7154
  // #443 — pass unified effort config so model_reasoning_effort in the .toml
6823
7155
  // follows the same config-driven precedence as the Claude .md effort key.
6824
7156
  const effortCfg = readGsdEffectiveEffortConfig(targetDir);
6825
- const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
7157
+ // Pass `fileStem`; `generateCodexAgentToml` itself additionally resolves
7158
+ // and folds in the frontmatter `name:` for the sandbox decision (F1
7159
+ // above) — this call site does not need to pass `name` explicitly.
7160
+ const tomlContent = generateCodexAgentToml(fileStem, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
6826
7161
  // Confine the per-agent write to the agents/ dir itself: a crafted agent
6827
7162
  // `name` containing path separators must not escape agents/ (which would let
6828
7163
  // it clobber config.toml or write elsewhere under the configHome).
@@ -7093,7 +7428,8 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7093
7428
 
7094
7429
  if (isAgent && inAgentTools) {
7095
7430
  if (trimmed.startsWith('- ')) {
7096
- agentTools.push(trimmed.substring(2).trim());
7431
+ const tool = runtimeArtifactConversion._decodeToolScalar(trimmed.substring(2));
7432
+ if (tool !== null) agentTools.push(tool);
7097
7433
  continue;
7098
7434
  }
7099
7435
  if (trimmed && !trimmed.startsWith('-')) {
@@ -7105,8 +7441,12 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7105
7441
  if (trimmed.startsWith('tools:')) {
7106
7442
  if (isAgent) {
7107
7443
  const toolsValue = trimmed.substring(6).trim();
7108
- if (toolsValue) {
7109
- const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
7444
+ // A comment-only value (`tools: # note`) is not real inline content —
7445
+ // fall through to the block-list scan instead of decoding the
7446
+ // comment as a bogus tool name and dropping the list (mirrors
7447
+ // src/runtime-artifact-conversion.cts's convertClaudeToKiloFrontmatter, #4032).
7448
+ if (toolsValue && !toolsValue.startsWith('#')) {
7449
+ const tools = runtimeArtifactConversion._splitToolScalars(toolsValue).map(runtimeArtifactConversion._decodeToolScalar).filter(tool => tool !== null);
7110
7450
  agentTools.push(...tools);
7111
7451
  } else {
7112
7452
  inAgentTools = true;
@@ -7172,7 +7512,8 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7172
7512
  if (trimmed.startsWith('- ')) {
7173
7513
  const tool = trimmed.substring(2).trim();
7174
7514
  if (isAgent) {
7175
- agentTools.push(tool);
7515
+ const decoded = runtimeArtifactConversion._decodeToolScalar(tool);
7516
+ if (decoded !== null) agentTools.push(decoded);
7176
7517
  } else {
7177
7518
  allowedTools.push(tool);
7178
7519
  }
@@ -7822,6 +8163,133 @@ function validateHookFields(settings) {
7822
8163
  */
7823
8164
  const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd'];
7824
8165
 
8166
+ /**
8167
+ * Whether two paths denote the SAME directory — used to stop a reclaim from
8168
+ * deleting the very root the current install just wrote (#3031).
8169
+ *
8170
+ * A plain `path.resolve` comparison is not enough here, because both roots come
8171
+ * from user-controlled env vars (`KIMI_SHARE_DIR`, `KIMI_CODE_HOME`) and two
8172
+ * different strings routinely name one directory:
8173
+ * - case-insensitive filesystems (macOS, Windows): `~/Kimi` vs `~/kimi`
8174
+ * - symlinks / bind mounts: `~/link-to-kimi` vs the real target
8175
+ * Getting this wrong is not cosmetic — it is the difference between skipping a
8176
+ * reclaim and deleting a live install's own hooks.
8177
+ *
8178
+ * Strategy, cheapest-first: string equality after `resolve`, then identity by
8179
+ * `dev`+`ino` (definitive when both exist and the platform reports them), then
8180
+ * `realpath` string equality (resolves symlinks AND canonicalizes case). Any
8181
+ * rung answering "same" wins; a path that does not exist cannot be the root we
8182
+ * just wrote, so a failed stat simply falls through.
8183
+ *
8184
+ * @returns {boolean} true only when both paths are proven to be one directory.
8185
+ */
8186
+ function isSameDirectory(a, b) {
8187
+ if (path.resolve(a) === path.resolve(b)) return true;
8188
+ try {
8189
+ const sa = fs.statSync(a);
8190
+ const sb = fs.statSync(b);
8191
+ // `ino` is 0 on some Windows filesystems; only trust a positive match.
8192
+ if (sa.ino && sb.ino && sa.dev === sb.dev && sa.ino === sb.ino) return true;
8193
+ } catch (_) { /* one side missing — fall through to realpath */ }
8194
+ try {
8195
+ return fs.realpathSync.native(a) === fs.realpathSync.native(b);
8196
+ } catch (_) {
8197
+ return false;
8198
+ }
8199
+ }
8200
+
8201
+ /**
8202
+ * Remove every GSD-owned artifact from a Kimi hooks root (`~/.kimi` for kimi,
8203
+ * `~/.kimi-code` for kimi-code — resolveKimiHooksTomlDir, #2755): the managed
8204
+ * `[[hooks]]` block in the native config.toml, the hook scripts, hooks/lib/,
8205
+ * and the CommonJS marker at both its current (hooks/) and pre-#2544 (root)
8206
+ * locations.
8207
+ *
8208
+ * This root is Kimi's own native config home — SHARED space that may hold the
8209
+ * user's real config.toml, providers and their own scripts — so only exact
8210
+ * GSD-owned filenames are removed and directories are pruned only when that
8211
+ * removal leaves them empty.
8212
+ *
8213
+ * TWO callers, deliberately one implementation (#3031). `uninstall()` calls it
8214
+ * for the runtime being uninstalled; the opt-in `--reclaim-kimi-legacy` path in
8215
+ * `install()` calls it for the LEGACY `~/.kimi` root a pre-#2755 `--kimi-code`
8216
+ * install orphaned. Duplicating this sequence for the second caller would be
8217
+ * exactly the generative-divergence hazard the repo bans — the reclaim must
8218
+ * remove precisely what a real uninstall removes, forever, by construction.
8219
+ *
8220
+ * @param {string} kimiHooksRoot - Absolute path to the Kimi hooks root.
8221
+ * @returns {number} count of removal steps performed (0 when nothing matched).
8222
+ */
8223
+ function reclaimKimiHooksRoot(kimiHooksRoot) {
8224
+ let steps = 0;
8225
+ const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8226
+ const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8227
+ if (kimiHooksCleanup.changed) {
8228
+ steps++;
8229
+ console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
8230
+ }
8231
+
8232
+ // Kimi's shared hook scripts + CommonJS package.json marker are installed
8233
+ // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
8234
+ // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
8235
+ // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
8236
+ // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
8237
+ // may hold the user's real config.toml/providers), so only the exact
8238
+ // GSD-owned filenames are removed, and directories are pruned only if left
8239
+ // empty by that removal.
8240
+ const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
8241
+ if (fs.existsSync(kimiHooksDir)) {
8242
+ let kimiHookCount = 0;
8243
+ for (const hook of GSD_UNINSTALL_HOOKS) {
8244
+ const hookPath = path.join(kimiHooksDir, hook);
8245
+ if (fs.existsSync(hookPath)) {
8246
+ fs.unlinkSync(hookPath);
8247
+ kimiHookCount++;
8248
+ }
8249
+ }
8250
+ if (kimiHookCount > 0) {
8251
+ steps++;
8252
+ console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
8253
+ }
8254
+
8255
+ const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
8256
+ if (fs.existsSync(kimiHooksLibDir)) {
8257
+ let removedKimiLibFiles = 0;
8258
+ for (const file of GSD_HOOK_LIB_FILES) {
8259
+ try {
8260
+ fs.unlinkSync(path.join(kimiHooksLibDir, file));
8261
+ removedKimiLibFiles++;
8262
+ } catch (_) { /* best-effort */ }
8263
+ }
8264
+ try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
8265
+ if (removedKimiLibFiles > 0) {
8266
+ steps++;
8267
+ console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
8268
+ }
8269
+ }
8270
+
8271
+ // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8272
+ // before the emptiness check below, or the dir would never prune.
8273
+ if (removeCommonJsMarker(kimiHooksDir)) {
8274
+ steps++;
8275
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8276
+ }
8277
+
8278
+ try {
8279
+ if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8280
+ } catch (_) { /* not empty — leave it */ }
8281
+ }
8282
+
8283
+ // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8284
+ // used to write it. Exact content match — a user's own package.json in
8285
+ // kimi's native config home is never touched.
8286
+ if (removeCommonJsMarker(kimiHooksRoot)) {
8287
+ steps++;
8288
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8289
+ }
8290
+ return steps;
8291
+ }
8292
+
7825
8293
  /**
7826
8294
  * Uninstall GSD from the specified directory for a specific runtime
7827
8295
  * Removes only GSD-specific files/directories, preserves user content
@@ -8019,72 +8487,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8019
8487
  // cleanup can't be driven by anything under targetDir the way every other
8020
8488
  // hook surface above is.
8021
8489
  if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
8022
- const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
8023
- const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8024
- const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8025
- if (kimiHooksCleanup.changed) {
8026
- removedCount++;
8027
- console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
8028
- }
8029
-
8030
- // Kimi's shared hook scripts + CommonJS package.json marker are installed
8031
- // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
8032
- // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
8033
- // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
8034
- // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
8035
- // may hold the user's real config.toml/providers), so only the exact
8036
- // GSD-owned filenames are removed, and directories are pruned only if left
8037
- // empty by that removal.
8038
- const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
8039
- if (fs.existsSync(kimiHooksDir)) {
8040
- let kimiHookCount = 0;
8041
- for (const hook of GSD_UNINSTALL_HOOKS) {
8042
- const hookPath = path.join(kimiHooksDir, hook);
8043
- if (fs.existsSync(hookPath)) {
8044
- fs.unlinkSync(hookPath);
8045
- kimiHookCount++;
8046
- }
8047
- }
8048
- if (kimiHookCount > 0) {
8049
- removedCount++;
8050
- console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
8051
- }
8052
-
8053
- const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
8054
- if (fs.existsSync(kimiHooksLibDir)) {
8055
- let removedKimiLibFiles = 0;
8056
- for (const file of GSD_HOOK_LIB_FILES) {
8057
- try {
8058
- fs.unlinkSync(path.join(kimiHooksLibDir, file));
8059
- removedKimiLibFiles++;
8060
- } catch (_) { /* best-effort */ }
8061
- }
8062
- try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
8063
- if (removedKimiLibFiles > 0) {
8064
- removedCount++;
8065
- console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
8066
- }
8067
- }
8068
-
8069
- // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8070
- // before the emptiness check below, or the dir would never prune.
8071
- if (removeCommonJsMarker(kimiHooksDir)) {
8072
- removedCount++;
8073
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8074
- }
8075
-
8076
- try {
8077
- if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8078
- } catch (_) { /* not empty — leave it */ }
8079
- }
8080
-
8081
- // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8082
- // used to write it. Exact content match — a user's own package.json in
8083
- // kimi's native config home is never touched.
8084
- if (removeCommonJsMarker(kimiHooksRoot)) {
8085
- removedCount++;
8086
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8087
- }
8490
+ removedCount += reclaimKimiHooksRoot(resolveKimiHooksTomlDir({ runtime }));
8088
8491
  }
8089
8492
 
8090
8493
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
@@ -8698,17 +9101,29 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8698
9101
  );
8699
9102
  if (settings.permissions.allow.length !== before) {
8700
9103
  permissionsModified = true;
9104
+ // #4221: an array this filter emptied was GSD-only \u2014 remove the
9105
+ // key rather than leave an empty array behind (Antigravity symmetry).
9106
+ if (settings.permissions.allow.length === 0) {
9107
+ delete settings.permissions.allow;
9108
+ }
8701
9109
  }
8702
9110
  }
8703
9111
  if (Array.isArray(settings.permissions.deny)) {
8704
9112
  const before = settings.permissions.deny.length;
9113
+ // #4221: the deny rules are retired, so this is a legacy-only filter.
8705
9114
  settings.permissions.deny = settings.permissions.deny.filter(
8706
- (e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e)
9115
+ (e) => !GSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
8707
9116
  );
8708
9117
  if (settings.permissions.deny.length !== before) {
8709
9118
  permissionsModified = true;
9119
+ if (settings.permissions.deny.length === 0) {
9120
+ delete settings.permissions.deny;
9121
+ }
8710
9122
  }
8711
9123
  }
9124
+ if (permissionsModified && Object.keys(settings.permissions).length === 0) {
9125
+ delete settings.permissions;
9126
+ }
8712
9127
  if (permissionsModified) {
8713
9128
  settingsModified = true;
8714
9129
  console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
@@ -9450,7 +9865,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9450
9865
  const resolvedScope = options.scope === 'local' ? 'local' : 'global';
9451
9866
  const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
9452
9867
  const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
9453
- const agentsDir = path.join(configDir, 'agents');
9868
+ // #3738: resolve the ACTUAL agents-install dir honoring an agents-kind `home`
9869
+ // override (antigravity global → $HOME/.gemini/config/agents), mirroring
9870
+ // _resolveSkillsRootDir for skills. Hardcoding configDir/agents left the
9871
+ // manifest blind to the whole agents surface the moment the override landed —
9872
+ // no drift detection, no patch backup. Falls back to <configDir>/agents.
9873
+ const agentsDir = _kindDestDirSafe(runtime, configDir, resolvedScope, 'agents')
9874
+ || _kindDestDirSafe(runtime, configDir, resolvedScope, 'kimi-agents')
9875
+ || path.join(configDir, 'agents');
9454
9876
  const manifest = {
9455
9877
  // Schema version of this DOCUMENT (#2872) — distinct from `version`
9456
9878
  // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1)
@@ -9763,19 +10185,57 @@ function saveLocalPatches(configDir, pristineCtx) {
9763
10185
  const modified = [];
9764
10186
  const pristineHashes = {};
9765
10187
 
10188
+ // #4086: skills/ manifest keys may live OUTSIDE configDir at the runtime's
10189
+ // ACTUAL skills root — codex global installs to $HOME/.agents/skills (the
10190
+ // ADR-1239 skills-kind `home` override), which writeManifest() already
10191
+ // hashes from via _resolveSkillsRootDir (#2088/#3738). Resolving every key
10192
+ // against configDir alone made every skills/ key miss here, so user
10193
+ // modifications to Codex skills were never hash-compared, never backed up,
10194
+ // and were silently overwritten by the next update. configDir stays FIRST
10195
+ // (Postel: runtimes whose skills genuinely live under configDir resolve
10196
+ // byte-identically to before); the skills root is a fallback, only for keys
10197
+ // under the SAME descriptor-driven manifest prefix writeManifest uses, and
10198
+ // only when that root resolves outside configDir. Containment + symlink
10199
+ // guards apply to the alternate root too (resolveInstallRelativePath).
10200
+ const patchRuntime = (pristineCtx && pristineCtx.runtime) || manifest.runtime || null;
10201
+ const patchScope = manifest.scope === 'local' ? 'local' : 'global';
10202
+ let skillsRedirect = null;
10203
+ if (patchRuntime) {
10204
+ const skillsRoot = _resolveSkillsRootDir(patchRuntime, configDir, patchScope);
10205
+ const resolvedConfig = path.resolve(configDir);
10206
+ if (
10207
+ skillsRoot &&
10208
+ skillsRoot !== resolvedConfig &&
10209
+ !skillsRoot.startsWith(resolvedConfig + path.sep)
10210
+ ) {
10211
+ const prefix = _hostBehaviors(patchRuntime).skillsManifestPrefix || 'skills/';
10212
+ skillsRedirect = { root: skillsRoot, prefix };
10213
+ }
10214
+ }
10215
+
9766
10216
  for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
9767
10217
  const safeRef = resolveInstallRelativePath(configDir, relPath);
9768
10218
  if (!safeRef) continue;
9769
10219
  const { relPath: safeRelPath, fullPath } = safeRef;
9770
- if (!fs.existsSync(fullPath)) continue;
9771
- const currentHash = fileHash(fullPath);
10220
+ let installedPath = fullPath;
10221
+ if (!fs.existsSync(installedPath) && skillsRedirect && safeRelPath.startsWith(skillsRedirect.prefix)) {
10222
+ const altRef = resolveInstallRelativePath(
10223
+ skillsRedirect.root,
10224
+ safeRelPath.slice(skillsRedirect.prefix.length)
10225
+ );
10226
+ if (altRef && fs.existsSync(altRef.fullPath)) {
10227
+ installedPath = altRef.fullPath;
10228
+ }
10229
+ }
10230
+ if (!fs.existsSync(installedPath)) continue;
10231
+ const currentHash = fileHash(installedPath);
9772
10232
  if (currentHash !== originalHash) {
9773
10233
  // Back up the user's modified version
9774
10234
  const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath);
9775
10235
  if (!backupRef) continue;
9776
10236
  const backupPath = backupRef.fullPath;
9777
10237
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
9778
- fs.copyFileSync(fullPath, backupPath);
10238
+ fs.copyFileSync(installedPath, backupPath);
9779
10239
  modified.push(safeRelPath);
9780
10240
  pristineHashes[safeRelPath] = originalHash;
9781
10241
  }
@@ -10080,6 +10540,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10080
10540
  ? process.cwd()
10081
10541
  : path.join(process.cwd(), dirName);
10082
10542
 
10543
+ // #3664: a --config-dir destination holding foreign agent files gets an
10544
+ // explicit install-time warning — never a silent Claude-shaped emit.
10545
+ if (isGlobal) {
10546
+ warnIfForeignAgentDest(runtime, targetDir, _installScopeId, Boolean(explicitConfigDir));
10547
+ }
10548
+
10083
10549
  // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
10084
10550
  // artifact orphaned by a PRIOR install run that died between staging and
10085
10551
  // its own restore/discard, BEFORE this run's own preserve step stages
@@ -10158,7 +10624,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10158
10624
  // one) is honored on every profile, `full` included (#2322 blocker 2).
10159
10625
  const _commandsDir = path.join(src, 'commands', 'gsd');
10160
10626
  const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
10161
- const _resolvedProfile = resolveProfile({
10627
+ let _resolvedProfile = resolveProfile({
10162
10628
  modes: [_activeProfileName],
10163
10629
  manifest: _skillsManifest,
10164
10630
  registry: _installedCapabilityRegistry,
@@ -10509,6 +10975,28 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10509
10975
  return Array.isArray(scopeLayout) && scopeLayout.length > 0;
10510
10976
  })();
10511
10977
 
10978
+ // Install the distribution-owned gsd-core tree before layout materialization.
10979
+ // installRuntimeArtifacts then provisions the durable Runtime Surface corpus
10980
+ // exactly once into this final tree instead of having that corpus overwritten
10981
+ // by a later whole-tree copy and needing a second provisioning pass.
10982
+ const skillSrc = path.join(src, 'gsd-core');
10983
+ const skillDest = path.join(targetDir, 'gsd-core');
10984
+ const _gsdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
10985
+ if (_gsdArtifactsStagingRoot === null) {
10986
+ console.warn(` ${yellow}!${reset} Skipping gsd-core/${USER_OWNED_ARTIFACTS.join(', gsd-core/')} preservation (staging unavailable) — it will be lost if present.`);
10987
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10988
+ } else {
10989
+ const stagedGsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _gsdArtifactsStagingRoot);
10990
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
10991
+ restoreStagedUserArtifacts(skillDest, stagedGsdArtifacts);
10992
+ discardStagedUserArtifacts(stagedGsdArtifacts);
10993
+ }
10994
+ if (verifyInstalled(skillDest, 'gsd-core')) {
10995
+ console.log(` ${green}✓${reset} Installed workflow assets`);
10996
+ } else {
10997
+ failures.push('gsd-core');
10998
+ }
10999
+
10512
11000
  // #2624: write the .gsd-source marker. Extracted from its former late position so it can be
10513
11001
  // called BEFORE staging reads the marker (see the call site below). Scoped to the Claude-global
10514
11002
  // layout (issue #1477) — the only install path that ships the skills layout without a
@@ -10525,6 +11013,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10525
11013
  // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
10526
11014
  // must resolve under targetDir (parity with the other descriptor-driven writes).
10527
11015
  const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
11016
+ if (hasExistingSymlinkBetween(path.resolve(targetDir), _markerPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
11017
+ throw new Error(`compatibility marker "${_markerPath}" contains an untrusted symlink`);
11018
+ }
10528
11019
  fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
10529
11020
  } catch (err) {
10530
11021
  // Non-fatal: install proceeds. But on the Claude-global layout walk-up
@@ -10551,6 +11042,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10551
11042
  if (_isSkillsRuntime) {
10552
11043
  // Layout-driven install for skills-based runtimes (full and minimal modes)
10553
11044
  const scope = _installScopeId;
11045
+ // Preserve an existing Runtime Surface selection during upgrades. The
11046
+ // installer refreshes the source corpus first, then emits exactly the
11047
+ // already-committed selection instead of widening it to the base profile.
11048
+ const _committedSurface = readSurface(targetDir);
11049
+ if (_committedSurface) {
11050
+ _resolvedProfile = resolveSurface(
11051
+ targetDir,
11052
+ loadSkillsManifest(_commandsDir),
11053
+ undefined,
11054
+ _installedCapabilityRegistry,
11055
+ _committedSurface,
11056
+ );
11057
+ }
10554
11058
  // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
10555
11059
  // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
10556
11060
  // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
@@ -10843,36 +11347,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10843
11347
  _installNativePluginIfDeclared(runtime, targetDir, _hostBehaviors(runtime), src);
10844
11348
  }
10845
11349
 
10846
- // Copy gsd-core skill with path replacement
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...").
10852
- const skillSrc = path.join(src, 'gsd-core');
10853
- const skillDest = path.join(targetDir, 'gsd-core');
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
- }
10870
- if (verifyInstalled(skillDest, 'gsd-core')) {
10871
- console.log(` ${green}✓${reset} Installed workflow assets`);
10872
- } else {
10873
- failures.push('gsd-core');
10874
- }
10875
-
10876
11350
  // #2624: the .gsd-source marker is now written by _writeGsdSourceMarker()
10877
11351
  // BEFORE staging reads it (see the early call above the _isSkillsRuntime
10878
11352
  // block). The former write lived here — AFTER staging — which on an upgrade
@@ -10960,7 +11434,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10960
11434
  } else if (_isSkillsRuntime) {
10961
11435
  console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
10962
11436
  } else {
10963
- const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry);
11437
+ const _standaloneProjectDir = isGlobal ? process.cwd() : targetDir;
11438
+ const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry, _standaloneProjectDir);
10964
11439
  if (_standaloneAgentsResult) {
10965
11440
  // #2875 defect fix: installAgentsKindStandalone now returns `null`
10966
11441
  // (rather than a truthy result pointing at an empty destDir) whenever a
@@ -11374,10 +11849,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11374
11849
  // abort a successful install — log a warning and continue.
11375
11850
  // install() is never reached in --dry-run mode (the early-exit at the CLI
11376
11851
  // dispatch handles preview), so cleanup here always applies for real.
11377
- try {
11378
- cleanupLegacyGsdCc({ dryRun: false });
11379
- } catch (cleanupErr) {
11380
- console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
11852
+ //
11853
+ // #3799: when --config-dir redirected the install, the scan is SCOPED to
11854
+ // that destination ([targetDir]) — the default home's live install must
11855
+ // never be planned for removal from a sandboxed install. --no-legacy-cleanup
11856
+ // skips the scan entirely.
11857
+ const skipNoLegacyCleanup = parseNoLegacyCleanupArg();
11858
+ const legacyCleanupScope = (explicitConfigDir !== null && isGlobal)
11859
+ ? [targetDir]
11860
+ : undefined;
11861
+ if (!skipNoLegacyCleanup) {
11862
+ try {
11863
+ cleanupLegacyGsdCc({ dryRun: false, ...(legacyCleanupScope ? { configDirs: legacyCleanupScope } : {}) });
11864
+ } catch (cleanupErr) {
11865
+ console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
11866
+ }
11381
11867
  }
11382
11868
 
11383
11869
  if (failures.length > 0) {
@@ -11480,7 +11966,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11480
11966
  // #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
11481
11967
  // agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
11482
11968
  // rollback so the caller is never left in a partially-installed state.
11483
- rollbackInstallerMigrations();
11969
+ // (The second, identical rollbackInstallerMigrations() that used to sit here was
11970
+ // a duplicate of the line above, not a second phase — removed in #3725 review.)
11971
+ // #3712 — the test-home guard refuses before any LAYOUT-DRIVEN write, so no
11972
+ // gsd-* directory in the skills root has been touched and there is nothing
11973
+ // there to undo. (Legacy install migrations DO run first; that is why the
11974
+ // rollbackInstallerMigrations() calls above still execute, and why the one
11975
+ // migration that can reach a `home` override carries its own assertion.)
11976
+ // Running the codex rollback anyway would delete and recreate every
11977
+ // snapshotted gsd-* directory in the resolved skills root, which for an
11978
+ // un-sandboxed codex install IS the real ~/.agents/skills: the guard's own
11979
+ // refusal would provoke the mutation it exists to prevent. This is the only
11980
+ // _codexPreConfigRollback() call site, and applySurface/uninstall cannot
11981
+ // reach it. Every other error still rolls back. Found by review, not by CI.
11982
+ if (isTestHomeGuardRefusal(_earlyInstallErr)) throw _earlyInstallErr;
11484
11983
  if (_codexPreConfigRollback) {
11485
11984
  _codexPreConfigRollback();
11486
11985
  }
@@ -11686,8 +12185,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11686
12185
  // require()s managed-hooks-registry.cjs for MANAGED_HOOKS — so all four must be
11687
12186
  // installed/refreshed together for every profile, or Codex is wired to a dependency
11688
12187
  // chain the same installer never delivers.
11689
- // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
11690
- // in this change (graphify auto-update support for Codex is out of scope for #3579).
12188
+ // We deliberately do *not* copy gsd-graphify-update.sh for Codex in this
12189
+ // change (graphify auto-update support for Codex is out of scope for #3579).
12190
+ // hooks/lib/ WAS excluded here for the same reason, and that stopped being
12191
+ // correct when #3911 (2ea5efc15) gave gsd-context-monitor.js a real
12192
+ // `require('./lib/hook-exit.js')`: the allowlist below is flat and never
12193
+ // recursed, so the hook shipped without its helper and died with
12194
+ // MODULE_NOT_FOUND at load, before its own try/catch, on every registered
12195
+ // event (#4087, #4098). The libs are now derived from what the staged
12196
+ // scripts actually require rather than hand-listed — see the
12197
+ // stageTransitiveHookLibs call after the copy loop. The #3579 boundary is
12198
+ // preserved: helpers no staged Codex hook requires (graphify tooling among
12199
+ // them) are still not shipped.
11691
12200
  const CODEX_HOOKS_TO_COPY = [
11692
12201
  'gsd-check-update.js',
11693
12202
  'gsd-check-update-worker.js',
@@ -11703,6 +12212,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11703
12212
  // is not the same as an allowlisted file landing in it — see the marker
11704
12213
  // gate below.
11705
12214
  let codexStagedHooks = false;
12215
+ // The entries THIS invocation actually staged. Seeding the lib scan from
12216
+ // `existsSync` over the destination instead would also pick up a file
12217
+ // left by a PREVIOUS install whose source is no longer staged — e.g. a
12218
+ // name dropped from the allowlist — and derive helpers for a hook that is
12219
+ // no longer shipped (review of #4087).
12220
+ const codexStagedEntries = [];
11706
12221
  for (const entry of fs.readdirSync(codexHooksSrc)) {
11707
12222
  if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
11708
12223
  const srcFile = path.join(codexHooksSrc, entry);
@@ -11737,8 +12252,43 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11737
12252
  try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
11738
12253
  }
11739
12254
  codexStagedHooks = true;
12255
+ codexStagedEntries.push(entry);
12256
+ }
12257
+ // Stage the hooks/lib/ helpers the staged scripts require, transitively
12258
+ // (#4087, #4098). Shares writeCursorHooksJson's walker rather than a
12259
+ // second copy: both reduced bundles hand-pick SCRIPTS, and the identical
12260
+ // MODULE_NOT_FOUND was already fixed once for Cursor in 704859e9c. A flat
12261
+ // list of today's three helpers would re-break the next time a
12262
+ // Codex-bundled hook grows a lib dependency, which is exactly how this
12263
+ // regressed. Gated on codexStagedHooks for the same reason the CommonJS
12264
+ // marker below is: hooks/ is shared space, and staging nothing must not
12265
+ // leave a GSD-owned lib/ behind in a directory GSD created but did not
12266
+ // fill (#2544). Seeded from the copies staged by THIS invocation, so the
12267
+ // scan sees the same bytes Node will load and never derives helpers for a
12268
+ // hook left behind by an earlier install.
12269
+ let codexStagedLibs = [];
12270
+ if (codexStagedHooks) {
12271
+ codexStagedLibs = hooksSurface.stageTransitiveHookLibs({
12272
+ seedSources: codexStagedEntries
12273
+ .map((entry) => fs.readFileSync(path.join(codexHooksDest, entry), 'utf8')),
12274
+ srcLibDir: path.join(codexHooksSrc, 'lib'),
12275
+ destLibDir: path.join(codexHooksDest, 'lib'),
12276
+ runtimeLabel: 'Codex',
12277
+ // Same substitutions the .js branch above applies to hook scripts, so
12278
+ // a helper that ever gains a runtime path or version token is
12279
+ // rewritten identically instead of shipping a Claude-shaped path.
12280
+ // No-ops on today's helpers, which carry neither.
12281
+ transform: (content) => content
12282
+ .replace(/'\.claude'/g, configDirReplacement)
12283
+ .replace(/\/\.claude\//g, `/${getDirName(runtime)}/`)
12284
+ .replace(/\.claude\//g, `${getDirName(runtime)}/`)
12285
+ .replace(/\{\{GSD_VERSION\}\}/g, pkg.version),
12286
+ });
11740
12287
  }
11741
12288
  console.log(` ${green}✓${reset} Installed hooks (Codex)`);
12289
+ if (codexStagedLibs.length > 0) {
12290
+ console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (${codexStagedLibs.join(', ')})`);
12291
+ }
11742
12292
  // #2717: write the CommonJS marker into hooks/ alongside the staged .js
11743
12293
  // scripts. Codex is excluded from installSharedHooksBundle by the
11744
12294
  // !isCodex gate, so it never received the marker the shared-bundle path
@@ -12024,6 +12574,44 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12024
12574
  if (kimiHooksResult.changed) {
12025
12575
  console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
12026
12576
  }
12577
+
12578
+ // #3031: opt-in reclaim of the pre-#2755 legacy root. Runs LAST in this
12579
+ // branch so the kimi-code install above is already complete and durable —
12580
+ // a reclaim can only ever remove, never leave the install half-written.
12581
+ //
12582
+ // Gated on `runtime === 'kimi-code'`: a `--kimi` install resolves this
12583
+ // very same `~/.kimi` as its own hooks root, so reclaiming there would
12584
+ // delete the hooks it just wrote. The flag is silently inert for kimi
12585
+ // rather than an error — `--all` passes every runtime through this branch,
12586
+ // and one opt-in flag must not fail an otherwise valid multi-runtime run.
12587
+ //
12588
+ // ALSO gated on kimi NOT being installed by this same invocation. The
12589
+ // flag asserts "I only use Kimi Code"; `--all`, or an explicit `--kimi
12590
+ // --kimi-code`, falsifies that outright. Both orderings put `kimi` BEFORE
12591
+ // `kimi-code` (selectRuntimesFromArgs), so without this guard the run
12592
+ // installs Kimi CLI's hooks and then deletes them moments later — the run
12593
+ // reports success and the user is left with the very breakage the opt-in
12594
+ // exists to prevent. Verified reproducible before this guard existed.
12595
+ const kimiInstalledThisRun = selectedRuntimes.includes('kimi');
12596
+ if (hasReclaimKimiLegacy && runtime === 'kimi-code' && kimiInstalledThisRun) {
12597
+ console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: this run also installs --kimi, so ${resolveKimiHooksTomlDir({ runtime: 'kimi' })} is a live Kimi CLI install`);
12598
+ } else if (hasReclaimKimiLegacy && runtime === 'kimi-code') {
12599
+ const legacyKimiRoot = resolveKimiHooksTomlDir({ runtime: 'kimi' });
12600
+ // Both roots honor their own env override (KIMI_SHARE_DIR /
12601
+ // KIMI_CODE_HOME). A user who points both at ONE directory collapses
12602
+ // "the legacy root" onto "the root this install just wrote", and an
12603
+ // unguarded reclaim would delete its own output. isSameDirectory compares
12604
+ // the DIRECTORIES, not the strings — case-insensitive filesystems and
12605
+ // symlinked aliases both name one dir with two spellings.
12606
+ if (isSameDirectory(legacyKimiRoot, kimiHooksRoot)) {
12607
+ console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: ${legacyKimiRoot} is this install's own hooks root`);
12608
+ } else {
12609
+ const reclaimed = reclaimKimiHooksRoot(legacyKimiRoot);
12610
+ console.log(reclaimed > 0
12611
+ ? ` ${green}✓${reset} Reclaimed ${reclaimed} orphaned GSD artifact group(s) from ${legacyKimiRoot} (pre-#2755)`
12612
+ : ` ${dim}•${reset} No orphaned GSD artifacts found in ${legacyKimiRoot}`);
12613
+ }
12614
+ }
12027
12615
  }
12028
12616
 
12029
12617
  // ADR-1239 / #2100 Stage 2: Windsurf's own independent hooksSurface —
@@ -12117,9 +12705,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12117
12705
  );
12118
12706
  const hasGsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
12119
12707
  isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
12120
- if (hasGsdHooks || hasGsdStatusline) {
12708
+ const needsMigration = hasGsdHooks || hasGsdStatusline;
12709
+ // readSettings returns null ONLY for an unparseable file — its documented
12710
+ // "preserve existing, don't touch" signal. Stand the WHOLE migration down
12711
+ // in that case: skipping just the local merge while still stripping the
12712
+ // shared file below would destroy the GSD entries outright instead of
12713
+ // relocating them. Leaving both files untouched lets the migration retry
12714
+ // once the user repairs the local file.
12715
+ const localRaw = needsMigration ? readSettings(settingsPath) : null;
12716
+ if (needsMigration && localRaw === null) {
12717
+ console.log(' ' + yellow + 'i' + reset + ' Skipping #338 migration — ' + settingsFileName +
12718
+ ' could not be parsed. Your existing settings are preserved.');
12719
+ } else if (needsMigration) {
12121
12720
  // Merge GSD entries into settings.local.json
12122
- const localRaw = readSettings(settingsPath) || {};
12123
12721
  if (hasGsdStatusline && !localRaw.statusLine) {
12124
12722
  localRaw.statusLine = sharedRaw.statusLine;
12125
12723
  }
@@ -12179,16 +12777,22 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12179
12777
  if (rawSettings === null) {
12180
12778
  console.log(' ' + yellow + 'i' + reset + ' Skipping settings.local.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.');
12181
12779
  persistActiveProfileMarker();
12182
- return;
12780
+ // Callers index this result by `runtime` (installAllRuntimes' statusline
12781
+ // lookup), so every early exit must return the full shape — a bare return
12782
+ // crashes the install rather than skipping one file.
12783
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12183
12784
  }
12184
12785
  const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
12185
- // #3002 CR: rewrite legacy `node .../gsd-*.js` command strings carried over
12186
- // from pre-#2979 installs to use the absolute node binary path. Without this,
12187
- // existing managed hook entries stay bare-`node`-prefixed across reinstalls
12188
- // and remain broken under GUI/minimal-PATH runtimes.
12189
- const settingsRunner = resolveNodeRunner();
12786
+ // #3002 CR / #3662: rewrite legacy `node .../gsd-*.js` command strings (pre-
12787
+ // #2979 installs) AND entries baked with another environment's absolute node
12788
+ // path onto the runtime-resolving runner. Without this, existing managed
12789
+ // hook entries stay bare-`node`-prefixed or foreign-absolute across
12790
+ // reinstalls and remain broken under GUI/minimal-PATH runtimes and shared
12791
+ // config roots — the #3662 mixed state where no environment can run all
12792
+ // hooks.
12793
+ const settingsRunner = buildNodeRunnerChainToken();
12190
12794
  if (settingsRunner && rewriteLegacyManagedNodeHookCommands(settings, settingsRunner, { platform: process.platform, runtime })) {
12191
- console.log(` ${green}✓${reset} Rewrote legacy bare-node managed-hook commands to absolute path (#2979)`);
12795
+ console.log(` ${green}✓${reset} Rewrote legacy managed-hook commands to the runtime-resolving node runner (#2979/#3662)`);
12192
12796
  }
12193
12797
  // Local installs anchor hook paths so they resolve regardless of cwd (#1906).
12194
12798
  // Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
@@ -12199,12 +12803,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12199
12803
  // check inside projectLocalHookPrefix.
12200
12804
  const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
12201
12805
  const hookOpts = { portableHooks: hasPortableHooks, runtime };
12202
- // #2979: local-install hook commands also use the absolute node path so
12203
- // GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
12204
- // host launches the runtime with a stripped PATH (Finder/Antigravity/etc).
12205
- const localNodeRunner = resolveNodeRunner();
12806
+ // #2979: local-install hook commands also use a runner GUI/minimal-PATH
12807
+ // runtimes can resolve. Bare `node` fails when the host launches the
12808
+ // runtime with a stripped PATH (Finder/Antigravity/etc) — #3662 replaces
12809
+ // the baked absolute path with the runtime-resolving chain (baked path
12810
+ // first, so the minimal-PATH guarantee is unchanged).
12811
+ const localNodeRunner = buildNodeRunnerChainToken();
12206
12812
  const localBashRunner = resolveBashRunner({ platform: process.platform });
12207
- // If we cannot resolve an absolute node path AND this is a local install,
12813
+ // If we cannot resolve a node runner AND this is a local install,
12208
12814
  // skip managed-hook registration. Returning null from buildHookCommand on
12209
12815
  // global installs has the same effect. Better to skip than to emit a bare
12210
12816
  // `node` command that recreates the #2979 failure.
@@ -13209,31 +13815,49 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
13209
13815
  * @param {object} [opts.logger=console] - injectable logger
13210
13816
  * @returns {{ plan: {path:string,reason:string}[], result: object }}
13211
13817
  */
13212
- function cleanupLegacyGsdCc({ homeDir = os.homedir(), dryRun = false, logger = console } = {}) {
13818
+ function cleanupLegacyGsdCc({ homeDir = os.homedir(), configDirs = null, dryRun = false, logger = console } = {}) {
13213
13819
  // Build de-duplicated list of candidate config dirs to scan.
13214
13820
  // Only scan under homeDir — never cwd — to prevent accidental deletion of
13215
13821
  // the user's active-project hooks when the installer is invoked from a
13216
13822
  // project directory that has .claude/hooks or similar subdirs.
13823
+ // #3799: an explicit configDirs override (install() passes [targetDir]
13824
+ // whenever --config-dir redirected the destination) scopes the WHOLE scan
13825
+ // to that dir — the default-home scan must never plan removals of a live
13826
+ // install that lives outside the destination the user chose.
13217
13827
  const seen = new Set();
13218
- const configDirs = [];
13219
- for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
13220
- const candidate = path.join(homeDir, name);
13221
- if (!seen.has(candidate) && fs.existsSync(candidate)) {
13222
- seen.add(candidate);
13223
- configDirs.push(candidate);
13828
+ const scanDirs = [];
13829
+ if (Array.isArray(configDirs) && configDirs.length > 0) {
13830
+ for (const candidate of configDirs) {
13831
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
13832
+ seen.add(candidate);
13833
+ scanDirs.push(candidate);
13834
+ }
13835
+ }
13836
+ } else {
13837
+ for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
13838
+ const candidate = path.join(homeDir, name);
13839
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
13840
+ seen.add(candidate);
13841
+ scanDirs.push(candidate);
13842
+ }
13224
13843
  }
13225
13844
  }
13226
13845
 
13227
13846
  // planLegacyCleanup scans each configDir and already includes the legacy
13228
13847
  // shared cache (gsd-update-check.json) as a plan entry.
13229
- const plan = planLegacyCleanup(configDirs, { homeDir });
13848
+ const plan = planLegacyCleanup(scanDirs, { homeDir, ...(Array.isArray(configDirs) && configDirs.length > 0 ? { configDirs } : {}) });
13230
13849
 
13231
13850
  // Apply the plan (dryRun honors the flag).
13232
13851
  const result = applyLegacyCleanup(plan, { dryRun, logger });
13233
13852
 
13234
13853
  // Also clear / preview the per-package cache so next session re-evaluates
13235
13854
  // hook versions (replaces the former inline unlinkSync on line ~9104).
13236
- const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName);
13855
+ // #3799: under a configDirs override the cache is read/cleared under the
13856
+ // SCOPE root, never the default home — same invariant as the scan itself.
13857
+ const perPkgCacheRoot = (Array.isArray(configDirs) && configDirs.length > 0)
13858
+ ? configDirs[0]
13859
+ : homeDir;
13860
+ const perPkgCacheFile = path.join(perPkgCacheRoot, '.cache', 'gsd', updateCacheFileName);
13237
13861
  if (dryRun) {
13238
13862
  logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
13239
13863
  } else {
@@ -13352,7 +13976,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
13352
13976
  });
13353
13977
  };
13354
13978
 
13355
- if (primaryStatuslineResult) {
13979
+ // `settings` is null on every early exit (unparseable file, skipped runtime),
13980
+ // and handleStatusline dereferences it — an install that declined to touch a
13981
+ // settings file has no statusline to prompt about, so fall through.
13982
+ if (primaryStatuslineResult && primaryStatuslineResult.settings) {
13356
13983
  handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
13357
13984
  } else if (canInstallBanner) {
13358
13985
  // No statusline-capable runtime, but at least one runtime can host the
@@ -13372,6 +13999,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
13372
13999
  module.exports = {
13373
14000
  // #3677 — hyphen-namespace normalization seam for agent bodies
13374
14001
  shouldNormalizeHyphenNamespaceInAgentBody,
14002
+ // #3664: --config-dir foreign-agent-destination warning (warn-and-proceed)
14003
+ warnIfForeignAgentDest,
13375
14004
  normalizeAgentBodyForRuntime,
13376
14005
  yamlIdentifier,
13377
14006
  getCodexSkillAdapterHeader,
@@ -13424,9 +14053,12 @@ module.exports = {
13424
14053
  mergeClaudePermissions,
13425
14054
  GSD_CLAUDE_ALLOW_PERMISSIONS,
13426
14055
  GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
13427
- GSD_CLAUDE_DENY_PERMISSIONS,
14056
+ GSD_CLAUDE_LEGACY_DENY_PERMISSIONS,
13428
14057
  GSD_CODEX_MARKER,
13429
- CODEX_AGENT_SANDBOX,
14058
+ // #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
14059
+ CODEX_SANDBOX_HOLDS,
14060
+ deriveCodexSandboxMode,
14061
+ validateCodexSandboxHolds,
13430
14062
  getGlobalDir,
13431
14063
  getConfigDirFromHome,
13432
14064
  resolveKiloConfigPath,
@@ -13505,6 +14137,8 @@ module.exports = {
13505
14137
  cleanupLegacyGsdCc,
13506
14138
  // #1191 — exported so tests exercise the REAL readSettings, not a replica
13507
14139
  readSettings,
14140
+ writeSettings,
14141
+ writeNonClaudeDefaults,
13508
14142
  stripJsonComments,
13509
14143
  copyWithPathReplacement,
13510
14144
  };
@@ -13519,10 +14153,23 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
13519
14153
  console.log('Dry run — no files will be modified.\n');
13520
14154
  // cleanupLegacyGsdCc with dryRun:true is the single source of truth for
13521
14155
  // both the legacy artifacts and the per-package cache path — no duplicate
13522
- // printing here.
13523
- const { plan } = cleanupLegacyGsdCc({ dryRun: true });
13524
- if (plan.length === 0) {
13525
- console.log(' (no legacy get-shit-done-cc artifacts found)');
14156
+ // printing here. #3799: the preview honors the SAME scope and skip the
14157
+ // real install would apply (--config-dir scopes; --no-legacy-cleanup
14158
+ // skips) — a preview that listed default-home paths a real install would
14159
+ // never touch misrepresents the run.
14160
+ if (parseNoLegacyCleanupArg()) {
14161
+ console.log(' (--no-legacy-cleanup — legacy scan skipped)');
14162
+ } else {
14163
+ const previewScope = (explicitConfigDir !== null)
14164
+ ? [getGlobalConfigDir(DEFAULT_RUNTIME, explicitConfigDir)]
14165
+ : undefined;
14166
+ const { plan } = cleanupLegacyGsdCc({
14167
+ dryRun: true,
14168
+ ...(previewScope ? { configDirs: previewScope } : {}),
14169
+ });
14170
+ if (plan.length === 0) {
14171
+ console.log(' (no legacy get-shit-done-cc artifacts found)');
14172
+ }
13526
14173
  }
13527
14174
  process.exit(0);
13528
14175
  } else if (hasSkillsRoot) {