@opengsd/gsd-core 1.14.0 → 1.15.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 (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
package/bin/install.js CHANGED
@@ -438,6 +438,17 @@ const GSD_CHANGESET_FILES = [
438
438
  ];
439
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', 'npm-version-check-diagnosis.cjs', 'platform-conformance-tier.generated.cjs', 'suite-detection.cjs', 'macos-conformance-tier.generated.cjs'];
440
440
 
441
+ // #4544 — the Codex hook payload the install stages into <targetDir>/hooks/.
442
+ // Hoisted to module scope (the #3184 precedent above) so the rollback's
443
+ // incomplete-capture path can name exactly the files GSD owns without a
444
+ // second copy of the list drifting away from the staging site, which lives
445
+ // inside the Codex config block where the constant used to be declared.
446
+ const CODEX_HOOKS_TO_COPY = [
447
+ 'gsd-check-update.js',
448
+ 'gsd-check-update-worker.js',
449
+ 'managed-hooks-registry.cjs',
450
+ ];
451
+
441
452
  /**
442
453
  * Resolve a runtime's shared-hooks directory name from its descriptor.
443
454
  *
@@ -812,6 +823,7 @@ const {
812
823
  applyInstallerMigrationPlan,
813
824
  discoverInstallerMigrations,
814
825
  MANIFEST_SCHEMA_VERSION,
826
+ readInstallManifest,
815
827
  runInstallerMigrations,
816
828
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
817
829
  const {
@@ -925,6 +937,25 @@ const hasSkillsRoot = args.includes('--skills-root');
925
937
  const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
926
938
  const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
927
939
  const hasDryRun = args.includes('--dry-run');
940
+ // #4377: emit project-relative `@` includes (`.claude/gsd-core/...`) for a
941
+ // LOCAL install instead of this checkout's absolute path.
942
+ //
943
+ // Opt-in, and it stays opt-in: absolute includes work for a single checkout,
944
+ // which is nearly everyone, and flipping the default would change every
945
+ // existing local install to solve a problem those users do not have. The
946
+ // people who need it know they do — they run the same repo from several git
947
+ // worktrees, where a baked absolute path means every worktree reads its
948
+ // workflow prose out of whichever checkout happened to run the installer, and
949
+ // updating that one checkout breaks all the others at once with no way to
950
+ // stage it.
951
+ //
952
+ // Exported through the environment rather than threaded as a parameter,
953
+ // exactly like --portable-hooks/GSD_PORTABLE_HOOKS above: five separate seams
954
+ // compute a path prefix (the install engine, both rewrite entry points, the
955
+ // install plan, and applySurface), and one variable they all read cannot fall
956
+ // out of sync the way five signatures can.
957
+ const hasRelativeIncludes = args.includes('--relative-includes') || process.env.GSD_RELATIVE_INCLUDES === '1';
958
+ if (hasRelativeIncludes) process.env.GSD_RELATIVE_INCLUDES = '1';
928
959
  // #3031: opt-in reclaim of the GSD artifacts a PRE-#2755 `--kimi-code` install
929
960
  // orphaned in Kimi CLI's `~/.kimi`. Opt-in and not automatic because the stale
930
961
  // block is BYTE-IDENTICAL to a legitimate Kimi CLI one — both runtimes render
@@ -1252,7 +1283,7 @@ if (hasUninstall) {
1252
1283
 
1253
1284
  // Show help if requested
1254
1285
  if (hasHelp) {
1255
- 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`);
1286
+ 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}--relative-includes${reset} With --local: write project-relative @ includes\n (.claude/gsd-core/...) instead of this checkout's\n absolute path, so several git worktrees of one repo\n each read their own copy (also GSD_RELATIVE_INCLUDES=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}# Local install for a repo worked from several git worktrees${reset}\n npx ${pkg.name} --claude --local --relative-includes\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`);
1256
1287
  process.exit(0);
1257
1288
  }
1258
1289
 
@@ -1625,19 +1656,20 @@ const claudeToOpencodeTools = {
1625
1656
  WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
1626
1657
  };
1627
1658
 
1628
- // Tool name mapping from Claude Code to Gemini CLI
1629
- // Gemini CLI uses snake_case built-in tool names
1630
- const claudeToGeminiTools = {
1631
- Read: 'read_file',
1659
+ // Tool name mapping from Claude Code to Antigravity
1660
+ // Antigravity uses Gemini's snake_case built-in tool names
1661
+ const claudeToAntigravityTools = {
1662
+ // #4705: Antigravity-NATIVE tool names (see src/runtime-artifact-conversion.cts)
1663
+ Read: 'view_file',
1632
1664
  Write: 'write_file',
1633
- Edit: 'replace',
1634
- Bash: 'run_shell_command',
1665
+ Edit: 'replace_file_content',
1666
+ Bash: 'run_command',
1635
1667
  Glob: 'glob',
1636
- Grep: 'search_file_content',
1668
+ Grep: 'grep_search',
1637
1669
  WebSearch: 'google_web_search',
1638
1670
  WebFetch: 'web_fetch',
1639
1671
  TodoWrite: 'write_todos',
1640
- };
1672
+ }
1641
1673
 
1642
1674
  // Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
1643
1675
  // Kimi custom agent YAML requires fully-qualified module paths.
@@ -1687,24 +1719,24 @@ function convertToolName(claudeTool) {
1687
1719
  }
1688
1720
 
1689
1721
  /**
1690
- * Convert a Claude Code tool name to Gemini CLI format
1691
- * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
1692
- * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
1693
- * - Filters out Task/Agent — agents are auto-registered as tools in Gemini
1694
- * @returns {string|null} Gemini tool name, or null if tool should be excluded
1722
+ * Convert a Claude Code tool name to Antigravity format
1723
+ * - Applies Claude→Antigravity mapping (Read→read_file, Bash→run_shell_command, etc.)
1724
+ * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Antigravity
1725
+ * - Filters out Task/Agent — agents are auto-registered as tools in Antigravity
1726
+ * @returns {string|null} Antigravity tool name, or null if tool should be excluded
1695
1727
  */
1696
- function convertGeminiToolName(claudeTool) {
1728
+ function convertAntigravityToolName(claudeTool) {
1697
1729
  // MCP tools: exclude — auto-discovered from mcpServers config at runtime
1698
1730
  if (claudeTool.startsWith('mcp__')) {
1699
1731
  return null;
1700
1732
  }
1701
1733
  // Task/Agent: exclude — agents are auto-registered as callable tools.
1702
- // AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
1703
- // emitting it causes frontmatter validation errors (#3362).
1704
- // Skill/SlashCommand: exclude — Gemini CLI has no 'skill' built-in tool;
1705
- // the lowercase fallback would emit an invalid 'skill'/'slashcommand' name
1706
- // that fails frontmatter validation (tools.N: Invalid tool name) and aborts
1707
- // the entire agent load (#1394).
1734
+ // AskUserQuestion: exclude — Antigravity (Gemini tool dialect) does not expose
1735
+ // an ask_user tool; emitting it causes frontmatter validation errors (#3362).
1736
+ // Skill/SlashCommand: exclude — Antigravity (Gemini tool dialect) has no 'skill'
1737
+ // built-in tool; the lowercase fallback would emit an invalid
1738
+ // 'skill'/'slashcommand' name that fails frontmatter validation
1739
+ // (tools.N: Invalid tool name) and aborts the entire agent load (#1394).
1708
1740
  if (
1709
1741
  claudeTool === 'Task' ||
1710
1742
  claudeTool === 'Agent' ||
@@ -1716,8 +1748,8 @@ function convertGeminiToolName(claudeTool) {
1716
1748
  return null;
1717
1749
  }
1718
1750
  // Check for explicit mapping
1719
- if (claudeToGeminiTools[claudeTool]) {
1720
- return claudeToGeminiTools[claudeTool];
1751
+ if (claudeToAntigravityTools[claudeTool]) {
1752
+ return claudeToAntigravityTools[claudeTool];
1721
1753
  }
1722
1754
  // Default: lowercase
1723
1755
  return claudeTool.toLowerCase();
@@ -2023,7 +2055,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2023
2055
  const names = cmdNames || readGsdCommandNames();
2024
2056
  const normalizedBody = transformContentToHyphen(body, names);
2025
2057
 
2026
- const description = extractFrontmatterField(frontmatter, 'description') || '';
2058
+ // #4324: the description is the text the host's skill picker renders, so it
2059
+ // needs the same hyphen normalisation the body gets — otherwise a `/gsd:<cmd>`
2060
+ // mention in a command description ships the retired colon form to the user.
2061
+ const description = transformContentToHyphen(
2062
+ extractFrontmatterField(frontmatter, 'description') || '', names,
2063
+ );
2027
2064
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2028
2065
  const agent = extractFrontmatterField(frontmatter, 'agent');
2029
2066
  // #769: preserve context: from source command files so it is emitted into
@@ -2486,12 +2523,16 @@ function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
2486
2523
  const color = extractFrontmatterField(frontmatter, 'color');
2487
2524
  const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
2488
2525
 
2489
- // Map tools to Gemini equivalents (reuse existing convertGeminiToolName)
2526
+ // Map tools to Antigravity equivalents (reuse existing convertAntigravityToolName)
2490
2527
  const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
2491
- const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean);
2528
+ const mappedTools = claudeTools.map(t => convertAntigravityToolName(t)).filter(Boolean);
2492
2529
 
2493
2530
  // #2876: quote description for the same reason as the skill variant.
2494
- let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`;
2531
+ // #4705: tools is a YAML SEQUENCE of native names (see the src twin).
2532
+ const toolsBlock = mappedTools.length > 0
2533
+ ? `tools:\n${mappedTools.map((t) => `- ${t}`).join('\n')}\n`
2534
+ : 'tools: []\n';
2535
+ let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n${toolsBlock}`;
2495
2536
  if (color) fm += `color: ${color}\n`;
2496
2537
  fm += '---';
2497
2538
 
@@ -2947,6 +2988,8 @@ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cm
2947
2988
  let description = extractFrontmatterField(frontmatter, 'description');
2948
2989
  if (!description) description = `Run GSD workflow ${skillName}.`;
2949
2990
  description = toSingleLine(description);
2991
+ // #4324: same reason as the Claude skill converter above.
2992
+ description = transformContentToHyphen(description, names);
2950
2993
  // Cline documented max is 1024 code points (not UTF-16 code units).
2951
2994
  // Use Array.from to iterate by code point so that multibyte characters
2952
2995
  // (e.g. emoji, astral-plane chars) are never split, which would produce
@@ -3921,6 +3964,22 @@ Typed mapping (agent_type-capable schema only):
3921
3964
  never fabricate a manual worktree protocol — route through the negotiated
3922
3965
  isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
3923
3966
 
3967
+ Foreground handoffs:
3968
+ - spawn_agent is asynchronous. When the source Agent(...) or Task(...) declares
3969
+ run_in_background=false, call collaboration.wait_agent(timeout_ms=...) immediately after
3970
+ spawn and keep the parent turn active until that child returns a terminal result.
3971
+ - collaboration.wait_agent is a mailbox wakeup, NOT a completion oracle: "Wait completed"
3972
+ can mean only that a child sent an interim MESSAGE or status update. After every wakeup,
3973
+ inspect the named child's update/status. Only a FINAL_ANSWER or a terminal agent status
3974
+ (completed, failed, or cancelled) ends the foreground handoff.
3975
+ - On an interim MESSAGE or any non-terminal status, do not report an outcome, send a
3976
+ continuation, start parent work, or end the parent turn. Call collaboration.wait_agent
3977
+ again for the same child. If a terminal response is absent after an abnormal end, reconcile
3978
+ the workflow's durable artifacts before classifying the child.
3979
+ - This applies to one foreground child as well as fan-out. The child retains its workflow's
3980
+ own checkpoint loop; do not report an outcome or start any further parent work before its
3981
+ terminal result is available.
3982
+
3924
3983
  Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
3925
3984
  When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
3926
3985
  (\`gsd-planner\`, \`gsd-executor\`, etc.) is NOT possible. This is a known Codex limitation
@@ -3948,6 +4007,9 @@ Spawn restriction:
3948
4007
  defaulting to inline execution.
3949
4008
 
3950
4009
  Parallel fan-out:
4010
+ - For each child, loop on collaboration.wait_agent(timeout_ms=...) until its own terminal
4011
+ result is observed. A mailbox update from one child never completes another child, and an
4012
+ interim MESSAGE never completes its sender.
3951
4013
  - Spawn multiple agents → collect agent IDs → \`collaboration.wait_agent(timeout_ms=...)\` for each to complete
3952
4014
  - Do NOT use \`functions.wait(cell_id=...)\` — that is an unrelated exec-cell tool, not the collaboration wait
3953
4015
 
@@ -7914,44 +7976,63 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7914
7976
  content = filterRuntimeNotesForTarget(content, runtime);
7915
7977
 
7916
7978
  if (!dispatch.mdSkipGenericRewrite) {
7917
- const globalClaudeRegex = /~\/\.claude\//g;
7918
- const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
7919
- const localClaudeRegex = /\.\/\.claude\//g;
7920
- content = content.replace(globalClaudeRegex, pathPrefix);
7921
- content = content.replace(globalClaudeHomeRegex, pathPrefix);
7922
- content = content.replace(localClaudeRegex, `./${dirName}/`);
7923
- // #3544 review (Finding 1 fallout): guarded with the SAME
7924
- // negative-lookahead convention already used at ~:2859-2860 below
7925
- // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
7926
- // is satisfied by ANY non-word character, including '-' — so for a
7927
- // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
7928
- // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
7929
- // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
7930
- // and re-appended the full prefix, corrupting every emitted path to
7931
- // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
7932
- // default '.claude' (self-replace with an identical string), which
7933
- // is why this went undetected until a non-default config-dir name
7934
- // was exercised.
7935
- content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7936
- content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
7937
- content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7938
- content = content.replace(/~\/\.qwen\//g, pathPrefix);
7939
- content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
7940
- content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
7941
- content = content.replace(/~\/\.hermes\//g, pathPrefix);
7942
- content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
7943
- content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
7944
- // #3544: restore @-file-reference lines to the tilde form Claude Code
7945
- // actually expands — the SAME correction #3133 already applies to
7946
- // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
7947
- // restoreClaudeGlobalAtRefTilde's doc comment in
7948
- // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree
7949
- // emit path, which never had it: every @~/.claude/gsd-core/… include
7950
- // in a global install's workflows/references tree silently resolved
7951
- // to nothing (54 includes across 22 files on a live install).
7952
- if (runtime === 'claude') {
7953
- content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
7954
- }
7979
+ // #4377: with a project-relative prefix, mask `${VAR:-default}` shell
7980
+ // defaults out of the substitutions below and restore them after. The
7981
+ // runtime launcher snippet probes gsd-tools through a chain of those
7982
+ // (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/...`, one per
7983
+ // runtime); they are shell word expansions, not markdown @ includes,
7984
+ // and a relative value there resolves against the shell's cwd instead
7985
+ // of the project. Swapping an include that points at the wrong
7986
+ // checkout for a path that points at nothing is not a fix, and the
7987
+ // launcher already probes `$(git rev-parse --show-toplevel)/.claude`
7988
+ // first, so the multi-worktree case is handled before these defaults
7989
+ // are ever reached. The shared helper is the single owner of the
7990
+ // balanced masking grammar used by this path and the rewrite engine.
7991
+ const rewriteGenericPaths = (body) => {
7992
+ content = body;
7993
+ const globalClaudeRegex = /~\/\.claude\//g;
7994
+ const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
7995
+ const localClaudeRegex = /\.\/\.claude\//g;
7996
+ content = content.replace(globalClaudeRegex, pathPrefix);
7997
+ content = content.replace(globalClaudeHomeRegex, pathPrefix);
7998
+ content = content.replace(localClaudeRegex, `./${dirName}/`);
7999
+ // #3544 review (Finding 1 fallout): guarded with the SAME
8000
+ // negative-lookahead convention already used at ~:2859-2860 below
8001
+ // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
8002
+ // is satisfied by ANY non-word character, including '-' — so for a
8003
+ // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
8004
+ // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
8005
+ // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
8006
+ // and re-appended the full prefix, corrupting every emitted path to
8007
+ // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
8008
+ // default '.claude' (self-replace with an identical string), which
8009
+ // is why this went undetected until a non-default config-dir name
8010
+ // was exercised.
8011
+ content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
8012
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
8013
+ content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
8014
+ content = content.replace(/~\/\.qwen\//g, pathPrefix);
8015
+ content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
8016
+ content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
8017
+ content = content.replace(/~\/\.hermes\//g, pathPrefix);
8018
+ content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
8019
+ content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
8020
+ // #3544: restore @-file-reference lines to the tilde form Claude Code
8021
+ // actually expands — the SAME correction #3133 already applies to
8022
+ // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
8023
+ // restoreClaudeGlobalAtRefTilde's doc comment in
8024
+ // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree
8025
+ // emit path, which never had it: every @~/.claude/gsd-core/… include
8026
+ // in a global install's workflows/references tree silently resolved
8027
+ // to nothing (54 includes across 22 files on a live install).
8028
+ if (runtime === 'claude') {
8029
+ content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
8030
+ }
8031
+ return content;
8032
+ };
8033
+ content = runtimeArtifactConversion._isRelativePathPrefix(pathPrefix)
8034
+ ? runtimeArtifactConversion._withShellDefaultsPreserved(content, rewriteGenericPaths)
8035
+ : rewriteGenericPaths(content);
7955
8036
  }
7956
8037
  content = processAttribution(content, getCommitAttribution(runtime));
7957
8038
 
@@ -9890,6 +9971,11 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9890
9971
  // it from the directory it happened to be found in (#2872).
9891
9972
  runtime,
9892
9973
  scope: resolvedScope,
9974
+ // #4377: a surface re-apply is a separate process and cannot rely on the
9975
+ // installer's environment. Persist only a safe project-relative prefix.
9976
+ relativeIncludePrefix: resolvedScope === 'local' && hasRelativeIncludes
9977
+ ? runtimeArtifactConversion._projectRelativePrefixFromProjectRoot(process.cwd(), configDir)
9978
+ : undefined,
9893
9979
  files: {},
9894
9980
  };
9895
9981
 
@@ -10773,6 +10859,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10773
10859
  isWindowsHost,
10774
10860
  resolvedTarget,
10775
10861
  homeDir,
10862
+ // #4377: the runtime's own localConfigDir. This is the prefix that reaches
10863
+ // copyWithPathReplacement, i.e. the one actually written into every
10864
+ // emitted command/skill/workflow body — the rewrite-engine seams below
10865
+ // handle re-applied surfaces, not the first install.
10866
+ localDirName: _hostBehaviors(runtime).localTargetIsProjectRoot === true ? undefined : getDirName(runtime),
10776
10867
  });
10777
10868
 
10778
10869
  // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
@@ -10783,6 +10874,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10783
10874
 
10784
10875
  // Track installation failures
10785
10876
  const failures = [];
10877
+ const configuredEntrypoints = [];
10786
10878
  let installerMigrationResult = null;
10787
10879
  const rollbackInstallerMigrations = () => {
10788
10880
  if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
@@ -10835,7 +10927,48 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10835
10927
  // Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
10836
10928
  const codexPreInstallAgentContents = new Map();
10837
10929
  let codexPreInstallVersionBytes = null;
10838
- if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
10930
+ // #4544 — manifest-driven snapshot state (captured in the block below):
10931
+ // codexPreInstallManagedFiles — Map<normalizedRelPath, Buffer|null>; one
10932
+ // entry per path the PRIOR install's gsd-file-manifest.json recorded.
10933
+ // null means the path did not exist pre-install, so rollback re-deletes
10934
+ // whatever this install put there instead of resurrecting it.
10935
+ // codexPreInstallManifestBytes — Buffer (or null) of the prior manifest file
10936
+ // itself, which a reinstall rewrites.
10937
+ // codexPreInstallHooksTree — Map<relPath, Buffer>, a full recursive
10938
+ // snapshot of <targetDir>/hooks/. The Codex manifest deliberately omits
10939
+ // hooks/ (the !isCodex gate on shared-hooks tracking), and hooks/ is
10940
+ // shared space, so the restore is wholesale: user files that predate
10941
+ // the install are in the snapshot and come back; anything the failed
10942
+ // install staged does not.
10943
+ const codexPreInstallManagedFiles = new Map();
10944
+ let codexPreInstallManifestBytes = null;
10945
+ const codexPreInstallHooksTree = new Map();
10946
+ // #4544 (review) — capture-state flags the restore must consult:
10947
+ // codexManagedSnapshotCaptured — the capture gate ran at all. When
10948
+ // false (non-Codex runtimes, minimal mode) NO pre-install state was
10949
+ // recorded, and the only safe restore is no restore: an empty
10950
+ // snapshot must never be read as "hooks/ was absent".
10951
+ // codexPreInstallHooksDirPreExisted — hooks/ existed as a DIRECTORY
10952
+ // pre-install. A pre-existing hooks FILE is left alone on rollback
10953
+ // rather than deleted.
10954
+ // codexPreInstallHooksCaptureIncomplete — some part of the hooks/ tree
10955
+ // could not be read (permissions, special files). The restore
10956
+ // downgrades to per-file so an uncapturable user file is never
10957
+ // destroyed by a wholesale delete whose snapshot lacked it.
10958
+ let codexManagedSnapshotCaptured = false;
10959
+ // null = the gate never ran; true/false = the gate ran and hooks/ (did|did
10960
+ // not) exist as a directory pre-install. Two states are load-bearing: a
10961
+ // clean first install records false, so its rollback removes the staged
10962
+ // hooks/ tree entirely; a non-Codex runtime records null, so rollback does
10963
+ // nothing.
10964
+ let codexPreInstallHooksDirPreExisted = null;
10965
+ let codexPreInstallHooksCaptureIncomplete = false;
10966
+ // #4249 CR: not gated on install mode. restoreCodexSnapshot is reachable for
10967
+ // a core/--minimal install too (#2695), and its pass-2 sweeps remove every
10968
+ // gsd-* skill dir / agent file the snapshot does not claim — so an empty
10969
+ // minimal-mode snapshot deleted the whole surface with nothing to restore.
10970
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
10971
+ codexManagedSnapshotCaptured = true;
10839
10972
  const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
10840
10973
  if (fs.existsSync(_preSkillsDir)) {
10841
10974
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
@@ -10877,18 +11010,212 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10877
11010
  if (fs.existsSync(_preVersionPath)) {
10878
11011
  try { codexPreInstallVersionBytes = fs.readFileSync(_preVersionPath); } catch (_) { /* best-effort */ }
10879
11012
  }
10880
- }
11013
+ // #4544 — capture the manifest-driven surfaces, same best-effort
11014
+ // conventions as the skills/ snapshot above. readInstallManifest is the
11015
+ // same hardened reader installer-migrations uses (array/ garbage shapes
11016
+ // degrade to an empty file set — rollback then simply covers less, never
11017
+ // crashes), and resolveInstallRelativePath keeps a hostile manifest key
11018
+ // from turning into a write outside the install root.
11019
+ const _priorManifest = readInstallManifest(targetDir);
11020
+ for (const rel of Object.keys(_priorManifest.files)) {
11021
+ const resolved = resolveInstallRelativePath(targetDir, rel);
11022
+ if (!resolved) continue;
11023
+ try {
11024
+ codexPreInstallManagedFiles.set(resolved.relPath, fs.readFileSync(resolved.fullPath));
11025
+ } catch (_) {
11026
+ // Listed but absent/unreadable pre-install: snapshot absence, so
11027
+ // rollback re-deletes instead of resurrecting.
11028
+ codexPreInstallManagedFiles.set(resolved.relPath, null);
11029
+ }
11030
+ }
11031
+ const _preManifestPath = path.join(targetDir, MANIFEST_NAME);
11032
+ if (fs.existsSync(_preManifestPath)) {
11033
+ try { codexPreInstallManifestBytes = fs.readFileSync(_preManifestPath); } catch (_) { /* best-effort */ }
11034
+ }
11035
+ // #4544 (review) — a clean FIRST install has no prior manifest, so nothing
11036
+ // above records the payload this install is about to write, and a failed
11037
+ // clean install would roll back to a half-written tree. Enumerate the SAME
11038
+ // source directories the installer copies (a directory walk tracks the
11039
+ // source tree automatically — no second file list to keep in parity) and
11040
+ // record every path as absent-pre-install. On a reinstall most of these
11041
+ // already carry entries from the prior manifest; any that do not (files
11042
+ // new in this version) snapshot their pre-install bytes or absence exactly
11043
+ // like the rest, which also closes the new-version-file residual.
11044
+ const _recordWritePlanTree = (srcDir, relPrefix) => {
11045
+ let children;
11046
+ try { children = fs.readdirSync(srcDir, { withFileTypes: true }); } catch (_) { return; }
11047
+ for (const child of children) {
11048
+ const rel = relPrefix ? `${relPrefix}/${child.name}` : child.name;
11049
+ if (child.isDirectory()) {
11050
+ _recordWritePlanTree(path.join(srcDir, child.name), rel);
11051
+ } else if (child.isFile()) {
11052
+ if (codexPreInstallManagedFiles.has(rel)) continue;
11053
+ // USER_OWNED_ARTIFACTS are manifest-relative to gsd-core/ (#2771):
11054
+ // they are durably staged across reinstalls and must never enter a
11055
+ // rollback delete-set.
11056
+ const manifestRel = rel.startsWith('gsd-core/') ? rel.slice('gsd-core/'.length) : rel;
11057
+ if (USER_OWNED_ARTIFACTS.includes(manifestRel)) continue;
11058
+ const resolved = resolveInstallRelativePath(targetDir, rel);
11059
+ if (!resolved) continue;
11060
+ try {
11061
+ codexPreInstallManagedFiles.set(rel, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
11062
+ } catch (_) {
11063
+ codexPreInstallManagedFiles.set(rel, null);
11064
+ }
11065
+ }
11066
+ }
11067
+ };
11068
+ _recordWritePlanTree(path.join(src, 'gsd-core'), 'gsd-core');
11069
+ _recordWritePlanTree(path.join(src, 'scripts', 'changeset'), 'scripts/changeset');
11070
+ _recordWritePlanTree(path.join(src, 'scripts', 'lib'), 'scripts/lib');
11071
+ // gsd-core/CHANGELOG.md is sourced from the repo root (not src/gsd-core)
11072
+ // and gsd-core/.gsd-runtime is generated at install time — neither appears
11073
+ // in the directory walks, so record them explicitly.
11074
+ for (const standalone of ['gsd-core/CHANGELOG.md', 'gsd-core/.gsd-runtime', 'scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs']) {
11075
+ if (codexPreInstallManagedFiles.has(standalone)) continue;
11076
+ const resolved = resolveInstallRelativePath(targetDir, standalone);
11077
+ if (!resolved) continue;
11078
+ try {
11079
+ codexPreInstallManagedFiles.set(standalone, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
11080
+ } catch (_) {
11081
+ codexPreInstallManagedFiles.set(standalone, null);
11082
+ }
11083
+ }
11084
+ // hooks/ — full recursive snapshot, but never blind: lstat every entry so
11085
+ // a symlink under hooks/ is neither followed (a link to a FIFO would hang
11086
+ // the installer, /dev/zero would exhaust memory, and a link to private
11087
+ // data would copy that data into the snapshot — hooks/ is user-writable
11088
+ // shared space and, for local installs, repo-controllable) nor restored
11089
+ // as a link. Anything unreadable or special marks the capture INCOMPLETE
11090
+ // so the restore downgrades to per-file instead of wholesale-deleting a
11091
+ // tree it never fully saw. A pre-existing hooks FILE (not directory) is
11092
+ // recorded as such and left alone on rollback.
11093
+ const _preHooksPath = path.join(targetDir, 'hooks');
11094
+ let _preHooksStat = null;
11095
+ try { _preHooksStat = fs.lstatSync(_preHooksPath); } catch (_) { /* absent */ }
11096
+ codexPreInstallHooksDirPreExisted = Boolean(_preHooksStat && _preHooksStat.isDirectory());
11097
+ if (codexPreInstallHooksDirPreExisted) {
11098
+ const _snapshotHooksDir = (dir, relBase) => {
11099
+ let children;
11100
+ try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) {
11101
+ codexPreInstallHooksCaptureIncomplete = true;
11102
+ return;
11103
+ }
11104
+ for (const child of children) {
11105
+ const relPath = relBase ? `${relBase}/${child.name}` : child.name;
11106
+ const fullPath = path.join(dir, child.name);
11107
+ let st = null;
11108
+ try { st = fs.lstatSync(fullPath); } catch (_) {
11109
+ codexPreInstallHooksCaptureIncomplete = true;
11110
+ continue;
11111
+ }
11112
+ if (st.isDirectory()) {
11113
+ _snapshotHooksDir(fullPath, relPath);
11114
+ } else if (st.isFile()) {
11115
+ try { codexPreInstallHooksTree.set(relPath, fs.readFileSync(fullPath)); } catch (_) {
11116
+ codexPreInstallHooksCaptureIncomplete = true;
11117
+ }
11118
+ } else {
11119
+ codexPreInstallHooksCaptureIncomplete = true;
11120
+ }
11121
+ }
11122
+ };
11123
+ _snapshotHooksDir(_preHooksPath, '');
11124
+ }
11125
+ }
11126
+
11127
+ // #4544 — shared restore for the manifest-driven surfaces. Called by BOTH
11128
+ // rollback paths: _codexPreConfigRollback (CHANGELOG.md, scripts/, the
11129
+ // initial manifest write AND — via installer migrations' stale-hook removal
11130
+ // — hooks/ itself are all mutated BEFORE config.toml is touched, so the
11131
+ // early path must cover them) and the full restoreCodexSnapshot() below.
11132
+ // Best-effort throughout, matching the #3245 convention: restore failures
11133
+ // never mask the original install error.
11134
+ const restoreCodexManagedSnapshot = () => {
11135
+ // #4544 (review) — if the capture never ran (non-Codex runtimes, minimal
11136
+ // mode), no pre-install state was recorded. The only safe action is NONE:
11137
+ // an empty snapshot must never be read as "hooks/ was absent", or a
11138
+ // minimal-mode rollback would delete the user's entire hooks/ tree.
11139
+ if (!codexManagedSnapshotCaptured) return;
11140
+ // hooks/ — the pre-install tree is restored wholesale: a user file that
11141
+ // predated the install is IN the snapshot and comes back; anything the
11142
+ // failed install staged is not, and goes away with the tree. When the
11143
+ // capture was INCOMPLETE, wholesale deletion would permanently destroy a
11144
+ // file whose bytes were never captured, so the restore downgrades to
11145
+ // per-file: put back what was captured and remove only the names GSD
11146
+ // itself stages (the hoisted CODEX_HOOKS_TO_COPY set plus the CommonJS
11147
+ // marker). hooks/lib/ is left untouched in that mode — its contents are
11148
+ // transitive and cannot be enumerated safely without the capture.
11149
+ if (codexPreInstallHooksDirPreExisted !== null) {
11150
+ const _hooksRestoreDir = path.join(targetDir, 'hooks');
11151
+ if (!codexPreInstallHooksDirPreExisted) {
11152
+ // Clean first install: nothing pre-existed under hooks/, so nothing
11153
+ // the failed install staged may survive either.
11154
+ try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
11155
+ } else if (!codexPreInstallHooksCaptureIncomplete) {
11156
+ try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
11157
+ for (const [relPath, buf] of codexPreInstallHooksTree) {
11158
+ const destFile = path.join(_hooksRestoreDir, relPath);
11159
+ try {
11160
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
11161
+ fs.writeFileSync(destFile, buf);
11162
+ } catch (_) { /* best-effort */ }
11163
+ }
11164
+ } else {
11165
+ // GSD-owned names are removed FIRST: several of them are also
11166
+ // legitimate pre-install files the snapshot just restored, and a
11167
+ // removal pass after the restore would delete the restored bytes.
11168
+ for (const hookName of CODEX_HOOKS_TO_COPY) {
11169
+ try { fs.rmSync(path.join(_hooksRestoreDir, hookName), { force: true }); } catch (_) { /* best-effort */ }
11170
+ }
11171
+ try { fs.rmSync(path.join(_hooksRestoreDir, 'package.json'), { force: true }); } catch (_) { /* best-effort */ }
11172
+ for (const [relPath, buf] of codexPreInstallHooksTree) {
11173
+ const destFile = path.join(_hooksRestoreDir, relPath);
11174
+ try {
11175
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
11176
+ fs.writeFileSync(destFile, buf);
11177
+ } catch (_) { /* best-effort */ }
11178
+ }
11179
+ }
11180
+ }
11181
+ // Every GSD-owned path the prior manifest recorded (plus the clean-install
11182
+ // write plan): restore bytes, or re-delete a path that was absent
11183
+ // pre-install.
11184
+ for (const [relPath, buf] of codexPreInstallManagedFiles) {
11185
+ const resolved = resolveInstallRelativePath(targetDir, relPath);
11186
+ if (!resolved) continue;
11187
+ try {
11188
+ if (buf !== null) {
11189
+ fs.mkdirSync(path.dirname(resolved.fullPath), { recursive: true });
11190
+ fs.writeFileSync(resolved.fullPath, buf);
11191
+ } else if (fs.existsSync(resolved.fullPath)) {
11192
+ fs.rmSync(resolved.fullPath, { force: true });
11193
+ }
11194
+ } catch (_) { /* best-effort */ }
11195
+ }
11196
+ // The prior manifest file itself: reinstall rewrites it; rollback returns
11197
+ // the previous install's manifest (or removes it on a clean first install).
11198
+ const _manifestRestorePath = path.join(targetDir, MANIFEST_NAME);
11199
+ if (codexPreInstallManifestBytes !== null) {
11200
+ try { fs.writeFileSync(_manifestRestorePath, codexPreInstallManifestBytes); } catch (_) { /* best-effort */ }
11201
+ } else if (fs.existsSync(_manifestRestorePath)) {
11202
+ try { fs.unlinkSync(_manifestRestorePath); } catch (_) { /* best-effort */ }
11203
+ }
11204
+ };
10881
11205
 
10882
11206
  // #3245 CR finding 2 — Rollback coverage extends to ALL post-snapshot operations,
10883
11207
  // not just the Codex config/hook error paths. Any throw between snapshot capture and
10884
11208
  // the Codex config block (skills copy, agents copy, VERSION write, manifest write, etc.)
10885
11209
  // must also trigger rollback so the caller is never left in a partially-installed state.
10886
11210
  //
10887
- // _codexPreConfigRollback covers the four surfaces that can be mutated before
10888
- // config.toml is touched: skills/, agents/, gsd-core/VERSION, and orphaned
11211
+ // _codexPreConfigRollback covers the surfaces that can be mutated before
11212
+ // config.toml is touched: skills/, agents/, gsd-core/VERSION, the manifest-
11213
+ // driven surfaces (#4544 — CHANGELOG.md, scripts/, .gsd-runtime and the
11214
+ // manifest itself are all rewritten in this window), and orphaned
10889
11215
  // atomic-write temp files. It is safe to call before any writes have happened.
10890
11216
  // The full restoreCodexSnapshot() (defined inside the config block) additionally
10891
- // handles config.toml, which is not yet touched at this point in the pipeline.
11217
+ // handles config.toml and the staged hooks/ tree, which are not yet touched
11218
+ // at this point in the pipeline.
10892
11219
  const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
10893
11220
  rollbackInstallerMigrations();
10894
11221
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
@@ -10949,6 +11276,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10949
11276
  } else if (fs.existsSync(_earlyVersionPath)) {
10950
11277
  try { fs.unlinkSync(_earlyVersionPath); } catch (_) { /* best-effort */ }
10951
11278
  }
11279
+ // #4544 — manifest-driven surfaces (CHANGELOG.md, scripts/, the initial
11280
+ // manifest write, and — via installer migrations' stale-hook removal —
11281
+ // hooks/ itself are all mutated in this window). The shared restore is
11282
+ // also idempotent against an untouched tree.
11283
+ restoreCodexManagedSnapshot();
10952
11284
  // Orphaned atomic-write temp files.
10953
11285
  const _earlyTmpPattern = /\.tmp-\d+-\d+$/;
10954
11286
  function _earlyCleanTmpFiles(dir) {
@@ -11809,7 +12141,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11809
12141
  console.log(` ${green}✓${reset} Wrote ${sharedHooksDirName}/package.json (CommonJS mode)`);
11810
12142
  break;
11811
12143
  case 'preserved-foreign':
11812
- console.warn(` ${yellow}⚠${reset} Left existing ${sharedHooksDirName}/package.json untouched (not GSD's marker) — GSD hooks may not resolve as CommonJS`);
12144
+ // #4759: the foreign file usually DOES declare "type": "commonjs" —
12145
+ // any hand-written or formatter-touched package.json does — and Node
12146
+ // then loads the staged .js hooks as CommonJS, so the old
12147
+ // unconditional "may not resolve" claim was usually false. The
12148
+ // sibling plugin path (src/install-engine.cts) words this same
12149
+ // outcome conditionally; match it and keep will-not-load conditional
12150
+ // on "type": "module", the only case where it is true.
12151
+ console.warn(` ${yellow}⚠${reset} Left existing ${sharedHooksDirName}/package.json untouched (not GSD's marker). If it declares "type": "module", the staged hooks will not load.`);
11813
12152
  break;
11814
12153
  case 'failed':
11815
12154
  // Best-effort: a read-only or full config dir must not abort the
@@ -12041,6 +12380,42 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12041
12380
  manifestFiles = null;
12042
12381
  }
12043
12382
  if (manifestFiles !== null) {
12383
+ // #4667: codex-installed artifacts must not keep `@~/.claude/gsd-core/…`
12384
+ // include references — the `@` form resolves into the CLAUDE install
12385
+ // (wrong copy on dual-runtime machines at divergent versions, nothing at
12386
+ // all on codex-only ones; #570 cause 2 residue). Every target ships in
12387
+ // the codex install, so rewriting the `@~/` include form to the codex
12388
+ // root is mechanical and correct. This runs after all .md emitters
12389
+ // (several bypass the per-runtime converters — that is how the leak
12390
+ // survived the per-emitter fixes; the agent .tomls are generated later
12391
+ // and prefix themselves), and before the scan below, which stays as the
12392
+ // verification backstop. The `_GSD_RUNTIME_ROOT`/`$PREFERRED_CONFIG_DIR`
12393
+ // fallback chains and prose `.claude` mentions carry no `@~/` prefix and
12394
+ // are deliberately untouched, as is CHANGELOG.md.
12395
+ if (runtime === 'codex') {
12396
+ for (const relPath of manifestFiles) {
12397
+ const fileName = path.basename(relPath);
12398
+ if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
12399
+ if (fileName === 'CHANGELOG.md') continue;
12400
+ const rewritePath = path.join(targetDir, relPath);
12401
+ let rewriteContent;
12402
+ try {
12403
+ rewriteContent = fs.readFileSync(rewritePath, 'utf8');
12404
+ } catch (rewriteErr) {
12405
+ continue; // inaccessible or missing — the scan below reports or skips it
12406
+ }
12407
+ const rewritten = rewriteContent
12408
+ .split('@~/.claude/gsd-core/').join('@~/.codex/gsd-core/')
12409
+ .split('@$HOME/.claude/gsd-core/').join('@$HOME/.codex/gsd-core/');
12410
+ if (rewritten !== rewriteContent) {
12411
+ try {
12412
+ fs.writeFileSync(rewritePath, rewritten);
12413
+ } catch (writeErr) {
12414
+ continue; // never fail the install over the rewrite; the scan still warns
12415
+ }
12416
+ }
12417
+ }
12418
+ }
12044
12419
  for (const relPath of manifestFiles) {
12045
12420
  const fileName = path.basename(relPath);
12046
12421
  if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
@@ -12249,6 +12624,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12249
12624
  try { fs.unlinkSync(_rollbackVersionPath); } catch (_) { /* best-effort */ }
12250
12625
  }
12251
12626
 
12627
+ // 4b. #4544 — manifest-driven surfaces: the staged hooks/ tree, every
12628
+ // GSD-owned path the prior manifest recorded (scripts/, gsd-core/
12629
+ // payload), and the prior manifest file itself.
12630
+ restoreCodexManagedSnapshot();
12631
+
12252
12632
  // 5. Orphaned atomic-write temp files (<file>.tmp-<pid>-<n>) in targetDir.
12253
12633
  // These can accumulate if an atomic write fails mid-rename. Best-effort scan.
12254
12634
  //
@@ -12322,11 +12702,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12322
12702
  // ENOENT -> allow(undefined), every invocation, every event, no exceptions).
12323
12703
  // A pre-#2586 install's stale copy + hooks.json registrations are cleaned
12324
12704
  // up below (see the CODEX_EXTENDED_HOOK_EVENTS loop), not re-added here.
12325
- const CODEX_HOOKS_TO_COPY = [
12326
- 'gsd-check-update.js',
12327
- 'gsd-check-update-worker.js',
12328
- 'managed-hooks-registry.cjs',
12329
- ];
12705
+ // CODEX_HOOKS_TO_COPY itself lives at module scope (#4544) — the rollback's
12706
+ // incomplete-capture path must name the same set without a second literal.
12330
12707
  const codexHooksSrc = path.join(src, 'hooks', 'dist');
12331
12708
  if (fs.existsSync(codexHooksSrc)) {
12332
12709
  const codexHooksDest = path.join(targetDir, 'hooks');
@@ -12520,6 +12897,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12520
12897
  absoluteRunner: codexNodeRunner,
12521
12898
  platform: process.platform,
12522
12899
  });
12900
+ configuredEntrypoints.push(...(hookWrite.configuredEntrypoints || []));
12523
12901
  if (hookWrite.wrote) {
12524
12902
  console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via hooks.json)`);
12525
12903
  } else {
@@ -12599,7 +12977,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12599
12977
  }
12600
12978
 
12601
12979
  persistActiveProfileMarker();
12602
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
12980
+ // #4249: expose restoreCodexSnapshot (#3245) as a SECOND, separately named
12981
+ // rollback rather than rebinding `rollbackInstallerMigrations` to it. A
12982
+ // configured-entrypoint validation failure discovered later (outside this
12983
+ // function, after Codex's own hooks.json/config.toml write already
12984
+ // succeeded) previously had only the installer-migrations closure to call,
12985
+ // leaving the just-written config.toml/hooks.json broken on disk despite
12986
+ // Codex already owning a full pre-install snapshot/restore for exactly this.
12987
+ //
12988
+ // Every runtime's `rollbackInstallerMigrations` therefore still means what
12989
+ // it says — the installer-migrations-only closure, which is what a
12990
+ // finalize-stage failure that is NOT an entrypoint-validation failure gets
12991
+ // (the Phase 4 contract). `rollbackPreInstallSnapshot` is Codex-only and is
12992
+ // chosen only for entrypoint-validation failures. See the selection in
12993
+ // installAllRuntimes' rollbackFinalizedInstallerMigrations.
12994
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations, rollbackPreInstallSnapshot: restoreCodexSnapshot };
12603
12995
  }
12604
12996
 
12605
12997
  if (plan.installSurface === 'copilot-instructions') {
@@ -12629,7 +13021,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12629
13021
  writeCopilotHookConfig(targetDir);
12630
13022
  console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`);
12631
13023
  persistActiveProfileMarker();
12632
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
13024
+ // #4249: `[]`, not omitted — every Copilot hook is an inline `printf`
13025
+ // one-liner (GSD_COPILOT_*_HOOK_BASH/PWSH in src/runtime-hooks-surface.cts),
13026
+ // so this runtime genuinely launches no GSD-managed script and has no
13027
+ // interpreter to resolve. Stated explicitly like every other branch rather
13028
+ // than leaning on installAllRuntimes' `|| []` defence.
13029
+ // #4249 (antigravity review): `rollbackInstallerMigrations` was missing here
13030
+ // — every other branch returns it. This PR's own aggregate entrypoint gate
13031
+ // is what makes the gap reachable: an unrelated runtime's invalid entrypoint
13032
+ // now triggers rollbackFinalizedInstallerMigrations for every result in the
13033
+ // batch, and a Copilot result with no rollback function silently skips
13034
+ // reverting Copilot's own installer migrations.
13035
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: [], rollbackInstallerMigrations };
12633
13036
  }
12634
13037
 
12635
13038
  if (plan.installSurface === 'cursor-hooks-json') {
@@ -12652,7 +13055,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12652
13055
  // The re-run is retained for parity with the settings.json install path.
12653
13056
  writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12654
13057
  persistActiveProfileMarker();
12655
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
13058
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: cursorHookResult.configuredEntrypoints, rollbackInstallerMigrations };
12656
13059
  }
12657
13060
 
12658
13061
  if (plan.installSurface === 'profile-marker-only') {
@@ -12708,6 +13111,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12708
13111
  const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
12709
13112
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
12710
13113
  const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
13114
+ configuredEntrypoints.push(...kimiHooksResult.configuredEntrypoints);
12711
13115
  if (kimiHooksResult.changed) {
12712
13116
  console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
12713
13117
  }
@@ -12764,6 +13168,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12764
13168
  const windsurfHookResult = writeWindsurfHooksJson(targetDir, src, {
12765
13169
  platform: process.platform,
12766
13170
  });
13171
+ configuredEntrypoints.push(...windsurfHookResult.configuredEntrypoints);
12767
13172
  if (windsurfHookResult.changed) {
12768
13173
  console.log(` ${green}✓${reset} Configured Windsurf lifecycle hooks (pre_write_code, pre_run_command)`);
12769
13174
  } else {
@@ -12779,19 +13184,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12779
13184
  }
12780
13185
 
12781
13186
  persistActiveProfileMarker();
12782
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
13187
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations };
12783
13188
  }
12784
13189
 
12785
13190
  if (plan.installSurface === 'cline-rules') {
12786
13191
  // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
12787
13192
  // at .clinerules/gsd.md and a PreToolUse lifecycle hook at
12788
13193
  // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
12789
- writeClineArtifacts(targetDir, isGlobal);
13194
+ const clineArtifacts = writeClineArtifacts(targetDir, isGlobal);
12790
13195
  // Re-run the manifest pass: these artifacts are written *after* the earlier
12791
13196
  // writeManifest() call, so a second pass is needed to hash-track them.
12792
13197
  writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12793
13198
  persistActiveProfileMarker();
12794
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
13199
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: clineArtifacts.configuredEntrypoints, rollbackInstallerMigrations };
12795
13200
  }
12796
13201
 
12797
13202
  // Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
@@ -12916,8 +13321,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12916
13321
  persistActiveProfileMarker();
12917
13322
  // Callers index this result by `runtime` (installAllRuntimes' statusline
12918
13323
  // lookup), so every early exit must return the full shape — a bare return
12919
- // crashes the install rather than skipping one file.
12920
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
13324
+ // crashes the install rather than skipping one file. That includes
13325
+ // configuredEntrypoints/rollbackInstallerMigrations: rollbackFinalizedInstallerMigrations
13326
+ // reads result.rollbackInstallerMigrations unconditionally, and an omitted
13327
+ // field there silently skips this runtime's rollback on a finalize-stage failure.
13328
+ return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: [], rollbackInstallerMigrations };
12921
13329
  }
12922
13330
  const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
12923
13331
  // #3002 CR / #3662: rewrite legacy `node .../gsd-*.js` command strings (pre-
@@ -12939,7 +13347,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12939
13347
  // runtime's hostBehaviors instead of a hardcoded `runtime === 'antigravity'`
12940
13348
  // check inside projectLocalHookPrefix.
12941
13349
  const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
12942
- const hookOpts = { portableHooks: hasPortableHooks, runtime };
13350
+ const settingsEntrypoints = [];
13351
+ const hookOpts = {
13352
+ portableHooks: hasPortableHooks,
13353
+ runtime,
13354
+ configPath: settingsPath,
13355
+ // #4249: track unconditionally. Gating on `plan.hooksSurface ===
13356
+ // 'settings-json'` made tracking depend on an unasserted
13357
+ // installSurface/hooksSurface coupling — a descriptor that broke it would
13358
+ // silently drop this runtime out of validation. Everything recorded here
13359
+ // lands in settings.json by construction, and the registered-command
13360
+ // filter below already discards entries no hook actually references.
13361
+ configuredEntrypoints: settingsEntrypoints,
13362
+ };
12943
13363
  // #2979: local-install hook commands also use a runner GUI/minimal-PATH
12944
13364
  // runtimes can resolve. Bare `node` fails when the host launches the
12945
13365
  // runtime with a stripped PATH (Finder/Antigravity/etc) — #3662 replaces
@@ -12953,19 +13373,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12953
13373
  // `node` command that recreates the #2979 failure.
12954
13374
  const localCmd = (hookFile) => localNodeRunner === null
12955
13375
  ? null
12956
- : projectShellCommandText({
13376
+ : hooksSurface.recordConfiguredHookCommand(projectShellCommandText({
12957
13377
  runnerToken: localNodeRunner,
12958
13378
  argTokens: [`${localPrefix}/hooks/${hookFile}`],
12959
13379
  runtime,
12960
13380
  platform: process.platform,
12961
- });
12962
- const localShellCmd = (hookFile) => buildLocalShellHookCommand({
13381
+ }), targetDir, hookFile, hookOpts);
13382
+ const localShellCmd = (hookFile) => hooksSurface.recordConfiguredHookCommand(buildLocalShellHookCommand({
12963
13383
  localPrefix,
12964
13384
  hookFile,
12965
13385
  bashRunner: localBashRunner,
12966
13386
  runtime,
12967
13387
  platform: process.platform,
12968
- });
13388
+ }), targetDir, hookFile, hookOpts);
12969
13389
  const statuslineCommand = isGlobal
12970
13390
  ? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)
12971
13391
  : localCmd('gsd-statusline.js');
@@ -13035,6 +13455,31 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13035
13455
  ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
13036
13456
  : localCmd('gsd-update-banner.js'));
13037
13457
 
13458
+ const registeredHookCommands = Object.values(settings.hooks || {})
13459
+ .flatMap(groups => Array.isArray(groups) ? groups : [])
13460
+ .flatMap(group => Array.isArray(group && group.hooks) ? group.hooks : [])
13461
+ .map(hook => hook && hook.command)
13462
+ .filter(command => typeof command === 'string');
13463
+ // #4249: match by the managed script's `/hooks/<basename>` path segment, not
13464
+ // by exact command-string equality. The blocking-guard hooks above register
13465
+ // only-if-absent, so a hook already present from a prior install keeps its
13466
+ // OLD command untouched — but `track()` always records the FRESHLY computed
13467
+ // command for it, which never equals what's actually persisted. Matching on
13468
+ // the segment (present in the persisted command either way, since every
13469
+ // entry.scriptPath is <configDir>/hooks/<name> by construction) keeps an
13470
+ // already-registered, still-active hook in the validated set instead of
13471
+ // silently dropping it (#4154 Blocker) — anchored on `/hooks/` rather than a
13472
+ // bare basename so an unrelated user command that merely mentions the same
13473
+ // filename can't false-positive into GSD's validated set.
13474
+ configuredEntrypoints.push(
13475
+ ...settingsEntrypoints.filter(entry => {
13476
+ const hooksSegment = '/hooks/' + path.basename(entry.scriptPath);
13477
+ return registeredHookCommands.some(command => command.includes(hooksSegment));
13478
+ }),
13479
+ );
13480
+ const statuslineEntrypoints = settingsEntrypoints.filter(entry => entry.command === statuslineCommand);
13481
+ const updateBannerEntrypoints = settingsEntrypoints.filter(entry => entry.command === updateBannerCommand);
13482
+
13038
13483
  // #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
13039
13484
  // Both fresh and upgrade paths apply only when worktrees are enabled for the project.
13040
13485
  // Never applies to global installs, non-Claude runtimes, or when the user already
@@ -13103,15 +13548,65 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13103
13548
  settings,
13104
13549
  statuslineCommand,
13105
13550
  updateBannerCommand,
13551
+ statuslineEntrypoints,
13552
+ updateBannerEntrypoints,
13106
13553
  runtime,
13107
13554
  configDir: targetDir,
13108
13555
  rollbackInstallerMigrations,
13556
+ configuredEntrypoints,
13109
13557
  };
13110
13558
  }
13111
13559
 
13112
- /**
13113
- * Apply statusline config, then print completion message
13114
- */
13560
+ // #4249 (review, Major): rollback consequence differs by runtime surface —
13561
+ // see docs/how-to/update-gsd.md's rollback-matrix paragraph, which this
13562
+ // mirrors. Codex reverts (pre-install snapshot restore); Cursor/Windsurf/
13563
+ // Kimi/Kimi Code/Cline already wrote their config file inside install(),
13564
+ // ahead of this gate, with no revert path, so it is left on disk broken;
13565
+ // every other (settings.json-based) runtime writes strictly after this gate,
13566
+ // so a failure here means nothing new was persisted for it.
13567
+ const ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES = new Set(['cursor', 'windsurf', 'kimi', 'kimi-code', 'cline']);
13568
+ function describeEntrypointConsequence(invalidRuntime) {
13569
+ if (invalidRuntime === 'codex') return 'reverted: its pre-install snapshot was restored';
13570
+ if (ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES.has(invalidRuntime)) return 'NOT reverted: its config file is already written and was left on disk — fix the reported path and rerun install';
13571
+ return 'not persisted: this runtime writes its config after this check';
13572
+ }
13573
+
13574
+ function assertConfiguredEntrypoints(entries) {
13575
+ // #4249: some writers push the same (configPath, scriptPath) pair more than
13576
+ // once (e.g. Kimi's context-monitor hook registered under several events,
13577
+ // or the portable resolver script shared by every portable JS hook) — keep
13578
+ // one so a broken entry is reported once, not once per duplicate.
13579
+ const seen = new Set();
13580
+ const deduped = (entries || []).filter((entry) => {
13581
+ const key = JSON.stringify([entry.configPath, entry.scriptPath]);
13582
+ if (seen.has(key)) return false;
13583
+ seen.add(key);
13584
+ return true;
13585
+ });
13586
+ const validation = hooksSurface.validateConfiguredEntrypoints(deduped);
13587
+ if (validation.ok) return;
13588
+
13589
+ const error = new Error(
13590
+ // #4249: lead each entry with its runtime, and name the actual consequence
13591
+ // for that runtime (review, Major) — the aggregate gate is all-or-nothing
13592
+ // across every runtime being installed, and a failure here can revert a
13593
+ // runtime whose own entrypoints were fine (see
13594
+ // rollbackFinalizedInstallerMigrations) while leaving another runtime's
13595
+ // already-written config broken on disk with no revert at all, so an
13596
+ // operator reading only this message must be able to tell WHOSE
13597
+ // entrypoint broke and WHAT that means for their config, not just that
13598
+ // something did.
13599
+ `Configured entrypoint validation failed: ${validation.invalid.map(({ runtime: invalidRuntime, role, path: invalidPath, reason }) => `${invalidRuntime} ${role} ${invalidPath} (${reason}) [${describeEntrypointConsequence(invalidRuntime)}]`).join(', ')}`,
13600
+ );
13601
+ error.configuredEntrypointValidation = validation;
13602
+ throw error;
13603
+ }
13604
+
13605
+ // #4249: `bannerOpts.configuredEntrypoints` is the ONLY source assertConfiguredEntrypoints
13606
+ // checks below — a caller that omits it (or calls finishInstall directly instead of
13607
+ // through installAllRuntimes) gets zero entrypoint validation, silently. installAllRuntimes
13608
+ // always passes the full set (per-runtime entries plus statusline/updateBanner); any other
13609
+ // caller must do the same for this gate to mean anything.
13115
13610
  function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
13116
13611
  // #2093: isKilo dropped — the Kilo permissions-writer call below is gated
13117
13612
  // on plan.finishPermissionWriter === 'kilo' (descriptor-driven), not this flag.
@@ -13125,6 +13620,19 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
13125
13620
  const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
13126
13621
  const plan = resolveInstallPlan(runtime);
13127
13622
 
13623
+ // #4249 Major: validate BEFORE this function's own settings.json write (and
13624
+ // before writeNonClaudeDefaults) instead of after. Cursor/Windsurf/Kimi/Cline
13625
+ // already persisted their config inside install() by this point, with no
13626
+ // rollback path covering those writes; Codex also persists inside install()
13627
+ // but its rollback binds to a full pre-install snapshot restore, so it IS
13628
+ // covered (see docs/how-to/update-gsd.md). For the settings-json surface
13629
+ // this ordering means a failing validation never reaches this function's
13630
+ // own write at all. On the production path this is a redundant backstop —
13631
+ // installAllRuntimes's own aggregate assertConfiguredEntrypoints call
13632
+ // already validates the superset before finishInstall runs for any
13633
+ // runtime — kept for a caller that invokes finishInstall directly.
13634
+ assertConfiguredEntrypoints(bannerOpts.configuredEntrypoints);
13635
+
13128
13636
  if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
13129
13637
  if (!isGlobal && !forceStatusline) {
13130
13638
  // Local installs skip statusLine by default: repo settings.json takes precedence over
@@ -14022,10 +14530,32 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
14022
14530
 
14023
14531
  const rollbackFinalizedInstallerMigrations = (error) => {
14024
14532
  const rollbackFailures = [];
14533
+ // #4249: this discriminates on the error's KIND, never on which runtime
14534
+ // owns the failing entrypoint. `wide` is true for ANY entrypoint-validation
14535
+ // failure from ANY runtime, by design: the aggregate gate exists so a
14536
+ // multi-runtime install cannot report success while one of its entrypoints
14537
+ // is broken, so an invalid Cline entrypoint reverts Codex's pre-install
14538
+ // snapshot too — even though Codex itself was fine and its own "Done!"
14539
+ // summary already printed. tests/configured-entrypoint-validation.test.cjs
14540
+ // ('an aggregate entrypoint validation failure rolls the Codex install
14541
+ // back') exercises exactly that, and it is the all-or-nothing behaviour
14542
+ // docs/how-to/update-gsd.md documents.
14543
+ //
14544
+ // What this narrows is the OTHER axis: a finalize-stage exception that is
14545
+ // not an entrypoint-validation failure at all — e.g. a sibling runtime's
14546
+ // permission-config write dying with EACCES — gets only the
14547
+ // installer-migrations-only rollback that Phase 4 specifies
14548
+ // (docs/installer-migrations.md#phase-4-installupdate-integration).
14549
+ // Un-installing (and, on update, downgrading) an already-"Done!" Codex over
14550
+ // an unrelated error is not an outcome any doc promises, while the sibling
14551
+ // surfaces that write config inside install() would keep theirs regardless.
14552
+ const wide = !!(error && error.configuredEntrypointValidation);
14025
14553
  for (const result of [...results].reverse()) {
14026
- if (!result || typeof result.rollbackInstallerMigrations !== 'function') continue;
14554
+ if (!result) continue;
14555
+ const rollback = (wide && result.rollbackPreInstallSnapshot) || result.rollbackInstallerMigrations;
14556
+ if (typeof rollback !== 'function') continue;
14027
14557
  try {
14028
- result.rollbackInstallerMigrations();
14558
+ rollback();
14029
14559
  } catch (rollbackError) {
14030
14560
  rollbackFailures.push({
14031
14561
  runtime: result.runtime,
@@ -14053,6 +14583,19 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
14053
14583
 
14054
14584
  const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
14055
14585
  try {
14586
+ const selectedConfiguredEntrypoints = (result) => {
14587
+ if (!result || result.skipped) return [];
14588
+ const useStatusline = statuslineRuntimes.includes(result.runtime)
14589
+ && shouldInstallStatusline
14590
+ && (isGlobal || forceStatusline);
14591
+ return [
14592
+ ...(result.configuredEntrypoints || []),
14593
+ ...(useStatusline ? (result.statuslineEntrypoints || []) : []),
14594
+ ...(shouldInstallBanner ? (result.updateBannerEntrypoints || []) : []),
14595
+ ];
14596
+ };
14597
+ assertConfiguredEntrypoints(results.flatMap(selectedConfiguredEntrypoints));
14598
+
14056
14599
  const printSummaries = () => {
14057
14600
  for (const result of results) {
14058
14601
  if (result && result.skipped) continue;
@@ -14066,7 +14609,11 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
14066
14609
  result.runtime,
14067
14610
  isGlobal,
14068
14611
  result.configDir,
14069
- { shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
14612
+ {
14613
+ shouldInstallBanner: !!shouldInstallBanner,
14614
+ bannerCommand: result.updateBannerCommand,
14615
+ configuredEntrypoints: selectedConfiguredEntrypoints(result),
14616
+ }
14070
14617
  );
14071
14618
  }
14072
14619
  };