@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
package/bin/install.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  projectPathActionProjection,
15
15
  projectPortableHookBaseDir,
16
16
  projectPersistentPathExportActions,
17
+ PATH_ACTION_REASON,
17
18
  projectShellCommandText,
18
19
  projectCodexHookTomlCommand,
19
20
  shellHookOmitsBashRunner,
@@ -33,6 +34,7 @@ const {
33
34
  getGlobalConfigDir,
34
35
  getGlobalSkillsBase,
35
36
  resolveKimiHooksTomlDir,
37
+ isRegisteredRuntimeId,
36
38
  } = require('../gsd-core/bin/lib/runtime-homes.cjs');
37
39
  // getDirName (runtime -> local config dir name) is relocated out of this
38
40
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
@@ -45,7 +47,18 @@ const {
45
47
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
46
48
  const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
47
49
  const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
50
+ // #2930 (epic #1671 Phase 3): strips `<!-- gsd:section -->` markers from
51
+ // workflow .md content at emit time, before any per-runtime rewrite runs.
52
+ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
53
+ // #3072: THE shared composition-scope predicate (also consumed by the served
54
+ // MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
55
+ const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
48
56
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
57
+ // #2544: the CommonJS marker's single source of truth. classifyMarker() backs
58
+ // BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
59
+ // so the write side can no longer clobber a package.json the remove side would
60
+ // correctly refuse to delete.
61
+ const { ensureCommonJsMarker, removeCommonJsMarker } = require('../gsd-core/bin/lib/commonjs-marker.cjs');
49
62
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
50
63
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
51
64
  // the manifest and the installed hooks/ dir structurally identical. Avoids the
@@ -333,6 +346,68 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
333
346
  // that does receive hooks/lib.
334
347
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
335
348
 
349
+ /**
350
+ * Directory name GSD stages its shared hook bundle under, inside a runtime's
351
+ * install root. Defaults to 'hooks' — the name every runtime used before #3023.
352
+ *
353
+ * pi (pi.dev) reserves `hooks/` as its own now-deprecated extension location and
354
+ * prints a migration warning on every startup when one exists, so pi overrides
355
+ * this via hostBehaviors.sharedHooksDirName. Following pi's advised remediation
356
+ * (move it into extensions/) would break the adapter's path resolution AND expose
357
+ * GSD's .js helpers to pi's extension auto-discovery, so the bundle is renamed in
358
+ * place instead — same depth, so every `__dirname/..`-relative resolution inside
359
+ * the bundle (e.g. hooks/gsd-context-monitor.js reaching ../gsd-core/bin/) keeps
360
+ * working.
361
+ */
362
+ const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
363
+
364
+ /**
365
+ * Resolve a runtime's shared-hooks directory name from its descriptor.
366
+ *
367
+ * The value is a single path SEGMENT. This string is joined onto a user's config
368
+ * root and then written to and recursively read, so anything that is not a plain,
369
+ * non-empty, separator-free, non-dot segment is rejected back to the default —
370
+ * a descriptor typo must never let the installer write outside the install root.
371
+ *
372
+ * The "non-dot" part of that contract is enforced beyond the literal '.' / '..'
373
+ * segments: an all-dot (or dot-and-whitespace-only) segment is rejected as a
374
+ * meaningless name, a segment with a trailing dot or space is rejected because
375
+ * Windows silently strips it at directory-creation time (which would split the
376
+ * name the installer creates from the name callers probe for), and a Windows
377
+ * reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9, with or without an
378
+ * extension) is rejected because it cannot exist as a directory on Windows at
379
+ * all. These checks are unconditional on every platform: the descriptor is
380
+ * authored once and shipped everywhere, so a value invalid on Windows must be
381
+ * rejected identically on Linux/macOS, or the install and its fixtures disagree
382
+ * cross-platform.
383
+ *
384
+ * @param {string} runtime
385
+ * @returns {string}
386
+ */
387
+ function resolveSharedHooksDirName(runtime) {
388
+ const raw = _hostBehaviors(runtime).sharedHooksDirName;
389
+ if (typeof raw !== 'string') return SHARED_HOOKS_DIR_DEFAULT;
390
+ const name = raw.trim();
391
+ if (name === '') return SHARED_HOOKS_DIR_DEFAULT;
392
+ if (name === '.' || name === '..') return SHARED_HOOKS_DIR_DEFAULT;
393
+ // All-dot or dot+whitespace segments ('...', '. .') are not meaningful
394
+ // directory names and are almost certainly a descriptor typo.
395
+ if (name.replace(/[.\s]/g, '') === '') return SHARED_HOOKS_DIR_DEFAULT;
396
+ // Windows silently strips a trailing dot or space at creation time, so the
397
+ // directory the installer creates would not match the name the adapter
398
+ // probes for — a split-brain that only reproduces off-Linux.
399
+ if (/[. ]$/.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
400
+ // Windows reserved device names cannot exist as directories.
401
+ if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
402
+ if (name.includes('/') || name.includes('\\')) return SHARED_HOOKS_DIR_DEFAULT;
403
+ // Belt-and-braces: reject anything path.basename() would reduce, and any
404
+ // Windows drive/UNC-flavoured value.
405
+ if (path.basename(name) !== name) return SHARED_HOOKS_DIR_DEFAULT;
406
+ if (path.isAbsolute(name)) return SHARED_HOOKS_DIR_DEFAULT;
407
+ if (name.includes('\0')) return SHARED_HOOKS_DIR_DEFAULT;
408
+ return name;
409
+ }
410
+
336
411
  const CODEX_AGENT_SANDBOX = {
337
412
  'gsd-executor': 'workspace-write',
338
413
  'gsd-planner': 'workspace-write',
@@ -902,7 +977,7 @@ if (hasUninstall) {
902
977
 
903
978
  // Show help if requested
904
979
  if (hasHelp) {
905
- console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
980
+ console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
906
981
  process.exit(0);
907
982
  }
908
983
 
@@ -952,6 +1027,31 @@ const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.
952
1027
  const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
953
1028
  const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
954
1029
  const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
1030
+ // #2931 (ADR-1508): the windsurf converter family is single-sourced in the
1031
+ // conversion module, same pattern as the #1675 Augment dedup above. install.js
1032
+ // re-binds (does not re-define) these so there is exactly one body — the
1033
+ // generative-drift hazard the dedup removes. The two private helpers
1034
+ // (getWindsurfSkillAdapterHeader, convertSlashCommandsToWindsurfSkillMentions)
1035
+ // live only in the conversion module now; they are no longer duplicated here.
1036
+ // The reference-identity parity guard lives in
1037
+ // tests/install-runtime-artifacts.test.cjs (single-owner reference-identity
1038
+ // guard describe block), not tests/enh-1511-rewrite-engine-relocation.test.cjs
1039
+ // as the Augment comment above stated — that reference was stale.
1040
+ // (All call sites are below this line → no TDZ hazard.)
1041
+ const convertClaudeToWindsurfMarkdown = runtimeArtifactConversion.convertClaudeToWindsurfMarkdown;
1042
+ const convertClaudeCommandToWindsurfSkill = runtimeArtifactConversion.convertClaudeCommandToWindsurfSkill;
1043
+ const convertClaudeCommandToWindsurfWorkflow = runtimeArtifactConversion.convertClaudeCommandToWindsurfWorkflow;
1044
+ const convertClaudeAgentToWindsurfAgent = runtimeArtifactConversion.convertClaudeAgentToWindsurfAgent;
1045
+ // #2931 (ADR-1508): single-sourced in the conversion module — was a second,
1046
+ // unlinked verbatim copy here (used by the local Cursor/Trae/CodeBuddy/Cline
1047
+ // converters below), the exact drift class this PR exists to reduce. Verified
1048
+ // behaviorally identical (no block / one block / adjacent blocks / whole-
1049
+ // content block / unclosed opening tag / nested-looking tags / repeated
1050
+ // sequential calls for global-regex lastIndex leakage) before merging.
1051
+ // install.js re-binds (does not re-define) — RUNTIME_COMPATIBILITY_BLOCK_RE
1052
+ // is no longer duplicated here either. (All call sites are below this line
1053
+ // → no TDZ hazard.)
1054
+ const applyClaudeCodeBrandSwap = runtimeArtifactConversion.applyClaudeCodeBrandSwap;
955
1055
 
956
1056
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
957
1057
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
@@ -1947,7 +2047,9 @@ function skillFrontmatterName(skillDirName) {
1947
2047
  }
1948
2048
 
1949
2049
  function normalizeClaudeSkillEffort(effort) {
1950
- return effort === 'xhigh' ? 'max' : effort;
2050
+ // #3039: `max` is rejected by Anthropic models when extended thinking is disabled.
2051
+ if (effort === 'xhigh' || effort === 'max') return 'high';
2052
+ return effort;
1951
2053
  }
1952
2054
 
1953
2055
  /**
@@ -2501,56 +2603,6 @@ function extractFrontmatterField(frontmatter, fieldName) {
2501
2603
  return match[1].trim().replace(/^['"]|['"]$/g, '');
2502
2604
  }
2503
2605
 
2504
- // #2284 finding (b): the `<runtime_compatibility>` block appearing in
2505
- // gsd-core/workflows/{plan-phase,execute-phase}.md is a runtime-COMPARISON
2506
- // table ("**Claude Code:** Uses `Agent(...)`" / "a backgrounded Claude Code
2507
- // agent" / "top-level Claude Code") — every "Claude Code" mention inside it
2508
- // is a COMPARED-RUNTIME LABEL, not a host self-reference. The brand swap
2509
- // below (`Claude Code` → the installing runtime's own display name) is
2510
- // meant only for host self-references; applying it inside this block
2511
- // mislabels the comparison (e.g. Windsurf installs would read "**Windsurf:**
2512
- // Uses `Agent(...)`" describing what is actually Claude Code's behavior).
2513
- // This is cross-cutting across every runtime that brand-swaps workflow
2514
- // content (cursor/windsurf/trae/cline/codebuddy hardcoded; qwen/hermes
2515
- // descriptor-driven via hostBehaviors.brandingRewrites) — confirmed to
2516
- // reproduce on unmodified Windsurf, not Hermes-specific.
2517
- const RUNTIME_COMPATIBILITY_BLOCK_RE = /<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g;
2518
-
2519
- /**
2520
- * Rewrite bare "Claude Code" self-references in workflow content to
2521
- * `brandName`, EXCEPT inside `<runtime_compatibility>...</runtime_compatibility>`
2522
- * blocks, which are left byte-for-byte verbatim. Every other content
2523
- * transform in a runtime's `.md` converter (tool-name renames, path
2524
- * rewrites, etc.) is unaffected — only this literal brand-name swap is
2525
- * protected-region-aware, since only it risks mislabeling a
2526
- * runtime-comparison table.
2527
- *
2528
- * Implementation: SPLIT `content` on the protected-block regex, brand-swap
2529
- * only the GAP text between (and around) matches, then rejoin gap+block
2530
- * alternately. No placeholder/sentinel token of any kind is substituted in
2531
- * — a prior version used a sentinel-token mask/restore, which is exactly the
2532
- * kind of invisible landmine this rewrite eliminates (a sentinel string, no
2533
- * matter how obscure, is a theoretical collision risk with real content and
2534
- * is easy to silently reintroduce in a future edit without it showing in a
2535
- * diff). Behavior-identical to the removed sentinel-token version — verified
2536
- * via `npm run gen:golden` producing zero further diff.
2537
- */
2538
- function applyClaudeCodeBrandSwap(content, brandName) {
2539
- if (!brandName) return content;
2540
- let result = '';
2541
- let lastIndex = 0;
2542
- RUNTIME_COMPATIBILITY_BLOCK_RE.lastIndex = 0; // reset shared global-regex state before each use
2543
- let m;
2544
- while ((m = RUNTIME_COMPATIBILITY_BLOCK_RE.exec(content))) {
2545
- const gap = content.slice(lastIndex, m.index);
2546
- result += gap.replace(/\bClaude Code\b/g, brandName);
2547
- result += m[0]; // protected block, verbatim — never brand-swapped
2548
- lastIndex = m.index + m[0].length;
2549
- }
2550
- result += content.slice(lastIndex).replace(/\bClaude Code\b/g, brandName);
2551
- return result;
2552
- }
2553
-
2554
2606
  // Tool name mapping from Claude Code to Cursor CLI
2555
2607
  const claudeToCursorTools = {
2556
2608
  Bash: 'Shell',
@@ -2629,36 +2681,11 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2629
2681
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2630
2682
  const adapter = getCursorSkillAdapterHeader(skillName);
2631
2683
 
2632
- // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
2633
- // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
2634
- // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
2635
- // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
2636
- // stay model-invocable background knowledge. (user-invocable:false hides from
2637
- // '/' while keeping model invocation — distinct from disable-model-invocation.)
2638
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
2639
- }
2640
-
2641
- /**
2642
- * Convert a Claude Code command to a Cursor 1.6 slash command (#785).
2643
- *
2644
- * Cursor slash commands live in `.cursor/commands/<name>.md` and are
2645
- * plain markdown — no YAML frontmatter, no adapter header. The filename
2646
- * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`).
2647
- *
2648
- * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill
2649
- * converter (tool renames, brand substitution, slash-command normalisation),
2650
- * then strips the YAML frontmatter block so only the prose body remains.
2651
- *
2652
- * @param {string} content raw Claude Code command markdown (may have frontmatter)
2653
- * @param {string} _commandName the target command name (unused; present for
2654
- * API symmetry with other converters so the runtime-artifact-layout stage
2655
- * function can call it uniformly)
2656
- * @returns {string} plain markdown body, no frontmatter
2657
- */
2658
- function convertClaudeCommandToCursorCommand(content, _commandName) {
2659
- const converted = convertClaudeToCursorMarkdown(content);
2660
- const { body } = extractFrontmatterAndBody(converted);
2661
- return body.trimStart();
2684
+ // Cursor skills are both slash-invocable and model-invocable. Do not emit the
2685
+ // unsupported `user-invocable` field: it is ignored by Cursor and previously
2686
+ // hid the real cause of duplicate entries, the parallel commands/ surface
2687
+ // retired in #2644.
2688
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2662
2689
  }
2663
2690
 
2664
2691
  /**
@@ -2681,142 +2708,16 @@ function convertClaudeAgentToCursorAgent(content) {
2681
2708
  }
2682
2709
 
2683
2710
  // --- Windsurf converters ---
2684
- // Windsurf uses a tool set similar to Cursor.
2685
- // Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
2686
-
2687
- // Tool name mapping from Claude Code to Windsurf Cascade
2688
- const claudeToWindsurfTools = {
2689
- Bash: 'Shell',
2690
- Edit: 'StrReplace',
2691
- AskUserQuestion: null, // No direct equivalent — use conversational prompting
2692
- SlashCommand: null, // No equivalent — skills are auto-discovered
2693
- };
2694
-
2695
- function convertSlashCommandsToWindsurfSkillMentions(content) {
2696
- // Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
2697
- return content.replace(/gsd:/gi, 'gsd-');
2698
- }
2699
-
2700
- function convertClaudeToWindsurfMarkdown(content) {
2701
- let converted = convertSlashCommandsToWindsurfSkillMentions(content);
2702
- // Replace tool name references in body text
2703
- converted = converted.replace(/\bBash\(/g, 'Shell(');
2704
- converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
2705
- converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
2706
- // Replace subagent_type from Claude to Windsurf format
2707
- converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2708
- converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2709
- // Replace project-level Claude conventions with Windsurf equivalents.
2710
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
2711
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
2712
- converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
2713
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
2714
- converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
2715
- converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
2716
- converted = converted.replace(/\.claude\//g, '.windsurf/');
2717
- // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2718
- // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2719
- converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
2720
- converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
2721
- // Environment variable name rewrite
2722
- converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
2723
- // Remove Claude Code-specific bug workarounds before brand replacement
2724
- converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2725
- converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2726
- // Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips
2727
- // <runtime_compatibility> comparison-table content (protected region).
2728
- converted = applyClaudeCodeBrandSwap(converted, 'Windsurf');
2729
- return converted;
2730
- }
2731
-
2732
- function getWindsurfSkillAdapterHeader(skillName) {
2733
- return `<windsurf_skill_adapter>
2734
- ## A. Skill Invocation
2735
- - This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
2736
- - Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
2737
- - If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
2738
-
2739
- ## B. User Prompting
2740
- When the workflow needs user input, prompt the user conversationally:
2741
- - Present options as a numbered list in your response text
2742
- - Ask the user to reply with their choice
2743
- - For multi-select, ask for comma-separated numbers
2744
-
2745
- ## C. Tool Usage
2746
- Use these Windsurf tools when executing GSD workflows:
2747
- - \`Shell\` for running commands (terminal operations)
2748
- - \`StrReplace\` for editing existing files
2749
- - \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed
2750
-
2751
- ## D. Subagent Spawning
2752
- When the workflow needs to spawn a subagent:
2753
- - Use \`Task(subagent_type="generalPurpose", ...)\`
2754
- - The \`model\` parameter maps to Windsurf's model options (e.g., "fast")
2755
- </windsurf_skill_adapter>`;
2756
- }
2757
-
2758
- function convertClaudeCommandToWindsurfSkill(content, skillName) {
2759
- const converted = convertClaudeToWindsurfMarkdown(content);
2760
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2761
- let description = `Run GSD workflow ${skillName}.`;
2762
- if (frontmatter) {
2763
- const maybeDescription = extractFrontmatterField(frontmatter, 'description');
2764
- if (maybeDescription) {
2765
- description = maybeDescription;
2766
- }
2767
- }
2768
- description = toSingleLine(description);
2769
- const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2770
- const adapter = getWindsurfSkillAdapterHeader(skillName);
2771
-
2772
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2773
- }
2774
-
2775
- function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
2776
- // #1615 security: commandName flows unsanitized into a markdown body that
2777
- // Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
2778
- // (a) prompt injection via newlines / markdown structure in the filename,
2779
- // (b) path-component injection via .., /, \ in stem → @-reference target.
2780
- // Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
2781
- // everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
2782
- // PR #1622 security review.
2783
- if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
2784
- const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
2785
- throw new Error(
2786
- `convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
2787
- 'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
2788
- );
2789
- }
2790
- const converted = convertClaudeToWindsurfMarkdown(content);
2791
- const { frontmatter } = extractFrontmatterAndBody(converted);
2792
- const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
2793
- const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
2794
- const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
2795
- const byteLength = Buffer.byteLength(workflow, 'utf8');
2796
- if (byteLength > 12000) {
2797
- throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
2798
- }
2799
- return workflow;
2800
- }
2801
-
2802
- /**
2803
- * Convert Claude Code agent markdown to Windsurf agent format.
2804
- * Strips frontmatter fields Windsurf doesn't support (color, skills),
2805
- * converts tool references, and adds a role context header.
2806
- */
2807
- function convertClaudeAgentToWindsurfAgent(content) {
2808
- let converted = convertClaudeToWindsurfMarkdown(content);
2809
-
2810
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2811
- if (!frontmatter) return converted;
2812
-
2813
- const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
2814
- const description = extractFrontmatterField(frontmatter, 'description') || '';
2815
-
2816
- const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
2817
-
2818
- return `${cleanFrontmatter}\n${body}`;
2819
- }
2711
+ // #2931 (ADR-1508): single-sourced in runtimeArtifactConversion, bound near
2712
+ // the top of this file alongside the #1675 Augment family. This block
2713
+ // previously carried byte-identical local duplicates of
2714
+ // convertSlashCommandsToWindsurfSkillMentions, convertClaudeToWindsurfMarkdown,
2715
+ // getWindsurfSkillAdapterHeader, convertClaudeCommandToWindsurfSkill,
2716
+ // convertClaudeCommandToWindsurfWorkflow, and convertClaudeAgentToWindsurfAgent,
2717
+ // plus an unused claudeToWindsurfTools table. Deleted here; the two
2718
+ // unexported helpers (getWindsurfSkillAdapterHeader,
2719
+ // convertSlashCommandsToWindsurfSkillMentions) now live only in the
2720
+ // conversion module, with no other caller in this file.
2820
2721
 
2821
2722
  // --- Augment converters ---
2822
2723
  // Augment uses a tool set similar to Cursor/Windsurf.
@@ -2844,10 +2745,55 @@ function convertClaudeToTraeMarkdown(content) {
2844
2745
  // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task"
2845
2746
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"');
2846
2747
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2847
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`');
2848
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/');
2849
- converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`');
2850
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/');
2748
+ // #2658: full-path forms (with a leading dot-claude-slash prefix) MUST be
2749
+ // replaced before the bare Claude-instruction-file pattern and before the
2750
+ // generic dot-claude-slash rewrite below — otherwise the bare pattern
2751
+ // consumes only the instruction-filename tail, leaving that prefix stale
2752
+ // in place, and the generic rewrite then mutates the stale leftover too,
2753
+ // producing a doubled trae-prefix segment ahead of the rules path instead
2754
+ // of a single clean one. (Deliberately never spelling the instruction
2755
+ // filename as one contiguous "CLAUDE" + dot + "md" token, and never
2756
+ // spelling either malformed shape out as a literal contiguous string, in
2757
+ // ANY comment in this function: this file ships verbatim into local
2758
+ // `--trae` installs, where it is itself run through this same class of
2759
+ // find/replace — a literal instruction-filename token sitting in a
2760
+ // comment gets "fixed" right along with real code, and the emitted-content
2761
+ // regression test added alongside this fix asserts neither malformed
2762
+ // shape appears anywhere in the installed tree, comments included; this
2763
+ // bit the fix itself twice during development.) All forms converge on the
2764
+ // same concrete file (never a bare directory) so this stays in parity
2765
+ // with the `trae.js` RUNTIME_CONTENT_DISPATCH entry.
2766
+ converted = converted.replace(/`\.\/\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2767
+ converted = converted.replace(/\.\/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
2768
+ converted = converted.replace(/`\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2769
+ converted = converted.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
2770
+ // #2658 (found via the end-to-end install regression test, not the static
2771
+ // trace above): `copyWithPathReplacement` runs a GENERIC dot-claude-slash
2772
+ // -> runtime-config-dir rewrite on every .md file before calling this
2773
+ // converter — for `~/.claude/`, `$HOME/.claude/`, AND `./.claude/` alike —
2774
+ // substituting a runtime-appropriate `pathPrefix` this function is never
2775
+ // given and cannot itself compute (it differs per install invocation: a
2776
+ // relative `./.trae/` for a project-local install, an arbitrary absolute
2777
+ // path for a local install rooted elsewhere, `~/.trae/` for a global one).
2778
+ // So for source using any of those prefixed forms, the patterns above
2779
+ // never fire here — this converter only ever sees the ALREADY-rewritten
2780
+ // "<runtime-config-dir>/" + instruction-filename shape, with whatever
2781
+ // prefix the install actually used. The generic pattern below preserves
2782
+ // that prefix verbatim (via the capture group) and only fixes the
2783
+ // filename suffix, rather than assuming a fixed `./.trae/` shape — a
2784
+ // narrower fixed-prefix version of this pattern shipped first and still
2785
+ // left the doubled-prefix defect live for the `$HOME/.claude/` and
2786
+ // `~/.claude/` forms specifically (found the same way, one regression-test
2787
+ // run later). Scoped to a `.trae/` tail so it cannot also swallow the
2788
+ // unprefixed `./CLAUDE.md` form the very next pattern handles differently
2789
+ // (discarding the prefix entirely, not preserving it). Must run before
2790
+ // the bare pattern for the same consume-the-full-match-first reason.
2791
+ converted = converted.replace(/`([^\s`]*\.trae\/)CLAUDE\.md`/g, '`$1rules/rules.md`');
2792
+ converted = converted.replace(/([^\s`]*\.trae\/)CLAUDE\.md/g, '$1rules/rules.md');
2793
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2794
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/rules.md');
2795
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/rules.md`');
2796
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/rules.md');
2851
2797
  converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/');
2852
2798
  converted = converted.replace(/\.\/\.claude\//g, './.trae/');
2853
2799
  converted = converted.replace(/\.claude\//g, '.trae/');
@@ -4019,6 +3965,8 @@ Typed mapping (agent_type-capable schema only):
4019
3965
  inherited, or unsupported values; do not invent one-off effort literals in
4020
3966
  workflow prose.
4021
3967
  - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
3968
+ - \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task
3969
+ - \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement)
4022
3970
  - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
4023
3971
  but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
4024
3972
  \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
@@ -4055,11 +4003,13 @@ Spawn restriction:
4055
4003
  defaulting to inline execution.
4056
4004
 
4057
4005
  Parallel fan-out:
4058
- - Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
4006
+ - Spawn multiple agents → collect agent IDs → \`collaboration.wait_agent(timeout_ms=...)\` for each to complete
4007
+ - Do NOT use \`functions.wait(cell_id=...)\` — that is an unrelated exec-cell tool, not the collaboration wait
4059
4008
 
4060
4009
  Result parsing:
4061
4010
  - Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
4062
- - \`close_agent(id)\` after collecting results from each agent
4011
+ - \`close_agent(id)\` after collecting results — but only if \`close_agent\` is visible in the current
4012
+ tool schema (check via \`tool_search\` first, same schema-detection gate as \`spawn_agent\` above)
4063
4013
  </codex_skill_adapter>`;
4064
4014
  }
4065
4015
 
@@ -6445,17 +6395,40 @@ function mergeCodexConfig(configPath, gsdBlock) {
6445
6395
  const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
6446
6396
  const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
6447
6397
 
6448
- // Case 2: Has GSD marker — truncate and re-append
6398
+ // Case 2: Has GSD marker — preserve user content on BOTH sides, regenerate the GSD block.
6399
+ //
6400
+ // #2940: the marker delimits where GSD's OWN block begins, NOT where every post-marker byte
6401
+ // is GSD-owned. A fresh install writes the GSD block as the file's entire content, so any
6402
+ // settings the user or Codex CLI later adds ([model], [mcp_servers.*], [profiles.*]) land
6403
+ // AFTER the block. The previous truncate-to-marker logic discarded that trailing region on
6404
+ // every update, destroying user config. The fix routes the trailing region through the
6405
+ // existing AST-based `stripLeakedGsdCodexSections`, which removes GSD's own managed/leaked
6406
+ // sections (the bare [agents] table GSD regenerates, legacy [agents.gsd-*], [[agents]])
6407
+ // while preserving genuine user TOML — so #2406's de-dup still holds AND user content survives.
6449
6408
  if (markerIndex !== -1) {
6450
6409
  let before = existing.substring(0, markerIndex).trimEnd();
6451
6410
  if (before) {
6452
6411
  // Strip any GSD-managed sections that leaked above the marker from previous installs
6453
6412
  before = stripLeakedGsdCodexSections(before).trimEnd();
6454
-
6455
- atomicWriteFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
6456
- } else {
6457
- atomicWriteFileSync(configPath, normalizedGsdBlock + eol);
6458
6413
  }
6414
+ // Capture and preserve genuine user content AFTER the GSD-managed region. The whole
6415
+ // post-marker region is passed through stripLeakedGsdCodexSections: GSD's own previously-
6416
+ // emitted [agents] table (regenerated above as normalizedGsdBlock) and any leaked sections
6417
+ // are removed, while user tables ([model], [mcp_servers.*], [profiles.*]) are kept. The
6418
+ // marker comment line itself (and the optional codex_hooks ownership line right under it)
6419
+ // is GSD-owned and is stripped from the trailing region so it is not duplicated alongside
6420
+ // the freshly regenerated block.
6421
+ const rawAfter = existing.substring(markerIndex);
6422
+ const markerStripped = rawAfter
6423
+ .replace(GSD_CODEX_MARKER, '')
6424
+ .replace(/^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
6425
+ const afterUser = stripLeakedGsdCodexSections(markerStripped).trim();
6426
+
6427
+ const parts = [];
6428
+ if (before) parts.push(before);
6429
+ parts.push(normalizedGsdBlock);
6430
+ if (afterUser) parts.push(afterUser);
6431
+ atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
6459
6432
  return;
6460
6433
  }
6461
6434
 
@@ -6983,6 +6956,47 @@ function writeCopilotHookConfig(targetDir) {
6983
6956
  * Generate config.toml and per-agent .toml files for Codex.
6984
6957
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
6985
6958
  */
6959
+
6960
+ /**
6961
+ * #2834: Write ~/.gsd/defaults.json for non-Claude runtimes — sets
6962
+ * resolve_model_ids="omit" (so resolveModelInternal() returns '' instead of
6963
+ * Claude aliases the runtime can't resolve) and runtime=<runtime> (so
6964
+ * resolveRuntime() resolves correctly out of the box). MUST be called BEFORE
6965
+ * installCodexConfig (or any other step that reads defaults.json at generation
6966
+ * time), so a clean first install produces correctly-model-routed agent TOMLs.
6967
+ * No-op for Claude runtimes (Claude is the resolveRuntime fallback + has native
6968
+ * model aliases). Preserves an explicit `true` opt-in and existing values.
6969
+ */
6970
+ function writeNonClaudeDefaults(runtime) {
6971
+ if (_hostBehaviors(runtime).nativeModelAliases || process.env.GSD_TEST_MODE) return;
6972
+ const gsdDir = path.join(os.homedir(), '.gsd');
6973
+ const defaultsPath = path.join(gsdDir, 'defaults.json');
6974
+ try {
6975
+ fs.mkdirSync(gsdDir, { recursive: true });
6976
+ let defaults = {};
6977
+ try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
6978
+ if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
6979
+ defaults = {};
6980
+ }
6981
+ // Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
6982
+ const existing = defaults.resolve_model_ids;
6983
+ const shouldDefaultToOmit = existing !== true && existing !== 'omit';
6984
+ if (shouldDefaultToOmit) {
6985
+ defaults.resolve_model_ids = 'omit';
6986
+ fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6987
+ console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
6988
+ }
6989
+ // #2395: persist runtime for non-Claude runtimes.
6990
+ if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
6991
+ defaults.runtime = runtime;
6992
+ fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
6993
+ console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
6994
+ }
6995
+ } catch (e) {
6996
+ console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
6997
+ }
6998
+ }
6999
+
6986
7000
  function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
6987
7001
  // ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir.
6988
7002
  const configPath = assertDestWithinConfigHome(targetDir, 'config.toml');
@@ -7010,7 +7024,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7010
7024
  const codexGsdPath = `${path.resolve(targetDir, 'gsd-core').replace(/\\/g, '/')}/`;
7011
7025
 
7012
7026
  for (const file of agentEntries) {
7013
- let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
7027
+ const agentTomlSourcePath = path.join(agentsSrc, file);
7028
+ let content = fs.readFileSync(agentTomlSourcePath, 'utf8');
7029
+ // #2995 (epic #1671 Phase 6.4): Codex embeds each agent's prompt into a
7030
+ // per-agent `.toml`, reading the source .md independently of the inline
7031
+ // agent loop — a separate emission path that must strip gsd:section
7032
+ // markers too, or a marked agent ships its markers inside the TOML.
7033
+ // Found by the exhaustive per-runtime emission sweep in
7034
+ // tests/agent-fragments-emission.install.test.cjs, not by call-graph
7035
+ // analysis, which is why that guard is behavioral rather than structural.
7036
+ content = composeWorkflow(content, { sourcePath: agentTomlSourcePath });
7014
7037
  // Replace full .claude/gsd-core prefix so path resolves to the Codex
7015
7038
  // GSD install before generic .claude → .codex conversion rewrites it.
7016
7039
  content = content.replace(/~\/\.claude\/gsd-core\//g, codexGsdPath);
@@ -7627,7 +7650,18 @@ const RUNTIME_CONTENT_DISPATCH = {
7627
7650
  return `/gsd-${commandName}`;
7628
7651
  });
7629
7652
  content = content.replace(/\.claude\/skills\//g, '.trae/skills/');
7630
- content = content.replace(/CLAUDE\.md/g, '.trae/rules/');
7653
+ // #2658: the full dot-claude-slash-prefixed instruction-file path must
7654
+ // be replaced before the bare instruction-filename fallback, or the
7655
+ // bare regex only rewrites that filename and leaves the prefix stale
7656
+ // in place, producing a malformed doubled-prefix path (see the longer
7657
+ // note in convertClaudeToTraeMarkdown above — the instruction filename
7658
+ // and either malformed shape are deliberately never spelled out
7659
+ // contiguously here either, for the same reason: this file ships
7660
+ // verbatim). Both forms target the same concrete file (never a bare
7661
+ // directory), matching the `.md` converter (convertClaudeToTraeMarkdown)
7662
+ // so js/cjs and md content agree on one canonical path.
7663
+ content = content.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md');
7664
+ content = content.replace(/CLAUDE\.md/g, '.trae/rules/rules.md');
7631
7665
  content = content.replace(/\bClaude Code\b/g, 'Trae');
7632
7666
  return content;
7633
7667
  },
@@ -7745,6 +7779,42 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7745
7779
  // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
7746
7780
  // Skip generic replacement for Copilot/Antigravity — their converters handle all paths
7747
7781
  let content = fs.readFileSync(srcPath, 'utf8');
7782
+
7783
+ // #2930 (epic #1671 Phase 3): strip `<!-- gsd:section -->` markers
7784
+ // BEFORE any per-runtime rewrite so a `.claude/` -> `.windsurf/` regex
7785
+ // (or any other converter below) never reaches inside a marker
7786
+ // attribute and corrupts it. composeWorkflow is a no-op (byte-identical
7787
+ // return) for the 88+ workflows and every non-workflow .md that carries
7788
+ // no markers, and for a malformed marker it throws loudly naming
7789
+ // srcPath — never emit a half-composed workflow.
7790
+ //
7791
+ // Scoped to gsd-core/workflows/ ONLY (two independent reviewers,
7792
+ // chore/2930): copyWithPathReplacement is the emit path for every .md
7793
+ // under gsd-core/, skills/, and commands/ (see the three call sites),
7794
+ // not just workflows. A doc that merely DOCUMENTS the marker syntax
7795
+ // with an unfenced example (docs/reference/workflow-fragments.md is
7796
+ // the live instance of this class, though not under the install tree
7797
+ // today) would otherwise get silently mis-parsed as a real marker and
7798
+ // that line lossily dropped — a file class issue #2930 never scoped
7799
+ // to. Path is normalized UNCONDITIONALLY (backslash paths arrive on
7800
+ // Linux too — CONTEXT.md path-separator rule) and checked as a
7801
+ // path-segment match so the recursive descent (srcPath may be several
7802
+ // directory levels below gsd-core/workflows/) is still caught.
7803
+ //
7804
+ // #3072: the scoping predicate itself now lives in ONE place —
7805
+ // shouldCompose (src/mcp-catalog.cts, imported above) — rather than
7806
+ // being re-declared inline here. The MCP served catalog calls the
7807
+ // SAME function to decide what it composes vs serves verbatim, so this
7808
+ // install path and the catalog can never independently drift on what
7809
+ // gets composed (ADR-1671:309, DEFECT.GENERATIVE-FIX; the parity gate
7810
+ // is tests/mcp-catalog-parity.test.cjs). shouldCompose normalizes with
7811
+ // the identical unconditional `.replace(/\\/g, '/')` internally, so
7812
+ // this call is behavior-preserving byte-for-behavior with the regex it
7813
+ // replaces.
7814
+ if (shouldCompose(srcPath)) {
7815
+ content = composeWorkflow(content, { sourcePath: srcPath });
7816
+ }
7817
+
7748
7818
  if (!dispatch.mdSkipGenericRewrite) {
7749
7819
  const globalClaudeRegex = /~\/\.claude\//g;
7750
7820
  const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
@@ -8127,11 +8197,12 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8127
8197
 
8128
8198
  // 1a-kimi. Non-layout Kimi side-effect (#2095 EoS/kimi Upgrade 1): kimi's
8129
8199
  // native config.toml lives outside targetDir entirely (resolveKimiHooksTomlDir
8130
- // resolves ~/.kimi, a sibling of targetDir's ~/.config/agents), so its
8200
+ // resolves ~/.kimi for kimi and ~/.kimi-code for kimi-code (#2755), a sibling
8201
+ // of targetDir's ~/.config/agents), so its
8131
8202
  // cleanup can't be driven by anything under targetDir the way every other
8132
8203
  // hook surface above is.
8133
8204
  if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
8134
- const kimiHooksRoot = resolveKimiHooksTomlDir();
8205
+ const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
8135
8206
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
8136
8207
  const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
8137
8208
  if (kimiHooksCleanup.changed) {
@@ -8178,23 +8249,24 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8178
8249
  }
8179
8250
  }
8180
8251
 
8252
+ // #2544: the marker now lives inside kimi's hooks/ dir — remove it
8253
+ // before the emptiness check below, or the dir would never prune.
8254
+ if (removeCommonJsMarker(kimiHooksDir)) {
8255
+ removedCount++;
8256
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`);
8257
+ }
8258
+
8181
8259
  try {
8182
8260
  if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
8183
8261
  } catch (_) { /* not empty — leave it */ }
8184
8262
  }
8185
8263
 
8186
- const kimiPkgJsonPath = path.join(kimiHooksRoot, 'package.json');
8187
- if (fs.existsSync(kimiPkgJsonPath)) {
8188
- try {
8189
- const content = fs.readFileSync(kimiPkgJsonPath, 'utf8').trim();
8190
- if (content === '{"type":"commonjs"}') {
8191
- fs.unlinkSync(kimiPkgJsonPath);
8192
- removedCount++;
8193
- console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot}`);
8194
- }
8195
- } catch (e) {
8196
- // Ignore read errors
8197
- }
8264
+ // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle
8265
+ // used to write it. Exact content match — a user's own package.json in
8266
+ // kimi's native config home is never touched.
8267
+ if (removeCommonJsMarker(kimiHooksRoot)) {
8268
+ removedCount++;
8269
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
8198
8270
  }
8199
8271
  }
8200
8272
 
@@ -8460,7 +8532,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8460
8532
  }
8461
8533
 
8462
8534
  // 4. Remove GSD hooks
8463
- const hooksDir = path.join(targetDir, 'hooks');
8535
+ // #3023: mirror the install site's descriptor-driven bundle dir name.
8536
+ const hooksDir = path.join(targetDir, resolveSharedHooksDirName(runtime));
8464
8537
  if (fs.existsSync(hooksDir)) {
8465
8538
  let hookCount = 0;
8466
8539
  for (const hook of GSD_UNINSTALL_HOOKS) {
@@ -8502,13 +8575,18 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8502
8575
  }
8503
8576
  }
8504
8577
 
8505
- // #2717: remove the CommonJS marker GSD wrote into hooks/ for runtimes that
8506
- // stage .js hooks via dedicated paths (cursor/windsurf/codex) — but ONLY if
8507
- // it still carries GSD's exact content (a user-authored package.json is
8508
- // never deleted). Safe no-op for runtimes whose marker lives at the config
8509
- // root (the shared-bundle path) or that never received one.
8578
+ // Retire the CommonJS marker staged into hooks/. hooks/ is shared space and
8579
+ // is deliberately never rmdir'd here, so the marker must be removed
8580
+ // explicitly or it would be left behind. Removed ONLY when it still carries
8581
+ // GSD's exact content — a user-authored package.json is never deleted.
8582
+ //
8583
+ // #2717 reaches the runtimes that stage .js hooks via dedicated paths
8584
+ // (cursor/windsurf/codex); #2544 reaches the shared-bundle runtimes, whose
8585
+ // marker this PR moves out of the config root and into hooks/. Both land in
8586
+ // the same directory, so one guarded call covers both.
8510
8587
  try {
8511
8588
  if (hooksSurface.removeCommonJsMarkerIfGsdOwned(hooksDir)) {
8589
+ removedCount++;
8512
8590
  console.log(` ${green}✓${reset} Removed GSD hooks/package.json (CommonJS marker)`);
8513
8591
  }
8514
8592
  } catch { /* best-effort */ }
@@ -8523,12 +8601,38 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8523
8601
  if (_np) {
8524
8602
  const pluginsDir = path.join(targetDir, _np.dir);
8525
8603
  const pluginPath = path.join(pluginsDir, _np.file);
8604
+ // Tracks whether GSD actually removed anything from pluginsDir. The rmdir
8605
+ // below is gated on it: pruning a directory GSD never wrote to is the same
8606
+ // "don't touch territory GSD didn't fill" violation this issue is about,
8607
+ // just inverted — a user-created but empty plugin/ or extensions/ dir is
8608
+ // theirs, and an uninstall that never removed anything has no business
8609
+ // deleting it.
8610
+ let removedFromPluginsDir = false;
8526
8611
  if (fs.existsSync(pluginPath)) {
8527
8612
  try {
8528
8613
  fs.unlinkSync(pluginPath);
8529
8614
  removedCount++;
8615
+ removedFromPluginsDir = true;
8530
8616
  console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
8531
8617
  } catch (_) { /* best-effort */ }
8618
+ }
8619
+ // #2544: the adapter's CommonJS marker sits beside it. Cleaned up OUTSIDE
8620
+ // the adapter-exists guard above — a partial install (or a hand-deleted
8621
+ // adapter) would otherwise strand GSD's marker forever and keep the dir
8622
+ // from ever pruning. Conditioned on the adapter being GONE, though: if the
8623
+ // unlink above failed, pulling the marker out from under a still-present
8624
+ // CommonJS adapter would leave it unloadable. The exact content match
8625
+ // still leaves any user-authored package.json in place.
8626
+ if (!fs.existsSync(pluginPath) && removeCommonJsMarker(pluginsDir)) {
8627
+ removedCount++;
8628
+ removedFromPluginsDir = true;
8629
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${_np.dir}/`);
8630
+ }
8631
+ // Only prune a dir GSD emptied. Pre-fix this rmdir sat inside the
8632
+ // adapter-exists guard, so it could never fire on a dir GSD had not
8633
+ // written to; hoisting it out to catch the marker-only case must not
8634
+ // silently widen it to "any empty plugin dir".
8635
+ if (removedFromPluginsDir) {
8532
8636
  try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
8533
8637
  }
8534
8638
  }
@@ -8589,19 +8693,14 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8589
8693
  }
8590
8694
 
8591
8695
  // 5. Remove GSD package.json (CommonJS mode marker)
8592
- const pkgJsonPath = path.join(targetDir, 'package.json');
8593
- if (fs.existsSync(pkgJsonPath)) {
8594
- try {
8595
- const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
8596
- // Only remove if it's our minimal CommonJS marker
8597
- if (content === '{"type":"commonjs"}') {
8598
- fs.unlinkSync(pkgJsonPath);
8599
- removedCount++;
8600
- console.log(` ${green}✓${reset} Removed GSD package.json`);
8601
- }
8602
- } catch (e) {
8603
- // Ignore read errors
8604
- }
8696
+ // Since #2544 the marker is staged into hooks/ (and the nativePlugin dir,
8697
+ // handled at 4z above) rather than at targetDir. The targetDir removal is
8698
+ // retained to retire the marker written by pre-#2544 installs — same exact
8699
+ // content match as before, so a user-authored package.json is still never
8700
+ // touched.
8701
+ if (removeCommonJsMarker(targetDir)) {
8702
+ removedCount++;
8703
+ console.log(` ${green}✓${reset} Removed GSD package.json (pre-#2544 config-root marker)`);
8605
8704
  }
8606
8705
 
8607
8706
  // 6. Clean up settings.json (remove GSD hooks and statusline)
@@ -9533,7 +9632,10 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9533
9632
  // #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
9534
9633
  // skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
9535
9634
  if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
9536
- const hooksDir = path.join(configDir, 'hooks');
9635
+ // #3023: manifest keys must track the bundle wherever the descriptor put it,
9636
+ // or uninstall/saveLocalPatches silently orphan the tree.
9637
+ const sharedHooksDirName = resolveSharedHooksDirName(runtime);
9638
+ const hooksDir = path.join(configDir, sharedHooksDirName);
9537
9639
  if (fs.existsSync(hooksDir)) {
9538
9640
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
9539
9641
  // scripts/build-hooks.js) rather than a prefix/extension regex, so the
@@ -9545,7 +9647,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9545
9647
  for (const hook of INSTALLED_HOOK_FILES) {
9546
9648
  const hookPath = path.join(hooksDir, hook);
9547
9649
  if (fs.existsSync(hookPath)) {
9548
- manifest.files['hooks/' + hook] = fileHash(hookPath);
9650
+ manifest.files[sharedHooksDirName + '/' + hook] = fileHash(hookPath);
9549
9651
  }
9550
9652
  }
9551
9653
  // Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
@@ -9554,7 +9656,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
9554
9656
  if (fs.existsSync(hooksLibDir)) {
9555
9657
  for (const file of fs.readdirSync(hooksLibDir)) {
9556
9658
  if (GSD_HOOK_LIB_FILES.includes(file)) {
9557
- manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
9659
+ manifest.files[sharedHooksDirName + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
9558
9660
  }
9559
9661
  }
9560
9662
  }
@@ -10614,8 +10716,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10614
10716
  }
10615
10717
  }
10616
10718
 
10617
- // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands).
10618
- // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089).
10719
+ // Descriptor-driven commands/ output report (currently CodeBuddy).
10720
+ // Cursor retired this parallel surface in #2644 because its skills are
10721
+ // already slash-menu entries as well as model-invocable context.
10619
10722
  if (_hostBehaviors(runtime).reportCommandsDir) {
10620
10723
  const commandsDir = path.join(targetDir, 'commands');
10621
10724
  if (fs.existsSync(commandsDir)) {
@@ -10880,7 +10983,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10880
10983
  const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
10881
10984
  for (const entry of agentEntries) {
10882
10985
  if (entry.isFile() && entry.name.endsWith('.md')) {
10883
- let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
10986
+ const agentSourcePath = path.join(agentsSrc, entry.name);
10987
+ let content = fs.readFileSync(agentSourcePath, 'utf8');
10988
+ // #2995 (epic #1671 Phase 6.4): strip `<!-- gsd:section -->` markers BEFORE
10989
+ // the path-rewrite regexes below, so a rewrite can never reach inside a
10990
+ // marker attribute. No-op (byte-identical) for an unmarked agent.
10991
+ content = composeWorkflow(content, { sourcePath: agentSourcePath });
10884
10992
  // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo
10885
10993
  const dirRegex = /~\/\.claude\//g;
10886
10994
  const homeDirRegex = /\$HOME\/\.claude\//g;
@@ -11065,22 +11173,30 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11065
11173
  // a safe no-op when the dir is already present.
11066
11174
  fs.mkdirSync(destRootDir, { recursive: true });
11067
11175
 
11068
- // Write package.json to force CommonJS mode for GSD scripts
11069
- // Prevents "require is not defined" errors when project has "type": "module"
11070
- // Node.js walks up looking for package.json - this stops inheritance from project
11071
- const pkgJsonDest = path.join(destRootDir, 'package.json');
11072
- fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
11073
- console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
11176
+ // #3023: the bundle's directory NAME is descriptor-driven — a host that
11177
+ // reserves `hooks/` (pi) must be able to opt out. Resolved once here so the
11178
+ // stage / lib / marker sites can never disagree about where the bundle is.
11179
+ const sharedHooksDirName = resolveSharedHooksDirName(runtime);
11180
+
11181
+ // #2544: the CommonJS marker is NOT written here (destRootDir is the
11182
+ // runtime's shared config root — user-writable territory on OpenCode and
11183
+ // Kilo, where it is the documented place to declare local-plugin npm
11184
+ // dependencies). It is written into hooks/ below, the directory GSD
11185
+ // creates and fills with its own .js scripts, once that directory exists.
11074
11186
 
11075
11187
  let hooksOk = true;
11188
+ // #2544: true once GSD has actually written into destRootDir/hooks/, which
11189
+ // is what licenses the CommonJS marker below.
11190
+ let stagedHooks = false;
11076
11191
 
11077
11192
  // Copy hooks from dist/ (bundled with dependencies)
11078
11193
  // Template paths for the target runtime (replaces '.claude' with correct config dir)
11079
11194
  const hooksSrc = path.join(src, 'hooks', 'dist');
11080
11195
  if (fs.existsSync(hooksSrc)) {
11081
- const hooksDest = path.join(destRootDir, 'hooks');
11196
+ const hooksDest = path.join(destRootDir, sharedHooksDirName);
11082
11197
  fs.mkdirSync(hooksDest, { recursive: true });
11083
11198
  const hookEntries = fs.readdirSync(hooksSrc);
11199
+ if (hookEntries.some((e) => fs.statSync(path.join(hooksSrc, e)).isFile())) stagedHooks = true;
11084
11200
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
11085
11201
  for (const entry of hookEntries) {
11086
11202
  const srcFile = path.join(hooksSrc, entry);
@@ -11143,7 +11259,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11143
11259
  }
11144
11260
  }
11145
11261
  if (verifyInstalled(hooksDest, 'hooks')) {
11146
- console.log(` ${green}✓${reset} Installed hooks (bundled)`);
11262
+ console.log(` ${green}✓${reset} Installed ${sharedHooksDirName} (bundled)`);
11147
11263
  // Warn if expected community .sh hooks are missing (non-fatal)
11148
11264
  const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
11149
11265
  for (const sh of expectedShHooks) {
@@ -11172,10 +11288,48 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11172
11288
  // below; this helper itself only checks source presence.)
11173
11289
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
11174
11290
  if (fs.existsSync(hooksLibSrc)) {
11175
- const hooksLibDest = path.join(destRootDir, 'hooks', 'lib');
11291
+ const hooksLibDest = path.join(destRootDir, sharedHooksDirName, 'lib');
11176
11292
  fs.mkdirSync(hooksLibDest, { recursive: true });
11177
11293
  copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
11178
- console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
11294
+ if (GSD_HOOK_LIB_FILES.some((f) => fs.existsSync(path.join(hooksLibDest, f)))) stagedHooks = true;
11295
+ console.log(` ${green}✓${reset} Installed ${sharedHooksDirName}/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
11296
+ }
11297
+
11298
+ // #2544: pin the staged hook scripts to CommonJS from inside hooks/ — the
11299
+ // directory GSD just created and filled — instead of from destRootDir.
11300
+ // Scoping the marker to GSD's own directory keeps `require` working in
11301
+ // hooks/*.js and hooks/lib/*.js under any ambient "type": "module", while
11302
+ // leaving the shared config root untouched.
11303
+ //
11304
+ // Gated on `stagedHooks`, NOT on the directory merely existing: hooks/ is
11305
+ // shared space, so an existence check would drop a GSD marker into a
11306
+ // hooks/ directory the user created and GSD never wrote to — the same
11307
+ // write-into-someone-else's-territory this issue is about. And never
11308
+ // written over a package.json GSD does not own.
11309
+ //
11310
+ // ALSO gated on `hooksOk`: `stagedHooks` is computed from the SOURCE
11311
+ // listing before the copy loop, so it stays true when the copies land but
11312
+ // `verifyInstalled` then fails. Marking a hooks/ GSD did not successfully
11313
+ // populate as CommonJS claims an ownership the install did not earn — the
11314
+ // two flags answer different questions ("did we intend to fill it" vs "is
11315
+ // it actually filled"), and the marker needs both.
11316
+ const hooksMarkerDir = path.join(destRootDir, sharedHooksDirName);
11317
+ if (stagedHooks && hooksOk) {
11318
+ switch (ensureCommonJsMarker(hooksMarkerDir)) {
11319
+ case 'written':
11320
+ console.log(` ${green}✓${reset} Wrote ${sharedHooksDirName}/package.json (CommonJS mode)`);
11321
+ break;
11322
+ case 'preserved-foreign':
11323
+ console.warn(` ${yellow}⚠${reset} Left existing ${sharedHooksDirName}/package.json untouched (not GSD's marker) — GSD hooks may not resolve as CommonJS`);
11324
+ break;
11325
+ case 'failed':
11326
+ // Best-effort: a read-only or full config dir must not abort the
11327
+ // install with a raw stack trace. The hooks themselves are staged.
11328
+ console.warn(` ${yellow}⚠${reset} Could not write ${sharedHooksDirName}/package.json (CommonJS mode) — install continued; GSD hooks may not resolve as CommonJS`);
11329
+ break;
11330
+ default:
11331
+ break;
11332
+ }
11179
11333
  }
11180
11334
 
11181
11335
  return hooksOk;
@@ -11586,6 +11740,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11586
11740
 
11587
11741
  let agentCount = 0;
11588
11742
  if (!isMinimalMode(_effectiveInstallMode)) {
11743
+ // #2834: write ~/.gsd/defaults.json (resolve_model_ids + runtime) BEFORE generating
11744
+ // agent TOMLs — installCodexConfig reads defaults.json at generation time, so on a
11745
+ // clean first install the runtime-aware model resolver must already know the runtime.
11746
+ writeNonClaudeDefaults(runtime);
11589
11747
  try {
11590
11748
  // Generate Codex config.toml and per-agent .toml files.
11591
11749
  agentCount = installCodexConfig(targetDir, agentsSrc, plan.sandboxTier);
@@ -11622,6 +11780,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11622
11780
  const codexHooksDest = path.join(targetDir, 'hooks');
11623
11781
  fs.mkdirSync(codexHooksDest, { recursive: true });
11624
11782
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
11783
+ // #2544: track whether anything was actually staged. hooks/dist existing
11784
+ // is not the same as an allowlisted file landing in it — see the marker
11785
+ // gate below.
11786
+ let codexStagedHooks = false;
11625
11787
  for (const entry of fs.readdirSync(codexHooksSrc)) {
11626
11788
  if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
11627
11789
  const srcFile = path.join(codexHooksSrc, entry);
@@ -11655,6 +11817,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11655
11817
  fs.copyFileSync(srcFile, destFile);
11656
11818
  try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
11657
11819
  }
11820
+ codexStagedHooks = true;
11658
11821
  }
11659
11822
  console.log(` ${green}✓${reset} Installed hooks (Codex)`);
11660
11823
  // #2717: write the CommonJS marker into hooks/ alongside the staged .js
@@ -11665,7 +11828,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11665
11828
  // gsd-context-monitor.js as ESM and their require() calls fail silently.
11666
11829
  // Reuses the same helper the Cursor/Windsurf writers call so the marker
11667
11830
  // content + user-file-preservation contract is identical everywhere.
11668
- if (hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
11831
+ //
11832
+ // #2544: gated on codexStagedHooks, mirroring installSharedHooksBundle's
11833
+ // `stagedHooks`. The enclosing guard only proves hooks/dist EXISTS; if it
11834
+ // holds none of CODEX_HOOKS_TO_COPY, this block mkdirs hooks/ and stages
11835
+ // nothing, and an ungated marker would claim a directory GSD did not fill.
11836
+ if (codexStagedHooks && hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
11669
11837
  console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
11670
11838
  }
11671
11839
  }
@@ -11886,7 +12054,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11886
12054
  // hooks needed. Kimi is also artifact-only for its INSTALL surface (skills +
11887
12055
  // kimi-agents, no settings.json) but #2095 Upgrade 1 gives it its own
11888
12056
  // independent hooksSurface: kimi's native config.toml [[hooks]] array, which
11889
- // lives outside targetDir entirely (resolveKimiHooksTomlDir resolves ~/.kimi,
12057
+ // lives outside targetDir entirely (resolveKimiHooksTomlDir resolves the
12058
+ // per-runtime root — ~/.kimi for kimi, ~/.kimi-code for kimi-code, #2755 —
11890
12059
  // a sibling of targetDir's ~/.config/agents) — hence writing it here, inside
11891
12060
  // this early-return, rather than requiring installSurface to change.
11892
12061
  //
@@ -11907,7 +12076,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11907
12076
  // ~/.kimi/hooks/<script> rather than a script that doesn't exist under
11908
12077
  // targetDir/hooks (which kimi no longer receives).
11909
12078
  if (plan.hooksSurface === 'kimi-hooks-toml' && isGlobal) {
11910
- const kimiHooksRoot = resolveKimiHooksTomlDir();
12079
+ const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
11911
12080
  // Note: the `failures` array's hard-fail gate (`if (failures.length > 0)
11912
12081
  // process.exit(1)`) runs earlier in this function, before this
11913
12082
  // profile-marker-only branch is ever reached — pushing to it here would
@@ -11916,6 +12085,20 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
11916
12085
  if (!installSharedHooksBundle(kimiHooksRoot)) {
11917
12086
  console.warn(` ${yellow}⚠${reset} Kimi hook bundle did not verify at ${path.join(kimiHooksRoot, 'hooks')} — GSD lifecycle hooks may be incomplete`);
11918
12087
  }
12088
+ // #2544: retire the pre-fix marker at kimi's root. installSharedHooksBundle
12089
+ // used to write {"type":"commonjs"} at destRootDir itself; it now writes it
12090
+ // under destRootDir/hooks/, so on an upgrade the old root file is stale and
12091
+ // would keep ~/.kimi pinned to CommonJS.
12092
+ //
12093
+ // Done HERE rather than in installer-migration 007 (which retires the same
12094
+ // stale marker for every other runtime) because kimi's hook root is
12095
+ // the per-runtime Kimi root — resolved by resolveKimiHooksTomlDir, OUTSIDE the configDir.
12096
+ // Migration relPaths are structurally confined to configDir, so the
12097
+ // framework cannot address this path at all. Same exact-content predicate
12098
+ // either way, so a user-authored ~/.kimi/package.json is never touched.
12099
+ if (removeCommonJsMarker(kimiHooksRoot)) {
12100
+ console.log(` ${green}✓${reset} Removed stale package.json from ${kimiHooksRoot} (pre-#2544 marker)`);
12101
+ }
11919
12102
  const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
11920
12103
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
11921
12104
  const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
@@ -12361,58 +12544,11 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12361
12544
  configureAntigravityMcpConfig(isGlobal, configDir);
12362
12545
  }
12363
12546
 
12364
- // For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
12365
- // when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
12366
- // aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
12367
- // (resolveModelInternal returns full materialized model IDs) MUST be preserved —
12368
- // rewriting it to "omit" would make generated agent manifests inherit the active
12369
- // chat model instead of pinning the resolved model. See #1156 (default-to-omit
12370
- // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
12371
- // on configureOpencodePermissions above.
12372
- if (!_hostBehaviors(runtime).nativeModelAliases && !process.env.GSD_TEST_MODE) {
12373
- const gsdDir = path.join(os.homedir(), '.gsd');
12374
- const defaultsPath = path.join(gsdDir, 'defaults.json');
12375
- try {
12376
- fs.mkdirSync(gsdDir, { recursive: true });
12377
- let defaults = {};
12378
- try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
12379
- // Recover a malformed (valid-JSON-but-non-object) defaults.json to a fresh object so
12380
- // the write below succeeds and the file is no longer broken. Without this, `null` /
12381
- // `[]` / a number / a string bypass the parse catch and either throw a TypeError on
12382
- // property access (swallowed by the outer try/catch, leaving the file broken) or get
12383
- // a property set that won't round-trip through JSON.stringify. (#1657)
12384
- if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
12385
- defaults = {};
12386
- }
12387
- // Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
12388
- // Honor ONLY an explicit canonical `true` opt-in (full model IDs) and an existing
12389
- // "omit"; default everything else — absent, falsy, OR any non-canonical value — to
12390
- // "omit", the safe non-Claude default. Allowlist-based so malformed values
12391
- // (0, "", "yes", {}, …) don't leak Claude aliases the runtime can't resolve (#1569).
12392
- const existing = defaults.resolve_model_ids;
12393
- const shouldDefaultToOmit = existing !== true && existing !== 'omit';
12394
- if (shouldDefaultToOmit) {
12395
- defaults.resolve_model_ids = 'omit';
12396
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
12397
- console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
12398
- }
12399
-
12400
- // #2395: also persist `runtime: <runtime>` for non-Claude runtimes, so
12401
- // resolveRuntime() (precedence: GSD_RUNTIME env > config.runtime > 'claude')
12402
- // resolves to the install's actual runtime identity out of the box — without
12403
- // this, agent_runtime and every runtime-branded slash hint falls through to
12404
- // the hard-coded 'claude' default. Mirrors the resolve_model_ids write above:
12405
- // honor an explicit pre-existing value (any string), only default-populating
12406
- // when absent. Claude is the resolveRuntime() fallback, so it needs no write.
12407
- if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
12408
- defaults.runtime = runtime;
12409
- fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
12410
- console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
12411
- }
12412
- } catch (e) {
12413
- console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
12414
- }
12415
- }
12547
+ // #2834: defaults.json (resolve_model_ids + runtime) is now written BEFORE
12548
+ // installCodexConfig via writeNonClaudeDefaults(runtime) — extracted into a
12549
+ // function so it can run at the right point in the flow (before agent TOML
12550
+ // generation reads it). This call is idempotent (preserves existing values).
12551
+ writeNonClaudeDefaults(runtime);
12416
12552
 
12417
12553
  // program + command are now single-source lookups (ADR-1239 Phase B / #1679):
12418
12554
  // program is the runtime display label; command is the per-host /gsd-new-project
@@ -13075,14 +13211,26 @@ function maybeSuggestPathExport(globalBin, homeDir) {
13075
13211
 
13076
13212
  console.log('');
13077
13213
  console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
13078
- console.log(` Add it with one of:`);
13079
13214
  const projected = projectPersistentPathExportActions({
13080
13215
  targetDir: globalBin,
13081
13216
  platform: process.platform,
13082
13217
  });
13083
- for (const action of projected.shellActions) {
13084
- const labelPrefix = action.label ? `${action.label}: ` : '';
13085
- console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
13218
+ if (projected.reason === PATH_ACTION_REASON.WIN32_RESERVED_QUOTE) {
13219
+ // #3118 review MINOR: a win32 targetDir containing `"` makes
13220
+ // projectPathActionProjection return [] (no command can quote it safely
13221
+ // on Windows) — printing the "Add it with one of:" header with nothing
13222
+ // under it is a silent dead-end. Name the cause instead.
13223
+ console.log(` No command can be suggested: the path contains a ${cyan}"${reset} character, which cannot appear in a Windows path.`);
13224
+ } else if (projected.shellActions.length === 0) {
13225
+ // #3118: no target directory to talk about (reason === NO_TARGET_DIR, or
13226
+ // no reason at all) — there is nothing to print beyond the "not on your
13227
+ // PATH" line above.
13228
+ } else {
13229
+ console.log(` Add it with one of:`);
13230
+ for (const action of projected.shellActions) {
13231
+ const labelPrefix = action.label ? `${action.label}: ` : '';
13232
+ console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
13233
+ }
13086
13234
  }
13087
13235
  console.log('');
13088
13236
  }
@@ -13297,7 +13445,6 @@ module.exports = {
13297
13445
  applyRuntimeContentRewritesInPlace,
13298
13446
  getCodexSkillAdapterHeader,
13299
13447
  convertClaudeCommandToCursorSkill,
13300
- convertClaudeCommandToCursorCommand,
13301
13448
  convertClaudeAgentToCursorAgent,
13302
13449
  convertClaudeAgentToCodexAgent,
13303
13450
  generateCodexAgentToml,
@@ -13332,6 +13479,9 @@ module.exports = {
13332
13479
  // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
13333
13480
  _resolveHostBehaviors,
13334
13481
  FALLBACK_HOST_BEHAVIORS,
13482
+ // #3023 — shared hook bundle directory name, descriptor-driven
13483
+ SHARED_HOOKS_DIR_DEFAULT,
13484
+ resolveSharedHooksDirName,
13335
13485
  convertSlashCommandsToCodexSkillMentions,
13336
13486
  convertClaudeCommandToCodexSkill,
13337
13487
  convertClaudeCommandToKimiSkill,
@@ -13513,7 +13663,19 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
13513
13663
  console.error('Usage: node install.js --skills-root <runtime>');
13514
13664
  process.exit(1);
13515
13665
  }
13516
- const skillsRoot = getGlobalSkillsBase(runtimeArg);
13666
+ // #3024: validate the runtime id against the shipped capability registry
13667
+ // BEFORE resolving anything. getGlobalSkillsBase's bare `runtimes[runtime]`
13668
+ // lookup falls through the prototype chain to claude's skills root for an
13669
+ // unregistered/hostile id (`__proto__`, `constructor`, `prototype`, …)
13670
+ // instead of failing loudly. isRegisteredRuntimeId is the SAME validator
13671
+ // gsd-tools' `routeSkillsRoot` calls, so this entry point and the shipped
13672
+ // `gsd-tools query skills-root` entry point can never diverge on which
13673
+ // runtime ids they accept.
13674
+ if (!isRegisteredRuntimeId(runtimeArg)) {
13675
+ console.error(`Unknown runtime "${runtimeArg}" — must be a registered runtime id`);
13676
+ process.exit(1);
13677
+ }
13678
+ const skillsRoot = getGlobalSkillsBase(runtimeArg.trim());
13517
13679
  if (skillsRoot === null) {
13518
13680
  console.error(`${runtimeArg} does not use a skills directory`);
13519
13681
  process.exit(1);