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

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 (149) 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-phase-researcher.md +8 -8
  8. package/agents/gsd-plan-checker.md +12 -11
  9. package/agents/gsd-planner.md +21 -181
  10. package/agents/gsd-project-researcher.md +5 -4
  11. package/agents/gsd-research-synthesizer.md +2 -1
  12. package/agents/gsd-ui-researcher.md +2 -1
  13. package/agents/gsd-verifier.md +9 -8
  14. package/bin/install.js +205 -1402
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd_run +20 -0
  17. package/gsd-core/bin/lib/capability-registry.cjs +1820 -3
  18. package/gsd-core/bin/lib/check-command-router.cjs +133 -2
  19. package/gsd-core/bin/lib/core.cjs +51 -1
  20. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  21. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  22. package/gsd-core/bin/lib/intel.cjs +3 -3
  23. package/gsd-core/bin/lib/io.cjs +61 -6
  24. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  25. package/gsd-core/bin/lib/phase.cjs +17 -0
  26. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +40 -0
  28. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +51 -141
  29. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +67 -29
  30. package/gsd-core/bin/lib/runtime-homes.cjs +137 -101
  31. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  32. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
  33. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  34. package/gsd-core/bin/lib/state-document.cjs +8 -0
  35. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  36. package/gsd-core/bin/lib/update-context.cjs +4 -1
  37. package/gsd-core/bin/lib/verify.cjs +103 -0
  38. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  39. package/gsd-core/bin/shared/model-catalog.json +11 -6
  40. package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
  41. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  42. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  43. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  44. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  45. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  46. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  47. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  48. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  49. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  50. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  51. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  52. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  53. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  54. package/gsd-core/references/edge-probe.md +261 -0
  55. package/gsd-core/references/planner-antipatterns.md +41 -0
  56. package/gsd-core/references/planner-guidance.md +186 -0
  57. package/gsd-core/templates/spec.md +12 -0
  58. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  59. package/gsd-core/workflows/add-backlog.md +1 -1
  60. package/gsd-core/workflows/add-phase.md +1 -1
  61. package/gsd-core/workflows/add-tests.md +1 -1
  62. package/gsd-core/workflows/add-todo.md +1 -1
  63. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  64. package/gsd-core/workflows/audit-fix.md +1 -1
  65. package/gsd-core/workflows/audit-milestone.md +1 -1
  66. package/gsd-core/workflows/audit-uat.md +1 -1
  67. package/gsd-core/workflows/autonomous.md +26 -37
  68. package/gsd-core/workflows/check-todos.md +1 -1
  69. package/gsd-core/workflows/cleanup.md +1 -1
  70. package/gsd-core/workflows/code-review-fix.md +1 -1
  71. package/gsd-core/workflows/code-review.md +51 -16
  72. package/gsd-core/workflows/complete-milestone.md +1 -1
  73. package/gsd-core/workflows/debug.md +1 -1
  74. package/gsd-core/workflows/diagnose-issues.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  76. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  77. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  78. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  79. package/gsd-core/workflows/discuss-phase.md +1 -1
  80. package/gsd-core/workflows/do.md +1 -1
  81. package/gsd-core/workflows/docs-update.md +1 -1
  82. package/gsd-core/workflows/edit-phase.md +1 -1
  83. package/gsd-core/workflows/eval-review.md +1 -1
  84. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  85. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  86. package/gsd-core/workflows/execute-phase.md +1 -1
  87. package/gsd-core/workflows/execute-plan.md +1 -1
  88. package/gsd-core/workflows/explore.md +1 -1
  89. package/gsd-core/workflows/extract-learnings.md +1 -1
  90. package/gsd-core/workflows/forensics.md +1 -1
  91. package/gsd-core/workflows/graduation.md +1 -1
  92. package/gsd-core/workflows/health.md +1 -1
  93. package/gsd-core/workflows/import.md +1 -1
  94. package/gsd-core/workflows/ingest-docs.md +1 -1
  95. package/gsd-core/workflows/insert-phase.md +1 -1
  96. package/gsd-core/workflows/list-workspaces.md +1 -1
  97. package/gsd-core/workflows/manager.md +1 -1
  98. package/gsd-core/workflows/map-codebase.md +1 -1
  99. package/gsd-core/workflows/milestone-summary.md +1 -1
  100. package/gsd-core/workflows/mvp-phase.md +1 -1
  101. package/gsd-core/workflows/new-milestone.md +9 -1
  102. package/gsd-core/workflows/new-project.md +9 -1
  103. package/gsd-core/workflows/new-workspace.md +1 -1
  104. package/gsd-core/workflows/next.md +1 -1
  105. package/gsd-core/workflows/pause-work.md +1 -1
  106. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  107. package/gsd-core/workflows/plan-phase.md +39 -27
  108. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  109. package/gsd-core/workflows/plant-seed.md +1 -1
  110. package/gsd-core/workflows/profile-user.md +1 -1
  111. package/gsd-core/workflows/progress.md +1 -1
  112. package/gsd-core/workflows/quick.md +1 -1
  113. package/gsd-core/workflows/remove-phase.md +1 -1
  114. package/gsd-core/workflows/remove-workspace.md +1 -1
  115. package/gsd-core/workflows/resume-project.md +1 -1
  116. package/gsd-core/workflows/review.md +1 -1
  117. package/gsd-core/workflows/scan.md +1 -1
  118. package/gsd-core/workflows/secure-phase.md +1 -1
  119. package/gsd-core/workflows/settings-advanced.md +18 -14
  120. package/gsd-core/workflows/settings-integrations.md +1 -1
  121. package/gsd-core/workflows/settings.md +2 -2
  122. package/gsd-core/workflows/ship.md +1 -1
  123. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  124. package/gsd-core/workflows/sketch.md +1 -1
  125. package/gsd-core/workflows/spec-phase.md +130 -1
  126. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  127. package/gsd-core/workflows/spike.md +1 -1
  128. package/gsd-core/workflows/stats.md +1 -1
  129. package/gsd-core/workflows/thread.md +1 -1
  130. package/gsd-core/workflows/transition.md +1 -1
  131. package/gsd-core/workflows/ui-phase.md +1 -1
  132. package/gsd-core/workflows/ui-review.md +1 -1
  133. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  134. package/gsd-core/workflows/update.md +2 -2
  135. package/gsd-core/workflows/validate-phase.md +1 -1
  136. package/gsd-core/workflows/verify-phase.md +1 -1
  137. package/gsd-core/workflows/verify-work.md +1 -1
  138. package/package.json +6 -3
  139. package/scripts/gen-capability-registry.cjs +498 -13
  140. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  141. package/scripts/lint-test-file-count.allowlist.json +15 -2
  142. package/scripts/lint-windows-test-portability.cjs +178 -0
  143. package/scripts/research-profiles.cjs +10 -10
  144. package/scripts/run-tests.cjs +54 -13
  145. package/scripts/sync-next-version.cjs +133 -0
  146. package/scripts/sync-runtime-launcher.cjs +21 -5
  147. package/scripts/update-size-baseline.cjs +68 -0
  148. package/scripts/workflow-policy.cjs +42 -9
  149. package/scripts/workflow-size.cjs +90 -0
package/bin/install.js CHANGED
@@ -37,7 +37,7 @@ 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
41
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
42
42
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
43
43
  // the manifest and the installed hooks/ dir structurally identical. Avoids the
@@ -45,6 +45,11 @@ const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-conf
45
45
  const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
46
46
  const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
47
47
 
48
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
49
+ // bin/install.js re-exports everything from hooksSurface so existing callers
50
+ // (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
51
+ const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
52
+
48
53
  /**
49
54
  * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
50
55
  * verbatim (only branding swaps, no namespace conversion), so retired
@@ -405,7 +410,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
405
410
  if (runtimeArgs.includes('--copilot')) selected.push('copilot');
406
411
  if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
407
412
  if (runtimeArgs.includes('--cursor')) selected.push('cursor');
408
- if (runtimeArgs.includes('--windsurf')) selected.push('windsurf');
413
+ if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
409
414
  if (runtimeArgs.includes('--augment')) selected.push('augment');
410
415
  if (runtimeArgs.includes('--trae')) selected.push('trae');
411
416
  if (runtimeArgs.includes('--qwen')) selected.push('qwen');
@@ -460,9 +465,9 @@ function getDirName(runtime) {
460
465
  if (runtime === 'gemini') return '.gemini';
461
466
  if (runtime === 'kilo') return '.kilo';
462
467
  if (runtime === 'codex') return '.codex';
463
- if (runtime === 'antigravity') return '.agent';
468
+ if (runtime === 'antigravity') return '.agents';
464
469
  if (runtime === 'cursor') return '.cursor';
465
- if (runtime === 'windsurf') return '.windsurf';
470
+ if (runtime === 'windsurf') return '.devin';
466
471
  if (runtime === 'augment') return '.augment';
467
472
  if (runtime === 'trae') return '.trae';
468
473
  if (runtime === 'qwen') return '.qwen';
@@ -495,7 +500,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
495
500
  if (runtime === 'kilo') return "'.config', 'kilo'";
496
501
  if (runtime === 'codex') return "'.codex'";
497
502
  if (runtime === 'antigravity') {
498
- if (!isGlobal) return "'.agent'";
503
+ if (!isGlobal) return "'.agents'";
499
504
  const antigravityDir = resolveAntigravityGlobalDir();
500
505
  const rel = path.relative(os.homedir(), antigravityDir);
501
506
  const segments = rel.split(path.sep).filter(Boolean);
@@ -622,215 +627,22 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
622
627
  return `${resolvedTarget}/`;
623
628
  }
624
629
 
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
- }
630
+ // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
631
+ // now owned by the runtime-hooks-surface module. Import them here so
632
+ // install.js callers continue to work and so there is a single implementation
633
+ // of these helpers.
634
+ const normalizeNodePath = hooksSurface.normalizeNodePath;
635
+ const resolveNodeRunner = hooksSurface.resolveNodeRunner;
636
+ const resolveBashRunner = hooksSurface.resolveBashRunner;
637
+ // referencesHook: pure predicate over hook entry objects, shared between
638
+ // install() and finishInstall() (ADR-857 phase 5f-1b).
639
+ const referencesHook = hooksSurface.referencesHook;
640
+ // applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
641
+ // hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
642
+ const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
750
643
 
751
644
  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;
645
+ return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
834
646
  }
835
647
 
836
648
  /**
@@ -856,22 +668,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
856
668
  * @returns {string|null} The toml block to append, or null on missing runner.
857
669
  */
858
670
  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}`;
671
+ return hooksSurface.buildCodexHookBlock(targetDir, opts);
875
672
  }
876
673
 
877
674
  /**
@@ -888,45 +685,7 @@ function buildCodexHookBlock(targetDir, opts) {
888
685
  * @returns {{ content: string, changed: boolean }}
889
686
  */
890
687
  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 };
688
+ return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
930
689
  }
931
690
 
932
691
  /**
@@ -949,83 +708,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
949
708
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
950
709
  */
951
710
  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 };
711
+ return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
1029
712
  }
1030
713
 
1031
714
  /**
@@ -1037,7 +720,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1037
720
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1038
721
  */
1039
722
  function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1040
- return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
723
+ return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
1041
724
  }
1042
725
 
1043
726
  /**
@@ -1070,41 +753,7 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1070
753
  * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
1071
754
  */
1072
755
  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
- };
756
+ return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
1108
757
  }
1109
758
 
1110
759
  /**
@@ -1135,69 +784,7 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1135
784
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1136
785
  */
1137
786
  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 });
787
+ return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
1201
788
  }
1202
789
 
1203
790
  /**
@@ -1224,50 +811,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1224
811
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1225
812
  */
1226
813
  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 });
814
+ return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
1271
815
  }
1272
816
 
1273
817
  /**
@@ -1277,11 +821,11 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1277
821
  * @param {string} eventName
1278
822
  */
1279
823
  function removeCodexHooksJsonEvent(targetDir, eventName) {
1280
- return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
824
+ return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
1281
825
  }
1282
826
 
1283
827
  function removeCodexHooksJsonSessionStart(targetDir) {
1284
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
828
+ return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
1285
829
  }
1286
830
 
1287
831
  /**
@@ -1298,62 +842,7 @@ function removeCodexHooksJsonSessionStart(targetDir) {
1298
842
  * runtime: target runtime name for shell projection policy.
1299
843
  */
1300
844
  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
- });
845
+ return hooksSurface.buildHookCommand(configDir, hookName, opts);
1357
846
  }
1358
847
 
1359
848
  /**
@@ -1720,6 +1209,52 @@ function injectEffortFrontmatter(content, effortValue) {
1720
1209
  return `${before}effort: ${effortValue}${eol}${after}`;
1721
1210
  }
1722
1211
 
1212
+ /**
1213
+ * #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
1214
+ * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
1215
+ * inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
1216
+ * which break on unknown frontmatter keys.
1217
+ */
1218
+ function injectDisallowedToolsFrontmatter(content, disallowedValue) {
1219
+ // Detect the dominant EOL from the first line (the opening `---`).
1220
+ // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1221
+ const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1222
+
1223
+ // Build a frontmatter-matching regex that tolerates an optional \r before
1224
+ // each \n, so we handle both LF and CRLF files without needing to normalise
1225
+ // the whole content.
1226
+ const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1227
+ const match = fmRe.exec(content);
1228
+ if (!match) return content; // no YAML frontmatter — leave unchanged
1229
+
1230
+ // Idempotency guard: don't insert a second disallowedTools: line.
1231
+ const fmBody = match[1]; // content between the two `---` lines
1232
+ if (/^disallowedTools:/m.test(fmBody)) return content;
1233
+
1234
+ // Locate the exact position of the closing `---` line so we can insert
1235
+ // before it using a simple string splice.
1236
+ const openLen = 3 + eol.length; // "---" + eol
1237
+ const closingStart = match.index + openLen + fmBody.length;
1238
+
1239
+ const before = content.slice(0, closingStart);
1240
+ const after = content.slice(closingStart);
1241
+ return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
1242
+ }
1243
+
1244
+ // #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
1245
+ // Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
1246
+ // output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
1247
+ // excluded (it legitimately uses Write AND Edit to create/patch test files).
1248
+ const READONLY_AGENT_DISALLOWED_TOOLS = {
1249
+ 'gsd-plan-checker': 'Write, Edit, MultiEdit',
1250
+ 'gsd-integration-checker': 'Write, Edit, MultiEdit',
1251
+ 'gsd-ui-checker': 'Write, Edit, MultiEdit',
1252
+ 'gsd-verifier': 'Edit, MultiEdit',
1253
+ 'gsd-doc-verifier': 'Edit, MultiEdit',
1254
+ 'gsd-eval-auditor': 'Edit, MultiEdit',
1255
+ 'gsd-ui-auditor': 'Edit, MultiEdit',
1256
+ };
1257
+
1723
1258
  /**
1724
1259
  * #2517 — Read a single GSD config file (defaults.json or per-project
1725
1260
  * config.json) into a plain object, returning null on missing/empty files
@@ -2202,12 +1737,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
2202
1737
  return c;
2203
1738
  }
2204
1739
 
1740
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2205
1741
  /**
2206
1742
  * Convert a Claude command (.md) to a Copilot skill (SKILL.md).
2207
1743
  * Transforms frontmatter only — body passes through with CONV-06/07 applied.
2208
1744
  * Skills keep original tool names (no mapping) per CONTEXT.md decision.
2209
1745
  */
2210
- function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
1746
+ function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2211
1747
  const converted = convertClaudeToCopilotContent(content, isGlobal);
2212
1748
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2213
1749
  if (!frontmatter) return converted;
@@ -2675,8 +2211,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2675
2211
  /**
2676
2212
  * Apply Antigravity-specific content conversion — path replacement + command name conversion.
2677
2213
  * Path mappings depend on install mode:
2678
- * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agent/
2679
- * Local: ~/.claude/ → .agent/, ./.claude/ → ./.agent/
2214
+ * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2215
+ * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
2680
2216
  * Applied to ALL Antigravity content (skills, agents, engine files).
2681
2217
  * @param {string} content - Source content to convert
2682
2218
  * @param {boolean} [isGlobal=false] - Whether this is a global install
@@ -2690,14 +2226,14 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2690
2226
  c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
2691
2227
  c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
2692
2228
  } else {
2693
- c = c.replace(/\$HOME\/\.claude\//g, '.agent/');
2694
- c = c.replace(/~\/\.claude\//g, '.agent/');
2229
+ c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
2230
+ c = c.replace(/~\/\.claude\//g, '.agents/');
2695
2231
  // 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');
2232
+ c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
2233
+ c = c.replace(/~\/\.claude\b/g, '.agents');
2698
2234
  }
2699
- c = c.replace(/\.\/\.claude\//g, './.agent/');
2700
- c = c.replace(/\.claude\//g, '.agent/');
2235
+ c = c.replace(/\.\/\.claude\//g, './.agents/');
2236
+ c = c.replace(/\.claude\//g, '.agents/');
2701
2237
  // Command name conversion (all gsd: references → gsd-)
2702
2238
  c = c.replace(/gsd:/g, 'gsd-');
2703
2239
  // Runtime-neutral agent name replacement (#766)
@@ -2705,12 +2241,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2705
2241
  return c;
2706
2242
  }
2707
2243
 
2244
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2708
2245
  /**
2709
2246
  * Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
2710
2247
  * Transforms frontmatter to minimal name + description only.
2711
2248
  * Body passes through with path/command conversions applied.
2712
2249
  */
2713
- function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
2250
+ function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2714
2251
  const converted = convertClaudeToAntigravityContent(content, isGlobal);
2715
2252
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2716
2253
  if (!frontmatter) return converted;
@@ -2969,18 +2506,20 @@ function convertClaudeToWindsurfMarkdown(content) {
2969
2506
  // Replace subagent_type from Claude to Windsurf format
2970
2507
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2971
2508
  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/');
2509
+ // Replace project-level Claude conventions with Windsurf/Devin equivalents
2510
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
2511
+ // Legacy .windsurf/ is still recognized on read but new installs use .devin/.
2512
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
2513
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
2514
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
2515
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
2516
+ converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
2517
+ converted = converted.replace(/\.\/\.claude\//g, './.devin/');
2518
+ converted = converted.replace(/\.claude\//g, '.devin/');
2980
2519
  // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2981
2520
  // 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');
2521
+ converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
2522
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
2984
2523
  // Environment variable name rewrite
2985
2524
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
2986
2525
  // Remove Claude Code-specific bug workarounds before brand replacement
@@ -5671,36 +5210,14 @@ function rewriteTomlKeyLines(content, matches, key) {
5671
5210
  return rewritten;
5672
5211
  }
5673
5212
 
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
- }
5213
+ // atomicWriteFileSync and __atomicWrittenTmps are now owned by the
5214
+ // runtime-hooks-surface module and imported here so both install.js's
5215
+ // direct config.toml writes and the module's Cursor/Codex hooks.json
5216
+ // writes share the SAME tracking Set. _cleanTmpFiles() below reads
5217
+ // hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
5218
+ // temps only.
5219
+ const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
5220
+ const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
5704
5221
 
5705
5222
  /**
5706
5223
  * Merge GSD config block into an existing or new config.toml.
@@ -5748,7 +5265,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
5748
5265
 
5749
5266
  /**
5750
5267
  * 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]
5268
+ * Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
5752
5269
  * are relocated before the [features] header so Codex can parse them correctly.
5753
5270
  * Returns the content unchanged if no trapped keys are found.
5754
5271
  */
@@ -6038,23 +5555,12 @@ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
6038
5555
  * engine layout, not the (separate) #782 Cline skills directory.
6039
5556
  */
6040
5557
  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';
5558
+ return hooksSurface.buildClineRulesBody();
6053
5559
  }
6054
5560
 
6055
5561
  /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
6056
5562
  function buildClineAgentsMdBody() {
6057
- return buildClineRulesBody();
5563
+ return hooksSurface.buildClineAgentsMdBody();
6058
5564
  }
6059
5565
 
6060
5566
  /**
@@ -6070,53 +5576,7 @@ function buildClineAgentsMdBody() {
6070
5576
  * hook bug can never wedge the user. No dependency on the #782 skills work.
6071
5577
  */
6072
5578
  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
- `;
5579
+ return hooksSurface.buildClinePreToolUseHook();
6120
5580
  }
6121
5581
 
6122
5582
  /**
@@ -6124,31 +5584,7 @@ process.stdin.on('end', () => {
6124
5584
  * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
6125
5585
  */
6126
5586
  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');
5587
+ return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
6152
5588
  }
6153
5589
 
6154
5590
  /**
@@ -6178,53 +5614,7 @@ function stripGsdFromAgentsMd(content) {
6178
5614
  * caller can hash-track them).
6179
5615
  */
6180
5616
  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;
5617
+ return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
6228
5618
  }
6229
5619
 
6230
5620
  // ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
@@ -6254,11 +5644,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
6254
5644
  * @returns {object} Cursor hook entry object
6255
5645
  */
6256
5646
  function buildCursorHookEntry(scriptPath) {
6257
- return {
6258
- type: 'command',
6259
- command: scriptPath.replace(/\\/g, '/'),
6260
- [GSD_CURSOR_HOOK_MARKER]: true,
6261
- };
5647
+ return hooksSurface.buildCursorHookEntry(scriptPath);
6262
5648
  }
6263
5649
 
6264
5650
  /**
@@ -6269,7 +5655,7 @@ function buildCursorHookEntry(scriptPath) {
6269
5655
  * @returns {boolean}
6270
5656
  */
6271
5657
  function isManagedCursorHookEntry(entry) {
6272
- return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
5658
+ return hooksSurface.isManagedCursorHookEntry(entry);
6273
5659
  }
6274
5660
 
6275
5661
  /**
@@ -6290,74 +5676,7 @@ function isManagedCursorHookEntry(entry) {
6290
5676
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
6291
5677
  */
6292
5678
  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 };
5679
+ return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
6361
5680
  }
6362
5681
 
6363
5682
  /**
@@ -6373,65 +5692,7 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
6373
5692
  * @returns {{ hooksJsonPath: string, changed: boolean }}
6374
5693
  */
6375
5694
  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 };
5695
+ return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
6435
5696
  }
6436
5697
 
6437
5698
  /**
@@ -6442,31 +5703,7 @@ function writeCursorHooksJson(targetDir, src, opts) {
6442
5703
  * @returns {{ changed: boolean }}
6443
5704
  */
6444
5705
  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 };
5706
+ return hooksSurface.removeCursorHooksJson(targetDir);
6470
5707
  }
6471
5708
 
6472
5709
  /**
@@ -6484,19 +5721,7 @@ function removeCursorHooksJson(targetDir) {
6484
5721
  * @returns {object} Copilot hooks-configuration object
6485
5722
  */
6486
5723
  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
- };
5724
+ return hooksSurface.buildCopilotHookConfig();
6500
5725
  }
6501
5726
 
6502
5727
  /**
@@ -6513,11 +5738,7 @@ function buildCopilotHookConfig() {
6513
5738
  * @returns {string} The path the hook config was written to
6514
5739
  */
6515
5740
  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;
5741
+ return hooksSurface.writeCopilotHookConfig(targetDir);
6521
5742
  }
6522
5743
 
6523
5744
  /**
@@ -7565,8 +6786,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
7565
6786
  * @param {string} stagedDir
7566
6787
  * @param {string} runtime
7567
6788
  * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
6789
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7568
6790
  */
7569
- function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
6791
+ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7570
6792
  if (!fs.existsSync(stagedDir)) return;
7571
6793
 
7572
6794
  // Walk all SKILL.md files under stagedDir
@@ -7577,7 +6799,7 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7577
6799
  walkAndRewrite(fullPath);
7578
6800
  } else if (entry.name.endsWith('.md')) {
7579
6801
  let content = fs.readFileSync(fullPath, 'utf8');
7580
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6802
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7581
6803
  fs.writeFileSync(fullPath, content);
7582
6804
  }
7583
6805
  }
@@ -7598,9 +6820,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7598
6820
  * @param {string} stagedDir directory of staged flat .md command files (may be source dir)
7599
6821
  * @param {string} runtime
7600
6822
  * @param {string} pathPrefix
6823
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7601
6824
  * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
7602
6825
  */
7603
- function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
6826
+ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7604
6827
  if (!fs.existsSync(stagedDir)) return stagedDir;
7605
6828
  // Always copy to a temp dir — stageSkillsForProfile() returns the original source
7606
6829
  // dir on full/default profile (skills === '*'), so writing in-place would corrupt the
@@ -7610,7 +6833,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7610
6833
  for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
7611
6834
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7612
6835
  let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
7613
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6836
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7614
6837
  // For augment commands, apply the markdown conversion so tool references
7615
6838
  // and skill paths use Augment equivalents.
7616
6839
  if (runtime === 'augment') {
@@ -7632,9 +6855,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7632
6855
  * @param {string} content
7633
6856
  * @param {string} runtime
7634
6857
  * @param {string} pathPrefix trailing-slash string
6858
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7635
6859
  * @returns {string}
7636
6860
  */
7637
- function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6861
+ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
7638
6862
  const dirName = getDirName(runtime);
7639
6863
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
7640
6864
 
@@ -7671,18 +6895,30 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7671
6895
  content = processAttribution(content, getCommitAttribution(runtime));
7672
6896
  break;
7673
6897
 
7674
- case 'windsurf':
6898
+ case 'windsurf': {
7675
6899
  content = content.replace(/~\/\.claude\//g, pathPrefix);
7676
6900
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7677
6901
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
7678
6902
  // Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
7679
6903
  // .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
7680
- // fires between 'e' and '-', which rewrites .claude-plugin → .windsurf-plugin).
6904
+ // fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
7681
6905
  content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
7682
6906
  content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
7683
6907
  content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
6908
+ // Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
6909
+ // form). For global installs the real path is pathPrefix + skills/, so fix that up
6910
+ // here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
6911
+ // not derived from pathPrefix substring which misclassifies custom config dirs).
6912
+ // For local installs, the relative .devin/ form is correct — leave it. (#1085)
6913
+ if (isGlobal) {
6914
+ content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
6915
+ content = content.replace(/\.\/\.devin\//g, pathPrefix);
6916
+ content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
6917
+ content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
6918
+ }
7684
6919
  content = processAttribution(content, getCommitAttribution(runtime));
7685
6920
  break;
6921
+ }
7686
6922
 
7687
6923
  case 'augment':
7688
6924
  content = content.replace(/~\/\.claude\//g, pathPrefix);
@@ -8114,11 +7350,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
8114
7350
  // stagedForCopy: the directory to copy from (may differ from staged if rewrites
8115
7351
  // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
8116
7352
  let stagedForCopy = staged;
7353
+ const isGlobal = scope === 'global';
8117
7354
  if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
8118
- applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
7355
+ applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
8119
7356
  } else if (kind.kind === 'commands') {
8120
7357
  // Returns a temp dir with rewritten content so source files are never mutated.
8121
- stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
7358
+ stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
8122
7359
  }
8123
7360
  // applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
8124
7361
  // os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
@@ -8453,11 +7690,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
8453
7690
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
8454
7691
  fs.writeFileSync(destPath, jsContent);
8455
7692
  } else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
8456
- // For Windsurf, also convert Claude references in JS/CJS utility scripts
7693
+ // For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
7694
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
8457
7695
  let jsContent = fs.readFileSync(srcPath, 'utf8');
8458
7696
  jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
8459
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.windsurf/skills/');
8460
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.windsurf/rules');
7697
+ jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
7698
+ jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
8461
7699
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
8462
7700
  fs.writeFileSync(destPath, jsContent);
8463
7701
  } else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
@@ -10166,7 +9404,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10166
9404
  const isHermes = runtime === 'hermes';
10167
9405
  const isCodebuddy = runtime === 'codebuddy';
10168
9406
  const isCline = runtime === 'cline';
10169
- const configIntent = resolveRuntimeConfigIntent(runtime);
9407
+ const plan = resolveInstallPlan(runtime);
10170
9408
  const dirName = getDirName(runtime);
10171
9409
  const src = path.join(__dirname, '..');
10172
9410
 
@@ -10220,6 +9458,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10220
9458
  // Get the target directory based on runtime and install type.
10221
9459
  // Cline local installs write to the project root (like Claude Code) — .clinerules
10222
9460
  // lives at the root, not inside a .cline/ subdirectory.
9461
+ // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
9462
+ // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
9463
+ // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
9464
+ // auto-removed on reinstall (dual-read fallback per issue #791 spec).
10223
9465
  const targetDir = isGlobal
10224
9466
  ? getGlobalConfigDir(runtime, explicitConfigDir)
10225
9467
  : isCline
@@ -11010,6 +10252,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11010
10252
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
11011
10253
  const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
11012
10254
  content = injectEffortFrontmatter(content, _renderedEffort);
10255
+ const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
10256
+ if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
11013
10257
  }
11014
10258
  // #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
11015
10259
  // to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
@@ -11307,7 +10551,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11307
10551
  throw _earlyInstallErr;
11308
10552
  }
11309
10553
 
11310
- if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
10554
+ if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
11311
10555
  // Capture pre-install snapshots before ANY GSD mutation
11312
10556
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
11313
10557
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -11686,7 +10930,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11686
10930
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11687
10931
  }
11688
10932
 
11689
- if (configIntent.installSurface === 'copilot-instructions') {
10933
+ if (plan.installSurface === 'copilot-instructions') {
11690
10934
  // Generate copilot-instructions.md
11691
10935
  const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
11692
10936
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -11716,7 +10960,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11716
10960
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11717
10961
  }
11718
10962
 
11719
- if (configIntent.installSurface === 'cursor-hooks-json') {
10963
+ if (plan.installSurface === 'cursor-hooks-json') {
11720
10964
  // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
11721
10965
  // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
11722
10966
  const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
@@ -11731,13 +10975,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11731
10975
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11732
10976
  }
11733
10977
 
11734
- if (configIntent.installSurface === 'profile-marker-only') {
10978
+ if (plan.installSurface === 'profile-marker-only') {
11735
10979
  // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
11736
10980
  persistActiveProfileMarker();
11737
10981
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11738
10982
  }
11739
10983
 
11740
- if (configIntent.installSurface === 'cline-rules') {
10984
+ if (plan.installSurface === 'cline-rules') {
11741
10985
  // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
11742
10986
  // at .clinerules/gsd.md and a PreToolUse lifecycle hook at
11743
10987
  // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
@@ -11750,8 +10994,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11750
10994
  }
11751
10995
 
11752
10996
  // 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';
10997
+ // ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
10998
+ // runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
10999
+ // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check.
11000
+ // A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
11001
+ const _hookEventsDialect = plan.hookEvents;
11002
+ const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
11755
11003
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
11756
11004
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
11757
11005
  // settings.json. Global installs and all other runtimes continue to use settings.json.
@@ -11925,476 +11173,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11925
11173
  }
11926
11174
  }
11927
11175
 
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
- }
11176
+ // Register all GSD-managed hook entries into settings.hooks.* for runtimes
11177
+ // that use the settings.json hook surface (ADR-857 phase 5f-1b).
11178
+ // settings is mutated in place by applySettingsJsonHooks.
11179
+ applySettingsJsonHooks(settings, {
11180
+ runtime,
11181
+ isGlobal,
11182
+ targetDir,
11183
+ postToolEvent,
11184
+ hookEvents: _hookEventsDialect,
11185
+ extendedHookEvents: plan.extendedHookEvents,
11186
+ hooksSurface: plan.hooksSurface,
11187
+ updateCheckCommand,
11188
+ contextMonitorCommand,
11189
+ promptGuardCommand,
11190
+ readGuardCommand,
11191
+ readInjectionScannerCommand,
11192
+ configReloadCommand,
11193
+ hookOpts,
11194
+ localCmd,
11195
+ localShellCmd,
11196
+ });
12398
11197
 
12399
11198
  // ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
12400
11199
  // Detect `hooksConfig.enabled: false` in the already-loaded settings object
@@ -12509,9 +11308,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12509
11308
  const isWindsurf = runtime === 'windsurf';
12510
11309
  const isTrae = runtime === 'trae';
12511
11310
  const isCline = runtime === 'cline';
12512
- const configIntent = resolveRuntimeConfigIntent(runtime);
11311
+ const plan = resolveInstallPlan(runtime);
12513
11312
 
12514
- if (shouldInstallStatusline && configIntent.writesSharedSettings && !isOpencode) {
11313
+ if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
12515
11314
  if (!isGlobal && !forceStatusline) {
12516
11315
  // Local installs skip statusLine by default: repo settings.json takes precedence over
12517
11316
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -12537,7 +11336,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12537
11336
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
12538
11337
  // cline either lack the surface or use a different config schema.
12539
11338
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
12540
- if (shouldInstallBanner && settings && configIntent.writesSharedSettings && !isOpencode) {
11339
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
12541
11340
  if (!bannerCommand) {
12542
11341
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
12543
11342
  } else {
@@ -12577,17 +11376,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12577
11376
  // {type: 'command', command: null} items that the runtime hook schema
12578
11377
  // rejects at parse time. validateHookFields filters those out so the file
12579
11378
  // we write is always schema-valid.
12580
- if (settingsPath && settings && configIntent.writesSharedSettings) {
11379
+ if (settingsPath && settings && plan.writesSharedSettings) {
12581
11380
  writeSettings(settingsPath, validateHookFields(settings));
12582
11381
  }
12583
11382
 
12584
11383
  // Configure OpenCode permissions
12585
- if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
11384
+ if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
12586
11385
  configureOpencodePermissions(isGlobal, configDir);
12587
11386
  }
12588
11387
 
12589
11388
  // Configure Kilo permissions
12590
- if (configIntent.finishPermissionWriter === 'kilo') {
11389
+ if (plan.finishPermissionWriter === 'kilo') {
12591
11390
  configureKiloPermissions(isGlobal, configDir);
12592
11391
  }
12593
11392
 
@@ -13126,9 +11925,11 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
13126
11925
  '.codex',
13127
11926
  '.copilot',
13128
11927
  '.github', // copilot local form
13129
- '.agent', // antigravity local form
11928
+ '.agents', // antigravity local form (canonical, #791)
11929
+ '.agent', // antigravity local form (legacy, backward-compat)
13130
11930
  '.cursor',
13131
- '.windsurf',
11931
+ '.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
11932
+ '.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
13132
11933
  '.codeium/windsurf',
13133
11934
  '.augment',
13134
11935
  '.trae',
@@ -13447,6 +12248,8 @@ module.exports = {
13447
12248
  buildHookCommand,
13448
12249
  normalizeNodePath,
13449
12250
  resolveNodeRunner,
12251
+ referencesHook,
12252
+ applySettingsJsonHooks,
13450
12253
  rewriteLegacyManagedNodeHookCommands,
13451
12254
  buildCodexHookBlock,
13452
12255
  rewriteLegacyCodexHookBlock,