@opengsd/gsd-core 1.18.0 → 1.20.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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
package/bin/install.js CHANGED
@@ -63,6 +63,8 @@ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs'
63
63
  // #3072: THE shared composition-scope predicate (also consumed by the served
64
64
  // MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
65
65
  const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
66
+ // #5637: per-install terminator for the typed-text capture heredoc.
67
+ const { armArgsNonce, disarmArgsNonce, stampInstalledContent } =require('../gsd-core/bin/lib/args-terminator.cjs');
66
68
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
67
69
  const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
68
70
  // #2873: cross-scope shadow detection — reports (never fails) when a
@@ -844,6 +846,9 @@ const {
844
846
  migrateLegacyDevPreferencesToSkill,
845
847
  USER_OWNED_ARTIFACTS,
846
848
  _snapshotDir,
849
+ createSkillDirOwnership,
850
+ _warnPreservedSkillDirs,
851
+ USER_OWNED_SKILL_DIRS,
847
852
  } = installEngine;
848
853
 
849
854
  // #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
@@ -1640,14 +1645,13 @@ const claudeToOpencodeTools = {
1640
1645
  const claudeToAntigravityTools = {
1641
1646
  // #4705: Antigravity-NATIVE tool names (see src/runtime-artifact-conversion.cts)
1642
1647
  Read: 'view_file',
1643
- Write: 'write_file',
1648
+ Write: 'write_to_file',
1644
1649
  Edit: 'replace_file_content',
1645
1650
  Bash: 'run_command',
1646
- Glob: 'glob',
1651
+ Glob: 'find_by_name',
1647
1652
  Grep: 'grep_search',
1648
- WebSearch: 'google_web_search',
1649
- WebFetch: 'web_fetch',
1650
- TodoWrite: 'write_todos',
1653
+ WebSearch: 'search_web',
1654
+ WebFetch: 'read_url_content',
1651
1655
  }
1652
1656
 
1653
1657
  // Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
@@ -1716,13 +1720,16 @@ function convertAntigravityToolName(claudeTool) {
1716
1720
  // built-in tool; the lowercase fallback would emit an invalid
1717
1721
  // 'skill'/'slashcommand' name that fails frontmatter validation
1718
1722
  // (tools.N: Invalid tool name) and aborts the entire agent load (#1394).
1723
+ // TodoWrite: exclude — agy 1.3.5 registers no todo tool, and an unknown name
1724
+ // stops the agent from starting (#5447).
1719
1725
  if (
1720
1726
  claudeTool === 'Task' ||
1721
1727
  claudeTool === 'Agent' ||
1722
1728
  claudeTool === 'AskUserQuestion' ||
1723
1729
  claudeTool === 'ask_user' ||
1724
1730
  claudeTool === 'Skill' ||
1725
- claudeTool === 'SlashCommand'
1731
+ claudeTool === 'SlashCommand' ||
1732
+ claudeTool === 'TodoWrite'
1726
1733
  ) {
1727
1734
  return null;
1728
1735
  }
@@ -2156,43 +2163,11 @@ function parseKimiAgentSource(source) {
2156
2163
  };
2157
2164
  }
2158
2165
 
2166
+ // #5447: the tools list is read by the compiled module's parser (quoted entries,
2167
+ // `#` comments, inline and block lists), the one the runtime pipeline uses, so this
2168
+ // copy of the Antigravity and Kimi converters cannot diverge from it.
2159
2169
  function parseFrontmatterTools(frontmatter) {
2160
- if (!frontmatter) return [];
2161
- const lines = frontmatter.split(/\r?\n/);
2162
- const tools = [];
2163
- let collecting = false;
2164
-
2165
- for (const line of lines) {
2166
- const trimmed = line.trim();
2167
- if (!trimmed) continue;
2168
-
2169
- if (collecting) {
2170
- if (trimmed.startsWith('- ')) {
2171
- tools.push(trimmed.slice(2).trim());
2172
- continue;
2173
- }
2174
- collecting = false;
2175
- }
2176
-
2177
- if (trimmed === 'tools:' || trimmed === 'allowed-tools:') {
2178
- collecting = true;
2179
- continue;
2180
- }
2181
-
2182
- if (trimmed.startsWith('tools:') || trimmed.startsWith('allowed-tools:')) {
2183
- const value = trimmed.slice(trimmed.indexOf(':') + 1).trim();
2184
- if (value) {
2185
- for (const tool of value.split(',')) {
2186
- const name = tool.trim();
2187
- if (name) tools.push(name);
2188
- }
2189
- } else {
2190
- collecting = true;
2191
- }
2192
- }
2193
- }
2194
-
2195
- return tools;
2170
+ return runtimeArtifactConversion.parseFrontmatterTools(frontmatter);
2196
2171
  }
2197
2172
 
2198
2173
  function addKimiAgentDiagnostic(diagnostics, code, message, value, source = null) {
@@ -2500,10 +2475,11 @@ function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
2500
2475
  const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
2501
2476
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2502
2477
  const color = extractFrontmatterField(frontmatter, 'color');
2503
- const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
2504
2478
 
2505
- // Map tools to Antigravity equivalents (reuse existing convertAntigravityToolName)
2506
- const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
2479
+ // Map tools to Antigravity equivalents (reuse existing convertAntigravityToolName).
2480
+ // parseFrontmatterTools reads both the inline comma list and the YAML block
2481
+ // list (e.g. agents/gsd-security-auditor.md) (#5447).
2482
+ const claudeTools = parseFrontmatterTools(frontmatter);
2507
2483
  const mappedTools = claudeTools.map(t => convertAntigravityToolName(t)).filter(Boolean);
2508
2484
 
2509
2485
  // #2876: quote description for the same reason as the skill variant.
@@ -3275,6 +3251,12 @@ function _stripCallArgument(spanText, matchStart, matchEnd, { consumeLeadingComm
3275
3251
  */
3276
3252
  function _projectRoleArgument(spanText, matchStart, matchEnd, roleValueExpr, toolConfig, canOrchestrate) {
3277
3253
  const { namedRoleParam, promptContentParam, structuralRoleParam, leafRoleValue } = toolConfig;
3254
+ // #5569: a primitive that resolves the role prompt itself (Pi `gsd_dispatch`)
3255
+ // takes the role name alone — no prompt-content argument, no structural role.
3256
+ // The argument's own separator is left in place.
3257
+ if (toolConfig.rolePromptResolution === 'tool') {
3258
+ return spanText.slice(0, matchStart) + `${namedRoleParam}=${roleValueExpr}` + spanText.slice(matchEnd);
3259
+ }
3278
3260
  const lineStart = spanText.lastIndexOf('\n', matchStart - 1) + 1;
3279
3261
  const startsOwnLine = /^[ \t]*$/.test(spanText.slice(lineStart, matchStart));
3280
3262
 
@@ -3406,6 +3388,20 @@ function _normalizeDispatchCallSpan(spanText, hasBraceWrapper, dispatch, toolCon
3406
3388
  }
3407
3389
  }
3408
3390
 
3391
+ // 1b. #5569: a tool-resolved primitive (`rolePromptResolution: 'tool'`) takes
3392
+ // `role` + `task` only. The task text arrives as `prompt`, so the key is
3393
+ // renamed; `description` has no counterpart and is dropped.
3394
+ if (toolConfig.rolePromptResolution === 'tool') {
3395
+ const pm = /\bprompt(\s*[=:])/.exec(_maskStringsAndComments(text));
3396
+ if (pm) {
3397
+ text = text.slice(0, pm.index) + toolConfig.taskParam + text.slice(pm.index + 'prompt'.length);
3398
+ }
3399
+ const dm = _callArgValueRe('description').exec(_maskStringsAndComments(text));
3400
+ if (dm) {
3401
+ text = _stripCallArgument(text, dm.index, dm.index + dm[0].length);
3402
+ }
3403
+ }
3404
+
3409
3405
  // 2. Per-call model argument (model= / model:) — stripped entirely when the
3410
3406
  // target has no per-call model-selection parameter (there is no
3411
3407
  // `dispatch` axis for this — it is inherent tool vocabulary, like the
@@ -3637,6 +3633,15 @@ function projectNamedDispatchToStructuralDelegate(content, dispatch, toolConfig)
3637
3633
  if (namedDispatch) {
3638
3634
  return `The \`${toolName}\` tool IS available in a top-level ${sessionName} session. Always dispatch ${rosterFlat} as separate \`${toolName}()\` calls.`;
3639
3635
  }
3636
+ if (toolConfig.rolePromptResolution === 'tool') {
3637
+ return (
3638
+ `The \`${toolName}\` tool IS available in a top-level ${sessionName} session. It takes a \`${toolConfig.namedRoleParam}\` ` +
3639
+ `(the installed gsd-* role name) and a \`${toolConfig.taskParam}\`, resolves the role prompt from the install itself, and ` +
3640
+ 'runs the role in an isolated child session; the child cannot dispatch further. FAIL CLOSED — surface the ' +
3641
+ `tool's error and stop — if it reports the role unresolved; never execute the role inline as a substitute. ` +
3642
+ `Always dispatch ${rosterFlat} as separate \`${toolName}\` calls.`
3643
+ );
3644
+ }
3640
3645
  return (
3641
3646
  `${sessionName} has no \`Agent\` tool. It exposes \`${toolName}\`, which dispatches by structural role — ` +
3642
3647
  'it has no concept of a named subagent identity. GSD projects each named gsd-* role onto this primitive ' +
@@ -3794,6 +3799,31 @@ function convertClaudeToHermesMarkdown(content, ctx) {
3794
3799
 
3795
3800
  // ── End Hermes converters ────────────────────────────────────────────────────
3796
3801
 
3802
+ // #5569: Pi projects host-neutral `Agent(...)` calls onto its `gsd_dispatch`
3803
+ // tool (pi/gsd.cjs). The tool resolves and verifies the role prompt itself, so
3804
+ // the projection only renames the arguments (`rolePromptResolution: 'tool'`).
3805
+ const PI_DISPATCH_TOOL_CONFIG = Object.freeze({
3806
+ toolName: 'gsd_dispatch',
3807
+ namedRoleParam: 'role',
3808
+ taskParam: 'task',
3809
+ promptContentParam: 'task',
3810
+ structuralRoleParam: 'role',
3811
+ leafRoleValue: 'leaf',
3812
+ backgroundParam: 'background',
3813
+ supportsPerCallModel: false,
3814
+ rolePromptResolution: 'tool',
3815
+ });
3816
+
3817
+ function convertClaudeToPiMarkdown(content, ctx) {
3818
+ const runtime = (ctx && ctx.runtime) || 'pi';
3819
+ const dispatch = _hostIntegrationDispatch(runtime);
3820
+ const toolConfig = Object.assign({}, PI_DISPATCH_TOOL_CONFIG, {
3821
+ availableRoles: _resolveAvailableGsdRoles(),
3822
+ runtime,
3823
+ });
3824
+ return projectNamedDispatchToStructuralDelegate(content, dispatch, toolConfig);
3825
+ }
3826
+
3797
3827
  function convertSlashCommandsToCodexSkillMentions(content) {
3798
3828
  // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
3799
3829
  let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
@@ -4285,8 +4315,8 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4285
4315
  * - Returns immediately if skillsDir does not exist (fails open).
4286
4316
  * - Only touches directories whose names start with "gsd-".
4287
4317
  * - Skips user-owned dirs (gsd-dev-preferences) — their agents/ content is
4288
- * never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
4289
- * installOpencodeFamilySkills.
4318
+ * never modified, by the same USER_OWNED_SKILL_DIRS guard
4319
+ * installOpencodeFamilySkills uses.
4290
4320
  * - For each managed gsd-* dir, if agents/openai.yaml exists, deletes it.
4291
4321
  * - If agents/ is now empty, removes the directory; if it still contains
4292
4322
  * other files (e.g. user-added content), leaves it in place.
@@ -4296,15 +4326,17 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
4296
4326
  *
4297
4327
  * @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
4298
4328
  */
4299
- function cleanupCodexSkillMetadataSidecars(skillsDir) {
4329
+ function cleanupCodexSkillMetadataSidecars(skillsDir, owns) {
4300
4330
  if (!fs.existsSync(skillsDir)) return;
4301
- // Mirror the user-owned list from installOpencodeFamilySkills (#2973).
4302
- // We MUST skip these dirs — their contents are user-generated and must
4303
- // never be modified by GSD's install path.
4304
- const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
4331
+ // USER_OWNED_SKILL_DIRS (#2973) is the engine's one list. We MUST skip these
4332
+ // dirs — their contents are user-generated and must never be modified by
4333
+ // GSD's install path.
4305
4334
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
4306
4335
  if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
4307
- if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
4336
+ if (USER_OWNED_SKILL_DIRS.includes(entry.name)) continue; // preserve user content
4337
+ // #5161: install keeps gsd-* dirs GSD does not own; their files are not
4338
+ // GSD's sidecars to clean.
4339
+ if (owns && !owns(entry.name)) continue;
4308
4340
  const agentsSubdir = path.join(skillsDir, entry.name, 'agents');
4309
4341
  const sidecarPath = path.join(agentsSubdir, 'openai.yaml');
4310
4342
  try {
@@ -4338,21 +4370,27 @@ function cleanupCodexSkillMetadataSidecars(skillsDir) {
4338
4370
  * - non-gsd-* dirs under .devin/skills/ (user-authored skills)
4339
4371
  * - gsd-dev-preferences/ (user-owned per #2973)
4340
4372
  * - any files (not dirs) under .devin/skills/
4373
+ * - with `owns` (#5161), any gsd-* dir GSD does not own, which is named by path
4341
4374
  *
4342
4375
  * @param {string} workspaceDir - workspace root (process.cwd() for local installs)
4376
+ * @param {(name: string) => boolean} [owns] ownership predicate (createSkillDirOwnership)
4343
4377
  * @returns {number} count of removed legacy gsd-* skill directories
4344
4378
  */
4345
- function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
4379
+ function cleanupWindsurfLegacyDevinSkills(workspaceDir, owns) {
4346
4380
  const legacySkillsDir = path.join(workspaceDir, '.devin', 'skills');
4347
4381
  if (!fs.existsSync(legacySkillsDir)) return 0;
4348
4382
 
4349
- // Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
4350
- const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
4351
4383
  let removed = 0;
4352
4384
 
4353
- for (const entry of fs.readdirSync(legacySkillsDir, { withFileTypes: true })) {
4354
- if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
4355
- if (_userOwnedSkillDirs.has(entry.name)) continue;
4385
+ // Decide ownership once, BEFORE deleting: the capability-marker leg reads the
4386
+ // dir itself, so asking after a removal would report the removed dir as kept.
4387
+ // USER_OWNED_SKILL_DIRS (#2973) dirs are never candidates.
4388
+ const candidates = fs.readdirSync(legacySkillsDir, { withFileTypes: true })
4389
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('gsd-') && !USER_OWNED_SKILL_DIRS.includes(entry.name));
4390
+ const notOwned = owns ? candidates.filter((entry) => !owns(entry.name)).map((entry) => entry.name) : [];
4391
+
4392
+ for (const entry of candidates) {
4393
+ if (notOwned.includes(entry.name)) continue;
4356
4394
 
4357
4395
  const dirToRemove = path.join(legacySkillsDir, entry.name);
4358
4396
  try {
@@ -4367,6 +4405,7 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
4367
4405
  // Fail open — a single bad dir must not block the install.
4368
4406
  }
4369
4407
  }
4408
+ _warnPreservedSkillDirs(legacySkillsDir, notOwned);
4370
4409
 
4371
4410
  // If .devin/skills/ is now empty, prune it. If .devin/ itself is then empty,
4372
4411
  // prune that too — leaves the workspace clean for the new .windsurf/ layout.
@@ -4393,20 +4432,29 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
4393
4432
  * relocating skills to ~/.agents/skills) orphans the pre-move dirs at
4394
4433
  * ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
4395
4434
  * (non-prefixed dirs, gsd-dev-preferences, symlinks) is preserved. Fail-open.
4435
+ *
4436
+ * #5161: with `owns`, a `<prefix>*` dir is removed only when GSD owns it; any
4437
+ * other is kept and named by path. Install passes it. Uninstall does not: it
4438
+ * removes every `gsd-*` skill dir, at either location.
4396
4439
  * @param {string} oldSkillsDir absolute path to the pre-move skills location
4397
4440
  * @param {string} prefix managed skill-dir prefix (e.g. 'gsd-')
4441
+ * @param {(name: string) => boolean} [owns] ownership predicate (createSkillDirOwnership)
4398
4442
  * @returns {number} count of stale dirs removed
4399
4443
  */
4400
- function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
4444
+ function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix, owns) {
4401
4445
  if (!fs.existsSync(oldSkillsDir)) return 0;
4402
4446
 
4403
- // Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
4404
- const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
4405
4447
  let removed = 0;
4406
4448
 
4407
- for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
4408
- if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
4409
- if (_userOwnedSkillDirs.has(entry.name)) continue;
4449
+ // Decide ownership once, BEFORE deleting: the capability-marker leg reads the
4450
+ // dir itself, so asking after a removal would report the removed dir as kept.
4451
+ // USER_OWNED_SKILL_DIRS (#2973) dirs are never candidates.
4452
+ const candidates = fs.readdirSync(oldSkillsDir, { withFileTypes: true })
4453
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix) && !USER_OWNED_SKILL_DIRS.includes(entry.name));
4454
+ const notOwned = owns ? candidates.filter((entry) => !owns(entry.name)).map((entry) => entry.name) : [];
4455
+
4456
+ for (const entry of candidates) {
4457
+ if (notOwned.includes(entry.name)) continue;
4410
4458
 
4411
4459
  const dirToRemove = path.join(oldSkillsDir, entry.name);
4412
4460
  try {
@@ -4421,6 +4469,7 @@ function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
4421
4469
  // Fail open — a single bad dir must not block install/uninstall.
4422
4470
  }
4423
4471
  }
4472
+ _warnPreservedSkillDirs(oldSkillsDir, notOwned);
4424
4473
 
4425
4474
  // Prune the old skills dir if now empty — leaves the configHome clean.
4426
4475
  // Never remove a non-empty container (user may keep other content there).
@@ -6478,7 +6527,28 @@ function splitTopLevelKeys(content) {
6478
6527
  return { topLevel: content.trim(), rest: '' };
6479
6528
  }
6480
6529
 
6530
+ /**
6531
+ * #5301: the path a config write must target. atomicWriteFileSync ends in a
6532
+ * rename(2) onto its destination, which REPLACES a symlink at that path rather than
6533
+ * writing through it, so a dotfiles-managed `config.toml -> ~/dotfiles/codex.toml`
6534
+ * became a plain file and the user's copy never saw the merge. With
6535
+ * GSD_ALLOW_SYMLINKED_DEST the symlink is intentional: resolve it and write the
6536
+ * file it points at, leaving the link itself alone. Without the opt-in the path is
6537
+ * returned unchanged (the install-root symlink guard refuses such a layout before
6538
+ * any write), and so does a dangling link (the guard cannot resolve it), so only a
6539
+ * link whose target exists reaches the resolution below.
6540
+ */
6541
+ function resolveSymlinkWriteTarget(filePath) {
6542
+ if (!isSymlinkedDestOptIn()) return filePath;
6543
+ let stat;
6544
+ try { stat = fs.lstatSync(filePath); } catch (_) { return filePath; }
6545
+ if (!stat.isSymbolicLink()) return filePath;
6546
+ try { return fs.realpathSync(filePath); } catch (_) { return filePath; }
6547
+ }
6548
+
6481
6549
  function mergeCodexConfig(configPath, gsdBlock) {
6550
+ // #5301: both write sites below land on the symlink's target, never replace the link.
6551
+ configPath = resolveSymlinkWriteTarget(configPath);
6482
6552
  // Case 1: No config.toml — create fresh
6483
6553
  if (!fs.existsSync(configPath)) {
6484
6554
  atomicWriteFileSync(configPath, gsdBlock + '\n');
@@ -7691,6 +7761,23 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverrid
7691
7761
  // combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
7692
7762
  // bespoke inline block that used to call this function.
7693
7763
 
7764
+ /**
7765
+ * Count the gsd-* skill dirs in `skillsDir` for "Installed N skills" and the
7766
+ * non-empty check that detects a failed install (#5161): the dirs GSD owns,
7767
+ * plus any USER_OWNED_SKILL_DIRS entry present (gsd-dev-preferences), which
7768
+ * both counted before #5161. A gsd-* dir the prune kept because GSD does not
7769
+ * own it is not counted, so it can neither inflate the line nor stand in for
7770
+ * an install that wrote nothing.
7771
+ */
7772
+ function _countOwnedSkillDirs(runtime, configDir, skillsDir) {
7773
+ // A verification count fails closed: an unreadable install source must not
7774
+ // let a user's gsd-* dir stand in for an install that wrote nothing.
7775
+ const owns = createSkillDirOwnership(runtime, configDir, skillsDir, 'gsd-', { includeManifest: false, includeWritten: true });
7776
+ return fs.readdirSync(skillsDir, { withFileTypes: true })
7777
+ .filter((e) => e.isDirectory() && e.name.startsWith('gsd-') && (owns(e.name) || USER_OWNED_SKILL_DIRS.includes(e.name)))
7778
+ .length;
7779
+ }
7780
+
7694
7781
  function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
7695
7782
  if (!fs.existsSync(skillsDir)) return [];
7696
7783
  const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
@@ -7922,6 +8009,10 @@ const RUNTIME_CONTENT_DISPATCH = {
7922
8009
  return content;
7923
8010
  },
7924
8011
  },
8012
+ pi: {
8013
+ // #5569: `Agent(...)` -> `gsd_dispatch(role=..., task=...)`.
8014
+ md: (content, ctx) => convertClaudeToPiMarkdown(content, ctx),
8015
+ },
7925
8016
  hermes: {
7926
8017
  // #2284: brand-swap alone left the false "Agent tool IS available"
7927
8018
  // assertion + literal `Agent(...)` call syntax installed verbatim — see
@@ -8041,6 +8132,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
8041
8132
  if (shouldCompose(srcPath)) {
8042
8133
  content = composeWorkflow(content, { sourcePath: srcPath });
8043
8134
  }
8135
+ // #5637: the typed-text capture heredoc ends at a terminator the typed text cannot know.
8136
+ content = stampInstalledContent(content);
8044
8137
 
8045
8138
  content = filterRuntimeNotesForTarget(content, runtime);
8046
8139
 
@@ -10159,7 +10252,16 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
10159
10252
  if (!hostBehaviorsFor(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) {
10160
10253
  // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
10161
10254
  const skillListPrefix = 'gsd-';
10255
+ // #5161: record only skill dirs GSD owns. A user's gsd-* dir the prune
10256
+ // preserved must not enter the manifest, or the next install reads it back
10257
+ // as GSD-owned and deletes it. includeManifest:false keeps the previous
10258
+ // manifest from vouching for itself; manifestIfSourceUnresolved keeps an
10259
+ // unreadable install source from dropping every GSD skill from the manifest
10260
+ // by carrying the previous manifest's entries forward instead of recording
10261
+ // every gsd-* dir present.
10262
+ const ownsSkillDir = createSkillDirOwnership(runtime, configDir, codexSkillsDir, skillListPrefix, { includeManifest: false, manifestIfSourceUnresolved: true, includeWritten: true });
10162
10263
  for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
10264
+ if (!ownsSkillDir(skillName) && !USER_OWNED_SKILL_DIRS.includes(skillName)) continue;
10163
10265
  const skillRoot = path.join(codexSkillsDir, skillName);
10164
10266
  const skillHashes = generateManifest(skillRoot);
10165
10267
  for (const [rel, hash] of Object.entries(skillHashes)) {
@@ -10748,7 +10850,8 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
10748
10850
  if (meta.files && meta.files.length > 0) {
10749
10851
  const reapplyCommand = hostBehaviorsFor(runtime).reapplyCommand || '/gsd-update --reapply';
10750
10852
  console.log('');
10751
- console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
10853
+ const fromLabel = meta.from_version ? ' (from v' + meta.from_version + ')' : ' (from a first install)';
10854
+ console.log(' ' + yellow + 'Local patches detected' + reset + fromLabel + ':');
10752
10855
  for (const f of meta.files) {
10753
10856
  console.log(' ' + cyan + f + reset);
10754
10857
  }
@@ -10761,6 +10864,114 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
10761
10864
  return meta.files || [];
10762
10865
  }
10763
10866
 
10867
+ /**
10868
+ * #5522: the first-time baseline reports a user's `skills/gsd-<stem>/SKILL.md` as preserved, but the
10869
+ * skills writer owns a dir by its first-party name and replaces it. Before the writers run, set aside a copy of
10870
+ * every baseline-preserved file in the shipped-name namespaces (bounded to BASELINE_SNAPSHOT_MAX_BYTES) in
10871
+ * gsd-migration-backups/ — an installer-internal dir, so it can never collide with a user's existing
10872
+ * gsd-local-patches/ content — so the user's content is on disk before anything can replace it.
10873
+ * backUpReplacedBaselineFiles then moves the copies of replaced files into gsd-local-patches/.
10874
+ * Returns Map<relPath, { fullPath, content, stagedPath }>.
10875
+ */
10876
+ const BASELINE_SNAPSHOT_MAX_BYTES = 1024 * 1024;
10877
+ const BASELINE_SNAPSHOT_DIR = path.join('gsd-migration-backups', 'first-install-5522');
10878
+ const SHIPPED_NAME_NAMESPACE = /^(skills|commands?)\/gsd([-/]|$)/;
10879
+ function snapshotBaselinePreservedFiles(configDir, migrationResult) {
10880
+ const snapshots = new Map();
10881
+ const plan = migrationResult && migrationResult.plan;
10882
+ const actions = plan && Array.isArray(plan.actions) ? plan.actions : [];
10883
+ const stagingDir = path.join(configDir, BASELINE_SNAPSHOT_DIR);
10884
+ for (const action of actions) {
10885
+ if (!action || action.type !== 'baseline-preserve-user' || typeof action.relPath !== 'string') continue;
10886
+ const ref = resolveInstallRelativePath(configDir, action.relPath);
10887
+ if (!ref || !SHIPPED_NAME_NAMESPACE.test(ref.relPath)) continue;
10888
+ try {
10889
+ const st = fs.lstatSync(ref.fullPath);
10890
+ if (!st.isFile()) continue;
10891
+ if (st.size > BASELINE_SNAPSHOT_MAX_BYTES) {
10892
+ console.warn(` ${yellow}⚠${reset} ${action.relPath} is larger than ${BASELINE_SNAPSHOT_MAX_BYTES} bytes, so it is not copied aside; if the install replaces it, the original is not kept`);
10893
+ continue;
10894
+ }
10895
+ const content = fs.readFileSync(ref.fullPath);
10896
+ const stagedRef = resolveInstallRelativePath(stagingDir, ref.relPath);
10897
+ if (!stagedRef) continue;
10898
+ fs.mkdirSync(path.dirname(stagedRef.fullPath), { recursive: true });
10899
+ fs.writeFileSync(stagedRef.fullPath, content);
10900
+ snapshots.set(ref.relPath, { fullPath: ref.fullPath, content, stagedPath: stagedRef.fullPath });
10901
+ } catch (err) {
10902
+ console.warn(` ${yellow}⚠${reset} Could not set aside ${action.relPath} before install (${err.message}); if the install replaces it, the original is not kept`);
10903
+ }
10904
+ }
10905
+ return snapshots;
10906
+ }
10907
+
10908
+ /**
10909
+ * #5522: after the artifact writers ran, a snapshotted file that is gone or different was replaced. Its
10910
+ * pre-install copy moves into gsd-local-patches/ (next to, never over, an existing backup) and is recorded in
10911
+ * backup-meta.json, so the standard "Local patches detected" report names it as it does on the update path.
10912
+ * The staging dir is always removed. Never throws: bookkeeping must not abort the install.
10913
+ * Returns [{ relPath, backupRel }] for the files kept.
10914
+ */
10915
+ function backUpReplacedBaselineFiles(configDir, snapshots) {
10916
+ const replaced = [];
10917
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
10918
+ for (const [relPath, snap] of snapshots) {
10919
+ let current = null;
10920
+ try { current = fs.readFileSync(snap.fullPath); } catch { current = null; }
10921
+ if (current !== null && current.equals(snap.content)) continue;
10922
+ try {
10923
+ let backupRel = relPath;
10924
+ if (fs.existsSync(path.join(patchesDir, backupRel))) backupRel = `${relPath}.first-install`;
10925
+ const backupRef = resolveInstallRelativePath(patchesDir, backupRel);
10926
+ if (!backupRef) throw new Error('backup path escapes the config dir');
10927
+ fs.mkdirSync(path.dirname(backupRef.fullPath), { recursive: true });
10928
+ fs.writeFileSync(backupRef.fullPath, snap.content);
10929
+ replaced.push({ relPath, backupRel });
10930
+ } catch (err) {
10931
+ console.warn(` ${yellow}⚠${reset} Could not back up ${relPath} to ${PATCHES_DIR_NAME}/ (${err.message}); the original is kept in ${BASELINE_SNAPSHOT_DIR}/`);
10932
+ return replaced;
10933
+ }
10934
+ }
10935
+ if (replaced.length > 0) {
10936
+ try {
10937
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
10938
+ let meta = {};
10939
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) || {}; } catch { meta = {}; }
10940
+ const files = Array.from(new Set([...(Array.isArray(meta.files) ? meta.files : []), ...replaced.map((r) => r.backupRel)]));
10941
+ fs.writeFileSync(metaPath, JSON.stringify({
10942
+ ...meta,
10943
+ backed_up_at: new Date().toISOString(),
10944
+ from_version: meta.from_version ?? null,
10945
+ files,
10946
+ pristine_hashes: { ...(meta.pristine_hashes || {}) },
10947
+ }, null, 2));
10948
+ } catch (err) {
10949
+ console.warn(` ${yellow}⚠${reset} Could not record ${PATCHES_DIR_NAME}/backup-meta.json (${err.message}); your copies are in ${PATCHES_DIR_NAME}/`);
10950
+ }
10951
+ }
10952
+ try { fs.rmSync(path.join(configDir, BASELINE_SNAPSHOT_DIR), { recursive: true, force: true }); } catch { /* leftover is harmless */ }
10953
+ return replaced;
10954
+ }
10955
+
10956
+ /** relPaths in the snapshot whose name is a shipped skill/command the writers will lay down (#5522). */
10957
+ function predictShippedNameReplacements(snapshots, skillsManifest) {
10958
+ const out = [];
10959
+ for (const relPath of snapshots.keys()) {
10960
+ const m = /^skills\/gsd-([^/]+)\//.exec(relPath) || /^commands?\/gsd\/([^/]+?)\.md$/.exec(relPath);
10961
+ if (m && skillsManifest.has(m[1])) out.push(relPath);
10962
+ }
10963
+ return out;
10964
+ }
10965
+
10966
+ /** A copy of the migration result without the baseline-preserve rows for paths the install replaced (#5522). */
10967
+ function withoutReplacedBaselineFiles(migrationResult, replaced) {
10968
+ if (!replaced || replaced.length === 0 || !migrationResult || !migrationResult.plan) return migrationResult;
10969
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\/+/, '');
10970
+ const gone = new Set(replaced.map((r) => norm(r.relPath)));
10971
+ const actions = (migrationResult.plan.actions || []).filter((a) => !(a && a.type === 'baseline-preserve-user' && gone.has(norm(a.relPath))));
10972
+ return { ...migrationResult, plan: { ...migrationResult.plan, actions } };
10973
+ }
10974
+
10764
10975
  function reportInstallerMigrationResult(result) {
10765
10976
  const summary = summarizeInstallerMigrationResult(result);
10766
10977
  if (!summary.hasReportableActions) return;
@@ -10894,6 +11105,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10894
11105
  ? process.cwd()
10895
11106
  : path.join(process.cwd(), dirName);
10896
11107
 
11108
+ // #5637: this install's typed-text heredoc terminator: the directory's existing nonce on a
11109
+ // reinstall (so it changes nothing), a fresh one otherwise.
11110
+ if (process.env.GSD_ARGS_NONCE_OFF !== '1') {
11111
+ // Only a global install reuses its nonce; a project-local dir may be a committed clone.
11112
+ armArgsNonce(targetDir, isGlobal);
11113
+ } else {
11114
+ disarmArgsNonce();
11115
+ console.warn(' GSD_ARGS_NONCE_OFF=1: the typed-text heredoc terminator is NOT randomized in this install.');
11116
+ }
11117
+
11118
+ // #5161: this install's record of the skill dirs it writes starts empty,
11119
+ // whichever install path it takes.
11120
+ installEngine.clearWrittenSkillDirs();
11121
+
10897
11122
  // #3664: a --config-dir destination holding foreign agent files gets an
10898
11123
  // explicit install-time warning — never a silent Claude-shaped emit.
10899
11124
  if (isGlobal) {
@@ -10963,8 +11188,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10963
11188
  const _isCoreProfileAlias = _activeProfileName === 'core';
10964
11189
  const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
10965
11190
  // Load the manifest and compute resolved profile for named profiles.
10966
- // For --minimal/core: use an empty manifest (core profile has no transitive
10967
- // deps) to produce a resolvedProfile with the core skill set. For core/
11191
+ // For --minimal/core: use an empty manifest for the SKILL closure (core profile has no
11192
+ // transitive skill deps) to produce a resolvedProfile with the core skill set; agents
11193
+ // come from the full manifest (#5503, see below). For core/
10968
11194
  // standard profiles, resolveProfile's `registry` arg IS consulted (via
10969
11195
  // _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
10970
11196
  // unioned in when registered. #2322 correction: for the DEFAULT `full`
@@ -10977,10 +11203,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10977
11203
  // is passed so an INSTALLED third-party capability (not just a first-party
10978
11204
  // one) is honored on every profile, `full` included (#2322 blocker 2).
10979
11205
  const _commandsDir = path.join(src, 'commands', 'gsd');
10980
- const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
11206
+ // #5503: the empty manifest only keeps core's SKILL set at the eight core skills (no
11207
+ // `requires:` closure). The agents those skills spawn are still derived from the real
11208
+ // manifest, so a core install can run /gsd-plan-phase and friends.
11209
+ const _fullSkillsManifest = loadSkillsManifest(_commandsDir);
11210
+ const _skillsManifest = _isCoreProfileAlias ? new Map() : _fullSkillsManifest;
10981
11211
  let _resolvedProfile = resolveProfile({
10982
11212
  modes: [_activeProfileName],
10983
11213
  manifest: _skillsManifest,
11214
+ agentManifest: _fullSkillsManifest,
10984
11215
  registry: _installedCapabilityRegistry,
10985
11216
  });
10986
11217
  // Unified staging function: all profiles use stageSkillsForProfile with the
@@ -10989,9 +11220,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10989
11220
  return stageSkillsForProfile(commandsGsdDir, _resolvedProfile);
10990
11221
  }
10991
11222
  function _stageAgents(agentsDir) {
10992
- if (_isCoreProfileAlias) return agentsDir;
11223
+ // #5503: no core-alias shortcut — Codex gets the same profile-filtered agents as every runtime.
10993
11224
  return stageAgentsForProfile(agentsDir, _resolvedProfile);
10994
11225
  }
11226
+ // #5503: Codex registers agent roles in config.toml. That used to be gated on "minimal mode"
11227
+ // because minimal staged no agents; gate it on whether the resolved profile stages any.
11228
+ const _codexAgentRolesEnabled = () =>
11229
+ !isMinimalMode(_effectiveInstallMode) || (_resolvedProfile.agents instanceof Set && _resolvedProfile.agents.size > 0);
10995
11230
  const persistActiveProfileMarker = () => {
10996
11231
  try {
10997
11232
  writeActiveProfile(targetDir, _activeProfileName);
@@ -11366,7 +11601,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11366
11601
  // The full restoreCodexSnapshot() (defined inside the config block) additionally
11367
11602
  // handles config.toml and the staged hooks/ tree, which are not yet touched
11368
11603
  // at this point in the pipeline.
11369
- const _codexPreConfigRollback = !hostBehaviorsFor(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
11604
+ const _codexPreConfigRollback = !hostBehaviorsFor(runtime).tomlConfigInstall || !_codexAgentRolesEnabled() ? null : () => {
11370
11605
  rollbackInstallerMigrations();
11371
11606
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
11372
11607
  const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
@@ -11468,6 +11703,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11468
11703
  // scope (outside the try block below) so it is accessible in the settings section later.
11469
11704
  // Absent VERSION = fresh install; present VERSION = upgrade/re-install.
11470
11705
  const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
11706
+ // #5522: files the first-time baseline reported as preserved, snapshotted before the artifact writers run.
11707
+ let baselinePreservedSnapshots = new Map();
11471
11708
  try {
11472
11709
  installerMigrationResult = runInstallerMigrations({
11473
11710
  configDir: targetDir,
@@ -11521,8 +11758,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11521
11758
  };
11522
11759
  }
11523
11760
  }
11524
- reportInstallerMigrationResult(installerMigrationResult);
11761
+ if (Array.isArray(installerMigrationResult.blocked) && installerMigrationResult.blocked.length > 0) {
11762
+ reportInstallerMigrationResult(installerMigrationResult); // show what blocked before the assertion throws
11763
+ }
11525
11764
  assertInstallerMigrationsUnblocked(installerMigrationResult);
11765
+ baselinePreservedSnapshots = snapshotBaselinePreservedFiles(targetDir, installerMigrationResult);
11766
+ // #5522: the migration report stays ahead of materialization, but a baseline-preserved file the skills
11767
+ // writer is about to replace (its name is a shipped skill's) is not listed as preserved; the
11768
+ // replacement is reported once the writers have run.
11769
+ reportInstallerMigrationResult(withoutReplacedBaselineFiles(
11770
+ installerMigrationResult,
11771
+ predictShippedNameReplacements(baselinePreservedSnapshots, _fullSkillsManifest).map((relPath) => ({ relPath }))
11772
+ ));
11526
11773
 
11527
11774
  // Artifact install dispatcher — routes to layout-driven path for all
11528
11775
  // skills-based runtimes (both full and minimal/core profiles); keeps
@@ -11688,7 +11935,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11688
11935
  // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
11689
11936
  // sufficient for Codex discovery. User-owned dirs are never touched.
11690
11937
  if (hostBehaviorsFor(runtime).cleanupSkillSidecars) {
11691
- cleanupCodexSkillMetadataSidecars(_skillsRootDir);
11938
+ // #5161: no includeWritten here, unlike the count and writeManifest. The
11939
+ // record would add only skills staged from a command (a capability skill
11940
+ // carries the marker, which this predicate already owns). The writer prunes
11941
+ // each of those except gsd-dev-preferences and writes it fresh as a lone
11942
+ // SKILL.md, so it holds no sidecar for this cleanup to remove, and the
11943
+ // cleanup skips gsd-dev-preferences by name (USER_OWNED_SKILL_DIRS).
11944
+ cleanupCodexSkillMetadataSidecars(
11945
+ _skillsRootDir,
11946
+ createSkillDirOwnership(runtime, targetDir, _skillsRootDir, 'gsd-', { includeManifest: false }),
11947
+ );
11692
11948
  }
11693
11949
 
11694
11950
  // ADR-1239 split-home migration: when a runtime's skills kind moved to an
@@ -11696,10 +11952,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11696
11952
  // managed gsd-* skill dirs at the old configDir-rooted location
11697
11953
  // (~/.codex/skills). Reinstalling here writes the new location but would
11698
11954
  // otherwise orphan the old one — clean up the stale gsd-* dirs.
11955
+ // #5161: only the ones GSD owns; any other gsd-* dir is kept and named.
11956
+ // The manifest leg stays off: codex records its skills under `skills/`
11957
+ // whichever root they sit in, so a name the manifest recorded for the new
11958
+ // home would otherwise claim a same-named user dir at this old root that
11959
+ // GSD never wrote.
11699
11960
  {
11700
11961
  const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
11701
11962
  if (_movedOldSkillsDir) {
11702
- const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
11963
+ const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-',
11964
+ createSkillDirOwnership(runtime, targetDir, _movedOldSkillsDir, 'gsd-', { includeManifest: false }));
11703
11965
  if (migrated > 0) {
11704
11966
  console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
11705
11967
  }
@@ -11713,8 +11975,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11713
11975
  // Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
11714
11976
  // hostBehaviors.legacyDevinSkillsCleanup (windsurf is the only runtime that
11715
11977
  // declares it, so this is byte-parity).
11978
+ // #5161: only the gsd-* dirs GSD owns. The local manifest describes
11979
+ // .windsurf/, never .devin/, so the manifest leg stays off here: a name it
11980
+ // records under one root says nothing about a same-named dir under another.
11716
11981
  if (hostBehaviorsFor(runtime).legacyDevinSkillsCleanup && !isGlobal) {
11717
- const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
11982
+ const _devinSkillsDir = path.join(process.cwd(), '.devin', 'skills');
11983
+ const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd(),
11984
+ createSkillDirOwnership(runtime, targetDir, _devinSkillsDir, 'gsd-', { includeManifest: false }));
11718
11985
  if (removedCount > 0) {
11719
11986
  console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
11720
11987
  }
@@ -11730,8 +11997,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11730
11997
  const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
11731
11998
  if (fs.existsSync(hermesSkillsDir)) {
11732
11999
  // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
11733
- const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true })
11734
- .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
12000
+ const count = _countOwnedSkillDirs(runtime, targetDir, hermesSkillsDir);
11735
12001
  if (count > 0) {
11736
12002
  console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`);
11737
12003
  } else {
@@ -11744,8 +12010,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11744
12010
  const skillsDir = path.join(targetDir, 'skills');
11745
12011
  const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
11746
12012
  if (fs.existsSync(skillsDir)) {
11747
- const count = fs.readdirSync(skillsDir, { withFileTypes: true })
11748
- .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
12013
+ const count = _countOwnedSkillDirs(runtime, targetDir, skillsDir);
11749
12014
  if (count > 0) {
11750
12015
  console.log(` ${green}✓${reset} Installed ${count} Kimi skills to skills/`);
11751
12016
  } else {
@@ -11784,8 +12049,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11784
12049
  } else {
11785
12050
  const skillsDir = _skillsRootDir;
11786
12051
  if (fs.existsSync(skillsDir)) {
11787
- const count = fs.readdirSync(skillsDir, { withFileTypes: true })
11788
- .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
12052
+ const count = _countOwnedSkillDirs(runtime, targetDir, skillsDir);
11789
12053
  if (count > 0) {
11790
12054
  console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
11791
12055
  } else {
@@ -11845,6 +12109,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11845
12109
  // command/ directories), so writing flat gsd-<cmd>.md files here would be
11846
12110
  // dead weight the extension never reads. Skip the flat-commands fallback
11847
12111
  // entirely for pluginOnlyInstall runtimes.
12112
+ // NOTE (#5547): pi now declares a skills artifactLayout, so it takes the
12113
+ // `_isSkillsRuntime` branch above; this branch is reached only if that layout
12114
+ // is ever emptied again.
11848
12115
  console.log(` ${green}✓${reset} pi: /gsd registered via native extension (no declarative command files)`);
11849
12116
  } else {
11850
12117
  // Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
@@ -11921,28 +12188,40 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11921
12188
  }
11922
12189
  }
11923
12190
 
11924
- // Clean up any stale skills/ from a previous local install
12191
+ // Clean up any stale skills/ from a previous local install. #5161: only
12192
+ // dirs GSD owns — .claude/skills/ is also where a project keeps its own
12193
+ // skills, so a user's gsd-* dir there is preserved and named instead.
11925
12194
  const staleSkillsDir = path.join(targetDir, 'skills');
11926
12195
  if (fs.existsSync(staleSkillsDir)) {
11927
- const staleGsd = fs.readdirSync(staleSkillsDir, { withFileTypes: true })
11928
- .filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
12196
+ // Decide ownership once, BEFORE deleting: the capability-marker leg reads
12197
+ // the dir itself, so asking again afterwards reports a deleted dir as kept.
12198
+ // A symlink is never removed here (as before) but is named when unowned.
12199
+ const ownsSkillDir = createSkillDirOwnership(runtime, targetDir, staleSkillsDir, 'gsd-');
12200
+ const prefixed = fs.readdirSync(staleSkillsDir, { withFileTypes: true })
12201
+ .filter(e => (e.isDirectory() || e.isSymbolicLink()) && e.name.startsWith('gsd-'))
12202
+ .map(e => ({ name: e.name, isDir: e.isDirectory(), owned: ownsSkillDir(e.name) }));
12203
+ // gsd-dev-preferences is user content even when an old manifest records
12204
+ // it, and nothing here restores it (#2973), so it is never removed.
12205
+ const staleGsd = prefixed.filter(e => e.isDir && e.owned && !USER_OWNED_SKILL_DIRS.includes(e.name));
11929
12206
  for (const e of staleGsd) {
11930
12207
  fs.rmSync(path.join(staleSkillsDir, e.name), { recursive: true });
11931
12208
  }
11932
12209
  if (staleGsd.length > 0) {
11933
12210
  console.log(` ${green}✓${reset} Removed ${staleGsd.length} stale GSD skill(s) from skills/`);
11934
12211
  }
12212
+ _warnPreservedSkillDirs(staleSkillsDir, prefixed.filter(e => !e.owned).map(e => e.name));
11935
12213
  }
11936
12214
  }
11937
12215
 
11938
12216
  // Native-extension/plugin staging for runtimes OUTSIDE the layout-driven
11939
- // _isSkillsRuntime branch above (ADR-1239 / #2102 Stage 1: pi). OpenCode/Kilo
12217
+ // _isSkillsRuntime branch above (ADR-1239 / #2102 Stage 1). OpenCode/Kilo
11940
12218
  // already get their nativePlugin file from installOpencodeFamilyArtifacts
11941
12219
  // (called inside the _isSkillsRuntime branch, since both declare a non-empty
11942
12220
  // artifactLayout) — guard on `!_isSkillsRuntime` so this standalone call never
11943
- // double-stages their plugin file. A runtime like pi, whose artifactLayout is
11944
- // intentionally empty for both scopes (`_isSkillsRuntime` is false), still
11945
- // needs its declared hostBehaviors.nativePlugin file copied into targetDir.
12221
+ // double-stages their plugin file. pi (#5547) now declares a skills layout, so
12222
+ // its extension is staged by installRuntimeArtifacts' own native-plugin
12223
+ // post-step; this standalone call remains for any runtime that declares a
12224
+ // nativePlugin with an intentionally empty artifactLayout.
11946
12225
  if (!_isSkillsRuntime && hostBehaviorsFor(runtime).nativePlugin) {
11947
12226
  _installNativePluginIfDeclared(runtime, targetDir, hostBehaviorsFor(runtime), src);
11948
12227
  }
@@ -12025,10 +12304,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12025
12304
  // here explicitly to cover them (install-engine.cts's own doc comment
12026
12305
  // explains why; a regression here was caught by the install-tree golden
12027
12306
  // fixture, tests/fixtures/install-tree/claude-local.json). It is a no-op
12028
- // for pi: its capability.json declares an EMPTY artifactLayout for both
12029
- // scopes (programmatic dispatch, no named-dispatch subagent toolkit, no
12030
- // host-read markdown surface), so the resolved layout has no `agents` kind
12031
- // to stage and the function returns `null` without writing anything.
12307
+ // for pi: its capability.json declares a skills-only artifactLayout for both
12308
+ // scopes (#5547; programmatic dispatch, no named-dispatch subagent toolkit),
12309
+ // so the resolved layout has no `agents` kind to stage and the function
12310
+ // returns `null` without writing anything.
12032
12311
  if (hostBehaviorsFor(runtime).pluginOnlyInstall) {
12033
12312
  console.log(` ${green}✓${reset} pi: no subagent files (programmatic dispatch, no named-dispatch toolkit)`);
12034
12313
  } else if (_isSkillsRuntime) {
@@ -12064,7 +12343,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12064
12343
  // advertising the old full agent surface even though the descriptor-driven
12065
12344
  // write above already skipped writing the .md files for a minimal-tier
12066
12345
  // resolvedProfile. Reuse the same helper that powers `--uninstall`.
12067
- if (isMinimalMode(_effectiveInstallMode) && hostBehaviorsFor(runtime).tomlConfigInstall) {
12346
+ if (!_codexAgentRolesEnabled() && hostBehaviorsFor(runtime).tomlConfigInstall) {
12068
12347
  const codexConfigPath = path.join(targetDir, 'config.toml');
12069
12348
  if (fs.existsSync(codexConfigPath)) {
12070
12349
  const existing = fs.readFileSync(codexConfigPath, 'utf8');
@@ -12473,6 +12752,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12473
12752
  }
12474
12753
  }
12475
12754
 
12755
+ // #5522: keep the user's copy of any baseline-preserved file the writers replaced (the update path's
12756
+ // gsd-local-patches/ backup), then report the migrations with those paths no longer listed as preserved.
12757
+ const replacedBaselineFiles = backUpReplacedBaselineFiles(targetDir, baselinePreservedSnapshots);
12758
+ for (const { relPath, backupRel } of replacedBaselineFiles) {
12759
+ console.log(` ${yellow}i${reset} ${relPath} was replaced by the install (it has the name of a shipped GSD file) — your copy is backed up to ${PATCHES_DIR_NAME}/${backupRel}`);
12760
+ }
12761
+
12476
12762
  if (failures.length > 0) {
12477
12763
  console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
12478
12764
  process.exit(1);
@@ -12605,6 +12891,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12605
12891
  const fileName = path.basename(relPath);
12606
12892
  if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
12607
12893
  if (fileName === 'CHANGELOG.md') continue;
12894
+ // #5547: the Runtime Surface corpus is the package's canonical source
12895
+ // bytes, kept byte-identical so a refresh can restore them
12896
+ // (tests/install-runtime-artifacts.test.cjs); the converted artifacts
12897
+ // are what the host reads. Counting the source form as a "leak" made
12898
+ // every skills-runtime install print a warning about files nothing reads.
12899
+ // Windsurf is the exception: its workflow wrappers delegate to the
12900
+ // path-rewritten bodies at gsd-core/commands/gsd/ (#1629), so there they
12901
+ // ARE host-read artifacts and stay in the scan.
12902
+ const posixRelPath = relPath.replace(/\\/g, '/');
12903
+ if (!hostBehaviorsFor(runtime).installsCommandBodiesForWorkflowDelegation
12904
+ && (posixRelPath.startsWith('gsd-core/commands/') || posixRelPath.startsWith('gsd-core/agents/'))) continue;
12608
12905
  const fullPath = path.join(targetDir, relPath);
12609
12906
  let content;
12610
12907
  try {
@@ -12667,9 +12964,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12667
12964
  // #2695: this branch runs for BOTH `core` (minimal) and `full` profiles.
12668
12965
  // Hooks are lightweight infrastructure (update-check + context monitor), not the
12669
12966
  // "full agent surface" that `core` deliberately omits. The config.toml / agent
12670
- // generation below is still gated by its own inner `!isMinimalMode` guard, so
12671
- // `core` enters the branch to receive the hook-file copy + hooks.json wiring but
12672
- // does NOT get agent roles generated. Before #2695 the outer `!isMinimalMode`
12967
+ // generation below is gated by its own inner `_codexAgentRolesEnabled()` guard (#5503:
12968
+ // it used to be `!isMinimalMode`, which kept `core` from registering the agents its
12969
+ // skills spawn), so `core` enters the branch to receive the hook-file copy + hooks.json
12970
+ // wiring AND the agent roles its own skills spawn. Before #2695 the outer `!isMinimalMode`
12673
12971
  // here skipped the whole branch for `core`, so the registered parent hook pointed
12674
12972
  // at a worker/registry the same installer never delivered.
12675
12973
  if (plan.installSurface === 'codex-toml') {
@@ -12840,7 +13138,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12840
13138
  };
12841
13139
 
12842
13140
  let agentCount = 0;
12843
- if (!isMinimalMode(_effectiveInstallMode)) {
13141
+ if (_codexAgentRolesEnabled()) {
12844
13142
  // #2834: write ~/.gsd/defaults.json (resolve_model_ids + runtime) BEFORE generating
12845
13143
  // agent TOMLs — installCodexConfig reads defaults.json at generation time, so on a
12846
13144
  // clean first install the runtime-aware model resolver must already know the runtime.
@@ -12859,7 +13157,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
12859
13157
  // not include agents/gsd-*.toml because those files did not yet exist.
12860
13158
  writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
12861
13159
  } else {
12862
- console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
13160
+ console.log(` ${dim}↳${reset} Skipping Codex agent config generation (${_resolvedProfile.name} profile stages no agents)`);
12863
13161
  }
12864
13162
 
12865
13163
  // Copy only the hook files that Codex actually registers via its hook configuration (#2153).
@@ -13058,7 +13356,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13058
13356
  // existing config; the snapshot restore below is a second line of
13059
13357
  // defense if even the rename fails.
13060
13358
  try {
13061
- atomicWriteFileSync(configPath, configContent, 'utf-8');
13359
+ // #5301: write through an opted-in symlink instead of replacing it.
13360
+ atomicWriteFileSync(resolveSymlinkWriteTarget(configPath), configContent, 'utf-8');
13062
13361
  } catch (writeErr) {
13063
13362
  // #2760 CR4 finding 1 — write failure must be loud and fatal. Wrap
13064
13363
  // with a `post-write` prefix the outer catch recognises so install
@@ -13664,10 +13963,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13664
13963
  // bare basename so an unrelated user command that merely mentions the same
13665
13964
  // filename can't false-positive into GSD's validated set.
13666
13965
  configuredEntrypoints.push(
13667
- ...settingsEntrypoints.filter(entry => {
13668
- const hooksSegment = '/hooks/' + path.basename(entry.scriptPath);
13669
- return registeredHookCommands.some(command => command.includes(hooksSegment));
13670
- }),
13966
+ ...selectConfiguredEntrypointsForValidation(settingsEntrypoints, registeredHookCommands),
13671
13967
  );
13672
13968
  const statuslineEntrypoints = settingsEntrypoints.filter(entry => entry.command === statuslineCommand);
13673
13969
  const updateBannerEntrypoints = settingsEntrypoints.filter(entry => entry.command === updateBannerCommand);
@@ -13763,6 +14059,25 @@ function describeEntrypointConsequence(invalidRuntime) {
13763
14059
  return 'not persisted: this runtime writes its config after this check';
13764
14060
  }
13765
14061
 
14062
+ // #5100: select which tracked entrypoints reach the validation gate. A named
14063
+ // export (see module.exports) so tests can drive the fresh-vs-upgrade split
14064
+ // directly; the install() call site is covered by a child-process test that
14065
+ // runs the installer with win32 emulated.
14066
+ function selectConfiguredEntrypointsForValidation(settingsEntrypoints, registeredHookCommands) {
14067
+ return (settingsEntrypoints || []).filter(entry => {
14068
+ // A command-less entry never produced a registered command, so the
14069
+ // segment match below can never keep it — but it is exactly the
14070
+ // unresolved-bash entry the gate must see (buildHookCommand pushes it
14071
+ // without a `command` when no Git Bash is found). Exempt it: without this
14072
+ // a fresh install silently drops it (gate passes) while an upgrade keeps
14073
+ // it via the persisted command (gate fails) — same host, opposite
14074
+ // verdicts. Surface it in both cases and let the gate decide loudly.
14075
+ if (entry.command === undefined) return true;
14076
+ const hooksSegment = '/hooks/' + path.basename(entry.scriptPath);
14077
+ return registeredHookCommands.some(command => command.includes(hooksSegment));
14078
+ });
14079
+ }
14080
+
13766
14081
  function assertConfiguredEntrypoints(entries) {
13767
14082
  // #4249: some writers push the same (configPath, scriptPath) pair more than
13768
14083
  // once (e.g. Kimi's context-monitor hook registered under several events,
@@ -14949,6 +15264,8 @@ module.exports = {
14949
15264
  install,
14950
15265
  installAllRuntimes,
14951
15266
  uninstall,
15267
+ // #5100 — installer entrypoint-selection seam, exported for direct tests
15268
+ selectConfiguredEntrypointsForValidation,
14952
15269
  // #3023 — shared hook bundle directory name, descriptor-driven
14953
15270
  SHARED_HOOKS_DIR_DEFAULT,
14954
15271
  resolveSharedHooksDirName,
@@ -15014,6 +15331,8 @@ module.exports = {
15014
15331
  convertClaudeAgentToClineAgent,
15015
15332
  // #2284 — Hermes named-dispatch → delegate_task projection
15016
15333
  convertClaudeToHermesMarkdown,
15334
+ convertClaudeToPiMarkdown,
15335
+ PI_DISPATCH_TOOL_CONFIG,
15017
15336
  projectNamedDispatchToStructuralDelegate,
15018
15337
  _hostIntegrationDispatch,
15019
15338
  _resolveAvailableGsdRoles,