@opengsd/gsd-core 1.5.0-rc.1 → 1.5.0-rc.3

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 (183) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-code-fixer.md +3 -2
  3. package/agents/gsd-debug-session-manager.md +2 -1
  4. package/agents/gsd-debugger.md +4 -3
  5. package/agents/gsd-executor.md +16 -15
  6. package/agents/gsd-intel-updater.md +38 -41
  7. package/agents/gsd-mempalace-curator.md +47 -0
  8. package/agents/gsd-phase-researcher.md +8 -8
  9. package/agents/gsd-plan-checker.md +12 -11
  10. package/agents/gsd-planner.md +21 -181
  11. package/agents/gsd-project-researcher.md +5 -4
  12. package/agents/gsd-research-synthesizer.md +2 -1
  13. package/agents/gsd-ui-researcher.md +2 -1
  14. package/agents/gsd-verifier.md +9 -8
  15. package/bin/install.js +230 -1409
  16. package/commands/gsd/mempalace-capture.md +71 -0
  17. package/commands/gsd/mempalace-recall.md +102 -0
  18. package/commands/gsd/ns-context.md +4 -2
  19. package/gemini-extension.json +1 -1
  20. package/gsd-core/bin/gsd-tools.cjs +110 -92
  21. package/gsd-core/bin/gsd_run +20 -0
  22. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  23. package/gsd-core/bin/lib/capability-registry.cjs +3321 -47
  24. package/gsd-core/bin/lib/capability-state.cjs +31 -18
  25. package/gsd-core/bin/lib/check-command-router.cjs +460 -2
  26. package/gsd-core/bin/lib/clusters.cjs +2 -0
  27. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  28. package/gsd-core/bin/lib/commands.cjs +33 -10
  29. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  30. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  31. package/gsd-core/bin/lib/config.cjs +1 -2
  32. package/gsd-core/bin/lib/core.cjs +56 -3
  33. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  34. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  35. package/gsd-core/bin/lib/init.cjs +8 -5
  36. package/gsd-core/bin/lib/install-profiles.cjs +2 -1
  37. package/gsd-core/bin/lib/intel.cjs +3 -3
  38. package/gsd-core/bin/lib/io.cjs +61 -6
  39. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  40. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  41. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  42. package/gsd-core/bin/lib/phase.cjs +88 -11
  43. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  44. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  45. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  46. package/gsd-core/bin/lib/roadmap.cjs +137 -7
  47. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
  48. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -165
  49. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +77 -31
  50. package/gsd-core/bin/lib/runtime-homes.cjs +156 -114
  51. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
  53. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  54. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  55. package/gsd-core/bin/lib/state-document.cjs +54 -1
  56. package/gsd-core/bin/lib/state.cjs +221 -35
  57. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  58. package/gsd-core/bin/lib/update-context.cjs +4 -1
  59. package/gsd-core/bin/lib/verify.cjs +122 -4
  60. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  61. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  62. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  63. package/gsd-core/bin/shared/model-catalog.json +12 -6
  64. package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
  65. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  66. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  67. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  68. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  69. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  70. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  71. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  72. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  73. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  74. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  75. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  76. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  77. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  78. package/gsd-core/references/edge-probe.md +261 -0
  79. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  80. package/gsd-core/references/planner-antipatterns.md +41 -0
  81. package/gsd-core/references/planner-guidance.md +186 -0
  82. package/gsd-core/templates/config.json +1 -1
  83. package/gsd-core/templates/spec.md +12 -0
  84. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  85. package/gsd-core/workflows/add-backlog.md +1 -1
  86. package/gsd-core/workflows/add-phase.md +1 -1
  87. package/gsd-core/workflows/add-tests.md +1 -1
  88. package/gsd-core/workflows/add-todo.md +1 -1
  89. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  90. package/gsd-core/workflows/audit-fix.md +1 -1
  91. package/gsd-core/workflows/audit-milestone.md +6 -4
  92. package/gsd-core/workflows/audit-uat.md +1 -1
  93. package/gsd-core/workflows/autonomous.md +36 -42
  94. package/gsd-core/workflows/check-todos.md +1 -1
  95. package/gsd-core/workflows/cleanup.md +1 -1
  96. package/gsd-core/workflows/code-review-fix.md +10 -8
  97. package/gsd-core/workflows/code-review.md +59 -22
  98. package/gsd-core/workflows/complete-milestone.md +1 -1
  99. package/gsd-core/workflows/debug.md +1 -1
  100. package/gsd-core/workflows/diagnose-issues.md +1 -1
  101. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  102. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  103. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  104. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  105. package/gsd-core/workflows/discuss-phase.md +15 -1
  106. package/gsd-core/workflows/do.md +1 -1
  107. package/gsd-core/workflows/docs-update.md +1 -1
  108. package/gsd-core/workflows/edit-phase.md +1 -1
  109. package/gsd-core/workflows/eval-review.md +1 -1
  110. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  111. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  112. package/gsd-core/workflows/execute-phase.md +83 -144
  113. package/gsd-core/workflows/execute-plan.md +1 -1
  114. package/gsd-core/workflows/explore.md +1 -1
  115. package/gsd-core/workflows/extract-learnings.md +1 -1
  116. package/gsd-core/workflows/forensics.md +1 -1
  117. package/gsd-core/workflows/graduation.md +1 -1
  118. package/gsd-core/workflows/health.md +1 -1
  119. package/gsd-core/workflows/help/modes/full.md +3 -1
  120. package/gsd-core/workflows/import.md +1 -1
  121. package/gsd-core/workflows/ingest-docs.md +1 -1
  122. package/gsd-core/workflows/insert-phase.md +1 -1
  123. package/gsd-core/workflows/list-workspaces.md +1 -1
  124. package/gsd-core/workflows/manager.md +1 -1
  125. package/gsd-core/workflows/map-codebase.md +1 -1
  126. package/gsd-core/workflows/milestone-summary.md +1 -1
  127. package/gsd-core/workflows/mvp-phase.md +1 -1
  128. package/gsd-core/workflows/new-milestone.md +9 -1
  129. package/gsd-core/workflows/new-project.md +12 -4
  130. package/gsd-core/workflows/new-workspace.md +1 -1
  131. package/gsd-core/workflows/next.md +1 -1
  132. package/gsd-core/workflows/pause-work.md +1 -1
  133. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  134. package/gsd-core/workflows/plan-phase.md +120 -240
  135. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  136. package/gsd-core/workflows/plant-seed.md +1 -1
  137. package/gsd-core/workflows/profile-user.md +4 -2
  138. package/gsd-core/workflows/progress.md +59 -2
  139. package/gsd-core/workflows/quick.md +10 -7
  140. package/gsd-core/workflows/remove-phase.md +1 -1
  141. package/gsd-core/workflows/remove-workspace.md +1 -1
  142. package/gsd-core/workflows/resume-project.md +1 -1
  143. package/gsd-core/workflows/review.md +20 -3
  144. package/gsd-core/workflows/scan.md +1 -1
  145. package/gsd-core/workflows/secure-phase.md +5 -3
  146. package/gsd-core/workflows/settings-advanced.md +20 -14
  147. package/gsd-core/workflows/settings-integrations.md +1 -1
  148. package/gsd-core/workflows/settings.md +2 -2
  149. package/gsd-core/workflows/ship.md +58 -1
  150. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  151. package/gsd-core/workflows/sketch.md +1 -1
  152. package/gsd-core/workflows/spec-phase.md +130 -1
  153. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  154. package/gsd-core/workflows/spike.md +1 -1
  155. package/gsd-core/workflows/stats.md +1 -1
  156. package/gsd-core/workflows/thread.md +1 -1
  157. package/gsd-core/workflows/transition.md +1 -1
  158. package/gsd-core/workflows/ui-phase.md +1 -1
  159. package/gsd-core/workflows/ui-review.md +1 -1
  160. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  161. package/gsd-core/workflows/update.md +2 -2
  162. package/gsd-core/workflows/validate-phase.md +5 -3
  163. package/gsd-core/workflows/verify-phase.md +1 -1
  164. package/gsd-core/workflows/verify-work.md +28 -12
  165. package/package.json +7 -3
  166. package/scripts/affected-tests-lib.cjs +16 -4
  167. package/scripts/gen-capability-registry.cjs +857 -61
  168. package/scripts/gen-inventory-manifest.cjs +1 -4
  169. package/scripts/gen-loop-host-contract.cjs +55 -0
  170. package/scripts/issue-version-gate.cjs +140 -0
  171. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  172. package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
  173. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  174. package/scripts/lint-test-file-count.allowlist.json +23 -2
  175. package/scripts/lint-windows-test-portability.cjs +178 -0
  176. package/scripts/release-tarball-smoke.cjs +7 -1
  177. package/scripts/research-profiles.cjs +10 -10
  178. package/scripts/run-tests.cjs +128 -27
  179. package/scripts/sync-next-version.cjs +133 -0
  180. package/scripts/sync-runtime-launcher.cjs +21 -5
  181. package/scripts/update-size-baseline.cjs +68 -0
  182. package/scripts/workflow-policy.cjs +42 -9
  183. package/scripts/workflow-size.cjs +90 -0
package/bin/install.js CHANGED
@@ -26,8 +26,8 @@ const {
26
26
  // fs.readdirSync + RegExp work for every skill.
27
27
  const {
28
28
  transformContentToHyphen,
29
- readCmdNames: readGsdCommandNames,
30
- } = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
29
+ readGsdCommandNames,
30
+ } = require('../gsd-core/bin/lib/command-roster.cjs');
31
31
  const {
32
32
  resolveAntigravityGlobalDir,
33
33
  getGlobalConfigDir,
@@ -37,7 +37,8 @@ const {
37
37
  applyWorktreeBaseRef,
38
38
  readBaseRefFromSettings,
39
39
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
40
- const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
40
+ const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
41
+ const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
41
42
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
42
43
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
43
44
  // the manifest and the installed hooks/ dir structurally identical. Avoids the
@@ -45,6 +46,11 @@ const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-conf
45
46
  const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
46
47
  const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
47
48
 
49
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
50
+ // bin/install.js re-exports everything from hooksSurface so existing callers
51
+ // (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
52
+ const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
53
+
48
54
  /**
49
55
  * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
50
56
  * verbatim (only branding swaps, no namespace conversion), so retired
@@ -405,7 +411,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
405
411
  if (runtimeArgs.includes('--copilot')) selected.push('copilot');
406
412
  if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
407
413
  if (runtimeArgs.includes('--cursor')) selected.push('cursor');
408
- if (runtimeArgs.includes('--windsurf')) selected.push('windsurf');
414
+ if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
409
415
  if (runtimeArgs.includes('--augment')) selected.push('augment');
410
416
  if (runtimeArgs.includes('--trae')) selected.push('trae');
411
417
  if (runtimeArgs.includes('--qwen')) selected.push('qwen');
@@ -460,9 +466,9 @@ function getDirName(runtime) {
460
466
  if (runtime === 'gemini') return '.gemini';
461
467
  if (runtime === 'kilo') return '.kilo';
462
468
  if (runtime === 'codex') return '.codex';
463
- if (runtime === 'antigravity') return '.agent';
469
+ if (runtime === 'antigravity') return '.agents';
464
470
  if (runtime === 'cursor') return '.cursor';
465
- if (runtime === 'windsurf') return '.windsurf';
471
+ if (runtime === 'windsurf') return '.devin';
466
472
  if (runtime === 'augment') return '.augment';
467
473
  if (runtime === 'trae') return '.trae';
468
474
  if (runtime === 'qwen') return '.qwen';
@@ -495,7 +501,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
495
501
  if (runtime === 'kilo') return "'.config', 'kilo'";
496
502
  if (runtime === 'codex') return "'.codex'";
497
503
  if (runtime === 'antigravity') {
498
- if (!isGlobal) return "'.agent'";
504
+ if (!isGlobal) return "'.agents'";
499
505
  const antigravityDir = resolveAntigravityGlobalDir();
500
506
  const rel = path.relative(os.homedir(), antigravityDir);
501
507
  const segments = rel.split(path.sep).filter(Boolean);
@@ -622,215 +628,22 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
622
628
  return `${resolvedTarget}/`;
623
629
  }
624
630
 
625
- /**
626
- * Normalize a raw `process.execPath` to a stable, upgrade-safe node binary
627
- * path. On Homebrew installs, `process.execPath` resolves symlinks and returns
628
- * the versioned Cellar path (e.g.
629
- * `/usr/local/Cellar/node/25.8.1/bin/node`). Baking that path into hook
630
- * commands causes `dyld: Library not loaded` errors after `brew upgrade node`
631
- * because the shared libraries referenced by the Cellar binary have changed
632
- * SOVERSION. (#3181)
633
- *
634
- * The stable Homebrew symlinks (`/usr/local/bin/node` for Intel,
635
- * `/opt/homebrew/bin/node` for Apple Silicon) survive upgrades — Homebrew
636
- * re-points them atomically. We prefer those when a Cellar path is detected.
637
- *
638
- * Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is.
639
- */
640
- function normalizeNodePath(execPath, opts) {
641
- if (!execPath) return execPath;
642
- const env = (opts && opts.env) || process.env;
643
- const existsSync = (opts && opts.existsSync) || fs.existsSync;
644
-
645
- // fnm multishell shim: C:/Users/<u>/AppData/Local/fnm_multishells/<pid>_<ts>/node.exe
646
- // These are per-shell-session ephemeral directories that fnm cleans up on shell exit.
647
- // Probe the stable fnm alias paths instead so baked hook commands survive shell restarts.
648
- // (#977)
649
- // TODO: Volta (~/.volta/bin/node → ~/.volta/tools/image/node/<ver>/bin/node) and
650
- // nvm-windows (AppData/Roaming/nvm/<ver>/node.exe) have analogous issues — future work.
651
- const normalizedForMatch = execPath.replace(/\\/g, '/');
652
- if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
653
- const candidates = [];
654
- if (env.FNM_DIR) {
655
- // Preferred: alias installed directly under FNM_DIR/aliases/default/
656
- candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`);
657
- // POSIX layout (no .exe)
658
- candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`);
659
- }
660
- if (env.APPDATA) {
661
- // Fallback: fnm default location on Windows when FNM_DIR is not set
662
- candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`);
663
- }
664
- for (const candidate of candidates) {
665
- if (existsSync(candidate)) return candidate;
666
- }
667
- // No stable alias found — return raw path unchanged (graceful fallback)
668
- return execPath;
669
- }
670
-
671
- // Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
672
- // or /usr/local/Cellar/node@20/<version>/bin/node
673
- if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
674
- return '/usr/local/bin/node';
675
- }
676
- // Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
677
- // or /opt/homebrew/Cellar/node@18/<version>/bin/node
678
- if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
679
- return '/opt/homebrew/bin/node';
680
- }
681
- return execPath;
682
- }
683
-
684
- /**
685
- * Resolve the absolute path to the node binary running the installer.
686
- * Used as the runner for .js hooks so they execute in GUI/minimal-PATH
687
- * runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder
688
- * shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin`
689
- * and the hook would fail with `node: command not found` (#2979).
690
- *
691
- * Returns a forward-slash-normalized, double-quoted path so the emitted
692
- * command is shell-safe across POSIX and Windows. `process.execPath`
693
- * gives the absolute path of the node binary actively running the
694
- * installer — that is the version the user just installed under, and
695
- * the right default runtime for hooks invoked under the same install.
696
- *
697
- * When `process.execPath` is a versioned Homebrew Cellar path, the stable
698
- * Homebrew symlink is returned instead to survive `brew upgrade node` (#3181).
699
- *
700
- * When `process.execPath` is an ephemeral fnm multishell shim, the stable fnm
701
- * alias path is returned instead so managed hook commands survive shell restarts
702
- * (#977). An optional `opts` bag (`{ env, existsSync }`) is accepted for
703
- * testability; production callers omit it to get real env / fs.
704
- */
705
- function resolveNodeRunner(opts) {
706
- const execPath = typeof process.execPath === 'string' ? process.execPath : '';
707
- if (!execPath) return null;
708
- const stablePath = normalizeNodePath(execPath, opts);
709
- // JSON.stringify produces a properly escaped double-quoted shell token,
710
- // safe for paths containing spaces or unusual characters.
711
- return JSON.stringify(stablePath.replace(/\\/g, '/'));
712
- }
713
-
714
- /**
715
- * Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use
716
- * the absolute Node binary path (#2979 follow-up: CR feedback on #3002).
717
- *
718
- * The original #2979 fix only emitted absolute paths for *newly registered*
719
- * hooks. Pre-existing entries kept their bare `node ` prefix on reinstall,
720
- * which left them broken under minimal-PATH GUI runtimes — exactly the
721
- * failure mode the original fix was meant to close. This walker normalizes
722
- * any managed-hook entry whose command starts with bare `node ` to
723
- * `<absoluteRunner> <script>` while leaving non-managed and non-bare-node
724
- * entries (user-authored hooks, shell scripts, etc.) untouched.
725
- *
726
- * Returns true if any entry was rewritten.
727
- */
728
- function resolveBashRunner(opts) {
729
- const platform = (opts && opts.platform) || process.platform;
730
- if (platform !== 'win32') return 'bash';
731
-
732
- const env = (opts && opts.env) || process.env;
733
- const exists = (opts && opts.existsSync) || fs.existsSync;
734
- const candidates = [];
735
- if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH);
736
- if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
737
- if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
738
- if (env.SystemDrive) {
739
- candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
740
- candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
741
- }
742
-
743
- for (const candidate of candidates) {
744
- if (candidate && exists(candidate)) {
745
- return JSON.stringify(candidate.replace(/\\/g, '/'));
746
- }
747
- }
748
- return null;
749
- }
631
+ // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
632
+ // now owned by the runtime-hooks-surface module. Import them here so
633
+ // install.js callers continue to work and so there is a single implementation
634
+ // of these helpers.
635
+ const normalizeNodePath = hooksSurface.normalizeNodePath;
636
+ const resolveNodeRunner = hooksSurface.resolveNodeRunner;
637
+ const resolveBashRunner = hooksSurface.resolveBashRunner;
638
+ // referencesHook: pure predicate over hook entry objects, shared between
639
+ // install() and finishInstall() (ADR-857 phase 5f-1b).
640
+ const referencesHook = hooksSurface.referencesHook;
641
+ // applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
642
+ // hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
643
+ const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
750
644
 
751
645
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
752
- if (!settings || !settings.hooks || !absoluteRunner) return false;
753
- if (!opts) opts = {};
754
- const platform = opts.platform || process.platform;
755
- let changed = false;
756
- for (const entries of Object.values(settings.hooks)) {
757
- if (!Array.isArray(entries)) continue;
758
- for (const entry of entries) {
759
- if (!entry || !Array.isArray(entry.hooks)) continue;
760
- for (const h of entry.hooks) {
761
- if (!h || typeof h.command !== 'string') continue;
762
- // args-form entries have the script path in h.args[] and h.command is
763
- // the launcher executable (not a managed hook command). These are
764
- // intentional user wrappers — do not rewrite them. (#976)
765
- if (Array.isArray(h.args) && h.args.length > 0) continue;
766
- let trimmed = h.command.trim();
767
- const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
768
- if (hadPowerShellCallOperator) {
769
- trimmed = trimmed.replace(/^&\s+/, '').trim();
770
- }
771
- // Match two runner forms:
772
- // 1. Legacy bare-node form: `node <script>` (#2979/#3002)
773
- // 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
774
- // or `"/opt/homebrew/Cellar/node/<v>/bin/node" <script>` (#3181)
775
- //
776
- // Both patterns use the same script-token capture group so the rewrite
777
- // is uniform. We detect the Cellar form by extracting the runner token
778
- // and running it through normalizeNodePath.
779
- //
780
- // The previous shape used `trimmed.includes(<filename>)` which would
781
- // false-positive on user-authored hooks whose path merely contained
782
- // a managed filename as a substring (e.g.
783
- // /home/me/scripts/wraps-gsd-check-update.js-and-more.js). #3002 CR.
784
- const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
785
- trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
786
- if (!m) continue;
787
-
788
- let runnerToken, scriptToken, scriptPath;
789
- if (/^node\s+/.test(trimmed)) {
790
- // bare-node form
791
- runnerToken = 'node';
792
- scriptToken = m[1];
793
- scriptPath = m[2] || m[3] || m[4] || '';
794
- } else {
795
- // quoted/unquoted runner form — check whether runner is a Cellar path
796
- runnerToken = m[1];
797
- const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
798
- const stableRunner = normalizeNodePath(runnerPath);
799
- // Process Cellar paths so they normalize to a stable symlink. On
800
- // Windows, already-absolute runners still flow through the projection
801
- // seam because some runtimes need additional wrapper policy while
802
- // others must stay shell-neutral (#3362, #3413).
803
- if (stableRunner === runnerPath && platform !== 'win32') continue;
804
- scriptToken = m[5];
805
- scriptPath = m[6] || m[7] || m[8] || '';
806
- }
807
-
808
- // Take the basename — match against MANAGED_HOOK_FILES by exact
809
- // equality, not substring containment. Handles both forward and
810
- // backslash separators (Windows).
811
- if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
812
-
813
- const projectedCommand = projectLegacySettingsHookCommand({
814
- absoluteRunner,
815
- scriptPath,
816
- scriptToken,
817
- runtime: opts.runtime || 'generic',
818
- platform,
819
- });
820
- if (!projectedCommand) continue;
821
-
822
- // Skip only when the existing managed command already matches the
823
- // desired runtime-aware projected shape. This preserves Gemini's
824
- // required PowerShell prefix while still letting Claude strip stale
825
- // prefixes on reinstall (#3413).
826
- if (h.command === projectedCommand) continue;
827
-
828
- h.command = projectedCommand;
829
- changed = true;
830
- }
831
- }
832
- }
833
- return changed;
646
+ return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
834
647
  }
835
648
 
836
649
  /**
@@ -856,22 +669,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
856
669
  * @returns {string|null} The toml block to append, or null on missing runner.
857
670
  */
858
671
  function buildCodexHookBlock(targetDir, opts) {
859
- const absoluteRunner = opts && opts.absoluteRunner;
860
- if (!absoluteRunner) return null;
861
- const eol = (opts && opts.eol) || '\n';
862
- const platform = (opts && opts.platform) || process.platform;
863
- const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
864
- const commandValue = projectCodexHookTomlCommand({
865
- absoluteRunner,
866
- scriptPath: updateCheckScript,
867
- platform,
868
- });
869
- return `${eol}# GSD Hooks${eol}` +
870
- `[[hooks.SessionStart]]${eol}` +
871
- `${eol}` +
872
- `[[hooks.SessionStart.hooks]]${eol}` +
873
- `type = "command"${eol}` +
874
- `command = "${commandValue}"${eol}`;
672
+ return hooksSurface.buildCodexHookBlock(targetDir, opts);
875
673
  }
876
674
 
877
675
  /**
@@ -888,45 +686,7 @@ function buildCodexHookBlock(targetDir, opts) {
888
686
  * @returns {{ content: string, changed: boolean }}
889
687
  */
890
688
  function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
891
- if (!content || !absoluteRunner) return { content, changed: false };
892
- const platform = (opts && opts.platform) || process.platform;
893
- let changed = false;
894
- // Match `command = "node <scriptToken>"` lines where scriptToken is
895
- // either an unquoted path (no spaces) or a toml-escaped quoted path.
896
- // The whole RHS is a toml-double-quoted string; interior quotes are \".
897
- // Examples we want to migrate:
898
- // command = "node /Users/x/.codex/hooks/gsd-check-update.js"
899
- // command = "node \"/Users/x/.codex/hooks/gsd-check-update.js\""
900
- // Examples we must leave alone:
901
- // command = "\"/usr/local/bin/node\" \"/path/to/gsd-check-update.js\"" ← already absolute
902
- // command = "node /home/me/my-custom.js" ← user-owned filename
903
- const updated = content.replace(
904
- /^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
905
- (full, prefix, scriptToken, suffix) => {
906
- // Extract the underlying script path from the captured token —
907
- // either the bare token or the decoded inner content of \"...\".
908
- const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
909
- let scriptPath = scriptToken;
910
- if (quoted) {
911
- try {
912
- scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
913
- } catch {
914
- scriptPath = quoted[1];
915
- }
916
- }
917
- if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
918
- const desiredCommand = projectCodexHookTomlCommand({
919
- absoluteRunner,
920
- scriptPath,
921
- platform,
922
- });
923
- const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
924
- if (currentCommand === desiredCommand) return full;
925
- changed = true;
926
- return `${prefix}${desiredCommand}${suffix}`;
927
- },
928
- );
929
- return { content: updated, changed };
689
+ return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
930
690
  }
931
691
 
932
692
  /**
@@ -949,83 +709,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
949
709
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
950
710
  */
951
711
  function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
952
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
953
- const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
954
- const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
955
- const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
956
- const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
957
- let parsed = {};
958
- let currentContent = null;
959
- if (fs.existsSync(hooksJsonPath)) {
960
- const raw = fs.readFileSync(hooksJsonPath, 'utf8');
961
- currentContent = raw;
962
- if (raw.trim()) {
963
- try {
964
- parsed = JSON.parse(raw);
965
- } catch (err) {
966
- throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
967
- }
968
- }
969
- }
970
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
971
-
972
- const usesNestedHooksObject =
973
- parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
974
- const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
975
- const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
976
-
977
- let removedLegacy = false;
978
- const sanitizedEntries = [];
979
- for (const entry of eventEntries) {
980
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
981
- const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
982
- if (originalHooks.length === 0) {
983
- sanitizedEntries.push(entry);
984
- continue;
985
- }
986
- const keptHooks = originalHooks.filter((hook) => {
987
- const cmd = hook && typeof hook === 'object' ? hook.command : null;
988
- const managed = isManagedHookCommand(cmd, {
989
- surface: 'codex-hooks-json',
990
- includeLegacyAliases: true,
991
- configDir: targetDir,
992
- });
993
- if (managed) removedLegacy = true;
994
- return !managed;
995
- });
996
- if (keptHooks.length === 0) continue;
997
- const nextEntry = { ...entry, hooks: keptHooks };
998
- sanitizedEntries.push(nextEntry);
999
- }
1000
-
1001
- if (managedCommand) {
1002
- const hookEntry = { type: 'command', command: managedCommand };
1003
- // #772: emit commandWindows so Codex picks the .cmd shim on Windows and
1004
- // the POSIX command on other platforms — without requiring per-OS config
1005
- // regeneration. Sourced from HookHandlerConfig.command_windows field in
1006
- // codex-rs/config/src/hook_config.rs (alias: commandWindows).
1007
- if (commandWindows) hookEntry.commandWindows = commandWindows;
1008
- if (timeout !== undefined) hookEntry.timeout = timeout;
1009
- const newEntry = { hooks: [hookEntry] };
1010
- if (matcher !== undefined) newEntry.matcher = matcher;
1011
- sanitizedEntries.push(newEntry);
1012
- }
1013
-
1014
- if (sanitizedEntries.length > 0) {
1015
- hookTable[eventName] = sanitizedEntries;
1016
- } else {
1017
- delete hookTable[eventName];
1018
- }
1019
- if (usesNestedHooksObject) parsed.hooks = hookTable;
1020
-
1021
- const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
1022
- const changed = currentContent !== nextContent;
1023
- const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
1024
- if (shouldWrite) {
1025
- atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
1026
- }
1027
-
1028
- return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
712
+ return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
1029
713
  }
1030
714
 
1031
715
  /**
@@ -1037,7 +721,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1037
721
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1038
722
  */
1039
723
  function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1040
- return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
724
+ return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
1041
725
  }
1042
726
 
1043
727
  /**
@@ -1070,41 +754,7 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1070
754
  * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
1071
755
  */
1072
756
  function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1073
- if (!absoluteRunnerToken) return null;
1074
- // absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to
1075
- // get the raw interpreter path for the invocation record and render output.
1076
- let interpreter;
1077
- try {
1078
- interpreter = JSON.parse(absoluteRunnerToken);
1079
- } catch {
1080
- interpreter = absoluteRunnerToken;
1081
- }
1082
- // Normalise to forward slashes for cross-shell safety (same as other Windows
1083
- // hook path normalisations in this codebase).
1084
- const targetAbs = scriptAbsPath.replace(/\\/g, '/');
1085
- const scriptQuoted = JSON.stringify(targetAbs);
1086
- // .cmd shim lives alongside the .js file, replacing the extension.
1087
- const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
1088
- // The hook command written to hooks.json is just the .cmd path (double-quoted
1089
- // for spaces-in-path safety). cmd.exe executes .cmd files natively via
1090
- // CreateProcess — no runner prefix required.
1091
- const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
1092
- const runnerQuoted = JSON.stringify(interpreter);
1093
- return {
1094
- invocation: { interpreter, target: scriptAbsPath },
1095
- cmdPath,
1096
- hookCommand,
1097
- // Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565).
1098
- // These describe the render semantics in a structured way so tests can
1099
- // assert on the generator contract without coupling to rendered text.
1100
- eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files
1101
- passthroughArgs: true, // the shim forwards all args via %*
1102
- render: {
1103
- // Use CRLF line endings for strict cmd.exe compatibility (LF-only
1104
- // .cmd files work in modern Windows but CRLF is the canonical format).
1105
- cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
1106
- },
1107
- };
757
+ return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
1108
758
  }
1109
759
 
1110
760
  /**
@@ -1135,69 +785,7 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1135
785
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1136
786
  */
1137
787
  function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1138
- const platform = opts.platform || process.platform;
1139
- const absoluteRunner = opts.absoluteRunner || null;
1140
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
1141
- if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1142
-
1143
- // Normalize backslashes to forward slashes so isManagedHookCommand can
1144
- // match stored commands against configDir on Windows CI runners where
1145
- // path.resolve returns backslash paths but the stored command may use
1146
- // forward slashes (or vice versa). Forward-slash paths are always valid on
1147
- // Windows for both Node.js and Codex, so this normalization is safe for all
1148
- // platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
1149
- const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
1150
-
1151
- // #772: compute the Windows .cmd shim path cross-platform so that
1152
- // `commandWindows` can be emitted in hooks.json regardless of the host OS.
1153
- // The .cmd path is always the .js script path with extension replaced.
1154
- const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
1155
-
1156
- let managedCommand;
1157
- if (platform === 'win32') {
1158
- // #3426 fix: on Windows, write a .cmd shim and use its path as the hook
1159
- // command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's
1160
- // hook dispatcher tries to run node.exe through the Git Bash exec layer.
1161
- const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
1162
- if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
1163
- try {
1164
- atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
1165
- } catch (shimWriteErr) {
1166
- // Shim write failed — do NOT fall back to the old "node.exe script.js"
1167
- // command. That form triggers the `bash.exe: cannot execute binary file`
1168
- // failure that #3426 exists to fix, so a silent fallback would silently
1169
- // restore the original bug. Instead: warn loudly and skip the registration
1170
- // for this runtime so the user sees an actionable message rather than a
1171
- // successful install that fails at hook-dispatch time.
1172
- const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
1173
- console.warn(
1174
- ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
1175
- `Fix the write error (permissions? disk full?) and re-run the installer. ` +
1176
- `Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
1177
- );
1178
- return { changed: false, wrote: false, path: hooksJsonPath };
1179
- }
1180
- managedCommand = shimIR.hookCommand;
1181
- } else {
1182
- managedCommand = projectManagedHookCommand({
1183
- absoluteRunner,
1184
- scriptPath,
1185
- runtime: 'codex',
1186
- platform,
1187
- });
1188
- }
1189
-
1190
- if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1191
-
1192
- // #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
1193
- // the shim was actually written. On POSIX, commandWindows is omitted to avoid
1194
- // pointing Windows Codex at a non-existent .cmd file (the shim is only present
1195
- // when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
1196
- const commandWindows = platform === 'win32'
1197
- ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
1198
- : undefined;
1199
-
1200
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
788
+ return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
1201
789
  }
1202
790
 
1203
791
  /**
@@ -1224,50 +812,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1224
812
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1225
813
  */
1226
814
  function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1227
- const platform = opts.platform || process.platform;
1228
- const absoluteRunner = opts.absoluteRunner || null;
1229
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
1230
- if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1231
-
1232
- // Normalize backslashes to forward slashes so that isManagedHookCommand can
1233
- // match the stored command against configDir on Windows. path.resolve on
1234
- // Windows returns backslash paths, but when platform is not 'win32'
1235
- // (e.g. platform: 'linux' in a test running on a Windows CI runner),
1236
- // projectManagedHookCommand does not normalize them — producing a mismatch
1237
- // between the stored command and the configDir-based hook-dir prefix used
1238
- // for deduplication. Forward-slash paths are always valid on Windows (Node.js
1239
- // and Codex both accept them), so normalizing here is safe for all platforms.
1240
- const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
1241
-
1242
- let managedCommand;
1243
- if (platform === 'win32') {
1244
- // #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
1245
- // hook command. The same bash.exe POSIX-exec failure that affects
1246
- // gsd-check-update.js also affects gsd-context-monitor.js.
1247
- const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
1248
- if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
1249
- try {
1250
- atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
1251
- } catch (shimWriteErr) {
1252
- const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
1253
- console.warn(
1254
- ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
1255
- `Fix the write error (permissions? disk full?) and re-run the installer.`,
1256
- );
1257
- return { changed: false, wrote: false, path: hooksJsonPath };
1258
- }
1259
- managedCommand = shimIR.hookCommand;
1260
- } else {
1261
- managedCommand = projectManagedHookCommand({
1262
- absoluteRunner,
1263
- scriptPath,
1264
- runtime: 'codex',
1265
- platform,
1266
- });
1267
- }
1268
-
1269
- if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1270
- return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
815
+ return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
1271
816
  }
1272
817
 
1273
818
  /**
@@ -1277,11 +822,11 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1277
822
  * @param {string} eventName
1278
823
  */
1279
824
  function removeCodexHooksJsonEvent(targetDir, eventName) {
1280
- return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
825
+ return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
1281
826
  }
1282
827
 
1283
828
  function removeCodexHooksJsonSessionStart(targetDir) {
1284
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
829
+ return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
1285
830
  }
1286
831
 
1287
832
  /**
@@ -1298,62 +843,7 @@ function removeCodexHooksJsonSessionStart(targetDir) {
1298
843
  * runtime: target runtime name for shell projection policy.
1299
844
  */
1300
845
  function buildHookCommand(configDir, hookName, opts) {
1301
- if (!opts) opts = {};
1302
- const platform = opts.platform || process.platform;
1303
- const runtime = opts.runtime || 'generic';
1304
- const isShellHook = hookName.endsWith('.sh');
1305
-
1306
- // #166: Claude Code executes these hook commands inside a bash context on
1307
- // Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can
1308
- // trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted
1309
- // script path for Claude on Windows.
1310
- if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
1311
- if (opts.portableHooks) {
1312
- const portableBaseDir = projectPortableHookBaseDir({
1313
- configDir,
1314
- homeDir: os.homedir(),
1315
- });
1316
- return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
1317
- }
1318
- return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
1319
- }
1320
-
1321
- // POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
1322
- // but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
1323
- // Windows Codex launches hooks from PowerShell/cmd environments where bare
1324
- // `bash` may not be on PATH, so resolve Git Bash explicitly or return null so
1325
- // callers skip registration instead of installing a known-broken hook (#3393).
1326
- // .js hooks still need the absolute node path because GUI-launched runtimes
1327
- // start with a minimal PATH that may not include nvm/Homebrew/Volta node
1328
- // binaries (#2979).
1329
- const nodeRunner = resolveNodeRunner();
1330
- const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
1331
- // Runner resolvers return null when the executable path is unavailable.
1332
- // Fall through with null so callers can skip registration with a warning
1333
- // instead of emitting a command that recreates the original hook failure.
1334
- if (runner === null) return null;
1335
-
1336
- if (opts.portableHooks) {
1337
- const portableBaseDir = projectPortableHookBaseDir({
1338
- configDir,
1339
- homeDir: os.homedir(),
1340
- });
1341
- return projectManagedHookCommand({
1342
- absoluteRunner: runner,
1343
- scriptPath: `${portableBaseDir}/hooks/${hookName}`,
1344
- runtime: opts.runtime || 'generic',
1345
- platform,
1346
- });
1347
- }
1348
-
1349
- // Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
1350
- const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
1351
- return projectManagedHookCommand({
1352
- absoluteRunner: runner,
1353
- scriptPath: hooksPath,
1354
- runtime,
1355
- platform,
1356
- });
846
+ return hooksSurface.buildHookCommand(configDir, hookName, opts);
1357
847
  }
1358
848
 
1359
849
  /**
@@ -1720,6 +1210,52 @@ function injectEffortFrontmatter(content, effortValue) {
1720
1210
  return `${before}effort: ${effortValue}${eol}${after}`;
1721
1211
  }
1722
1212
 
1213
+ /**
1214
+ * #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
1215
+ * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
1216
+ * inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
1217
+ * which break on unknown frontmatter keys.
1218
+ */
1219
+ function injectDisallowedToolsFrontmatter(content, disallowedValue) {
1220
+ // Detect the dominant EOL from the first line (the opening `---`).
1221
+ // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1222
+ const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1223
+
1224
+ // Build a frontmatter-matching regex that tolerates an optional \r before
1225
+ // each \n, so we handle both LF and CRLF files without needing to normalise
1226
+ // the whole content.
1227
+ const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1228
+ const match = fmRe.exec(content);
1229
+ if (!match) return content; // no YAML frontmatter — leave unchanged
1230
+
1231
+ // Idempotency guard: don't insert a second disallowedTools: line.
1232
+ const fmBody = match[1]; // content between the two `---` lines
1233
+ if (/^disallowedTools:/m.test(fmBody)) return content;
1234
+
1235
+ // Locate the exact position of the closing `---` line so we can insert
1236
+ // before it using a simple string splice.
1237
+ const openLen = 3 + eol.length; // "---" + eol
1238
+ const closingStart = match.index + openLen + fmBody.length;
1239
+
1240
+ const before = content.slice(0, closingStart);
1241
+ const after = content.slice(closingStart);
1242
+ return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
1243
+ }
1244
+
1245
+ // #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
1246
+ // Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
1247
+ // output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
1248
+ // excluded (it legitimately uses Write AND Edit to create/patch test files).
1249
+ const READONLY_AGENT_DISALLOWED_TOOLS = {
1250
+ 'gsd-plan-checker': 'Write, Edit, MultiEdit',
1251
+ 'gsd-integration-checker': 'Write, Edit, MultiEdit',
1252
+ 'gsd-ui-checker': 'Write, Edit, MultiEdit',
1253
+ 'gsd-verifier': 'Edit, MultiEdit',
1254
+ 'gsd-doc-verifier': 'Edit, MultiEdit',
1255
+ 'gsd-eval-auditor': 'Edit, MultiEdit',
1256
+ 'gsd-ui-auditor': 'Edit, MultiEdit',
1257
+ };
1258
+
1723
1259
  /**
1724
1260
  * #2517 — Read a single GSD config file (defaults.json or per-project
1725
1261
  * config.json) into a plain object, returning null on missing/empty files
@@ -2202,12 +1738,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
2202
1738
  return c;
2203
1739
  }
2204
1740
 
1741
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2205
1742
  /**
2206
1743
  * Convert a Claude command (.md) to a Copilot skill (SKILL.md).
2207
1744
  * Transforms frontmatter only — body passes through with CONV-06/07 applied.
2208
1745
  * Skills keep original tool names (no mapping) per CONTEXT.md decision.
2209
1746
  */
2210
- function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
1747
+ function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2211
1748
  const converted = convertClaudeToCopilotContent(content, isGlobal);
2212
1749
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2213
1750
  if (!frontmatter) return converted;
@@ -2675,8 +2212,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2675
2212
  /**
2676
2213
  * Apply Antigravity-specific content conversion — path replacement + command name conversion.
2677
2214
  * Path mappings depend on install mode:
2678
- * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agent/
2679
- * Local: ~/.claude/ → .agent/, ./.claude/ → ./.agent/
2215
+ * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2216
+ * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
2680
2217
  * Applied to ALL Antigravity content (skills, agents, engine files).
2681
2218
  * @param {string} content - Source content to convert
2682
2219
  * @param {boolean} [isGlobal=false] - Whether this is a global install
@@ -2690,14 +2227,14 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2690
2227
  c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
2691
2228
  c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
2692
2229
  } else {
2693
- c = c.replace(/\$HOME\/\.claude\//g, '.agent/');
2694
- c = c.replace(/~\/\.claude\//g, '.agent/');
2230
+ c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
2231
+ c = c.replace(/~\/\.claude\//g, '.agents/');
2695
2232
  // Bare form (no trailing slash) — must come after slash form to avoid double-replace
2696
- c = c.replace(/\$HOME\/\.claude\b/g, '.agent');
2697
- c = c.replace(/~\/\.claude\b/g, '.agent');
2233
+ c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
2234
+ c = c.replace(/~\/\.claude\b/g, '.agents');
2698
2235
  }
2699
- c = c.replace(/\.\/\.claude\//g, './.agent/');
2700
- c = c.replace(/\.claude\//g, '.agent/');
2236
+ c = c.replace(/\.\/\.claude\//g, './.agents/');
2237
+ c = c.replace(/\.claude\//g, '.agents/');
2701
2238
  // Command name conversion (all gsd: references → gsd-)
2702
2239
  c = c.replace(/gsd:/g, 'gsd-');
2703
2240
  // Runtime-neutral agent name replacement (#766)
@@ -2705,12 +2242,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2705
2242
  return c;
2706
2243
  }
2707
2244
 
2245
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2708
2246
  /**
2709
2247
  * Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
2710
2248
  * Transforms frontmatter to minimal name + description only.
2711
2249
  * Body passes through with path/command conversions applied.
2712
2250
  */
2713
- function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
2251
+ function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2714
2252
  const converted = convertClaudeToAntigravityContent(content, isGlobal);
2715
2253
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2716
2254
  if (!frontmatter) return converted;
@@ -2969,18 +2507,20 @@ function convertClaudeToWindsurfMarkdown(content) {
2969
2507
  // Replace subagent_type from Claude to Windsurf format
2970
2508
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2971
2509
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2972
- // Replace project-level Claude conventions with Windsurf equivalents
2973
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
2974
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
2975
- converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
2976
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
2977
- converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
2978
- converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
2979
- converted = converted.replace(/\.claude\//g, '.windsurf/');
2510
+ // Replace project-level Claude conventions with Windsurf/Devin equivalents
2511
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
2512
+ // Legacy .windsurf/ is still recognized on read but new installs use .devin/.
2513
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
2514
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
2515
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
2516
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
2517
+ converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
2518
+ converted = converted.replace(/\.\/\.claude\//g, './.devin/');
2519
+ converted = converted.replace(/\.claude\//g, '.devin/');
2980
2520
  // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2981
2521
  // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2982
- converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
2983
- converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
2522
+ converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
2523
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
2984
2524
  // Environment variable name rewrite
2985
2525
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
2986
2526
  // Remove Claude Code-specific bug workarounds before brand replacement
@@ -3627,7 +3167,7 @@ purpose: ${toSingleLine(description)}
3627
3167
  * @param {object|null} runtimeResolver — runtime-aware tier resolver from readGsdRuntimeProfileResolver
3628
3168
  * @param {object|null} effortCfg — #443: merged effort config from readGsdEffectiveEffortConfig
3629
3169
  */
3630
- function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null) {
3170
+ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
3631
3171
  const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
3632
3172
  const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
3633
3173
  const frontmatterText = frontmatter || '';
@@ -3640,8 +3180,10 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3640
3180
  const lines = [
3641
3181
  `name = ${JSON.stringify(resolvedName)}`,
3642
3182
  `description = ${JSON.stringify(resolvedDescription)}`,
3643
- `sandbox_mode = "${sandboxMode}"`,
3644
3183
  ];
3184
+ if (sandboxTier != null && sandboxTier !== 'none') {
3185
+ lines.push(`sandbox_mode = "${sandboxMode}"`);
3186
+ }
3645
3187
 
3646
3188
  // Embed model override when configured in ~/.gsd/defaults.json so that
3647
3189
  // model_overrides is respected on Codex (which uses static TOML, not inline
@@ -5671,36 +5213,14 @@ function rewriteTomlKeyLines(content, matches, key) {
5671
5213
  return rewritten;
5672
5214
  }
5673
5215
 
5674
- /**
5675
- * Atomic write — write to <target>.tmp-<pid>-<n> first, then renameSync over
5676
- * the target. Eliminates the partial-write corruption window: an interrupted
5677
- * write leaves the temp file (which we clean up) but never truncates the
5678
- * original target. Used for any mutation of Codex config.toml so we cannot
5679
- * leave the user with a half-written file (#2760 fix 4).
5680
- *
5681
- * Every temp path written is recorded in __atomicWrittenTmps so that
5682
- * _cleanTmpFiles() can scope cleanup to files this installer process actually
5683
- * created, avoiding accidental deletion of unrelated tools' temp files.
5684
- */
5685
- let __atomicWriteCounter = 0;
5686
- // Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
5687
- const __atomicWrittenTmps = new Set();
5688
- function atomicWriteFileSync(target, data, options) {
5689
- __atomicWriteCounter += 1;
5690
- const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
5691
- __atomicWrittenTmps.add(tmp);
5692
- try {
5693
- fs.writeFileSync(tmp, data, options);
5694
- fs.renameSync(tmp, target);
5695
- // Successful rename: the tmp path no longer exists, but leave it in the
5696
- // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
5697
- // lingers (e.g. a rename succeeded but left a stale entry on some FS).
5698
- } catch (e) {
5699
- // Best-effort cleanup of the partial temp file; never mask the real error.
5700
- try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
5701
- throw e;
5702
- }
5703
- }
5216
+ // atomicWriteFileSync and __atomicWrittenTmps are now owned by the
5217
+ // runtime-hooks-surface module and imported here so both install.js's
5218
+ // direct config.toml writes and the module's Cursor/Codex hooks.json
5219
+ // writes share the SAME tracking Set. _cleanTmpFiles() below reads
5220
+ // hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
5221
+ // temps only.
5222
+ const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
5223
+ const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
5704
5224
 
5705
5225
  /**
5706
5226
  * Merge GSD config block into an existing or new config.toml.
@@ -5748,7 +5268,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
5748
5268
 
5749
5269
  /**
5750
5270
  * Repair config.toml files corrupted by pre-#1346 GSD installs.
5751
- * Non-boolean keys (e.g. model = "gpt-5.3-codex") that ended up under [features]
5271
+ * Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
5752
5272
  * are relocated before the [features] header so Codex can parse them correctly.
5753
5273
  * Returns the content unchanged if no trapped keys are found.
5754
5274
  */
@@ -6038,23 +5558,12 @@ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
6038
5558
  * engine layout, not the (separate) #782 Cline skills directory.
6039
5559
  */
6040
5560
  function buildClineRulesBody() {
6041
- return [
6042
- '# GSD Core — Git. Ship. Done.',
6043
- '',
6044
- '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
6045
- ' the user runs a `/gsd-*` command.',
6046
- '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
6047
- '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
6048
- '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
6049
- '- Do not apply GSD workflows unless the user explicitly asks for them.',
6050
- '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
6051
- ' step to the user using Cline\'s ask_user tool after completing it.',
6052
- ].join('\n') + '\n';
5561
+ return hooksSurface.buildClineRulesBody();
6053
5562
  }
6054
5563
 
6055
5564
  /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
6056
5565
  function buildClineAgentsMdBody() {
6057
- return buildClineRulesBody();
5566
+ return hooksSurface.buildClineAgentsMdBody();
6058
5567
  }
6059
5568
 
6060
5569
  /**
@@ -6070,53 +5579,7 @@ function buildClineAgentsMdBody() {
6070
5579
  * hook bug can never wedge the user. No dependency on the #782 skills work.
6071
5580
  */
6072
5581
  function buildClinePreToolUseHook() {
6073
- return `#!/usr/bin/env node
6074
- 'use strict';
6075
- /* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
6076
- * Protocol: JSON on stdin -> JSON decision on stdout.
6077
- * Honored fields: { cancel, errorMessage, contextModification }.
6078
- * Fails open: any error allows the operation. */
6079
- let raw = '';
6080
- process.stdin.setEncoding('utf8');
6081
- process.stdin.on('data', (c) => { raw += c; });
6082
- process.stdin.on('end', () => {
6083
- const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
6084
- let input;
6085
- try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
6086
- try {
6087
- const tool = String(
6088
- input.toolName || input.tool_name || input.tool ||
6089
- (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
6090
- ).toLowerCase();
6091
- const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
6092
- // Collect only PATH-bearing field values (not free-form content), so a doc
6093
- // that merely mentions ".planning/" in its body is never falsely blocked.
6094
- const paths = [];
6095
- const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
6096
- const walk = (v, depth) => {
6097
- if (depth > 5 || paths.length > 64) return;
6098
- if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
6099
- if (v && typeof v === 'object') {
6100
- for (const k of Object.keys(v)) {
6101
- const val = v[k];
6102
- if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
6103
- else walk(val, depth + 1);
6104
- }
6105
- }
6106
- };
6107
- walk(input, 0);
6108
- const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
6109
- if (isWrite && paths.some(isPlanningPath)) {
6110
- return process.stdout.write(JSON.stringify({
6111
- cancel: true,
6112
- errorMessage:
6113
- 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
6114
- }));
6115
- }
6116
- } catch { /* fall through to allow */ }
6117
- return allow();
6118
- });
6119
- `;
5582
+ return hooksSurface.buildClinePreToolUseHook();
6120
5583
  }
6121
5584
 
6122
5585
  /**
@@ -6124,31 +5587,7 @@ process.stdin.on('end', () => {
6124
5587
  * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
6125
5588
  */
6126
5589
  function mergeGsdAgentsMd(filePath, gsdContent) {
6127
- const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
6128
-
6129
- if (!fs.existsSync(filePath)) {
6130
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
6131
- fs.writeFileSync(filePath, gsdBlock + '\n');
6132
- return;
6133
- }
6134
-
6135
- const existing = fs.readFileSync(filePath, 'utf8');
6136
- const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
6137
- const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
6138
-
6139
- if (openIndex !== -1 && closeIndex !== -1) {
6140
- const before = existing.substring(0, openIndex).trimEnd();
6141
- const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
6142
- let newContent = '';
6143
- if (before) newContent += before + '\n\n';
6144
- newContent += gsdBlock;
6145
- if (after) newContent += '\n\n' + after;
6146
- newContent += '\n';
6147
- fs.writeFileSync(filePath, newContent);
6148
- return;
6149
- }
6150
-
6151
- fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
5590
+ return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
6152
5591
  }
6153
5592
 
6154
5593
  /**
@@ -6178,53 +5617,7 @@ function stripGsdFromAgentsMd(content) {
6178
5617
  * caller can hash-track them).
6179
5618
  */
6180
5619
  function writeClineArtifacts(targetDir, isGlobalInstall) {
6181
- const written = [];
6182
- const clinerulesDir = path.join(targetDir, '.clinerules');
6183
-
6184
- // Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
6185
- // file and a directory, so the legacy file must be removed first. The legacy
6186
- // file is GSD-authored (the installer wrote its full contents with no user
6187
- // merge surface), so replacing it with the newer directory form is the
6188
- // intended upgrade. Use lstat so a symlink is unlinked in place rather than
6189
- // followed (which would write GSD files through the link into an external dir).
6190
- try {
6191
- if (fs.existsSync(clinerulesDir)) {
6192
- const st = fs.lstatSync(clinerulesDir);
6193
- if (st.isFile() || st.isSymbolicLink()) {
6194
- fs.unlinkSync(clinerulesDir);
6195
- console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
6196
- }
6197
- }
6198
- } catch { /* best-effort migration */ }
6199
-
6200
- fs.mkdirSync(clinerulesDir, { recursive: true });
6201
- fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
6202
- written.push('.clinerules/gsd.md');
6203
- console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
6204
-
6205
- const hooksDir = path.join(clinerulesDir, 'hooks');
6206
- fs.mkdirSync(hooksDir, { recursive: true });
6207
- const hookPath = path.join(hooksDir, 'PreToolUse');
6208
- fs.writeFileSync(hookPath, buildClinePreToolUseHook());
6209
- try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
6210
- written.push('.clinerules/hooks/PreToolUse');
6211
- console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
6212
-
6213
- // Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
6214
- // (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
6215
- // a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
6216
- // not the per-configDir manifest, since it lives outside configDir.
6217
- if (isGlobalInstall) {
6218
- try {
6219
- const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
6220
- mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
6221
- console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
6222
- } catch (err) {
6223
- console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
6224
- }
6225
- }
6226
-
6227
- return written;
5620
+ return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
6228
5621
  }
6229
5622
 
6230
5623
  // ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
@@ -6254,11 +5647,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
6254
5647
  * @returns {object} Cursor hook entry object
6255
5648
  */
6256
5649
  function buildCursorHookEntry(scriptPath) {
6257
- return {
6258
- type: 'command',
6259
- command: scriptPath.replace(/\\/g, '/'),
6260
- [GSD_CURSOR_HOOK_MARKER]: true,
6261
- };
5650
+ return hooksSurface.buildCursorHookEntry(scriptPath);
6262
5651
  }
6263
5652
 
6264
5653
  /**
@@ -6269,7 +5658,7 @@ function buildCursorHookEntry(scriptPath) {
6269
5658
  * @returns {boolean}
6270
5659
  */
6271
5660
  function isManagedCursorHookEntry(entry) {
6272
- return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
5661
+ return hooksSurface.isManagedCursorHookEntry(entry);
6273
5662
  }
6274
5663
 
6275
5664
  /**
@@ -6290,74 +5679,7 @@ function isManagedCursorHookEntry(entry) {
6290
5679
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
6291
5680
  */
6292
5681
  function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
6293
- let parsed = {};
6294
- let currentContent = null;
6295
-
6296
- if (fs.existsSync(hooksJsonPath)) {
6297
- const raw = fs.readFileSync(hooksJsonPath, 'utf8');
6298
- currentContent = raw;
6299
- if (raw.trim()) {
6300
- try {
6301
- parsed = JSON.parse(raw);
6302
- } catch (err) {
6303
- throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
6304
- }
6305
- }
6306
- }
6307
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
6308
-
6309
- // Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
6310
- // GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
6311
- // The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
6312
- // with manually-written files, but the output always uses the nested form.
6313
- const hasNestedHooksObject =
6314
- parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
6315
- if (!hasNestedHooksObject) {
6316
- // Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
6317
- const eventKeys = ['sessionStart', 'postToolUse'];
6318
- const lifted = {};
6319
- for (const k of eventKeys) {
6320
- if (Array.isArray(parsed[k])) {
6321
- lifted[k] = parsed[k];
6322
- delete parsed[k];
6323
- }
6324
- }
6325
- parsed.hooks = lifted;
6326
- }
6327
- if (!parsed.version) parsed.version = 1;
6328
- const hookTable = parsed.hooks;
6329
-
6330
- // Events GSD manages.
6331
- const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
6332
- const entries = managedEntries || {};
6333
-
6334
- for (const event of MANAGED_EVENTS) {
6335
- const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
6336
- // Strip all prior GSD-managed entries for this event.
6337
- const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
6338
- const newEntry = entries[event] || null;
6339
- if (newEntry) {
6340
- hookTable[event] = [...userOwned, newEntry];
6341
- } else {
6342
- // Remove-only: keep user entries, or delete the key if it would be empty.
6343
- if (userOwned.length > 0) {
6344
- hookTable[event] = userOwned;
6345
- } else {
6346
- delete hookTable[event];
6347
- }
6348
- }
6349
- }
6350
-
6351
- // hookTable is parsed.hooks (always nested now); no reassignment needed.
6352
- // Write only if content changed or if we're creating the file for the first time.
6353
- const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
6354
- const changed = currentContent !== nextContent;
6355
- const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
6356
- if (shouldWrite) {
6357
- atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
6358
- }
6359
-
6360
- return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
5682
+ return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
6361
5683
  }
6362
5684
 
6363
5685
  /**
@@ -6373,65 +5695,7 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
6373
5695
  * @returns {{ hooksJsonPath: string, changed: boolean }}
6374
5696
  */
6375
5697
  function writeCursorHooksJson(targetDir, src, opts) {
6376
- opts = opts || {};
6377
- const hooksDir = path.join(targetDir, 'hooks');
6378
- fs.mkdirSync(hooksDir, { recursive: true });
6379
-
6380
- // Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
6381
- // Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
6382
- // JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
6383
- // Track which scripts were successfully installed so we never register a hook entry
6384
- // that references a script that wasn't copied (dangling command guard).
6385
- const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
6386
- const srcHooksDir = path.join(src, 'hooks');
6387
- const installedScripts = new Set();
6388
- for (const script of hookScripts) {
6389
- const srcPath = path.join(srcHooksDir, script);
6390
- const destPath = path.join(hooksDir, script);
6391
- if (fs.existsSync(srcPath)) {
6392
- let content = fs.readFileSync(srcPath, 'utf8');
6393
- // Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
6394
- // with the Cursor convention (no colon-form slash commands in agent context).
6395
- content = content.replace(/gsd:/gi, 'gsd-');
6396
- fs.writeFileSync(destPath, content);
6397
- try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
6398
- installedScripts.add(script);
6399
- }
6400
- }
6401
-
6402
- // Build command strings using the same buildHookCommand helper used by other runtimes.
6403
- // buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
6404
- const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
6405
- // buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
6406
- // Only register the hook entry if the script was actually installed (dangling guard).
6407
- const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
6408
- ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
6409
- : null;
6410
- // buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
6411
- const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
6412
- ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
6413
- : null;
6414
-
6415
- // Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
6416
- const managedEntries = {};
6417
- if (sessionStartCmd) {
6418
- managedEntries.sessionStart = {
6419
- type: 'command',
6420
- command: sessionStartCmd,
6421
- [GSD_CURSOR_HOOK_MARKER]: true,
6422
- };
6423
- }
6424
- if (postToolCmd) {
6425
- managedEntries.postToolUse = {
6426
- type: 'command',
6427
- command: postToolCmd,
6428
- [GSD_CURSOR_HOOK_MARKER]: true,
6429
- };
6430
- }
6431
-
6432
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
6433
- const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
6434
- return { hooksJsonPath, changed: result.changed };
5698
+ return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
6435
5699
  }
6436
5700
 
6437
5701
  /**
@@ -6442,31 +5706,7 @@ function writeCursorHooksJson(targetDir, src, opts) {
6442
5706
  * @returns {{ changed: boolean }}
6443
5707
  */
6444
5708
  function removeCursorHooksJson(targetDir) {
6445
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
6446
- if (!fs.existsSync(hooksJsonPath)) return { changed: false };
6447
- const result = reconcileCursorHooksJson(hooksJsonPath, null);
6448
- // If the resulting file has no meaningful hook content, remove it.
6449
- // A file is "empty" if it contains only the scaffolding (version, empty hooks
6450
- // object, or a bare {}) with no user-authored hook entries.
6451
- if (result.changed) {
6452
- try {
6453
- const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
6454
- const parsed = JSON.parse(contentRaw);
6455
- // reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
6456
- // The file is "empty" when there are no remaining hook events with entries.
6457
- const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
6458
- ? parsed.hooks
6459
- : {};
6460
- const hasAnyEvents = Object.keys(hookTable).some(
6461
- (k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
6462
- );
6463
- if (!hasAnyEvents) {
6464
- fs.unlinkSync(hooksJsonPath);
6465
- return { changed: true };
6466
- }
6467
- } catch { /* best-effort: leave the file */ }
6468
- }
6469
- return { changed: result.changed };
5709
+ return hooksSurface.removeCursorHooksJson(targetDir);
6470
5710
  }
6471
5711
 
6472
5712
  /**
@@ -6484,19 +5724,7 @@ function removeCursorHooksJson(targetDir) {
6484
5724
  * @returns {object} Copilot hooks-configuration object
6485
5725
  */
6486
5726
  function buildCopilotHookConfig() {
6487
- return {
6488
- version: 1,
6489
- hooks: {
6490
- sessionStart: [
6491
- {
6492
- type: 'command',
6493
- bash: GSD_COPILOT_SESSION_HOOK_BASH,
6494
- powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
6495
- timeoutSec: 10,
6496
- },
6497
- ],
6498
- },
6499
- };
5727
+ return hooksSurface.buildCopilotHookConfig();
6500
5728
  }
6501
5729
 
6502
5730
  /**
@@ -6513,18 +5741,14 @@ function buildCopilotHookConfig() {
6513
5741
  * @returns {string} The path the hook config was written to
6514
5742
  */
6515
5743
  function writeCopilotHookConfig(targetDir) {
6516
- const hooksDir = path.join(targetDir, 'hooks');
6517
- fs.mkdirSync(hooksDir, { recursive: true });
6518
- const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
6519
- fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
6520
- return hookPath;
5744
+ return hooksSurface.writeCopilotHookConfig(targetDir);
6521
5745
  }
6522
5746
 
6523
5747
  /**
6524
5748
  * Generate config.toml and per-agent .toml files for Codex.
6525
5749
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
6526
5750
  */
6527
- function installCodexConfig(targetDir, agentsSrc) {
5751
+ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
6528
5752
  const configPath = path.join(targetDir, 'config.toml');
6529
5753
  const agentsTomlDir = path.join(targetDir, 'agents');
6530
5754
  fs.mkdirSync(agentsTomlDir, { recursive: true });
@@ -6570,7 +5794,7 @@ function installCodexConfig(targetDir, agentsSrc) {
6570
5794
  // #443 — pass unified effort config so model_reasoning_effort in the .toml
6571
5795
  // follows the same config-driven precedence as the Claude .md effort key.
6572
5796
  const effortCfg = readGsdEffectiveEffortConfig(targetDir);
6573
- const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg);
5797
+ const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
6574
5798
  fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
6575
5799
  }
6576
5800
 
@@ -7565,8 +6789,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
7565
6789
  * @param {string} stagedDir
7566
6790
  * @param {string} runtime
7567
6791
  * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
6792
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7568
6793
  */
7569
- function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
6794
+ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7570
6795
  if (!fs.existsSync(stagedDir)) return;
7571
6796
 
7572
6797
  // Walk all SKILL.md files under stagedDir
@@ -7577,7 +6802,7 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7577
6802
  walkAndRewrite(fullPath);
7578
6803
  } else if (entry.name.endsWith('.md')) {
7579
6804
  let content = fs.readFileSync(fullPath, 'utf8');
7580
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6805
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7581
6806
  fs.writeFileSync(fullPath, content);
7582
6807
  }
7583
6808
  }
@@ -7598,9 +6823,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7598
6823
  * @param {string} stagedDir directory of staged flat .md command files (may be source dir)
7599
6824
  * @param {string} runtime
7600
6825
  * @param {string} pathPrefix
6826
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7601
6827
  * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
7602
6828
  */
7603
- function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
6829
+ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7604
6830
  if (!fs.existsSync(stagedDir)) return stagedDir;
7605
6831
  // Always copy to a temp dir — stageSkillsForProfile() returns the original source
7606
6832
  // dir on full/default profile (skills === '*'), so writing in-place would corrupt the
@@ -7610,7 +6836,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7610
6836
  for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
7611
6837
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7612
6838
  let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
7613
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6839
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7614
6840
  // For augment commands, apply the markdown conversion so tool references
7615
6841
  // and skill paths use Augment equivalents.
7616
6842
  if (runtime === 'augment') {
@@ -7632,9 +6858,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7632
6858
  * @param {string} content
7633
6859
  * @param {string} runtime
7634
6860
  * @param {string} pathPrefix trailing-slash string
6861
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7635
6862
  * @returns {string}
7636
6863
  */
7637
- function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6864
+ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
7638
6865
  const dirName = getDirName(runtime);
7639
6866
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
7640
6867
 
@@ -7671,24 +6898,42 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7671
6898
  content = processAttribution(content, getCommitAttribution(runtime));
7672
6899
  break;
7673
6900
 
7674
- case 'windsurf':
6901
+ case 'windsurf': {
7675
6902
  content = content.replace(/~\/\.claude\//g, pathPrefix);
7676
6903
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7677
6904
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
7678
6905
  // Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
7679
6906
  // .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
7680
- // fires between 'e' and '-', which rewrites .claude-plugin → .windsurf-plugin).
6907
+ // fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
7681
6908
  content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
7682
6909
  content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
7683
6910
  content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
6911
+ // Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
6912
+ // form). For global installs the real path is pathPrefix + skills/, so fix that up
6913
+ // here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
6914
+ // not derived from pathPrefix substring which misclassifies custom config dirs).
6915
+ // For local installs, the relative .devin/ form is correct — leave it. (#1085)
6916
+ if (isGlobal) {
6917
+ content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
6918
+ content = content.replace(/\.\/\.devin\//g, pathPrefix);
6919
+ content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
6920
+ content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
6921
+ }
7684
6922
  content = processAttribution(content, getCommitAttribution(runtime));
7685
6923
  break;
6924
+ }
7686
6925
 
7687
6926
  case 'augment':
7688
6927
  content = content.replace(/~\/\.claude\//g, pathPrefix);
7689
6928
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7690
6929
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
6930
+ content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
6931
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
6932
+ content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`);
7691
6933
  content = content.replace(/~\/\.augment\//g, pathPrefix);
6934
+ content = content.replace(/\$HOME\/\.augment\//g, pathPrefix);
6935
+ content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix);
6936
+ content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix);
7692
6937
  content = processAttribution(content, getCommitAttribution(runtime));
7693
6938
  break;
7694
6939
 
@@ -7748,6 +6993,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7748
6993
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7749
6994
  content = content.replace(/~\/\.qwen\//g, pathPrefix);
7750
6995
  content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
6996
+ content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
6997
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
6998
+ content = content.replace(/~\/\.qwen(?![\w-])/g, normalizedPathPrefix);
6999
+ content = content.replace(/\$HOME\/\.qwen(?![\w-])/g, normalizedPathPrefix);
7751
7000
  // Bare relative .claude/ → .qwen/ (residual refs not matched above)
7752
7001
  content = content.replace(/\.claude\//g, '.qwen/');
7753
7002
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
@@ -7764,6 +7013,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7764
7013
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7765
7014
  content = content.replace(/~\/\.hermes\//g, pathPrefix);
7766
7015
  content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
7016
+ content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
7017
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
7018
+ content = content.replace(/~\/\.hermes(?![\w-])/g, normalizedPathPrefix);
7019
+ content = content.replace(/\$HOME\/\.hermes(?![\w-])/g, normalizedPathPrefix);
7767
7020
  // Bare relative .claude/ → .hermes/ (residual refs)
7768
7021
  content = content.replace(/\.claude\//g, '.hermes/');
7769
7022
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
@@ -8114,11 +7367,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
8114
7367
  // stagedForCopy: the directory to copy from (may differ from staged if rewrites
8115
7368
  // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
8116
7369
  let stagedForCopy = staged;
7370
+ const isGlobal = scope === 'global';
8117
7371
  if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
8118
- applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
7372
+ applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
8119
7373
  } else if (kind.kind === 'commands') {
8120
7374
  // Returns a temp dir with rewritten content so source files are never mutated.
8121
- stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
7375
+ stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
8122
7376
  }
8123
7377
  // applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
8124
7378
  // os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
@@ -8453,11 +7707,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
8453
7707
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
8454
7708
  fs.writeFileSync(destPath, jsContent);
8455
7709
  } else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
8456
- // For Windsurf, also convert Claude references in JS/CJS utility scripts
7710
+ // For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
7711
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
8457
7712
  let jsContent = fs.readFileSync(srcPath, 'utf8');
8458
7713
  jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
8459
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.windsurf/skills/');
8460
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.windsurf/rules');
7714
+ jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
7715
+ jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
8461
7716
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
8462
7717
  fs.writeFileSync(destPath, jsContent);
8463
7718
  } else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
@@ -10166,7 +9421,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10166
9421
  const isHermes = runtime === 'hermes';
10167
9422
  const isCodebuddy = runtime === 'codebuddy';
10168
9423
  const isCline = runtime === 'cline';
10169
- const configIntent = resolveRuntimeConfigIntent(runtime);
9424
+ const plan = resolveInstallPlan(runtime);
10170
9425
  const dirName = getDirName(runtime);
10171
9426
  const src = path.join(__dirname, '..');
10172
9427
 
@@ -10220,6 +9475,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10220
9475
  // Get the target directory based on runtime and install type.
10221
9476
  // Cline local installs write to the project root (like Claude Code) — .clinerules
10222
9477
  // lives at the root, not inside a .cline/ subdirectory.
9478
+ // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
9479
+ // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
9480
+ // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
9481
+ // auto-removed on reinstall (dual-read fallback per issue #791 spec).
10223
9482
  const targetDir = isGlobal
10224
9483
  ? getGlobalConfigDir(runtime, explicitConfigDir)
10225
9484
  : isCline
@@ -11010,6 +10269,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11010
10269
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
11011
10270
  const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
11012
10271
  content = injectEffortFrontmatter(content, _renderedEffort);
10272
+ const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
10273
+ if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
11013
10274
  }
11014
10275
  // #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
11015
10276
  // to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
@@ -11307,7 +10568,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11307
10568
  throw _earlyInstallErr;
11308
10569
  }
11309
10570
 
11310
- if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
10571
+ if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
11311
10572
  // Capture pre-install snapshots before ANY GSD mutation
11312
10573
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
11313
10574
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -11473,7 +10734,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11473
10734
  if (!isMinimalMode(_effectiveInstallMode)) {
11474
10735
  try {
11475
10736
  // Generate Codex config.toml and per-agent .toml files.
11476
- agentCount = installCodexConfig(targetDir, agentsSrc);
10737
+ agentCount = installCodexConfig(targetDir, agentsSrc, plan.sandboxTier);
11477
10738
  } catch (e) {
11478
10739
  restoreCodexSnapshot();
11479
10740
  throw e;
@@ -11686,7 +10947,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11686
10947
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11687
10948
  }
11688
10949
 
11689
- if (configIntent.installSurface === 'copilot-instructions') {
10950
+ if (plan.installSurface === 'copilot-instructions') {
11690
10951
  // Generate copilot-instructions.md
11691
10952
  const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
11692
10953
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -11716,7 +10977,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11716
10977
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11717
10978
  }
11718
10979
 
11719
- if (configIntent.installSurface === 'cursor-hooks-json') {
10980
+ if (plan.installSurface === 'cursor-hooks-json') {
11720
10981
  // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
11721
10982
  // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
11722
10983
  const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
@@ -11731,13 +10992,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11731
10992
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11732
10993
  }
11733
10994
 
11734
- if (configIntent.installSurface === 'profile-marker-only') {
10995
+ if (plan.installSurface === 'profile-marker-only') {
11735
10996
  // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
11736
10997
  persistActiveProfileMarker();
11737
10998
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11738
10999
  }
11739
11000
 
11740
- if (configIntent.installSurface === 'cline-rules') {
11001
+ if (plan.installSurface === 'cline-rules') {
11741
11002
  // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
11742
11003
  // at .clinerules/gsd.md and a PreToolUse lifecycle hook at
11743
11004
  // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
@@ -11750,8 +11011,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11750
11011
  }
11751
11012
 
11752
11013
  // Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
11753
- // Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks
11754
- const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse';
11014
+ // ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
11015
+ // runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
11016
+ // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check.
11017
+ // A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
11018
+ const _hookEventsDialect = plan.hookEvents;
11019
+ const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
11755
11020
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
11756
11021
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
11757
11022
  // settings.json. Global installs and all other runtimes continue to use settings.json.
@@ -11925,476 +11190,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11925
11190
  }
11926
11191
  }
11927
11192
 
11928
- // Helper: detect whether a hook entry references a managed hook by name.
11929
- // Checks both the plain command string (standard form) and the args array
11930
- // (command+args / wrapped-launcher form used by windowless launchers on
11931
- // Windows and some custom PATH-less environments). Without this check the
11932
- // presence guards below only inspect h.command, so an args-form wrapper is
11933
- // invisible and a stock string-command entry is appended on every
11934
- // install/update, running the hook twice. (#976)
11935
- function referencesHook(h, hookName) {
11936
- return (typeof h.command === 'string' && h.command.includes(hookName)) ||
11937
- (Array.isArray(h.args) && h.args.some(a => typeof a === 'string' && a.includes(hookName)));
11938
- }
11939
-
11940
- // Configure SessionStart hook for update checking (skip for opencode)
11941
- if (!isOpencode && !isKilo) {
11942
- if (!settings.hooks) {
11943
- settings.hooks = {};
11944
- }
11945
- if (!settings.hooks.SessionStart) {
11946
- settings.hooks.SessionStart = [];
11947
- }
11948
-
11949
- const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
11950
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-check-update'))
11951
- );
11952
-
11953
- // Guard: only register if the hook file was actually installed (#1754).
11954
- // When hooks/dist/ is missing from the npm package (as in v1.32.0), the
11955
- // copy step produces no files but the registration step ran unconditionally,
11956
- // causing "hook error" on every tool invocation.
11957
- const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js');
11958
- if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) {
11959
- settings.hooks.SessionStart.push({
11960
- hooks: [
11961
- {
11962
- type: 'command',
11963
- command: updateCheckCommand
11964
- }
11965
- ]
11966
- });
11967
- console.log(` ${green}✓${reset} Configured update check hook`);
11968
- } else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) {
11969
- console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
11970
- }
11971
-
11972
- // Configure post-tool hook for context window monitoring
11973
- if (!settings.hooks[postToolEvent]) {
11974
- settings.hooks[postToolEvent] = [];
11975
- }
11976
-
11977
- const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
11978
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
11979
- );
11980
-
11981
- const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
11982
- if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11983
- settings.hooks[postToolEvent].push({
11984
- matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
11985
- hooks: [
11986
- {
11987
- type: 'command',
11988
- command: contextMonitorCommand,
11989
- timeout: 10
11990
- }
11991
- ]
11992
- });
11993
- console.log(` ${green}✓${reset} Configured context window monitor hook`);
11994
- } else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) {
11995
- console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
11996
- } else {
11997
- // Migrate existing context monitor hooks: add matcher and timeout if missing
11998
- for (const entry of settings.hooks[postToolEvent]) {
11999
- if (entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))) {
12000
- let migrated = false;
12001
- if (!entry.matcher) {
12002
- entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
12003
- migrated = true;
12004
- }
12005
- for (const h of entry.hooks) {
12006
- if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) {
12007
- h.timeout = 10;
12008
- migrated = true;
12009
- }
12010
- }
12011
- if (migrated) {
12012
- console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
12013
- }
12014
- }
12015
- }
12016
- }
12017
-
12018
- // Configure PreToolUse hook for prompt injection detection
12019
- // Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks
12020
- const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse';
12021
- if (!settings.hooks[preToolEvent]) {
12022
- settings.hooks[preToolEvent] = [];
12023
- }
12024
-
12025
- const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
12026
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-prompt-guard'))
12027
- );
12028
-
12029
- const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
12030
- if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) {
12031
- settings.hooks[preToolEvent].push({
12032
- matcher: 'Write|Edit',
12033
- hooks: [
12034
- {
12035
- type: 'command',
12036
- command: promptGuardCommand,
12037
- timeout: 5
12038
- }
12039
- ]
12040
- });
12041
- console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
12042
- } else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) {
12043
- console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
12044
- }
12045
-
12046
- // Configure PreToolUse hook for read-before-edit guidance (#1628)
12047
- // Prevents infinite retry loops when non-Claude models attempt to edit
12048
- // files without reading them first. Advisory-only — does not block.
12049
- const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
12050
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-guard'))
12051
- );
12052
-
12053
- const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
12054
- if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) {
12055
- settings.hooks[preToolEvent].push({
12056
- matcher: 'Write|Edit',
12057
- hooks: [
12058
- {
12059
- type: 'command',
12060
- command: readGuardCommand,
12061
- timeout: 5
12062
- }
12063
- ]
12064
- });
12065
- console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
12066
- } else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) {
12067
- console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
12068
- }
12069
-
12070
- // Configure PostToolUse hook for read-time prompt injection scanning (#2201)
12071
- // Scans content returned by the Read tool for injection patterns, including
12072
- // summarisation-specific patterns that survive context compression.
12073
- const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
12074
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-injection-scanner'))
12075
- );
12076
-
12077
- const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
12078
- if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
12079
- settings.hooks[postToolEvent].push({
12080
- matcher: 'Read',
12081
- hooks: [
12082
- {
12083
- type: 'command',
12084
- command: readInjectionScannerCommand,
12085
- timeout: 5
12086
- }
12087
- ]
12088
- });
12089
- console.log(` ${green}✓${reset} Configured read injection scanner hook`);
12090
- } else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) {
12091
- console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
12092
- }
12093
-
12094
- // Community hooks — registered on install but opt-in at runtime.
12095
- // Each hook checks .planning/config.json for hooks.community: true
12096
- // and exits silently (no-op) if not enabled. This lets users enable
12097
- // them per-project by adding: "hooks": { "community": true }
12098
-
12099
- // Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
12100
- // Detects file edits outside GSD workflow context and advises using
12101
- // /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
12102
- // unsafe Bash commands that violate worktree-agent isolation.
12103
- const workflowGuardCommand = isGlobal
12104
- ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
12105
- : localCmd('gsd-workflow-guard.js');
12106
- const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
12107
- const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
12108
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-workflow-guard'))
12109
- );
12110
- const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
12111
-
12112
- const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
12113
- if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
12114
- workflowGuardHookEntry.matcher = workflowGuardMatcher;
12115
- console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
12116
- } else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) {
12117
- settings.hooks[preToolEvent].push({
12118
- matcher: workflowGuardMatcher,
12119
- hooks: [
12120
- {
12121
- type: 'command',
12122
- command: workflowGuardCommand,
12123
- timeout: 5
12124
- }
12125
- ]
12126
- });
12127
- console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
12128
- } else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) {
12129
- console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
12130
- }
12131
-
12132
- // Configure PreToolUse hook for worktree absolute-path safety (#260)
12133
- // Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
12134
- // outside the current worktree root. Prevents executor agents from
12135
- // accidentally writing to the main checkout when running in isolation="worktree".
12136
- const worktreePathGuardCommand = isGlobal
12137
- ? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
12138
- : localCmd('gsd-worktree-path-guard.js');
12139
- const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
12140
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-worktree-path-guard'))
12141
- );
12142
- const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
12143
- if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
12144
- settings.hooks[preToolEvent].push({
12145
- matcher: 'Write|Edit|MultiEdit',
12146
- hooks: [
12147
- {
12148
- type: 'command',
12149
- command: worktreePathGuardCommand,
12150
- timeout: 5
12151
- }
12152
- ]
12153
- });
12154
- console.log(` ${green}✓${reset} Configured worktree path guard hook`);
12155
- } else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) {
12156
- console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
12157
- }
12158
-
12159
- // Configure commit validation hook (Conventional Commits enforcement, opt-in)
12160
- const validateCommitCommand = isGlobal
12161
- ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
12162
- : localShellCmd('gsd-validate-commit.sh');
12163
- const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
12164
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-validate-commit'))
12165
- );
12166
- // Guard: only register if the .sh file was actually installed. If the npm package
12167
- // omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
12168
- // causes a hook error on every Bash tool invocation.
12169
- const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
12170
- if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) {
12171
- settings.hooks[preToolEvent].push({
12172
- matcher: 'Bash',
12173
- hooks: [
12174
- {
12175
- type: 'command',
12176
- command: validateCommitCommand,
12177
- timeout: 5
12178
- }
12179
- ]
12180
- });
12181
- console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
12182
- } else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) {
12183
- console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
12184
- } else if (!hasValidateCommitHook && !validateCommitCommand) {
12185
- console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
12186
- }
12187
-
12188
- // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
12189
- // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
12190
- // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
12191
- // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
12192
- const graphifyUpdateCommand = isGlobal
12193
- ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
12194
- : localShellCmd('gsd-graphify-update.sh');
12195
- const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
12196
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-graphify-update'))
12197
- );
12198
- const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
12199
- if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
12200
- settings.hooks[postToolEvent].push({
12201
- matcher: 'Bash',
12202
- hooks: [
12203
- {
12204
- type: 'command',
12205
- command: graphifyUpdateCommand,
12206
- timeout: 5
12207
- }
12208
- ]
12209
- });
12210
- console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
12211
- } else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
12212
- console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
12213
- } else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
12214
- console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
12215
- }
12216
-
12217
- // Configure session state orientation hook (opt-in)
12218
- const sessionStateCommand = isGlobal
12219
- ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
12220
- : localShellCmd('gsd-session-state.sh');
12221
- const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
12222
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-session-state'))
12223
- );
12224
- const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
12225
- if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
12226
- settings.hooks.SessionStart.push({
12227
- hooks: [
12228
- {
12229
- type: 'command',
12230
- command: sessionStateCommand
12231
- }
12232
- ]
12233
- });
12234
- console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
12235
- } else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) {
12236
- console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
12237
- } else if (!hasSessionStateHook && !sessionStateCommand) {
12238
- console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
12239
- }
12240
-
12241
- // Configure phase boundary detection hook (opt-in)
12242
- const phaseBoundaryCommand = isGlobal
12243
- ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
12244
- : localShellCmd('gsd-phase-boundary.sh');
12245
- const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
12246
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-phase-boundary'))
12247
- );
12248
- const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
12249
- if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
12250
- settings.hooks[postToolEvent].push({
12251
- matcher: 'Write|Edit',
12252
- hooks: [
12253
- {
12254
- type: 'command',
12255
- command: phaseBoundaryCommand,
12256
- timeout: 5
12257
- }
12258
- ]
12259
- });
12260
- console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
12261
- } else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) {
12262
- console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
12263
- } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
12264
- console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
12265
- }
12266
-
12267
- // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
12268
- // Claude Code (since #770) and Qwen Code (since #788) both support these
12269
- // three lifecycle events. Wire gsd-context-monitor so agents get context-
12270
- // headroom warnings at subagent completion, model stop, and pre-compaction
12271
- // (the most critical moment to surface headroom info).
12272
- //
12273
- // SubagentStop — subagent lifecycle completion (context headroom tracking)
12274
- // Stop — model stop / final-response moment (context headroom)
12275
- // PreCompact — fires before conversation compaction (most critical
12276
- // moment to surface context headroom warnings)
12277
- //
12278
- // Note: UserPromptSubmit is NOT wired here. That event carries the raw
12279
- // user prompt text, not a tool invocation, so gsd-prompt-guard (which
12280
- // exits unless tool_name is Write/Edit) would be a silent no-op. A
12281
- // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
12282
- if (isQwen || runtime === 'claude') {
12283
- const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
12284
- // SubagentStop, Stop, PreCompact — route through the context monitor.
12285
- for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
12286
- if (!settings.hooks[event]) {
12287
- settings.hooks[event] = [];
12288
- }
12289
- const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
12290
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
12291
- );
12292
- if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
12293
- settings.hooks[event].push({
12294
- hooks: [
12295
- {
12296
- type: 'command',
12297
- command: contextMonitorCommand,
12298
- timeout: 10
12299
- }
12300
- ]
12301
- });
12302
- console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
12303
- } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
12304
- console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
12305
- }
12306
- }
12307
- }
12308
- // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
12309
-
12310
- // ── Gemini-only extended hook events (#776) ───────────────────────────────
12311
- // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
12312
- // gsd previously did not register. Three high-value events are added here:
12313
- //
12314
- // BeforeAgent — fires after user submits a prompt, before the agent
12315
- // plans. Wire gsd-context-monitor for context headroom
12316
- // awareness at prompt time.
12317
- // AfterAgent — fires once per turn after the model generates its final
12318
- // response. Wire gsd-context-monitor to track headroom
12319
- // after each agent turn completes.
12320
- // BeforeModel — fires before each LLM call (per-turn, not per-session).
12321
- // Wire gsd-context-monitor for per-turn context injection
12322
- // — more precise than session-start-only injection.
12323
- //
12324
- // All three reuse gsd-context-monitor.js — no new hook files needed.
12325
- // The `decision:"deny"` retry capability of AfterAgent is intentionally
12326
- // left to the hook script to implement when triggered (gsd-context-monitor
12327
- // exits 0 / advisory-only today; an active quality gate is a follow-on).
12328
- //
12329
- // Note: BeforeToolSelection is NOT wired. That event does not map to a
12330
- // gsd hook use case at this time; deferred to a follow-on issue.
12331
- //
12332
- // Guard: isGemini is defined at the top of install() (line ~8696).
12333
- if (isGemini) {
12334
- for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
12335
- if (!Array.isArray(settings.hooks[geminiEvent])) {
12336
- settings.hooks[geminiEvent] = [];
12337
- }
12338
- const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
12339
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
12340
- );
12341
- if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
12342
- settings.hooks[geminiEvent].push({
12343
- hooks: [
12344
- {
12345
- type: 'command',
12346
- command: contextMonitorCommand,
12347
- timeout: 10
12348
- }
12349
- ]
12350
- });
12351
- console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
12352
- } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
12353
- console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
12354
- }
12355
- }
12356
- }
12357
- // ── end Gemini-only extended hook events ──────────────────────────────────
12358
-
12359
- // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
12360
- // Claude Code fires FileChanged when a watched file changes on disk. Wire
12361
- // gsd-config-reload.js to reload the gsd config context whenever the user
12362
- // edits .planning/config.json mid-session, eliminating the need to restart.
12363
- //
12364
- // The matcher "config.json" watches for changes to any file named config.json
12365
- // (Claude Code matches by filename, not full path). The hook exits silently
12366
- // when the changed file is not the gsd config.
12367
- //
12368
- // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
12369
- // verified; extend in a follow-on if empirically confirmed.
12370
- if (runtime === 'claude') {
12371
- if (!settings.hooks.FileChanged) {
12372
- settings.hooks.FileChanged = [];
12373
- }
12374
- const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
12375
- const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
12376
- entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-config-reload'))
12377
- );
12378
- if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
12379
- settings.hooks.FileChanged.push({
12380
- matcher: 'config.json',
12381
- hooks: [
12382
- {
12383
- type: 'command',
12384
- command: configReloadCommand,
12385
- timeout: 8
12386
- }
12387
- ]
12388
- });
12389
- console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
12390
- } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
12391
- console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
12392
- } else if (!alreadyHasConfigReload && !configReloadCommand) {
12393
- console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
12394
- }
12395
- }
12396
- // ── end FileChanged hook ────────────────────────────────────────────────────
12397
- }
11193
+ // Register all GSD-managed hook entries into settings.hooks.* for runtimes
11194
+ // that use the settings.json hook surface (ADR-857 phase 5f-1b).
11195
+ // settings is mutated in place by applySettingsJsonHooks.
11196
+ applySettingsJsonHooks(settings, {
11197
+ runtime,
11198
+ isGlobal,
11199
+ targetDir,
11200
+ postToolEvent,
11201
+ hookEvents: _hookEventsDialect,
11202
+ extendedHookEvents: plan.extendedHookEvents,
11203
+ hooksSurface: plan.hooksSurface,
11204
+ updateCheckCommand,
11205
+ contextMonitorCommand,
11206
+ promptGuardCommand,
11207
+ readGuardCommand,
11208
+ readInjectionScannerCommand,
11209
+ configReloadCommand,
11210
+ hookOpts,
11211
+ localCmd,
11212
+ localShellCmd,
11213
+ });
12398
11214
 
12399
11215
  // ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
12400
11216
  // Detect `hooksConfig.enabled: false` in the already-loaded settings object
@@ -12509,9 +11325,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12509
11325
  const isWindsurf = runtime === 'windsurf';
12510
11326
  const isTrae = runtime === 'trae';
12511
11327
  const isCline = runtime === 'cline';
12512
- const configIntent = resolveRuntimeConfigIntent(runtime);
11328
+ const plan = resolveInstallPlan(runtime);
12513
11329
 
12514
- if (shouldInstallStatusline && configIntent.writesSharedSettings && !isOpencode) {
11330
+ if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
12515
11331
  if (!isGlobal && !forceStatusline) {
12516
11332
  // Local installs skip statusLine by default: repo settings.json takes precedence over
12517
11333
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -12537,7 +11353,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12537
11353
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
12538
11354
  // cline either lack the surface or use a different config schema.
12539
11355
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
12540
- if (shouldInstallBanner && settings && configIntent.writesSharedSettings && !isOpencode) {
11356
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
12541
11357
  if (!bannerCommand) {
12542
11358
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
12543
11359
  } else {
@@ -12577,17 +11393,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12577
11393
  // {type: 'command', command: null} items that the runtime hook schema
12578
11394
  // rejects at parse time. validateHookFields filters those out so the file
12579
11395
  // we write is always schema-valid.
12580
- if (settingsPath && settings && configIntent.writesSharedSettings) {
11396
+ if (settingsPath && settings && plan.writesSharedSettings) {
12581
11397
  writeSettings(settingsPath, validateHookFields(settings));
12582
11398
  }
12583
11399
 
12584
11400
  // Configure OpenCode permissions
12585
- if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
11401
+ if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
12586
11402
  configureOpencodePermissions(isGlobal, configDir);
12587
11403
  }
12588
11404
 
12589
11405
  // Configure Kilo permissions
12590
- if (configIntent.finishPermissionWriter === 'kilo') {
11406
+ if (plan.finishPermissionWriter === 'kilo') {
12591
11407
  configureKiloPermissions(isGlobal, configDir);
12592
11408
  }
12593
11409
 
@@ -13126,9 +11942,11 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
13126
11942
  '.codex',
13127
11943
  '.copilot',
13128
11944
  '.github', // copilot local form
13129
- '.agent', // antigravity local form
11945
+ '.agents', // antigravity local form (canonical, #791)
11946
+ '.agent', // antigravity local form (legacy, backward-compat)
13130
11947
  '.cursor',
13131
- '.windsurf',
11948
+ '.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
11949
+ '.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
13132
11950
  '.codeium/windsurf',
13133
11951
  '.augment',
13134
11952
  '.trae',
@@ -13447,6 +12265,8 @@ module.exports = {
13447
12265
  buildHookCommand,
13448
12266
  normalizeNodePath,
13449
12267
  resolveNodeRunner,
12268
+ referencesHook,
12269
+ applySettingsJsonHooks,
13450
12270
  rewriteLegacyManagedNodeHookCommands,
13451
12271
  buildCodexHookBlock,
13452
12272
  rewriteLegacyCodexHookBlock,
@@ -13462,6 +12282,7 @@ module.exports = {
13462
12282
  parseConfigDirFromArgs,
13463
12283
  cleanupLegacyGsdCc,
13464
12284
  _applyRuntimeRewrites,
12285
+ ...runtimeArtifactConversion,
13465
12286
  };
13466
12287
 
13467
12288
  // Main logic — only run when not loaded as a module for testing