@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -31,7 +31,7 @@ const node_os_1 = __importDefault(require("node:os"));
31
31
  const imperative_hook_bus_cjs_1 = require("./host-integration-adapters/imperative-hook-bus.cjs");
32
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
33
  const shellCmdProjection = require("./shell-command-projection.cjs");
34
- const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
34
+ const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, escapeTomlDoubleQuotedString, } = shellCmdProjection;
35
35
  // ---------------------------------------------------------------------------
36
36
  // Terminal color constants (mirrors install.js for console output parity)
37
37
  // ---------------------------------------------------------------------------
@@ -53,6 +53,23 @@ const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' +
53
53
  const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
54
54
  `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
55
55
  `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
56
+ // #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static,
57
+ // deterministic advisory (no branching/no-op-style, matching sessionStart's
58
+ // tone) so the emitted hooks/gsd-session.json stays golden-trackable — no
59
+ // node-runner invocation, no filesystem probing beyond what sessionStart
60
+ // already does.
61
+ const GSD_COPILOT_PRE_TOOL_MSG = 'GSD: confirm this tool use is in scope for the active phase before proceeding.';
62
+ const GSD_COPILOT_PRE_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
63
+ const GSD_COPILOT_PRE_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
64
+ const GSD_COPILOT_POST_TOOL_MSG = 'GSD: review the tool result against the active phase before continuing.';
65
+ const GSD_COPILOT_POST_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
66
+ const GSD_COPILOT_POST_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
67
+ const GSD_COPILOT_PROMPT_SUBMIT_MSG = 'GSD: check this request against .planning/STATE.md scope before acting.';
68
+ const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
69
+ const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
70
+ const GSD_COPILOT_SESSION_END_MSG = 'GSD: update .planning/STATE.md with the session outcome before ending.';
71
+ const GSD_COPILOT_SESSION_END_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
72
+ const GSD_COPILOT_SESSION_END_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
56
73
  // ---------------------------------------------------------------------------
57
74
  // Cursor hook constants
58
75
  // ---------------------------------------------------------------------------
@@ -76,6 +93,28 @@ const CURSOR_MANAGED_EVENTS = imperative_hook_bus_cjs_1.CURSOR_HOOK_EVENTS;
76
93
  const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
77
94
  const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
78
95
  // ---------------------------------------------------------------------------
96
+ // Descriptor-driven runtime title lookup (ADR-1239 / #2092)
97
+ // ---------------------------------------------------------------------------
98
+ /**
99
+ * Console-log label for a runtime, sourced from the capability registry's
100
+ * `title` field (capabilities/<runtime>/capability.json). Folded from a
101
+ * hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ?
102
+ * 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves
103
+ * to the same 'Qwen Code' / 'Claude Code' values for those two runtimes.
104
+ * Falls back to the raw runtime id if the registry can't be loaded or the
105
+ * runtime has no title.
106
+ */
107
+ function _capabilityTitle(runtime) {
108
+ try {
109
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
110
+ const reg = require('./capability-registry.cjs');
111
+ return reg?.runtimes?.[runtime]?.title || runtime;
112
+ }
113
+ catch {
114
+ return runtime;
115
+ }
116
+ }
117
+ // ---------------------------------------------------------------------------
79
118
  // atomicWriteFileSync — shared canonical implementation.
80
119
  //
81
120
  // __atomicWrittenTmps is exported so bin/install.js can merge it into its
@@ -228,7 +267,7 @@ function normalizeNodePath(execPath, opts) {
228
267
  return execPath;
229
268
  const env = (opts && opts.env) || process.env;
230
269
  const existsSync = (opts && opts.existsSync) || node_fs_1.default.existsSync;
231
- const normalizedForMatch = execPath.replace(/\\/g, '/');
270
+ const normalizedForMatch = shellCmdProjection.posixNormalize(execPath);
232
271
  if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
233
272
  const candidates = [];
234
273
  if (env.FNM_DIR) {
@@ -244,11 +283,16 @@ function normalizeNodePath(execPath, opts) {
244
283
  }
245
284
  return execPath;
246
285
  }
247
- if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
248
- return '/usr/local/bin/node';
249
- }
250
- if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
251
- return '/opt/homebrew/bin/node';
286
+ // Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew
287
+ // /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at
288
+ // <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node, then deletes prior versions on
289
+ // `brew upgrade node`. Rewrite to the stable <prefix>/bin/node symlink, which
290
+ // survives the upgrade. Derive <prefix> from the path itself (more reliable
291
+ // than HOMEBREW_PREFIX env — the path IS the install location) so every layout
292
+ // is covered by one branch instead of one per known prefix (#2185).
293
+ const homebrewMatch = normalizedForMatch.match(/^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i);
294
+ if (homebrewMatch) {
295
+ return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
252
296
  }
253
297
  // mise pins a concrete node version at <data>/installs/node/<ver>/bin/node
254
298
  // (Windows: <data>/installs/node/<ver>/node.exe). Node realpaths
@@ -272,7 +316,7 @@ function resolveNodeRunner(opts) {
272
316
  if (!execPath)
273
317
  return null;
274
318
  const stablePath = normalizeNodePath(execPath, opts);
275
- return JSON.stringify(stablePath.replace(/\\/g, '/'));
319
+ return JSON.stringify(shellCmdProjection.posixNormalize(stablePath));
276
320
  }
277
321
  function resolveBashRunner(opts) {
278
322
  const platform = (opts && opts.platform) || process.platform;
@@ -293,7 +337,7 @@ function resolveBashRunner(opts) {
293
337
  }
294
338
  for (const candidate of candidates) {
295
339
  if (candidate && exists(candidate)) {
296
- return JSON.stringify(candidate.replace(/\\/g, '/'));
340
+ return JSON.stringify(shellCmdProjection.posixNormalize(candidate));
297
341
  }
298
342
  }
299
343
  return null;
@@ -333,7 +377,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
333
377
  }
334
378
  else {
335
379
  _runnerToken = m[1];
336
- const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
380
+ const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || '');
337
381
  const stableRunner = normalizeNodePath(runnerPath);
338
382
  if (stableRunner === runnerPath && platform !== 'win32')
339
383
  continue;
@@ -523,10 +567,10 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
523
567
  catch {
524
568
  interpreter = absoluteRunnerToken;
525
569
  }
526
- const targetAbs = scriptAbsPath.replace(/\\/g, '/');
570
+ const targetAbs = shellCmdProjection.posixNormalize(scriptAbsPath);
527
571
  const scriptQuoted = JSON.stringify(targetAbs);
528
572
  const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
529
- const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
573
+ const hookCommand = JSON.stringify(shellCmdProjection.posixNormalize(cmdPath));
530
574
  const runnerQuoted = JSON.stringify(interpreter);
531
575
  return {
532
576
  invocation: { interpreter, target: scriptAbsPath },
@@ -545,7 +589,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
545
589
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
546
590
  if (!absoluteRunner)
547
591
  return { changed: false, wrote: false, path: hooksJsonPath };
548
- const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
592
+ const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js'));
549
593
  const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
550
594
  let managedCommand;
551
595
  if (platform === 'win32') {
@@ -575,7 +619,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
575
619
  if (!managedCommand)
576
620
  return { changed: false, wrote: false, path: hooksJsonPath };
577
621
  const commandWindows = platform === 'win32'
578
- ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
622
+ ? JSON.stringify(shellCmdProjection.posixNormalize(cmdShimPath))
579
623
  : undefined;
580
624
  return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
581
625
  }
@@ -585,7 +629,7 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
585
629
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
586
630
  if (!absoluteRunner)
587
631
  return { changed: false, wrote: false, path: hooksJsonPath };
588
- const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
632
+ const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js'));
589
633
  let managedCommand;
590
634
  if (platform === 'win32') {
591
635
  const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
@@ -628,6 +672,7 @@ function buildHookCommand(configDir, hookName, opts) {
628
672
  opts = {};
629
673
  const platform = opts.platform || process.platform;
630
674
  const runtime = opts.runtime || 'generic';
675
+ const hookShell = opts.hookShell;
631
676
  const isShellHook = hookName.endsWith('.sh');
632
677
  if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
633
678
  if (opts.portableHooks) {
@@ -637,7 +682,7 @@ function buildHookCommand(configDir, hookName, opts) {
637
682
  });
638
683
  return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
639
684
  }
640
- return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
685
+ return JSON.stringify(shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName);
641
686
  }
642
687
  const nodeRunner = resolveNodeRunner();
643
688
  const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
@@ -653,14 +698,16 @@ function buildHookCommand(configDir, hookName, opts) {
653
698
  scriptPath: `${portableBaseDir}/hooks/${hookName}`,
654
699
  runtime: opts.runtime || 'generic',
655
700
  platform,
701
+ hookShell,
656
702
  });
657
703
  }
658
- const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
704
+ const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
659
705
  return projectManagedHookCommand({
660
706
  absoluteRunner: runner,
661
707
  scriptPath: hooksPath,
662
708
  runtime,
663
709
  platform,
710
+ hookShell,
664
711
  });
665
712
  }
666
713
  // ---------------------------------------------------------------------------
@@ -805,7 +852,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
805
852
  function buildCursorHookEntry(scriptPath) {
806
853
  return {
807
854
  type: 'command',
808
- command: scriptPath.replace(/\\/g, '/'),
855
+ command: shellCmdProjection.posixNormalize(scriptPath),
809
856
  [GSD_CURSOR_HOOK_MARKER]: true,
810
857
  };
811
858
  }
@@ -932,6 +979,193 @@ function removeCursorHooksJson(targetDir) {
932
979
  return { changed: result.changed };
933
980
  }
934
981
  // ---------------------------------------------------------------------------
982
+ // Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
983
+ //
984
+ // Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
985
+ // { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
986
+ // Each entry carries a bare `command` STRING (a shell command line) — not
987
+ // Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
988
+ // top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
989
+ // https://docs.devin.ai/desktop/cascade/hooks
990
+ //
991
+ // Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
992
+ // `{ block: true, reason }` form — so the two hook scripts installed here
993
+ // (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
994
+ // different protocol than the Cursor scripts, even though the surrounding
995
+ // install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
996
+ //
997
+ // Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
998
+ // BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
999
+ // context-injection channel (no `additional_context`-style advisory
1000
+ // response), so the 4 advisory events GSD registers on Cursor (sessionStart,
1001
+ // postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
1002
+ // ---------------------------------------------------------------------------
1003
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
1004
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
1005
+ const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
1006
+ /** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
1007
+ const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command']);
1008
+ /** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
1009
+ const WINDSURF_EVENT_SCRIPT_MAP = Object.freeze({
1010
+ pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1011
+ pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1012
+ });
1013
+ /** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
1014
+ const GSD_WINDSURF_HOOK_SCRIPTS = [
1015
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1016
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1017
+ ];
1018
+ /**
1019
+ * Build a single Cascade hooks.json managed entry. Cascade's entry shape has
1020
+ * no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
1021
+ * bare `command` shell string plus the GSD marker.
1022
+ */
1023
+ function buildWindsurfHookEntry(command) {
1024
+ return {
1025
+ command,
1026
+ [GSD_WINDSURF_HOOK_MARKER]: true,
1027
+ };
1028
+ }
1029
+ function isManagedWindsurfHookEntry(entry) {
1030
+ return Boolean(entry && typeof entry === 'object' && entry[GSD_WINDSURF_HOOK_MARKER]);
1031
+ }
1032
+ /**
1033
+ * Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
1034
+ * preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
1035
+ * merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
1036
+ * `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
1037
+ * top-level-array lift — Cascade's hooks.json is a brand-new surface with no
1038
+ * prior shape to migrate from).
1039
+ */
1040
+ function reconcileWindsurfHooksJson(hooksJsonPath, managedEntries) {
1041
+ let parsed = {};
1042
+ let currentContent = null;
1043
+ if (node_fs_1.default.existsSync(hooksJsonPath)) {
1044
+ const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
1045
+ currentContent = raw;
1046
+ if (raw.trim()) {
1047
+ try {
1048
+ parsed = JSON.parse(raw);
1049
+ }
1050
+ catch (err) {
1051
+ throw new Error(`Windsurf hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
1052
+ }
1053
+ }
1054
+ }
1055
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1056
+ parsed = {};
1057
+ const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
1058
+ if (!hasNestedHooksObject)
1059
+ parsed['hooks'] = {};
1060
+ const hookTable = parsed['hooks'];
1061
+ const entries = managedEntries || {};
1062
+ for (const event of WINDSURF_HOOK_EVENTS) {
1063
+ const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
1064
+ const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
1065
+ const newEntry = entries[event] || null;
1066
+ if (newEntry) {
1067
+ hookTable[event] = [...userOwned, newEntry];
1068
+ }
1069
+ else if (userOwned.length > 0) {
1070
+ hookTable[event] = userOwned;
1071
+ }
1072
+ else {
1073
+ delete hookTable[event];
1074
+ }
1075
+ }
1076
+ // Avoid writing an empty `{ "hooks": {} }` artifact.
1077
+ if (Object.keys(hookTable).length === 0)
1078
+ delete parsed['hooks'];
1079
+ const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
1080
+ const changed = currentContent !== nextContent;
1081
+ const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
1082
+ if (shouldWrite) {
1083
+ atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
1084
+ }
1085
+ return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
1086
+ }
1087
+ /**
1088
+ * Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
1089
+ * Both managed hook scripts (gsd-windsurf-pre-write.js,
1090
+ * gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
1091
+ * `<targetDir>/hooks/` first, so the hooks.json entries never reference a
1092
+ * script that wasn't installed. Mirrors writeCursorHooksJson's structure;
1093
+ * `buildHookCommand` is runtime-agnostic (it already returns a plain shell
1094
+ * command string), so it is reused as-is with `runtime: 'windsurf'` — only
1095
+ * the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
1096
+ * function differ from Cursor's.
1097
+ *
1098
+ * @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
1099
+ * @param src - The GSD install source root (for copying hook scripts)
1100
+ * @param opts - `{ platform? }`
1101
+ * @returns `{ hooksJsonPath, changed }`
1102
+ */
1103
+ function writeWindsurfHooksJson(targetDir, src, opts) {
1104
+ opts = opts || {};
1105
+ const hooksDir = node_path_1.default.join(targetDir, 'hooks');
1106
+ node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
1107
+ const srcHooksDir = node_path_1.default.join(src, 'hooks');
1108
+ const installedScripts = new Set();
1109
+ for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
1110
+ const srcPath = node_path_1.default.join(srcHooksDir, script);
1111
+ const destPath = node_path_1.default.join(hooksDir, script);
1112
+ if (node_fs_1.default.existsSync(srcPath)) {
1113
+ let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
1114
+ content = content.replace(/gsd:/gi, 'gsd-');
1115
+ node_fs_1.default.writeFileSync(destPath, content);
1116
+ try {
1117
+ node_fs_1.default.chmodSync(destPath, 0o755);
1118
+ }
1119
+ catch { /* Windows: ignore chmod */ }
1120
+ installedScripts.add(script);
1121
+ }
1122
+ }
1123
+ const hookOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
1124
+ const commands = {};
1125
+ for (const ev of WINDSURF_HOOK_EVENTS) {
1126
+ const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
1127
+ commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
1128
+ }
1129
+ const managedEntries = {};
1130
+ for (const ev of WINDSURF_HOOK_EVENTS) {
1131
+ const cmd = commands[ev];
1132
+ if (cmd)
1133
+ managedEntries[ev] = buildWindsurfHookEntry(cmd);
1134
+ }
1135
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
1136
+ const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
1137
+ return { hooksJsonPath, changed: result.changed };
1138
+ }
1139
+ /**
1140
+ * Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
1141
+ * entries are preserved. If the file becomes empty, it is removed.
1142
+ *
1143
+ * @param targetDir - The Windsurf config dir
1144
+ * @returns `{ changed }`
1145
+ */
1146
+ function removeWindsurfHooksJson(targetDir) {
1147
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
1148
+ if (!node_fs_1.default.existsSync(hooksJsonPath))
1149
+ return { changed: false };
1150
+ const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
1151
+ if (result.changed) {
1152
+ try {
1153
+ const contentRaw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
1154
+ const parsed = JSON.parse(contentRaw);
1155
+ const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
1156
+ ? parsed['hooks']
1157
+ : {};
1158
+ const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
1159
+ if (!hasAnyEvents) {
1160
+ node_fs_1.default.unlinkSync(hooksJsonPath);
1161
+ return { changed: true };
1162
+ }
1163
+ }
1164
+ catch { /* best-effort: leave the file */ }
1165
+ }
1166
+ return { changed: result.changed };
1167
+ }
1168
+ // ---------------------------------------------------------------------------
935
1169
  // Copilot hook functions
936
1170
  // ---------------------------------------------------------------------------
937
1171
  function buildCopilotHookConfig() {
@@ -946,6 +1180,41 @@ function buildCopilotHookConfig() {
946
1180
  timeoutSec: 10,
947
1181
  },
948
1182
  ],
1183
+ // #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
1184
+ // advisory), postToolUse (context-monitor advisory), userPromptSubmitted
1185
+ // (prompt-guard advisory), sessionEnd (session-finalize advisory).
1186
+ preToolUse: [
1187
+ {
1188
+ type: 'command',
1189
+ bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH,
1190
+ powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH,
1191
+ timeoutSec: 10,
1192
+ },
1193
+ ],
1194
+ postToolUse: [
1195
+ {
1196
+ type: 'command',
1197
+ bash: GSD_COPILOT_POST_TOOL_HOOK_BASH,
1198
+ powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH,
1199
+ timeoutSec: 10,
1200
+ },
1201
+ ],
1202
+ userPromptSubmitted: [
1203
+ {
1204
+ type: 'command',
1205
+ bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
1206
+ powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
1207
+ timeoutSec: 10,
1208
+ },
1209
+ ],
1210
+ sessionEnd: [
1211
+ {
1212
+ type: 'command',
1213
+ bash: GSD_COPILOT_SESSION_END_HOOK_BASH,
1214
+ powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH,
1215
+ timeoutSec: 10,
1216
+ },
1217
+ ],
949
1218
  },
950
1219
  };
951
1220
  }
@@ -970,7 +1239,13 @@ function applySettingsJsonHooks(settings, opts) {
970
1239
  // register settings.json hooks; runtimes with hooksSurface === 'none'
971
1240
  // (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
972
1241
  // the old !isOpencode && !isKilo check.
973
- if (hooksSurface !== 'none') {
1242
+ // #2095: kimi's hooksSurface is 'kimi-hooks-toml' — it registers hooks into
1243
+ // its own native config.toml via writeKimiHooksToml, not settings.json (kimi
1244
+ // never writes settings.json at all: writesSharedSettings stays false). This
1245
+ // guard must also skip kimi's surface so applySettingsJsonHooks doesn't log
1246
+ // misleading "Configured ..." console messages for a settings object that
1247
+ // finishInstall() will never persist for kimi.
1248
+ if (hooksSurface !== 'none' && hooksSurface !== 'kimi-hooks-toml') {
974
1249
  if (!settings.hooks) {
975
1250
  settings.hooks = {};
976
1251
  }
@@ -1272,12 +1547,18 @@ function applySettingsJsonHooks(settings, opts) {
1272
1547
  else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
1273
1548
  console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
1274
1549
  }
1275
- // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
1276
- // Claude Code (since #770) and Qwen Code (since #788) both support these
1277
- // three lifecycle events. Wire gsd-context-monitor so agents get context-
1278
- // headroom warnings at subagent completion, model stop, and pre-compaction
1279
- // (the most critical moment to surface headroom info).
1550
+ // ── Extended hook events: SubagentStop / Stop / PreCompact / SubagentStart
1551
+ // (#788 + #770 + #2092) ────────────────────────────────────────────────
1552
+ // Claude Code (since #770) and Qwen Code (since #788) both support the
1553
+ // SubagentStop / Stop / PreCompact lifecycle events. Qwen Code additionally
1554
+ // supports SubagentStart (#2092 Phase B, Upgrade 2). Wire gsd-context-
1555
+ // monitor so agents get context-headroom warnings at subagent start,
1556
+ // subagent completion, model stop, and pre-compaction (the most critical
1557
+ // moment to surface headroom info).
1280
1558
  //
1559
+ // SubagentStart — subagent lifecycle start (context headroom tracking;
1560
+ // qwen-only today — no other runtime declares it in
1561
+ // extendedHookEvents)
1281
1562
  // SubagentStop — subagent lifecycle completion (context headroom tracking)
1282
1563
  // Stop — model stop / final-response moment (context headroom)
1283
1564
  // PreCompact — fires before conversation compaction (most critical
@@ -1287,11 +1568,15 @@ function applySettingsJsonHooks(settings, opts) {
1287
1568
  // user prompt text, not a tool invocation, so gsd-prompt-guard (which
1288
1569
  // exits unless tool_name is Write/Edit) would be a silent no-op. A
1289
1570
  // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
1290
- // SubagentStop, Stop, PreCompact — route through the context monitor.
1291
- // Guard is now descriptor-driven: only events present in extendedEvents are wired.
1571
+ // SubagentStart, SubagentStop, Stop, PreCompact — route through the context monitor.
1572
+ // Guard is descriptor-driven: only events present in extendedEvents are wired,
1573
+ // so this loop is a no-op for every runtime that doesn't list SubagentStart.
1292
1574
  {
1293
- const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime;
1294
- for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
1575
+ // Descriptor-driven (ADR-1239 / #2092): folded from a hardcoded
1576
+ // `runtime === 'qwen' ? ... : ...` ternary into a capability-title
1577
+ // lookup (see _capabilityTitle above).
1578
+ const runtimeLabel = _capabilityTitle(runtime);
1579
+ for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']) {
1295
1580
  if (!extendedEvents.includes(event))
1296
1581
  continue;
1297
1582
  if (!settings.hooks[event]) {
@@ -1315,7 +1600,7 @@ function applySettingsJsonHooks(settings, opts) {
1315
1600
  }
1316
1601
  }
1317
1602
  }
1318
- // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
1603
+ // ── end SubagentStop / Stop / PreCompact / SubagentStart events ────────────
1319
1604
  // ── Extended hook events (#776; Gemini runtime removed #1928) ──────────────
1320
1605
  // The Gemini-3-backend dialect exposes several hook events beyond
1321
1606
  // BeforeTool/AfterTool. These were added for the now-removed Gemini CLI
@@ -1412,6 +1697,199 @@ function applySettingsJsonHooks(settings, opts) {
1412
1697
  @typescript-eslint/no-unsafe-assignment */
1413
1698
  }
1414
1699
  // ---------------------------------------------------------------------------
1700
+ // Kimi hooks.toml (#2095 EoS/kimi Upgrade 1 — native hook bus)
1701
+ //
1702
+ // Kimi CLI reads lifecycle hooks from a flat `[[hooks]]` array in its own
1703
+ // config.toml (moonshotai.github.io/kimi-cli/en/customization/hooks.html),
1704
+ // not from settings.json. Unlike every other hooksSurface writer above, this
1705
+ // file lives OUTSIDE the runtime's GSD configDir: kimi's configDir is the
1706
+ // generic Agent-Skills root (~/.config/agents by default), while config.toml
1707
+ // is a sibling at ~/.kimi (KIMI_SHARE_DIR override), resolved by
1708
+ // resolveKimiHooksTomlDir in runtime-homes.cts. Callers resolve that path and
1709
+ // pass it in explicitly — this module never reaches into runtime-homes.cjs
1710
+ // itself, keeping the same configDir/targetDir-passed-in shape every other
1711
+ // writer in this file uses.
1712
+ //
1713
+ // GSD-owned [[hooks]] entries are wrapped in marker comments so a reinstall
1714
+ // can find-and-replace only GSD's own block, leaving any user-authored
1715
+ // [[hooks]] entries elsewhere in the file untouched — mirrors the marker
1716
+ // approach stripStaleGsdHookBlocks uses for Codex's config.toml, simplified
1717
+ // to plain string slicing since this block is a flat, self-contained span
1718
+ // (no nested per-key structural TOML parsing is needed).
1719
+ // ---------------------------------------------------------------------------
1720
+ const KIMI_HOOKS_TOML_MARKER_BEGIN = '# GSD Hooks BEGIN — managed by GSD, do not edit between these markers';
1721
+ const KIMI_HOOKS_TOML_MARKER_END = '# GSD Hooks END';
1722
+ function buildKimiHookEntryToml(spec) {
1723
+ if (!spec.command)
1724
+ return null;
1725
+ const lines = ['[[hooks]]', `event = "${spec.event}"`];
1726
+ if (spec.matcher) {
1727
+ lines.push(`matcher = "${escapeTomlDoubleQuotedString(spec.matcher)}"`);
1728
+ }
1729
+ lines.push(`command = "${escapeTomlDoubleQuotedString(spec.command)}"`);
1730
+ if (typeof spec.timeout === 'number') {
1731
+ lines.push(`timeout = ${spec.timeout}`);
1732
+ }
1733
+ return lines.join('\n');
1734
+ }
1735
+ /**
1736
+ * Build the full marker-delimited GSD [[hooks]] block for kimi's config.toml,
1737
+ * or null when no GSD hook resolved to a usable command (hooks/ missing, or
1738
+ * the node/bash runner could not be resolved — mirrors the #1754/#3002
1739
+ * defensive guards applySettingsJsonHooks applies per-hook above).
1740
+ *
1741
+ * Event -> hook mapping mirrors applySettingsJsonHooks' settings.json wiring
1742
+ * 1:1 by GSD hook script (update check, session-state, phase-boundary,
1743
+ * graphify, context monitor, prompt/read/workflow/worktree guards, commit
1744
+ * validation). Kimi's 13 lifecycle events include exact-name equivalents for
1745
+ * every Claude-dialect event GSD currently wires (SessionStart, PreToolUse,
1746
+ * PostToolUse, Stop, PreCompact, SubagentStart, SubagentStop) — see
1747
+ * moonshotai.github.io/kimi-cli/en/customization/hooks.html.
1748
+ *
1749
+ * Matcher translation (best-effort — Kimi's tool-name vocabulary is
1750
+ * confirmed distinct from Claude's by the upstream hooks doc's own examples):
1751
+ * Bash -> Shell, Write -> WriteFile, Edit/MultiEdit -> StrReplaceFile.
1752
+ * Read -> ReadFile follows the same WriteFile/StrReplaceFile naming
1753
+ * convention but is not independently doc-confirmed. Claude's Agent|Task
1754
+ * (subagent-dispatch) matcher segment has no confirmed Kimi tool name and is
1755
+ * dropped rather than guessed — gsd-context-monitor's PostToolUse entry runs
1756
+ * unmatched (all tools) instead, which only widens when it fires, it never
1757
+ * narrows incorrectly.
1758
+ */
1759
+ function buildKimiHooksTomlBlock(targetDir, opts) {
1760
+ const { hookOpts } = opts;
1761
+ const cmd = (hookName) => {
1762
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(targetDir, 'hooks', hookName)))
1763
+ return null;
1764
+ return buildHookCommand(targetDir, hookName, hookOpts);
1765
+ };
1766
+ const specs = [
1767
+ // SessionStart — unmatched (session-level; no tool_name to filter on).
1768
+ { event: 'SessionStart', command: cmd('gsd-check-update.js') },
1769
+ { event: 'SessionStart', command: cmd('gsd-session-state.sh') },
1770
+ // PreToolUse
1771
+ { event: 'PreToolUse', command: cmd('gsd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1772
+ { event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1773
+ { event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1774
+ { event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
1775
+ { event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
1776
+ // PostToolUse
1777
+ { event: 'PostToolUse', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1778
+ { event: 'PostToolUse', command: cmd('gsd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1779
+ { event: 'PostToolUse', command: cmd('gsd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
1780
+ { event: 'PostToolUse', command: cmd('gsd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
1781
+ // Extended lifecycle events — context-headroom tracking (unmatched).
1782
+ { event: 'Stop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1783
+ { event: 'PreCompact', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1784
+ { event: 'SubagentStart', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1785
+ { event: 'SubagentStop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1786
+ ];
1787
+ const entries = specs
1788
+ .map(buildKimiHookEntryToml)
1789
+ .filter((entry) => entry !== null);
1790
+ if (entries.length === 0)
1791
+ return null;
1792
+ return [KIMI_HOOKS_TOML_MARKER_BEGIN, '', entries.join('\n\n'), '', KIMI_HOOKS_TOML_MARKER_END].join('\n');
1793
+ }
1794
+ /**
1795
+ * Strip a previously-written GSD [[hooks]] block from kimi's config.toml
1796
+ * content. Pure string function (no fs access) so install, uninstall, and
1797
+ * tests share one strip implementation. Returns null when stripping leaves
1798
+ * nothing but whitespace (the file was GSD-only), so the caller can unlink
1799
+ * it instead of writing an empty file.
1800
+ */
1801
+ function stripKimiHooksTomlBlock(content) {
1802
+ const beginIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_BEGIN);
1803
+ if (beginIdx === -1) {
1804
+ return content.trim() === '' ? null : content;
1805
+ }
1806
+ const endMarkerIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_END, beginIdx);
1807
+ if (endMarkerIdx === -1) {
1808
+ // Malformed marker pair — BEGIN present but no END after it (missing END,
1809
+ // or an END that only appears earlier in the file, before BEGIN). Never
1810
+ // fall back to content.length here: that would slice to EOF and destroy
1811
+ // every user section that follows. Leave the content untouched instead;
1812
+ // a subsequent writeKimiHooksToml call will append a fresh, well-formed
1813
+ // block rather than silently deleting user data.
1814
+ return content;
1815
+ }
1816
+ const endIdx = endMarkerIdx + KIMI_HOOKS_TOML_MARKER_END.length;
1817
+ // Swallow blank lines immediately surrounding the block so repeated
1818
+ // strip+rewrite cycles never accumulate blank lines.
1819
+ let sliceStart = beginIdx;
1820
+ while (sliceStart > 0 && (content[sliceStart - 1] === '\n' || content[sliceStart - 1] === '\r'))
1821
+ sliceStart -= 1;
1822
+ let sliceEnd = endIdx;
1823
+ while (sliceEnd < content.length && (content[sliceEnd] === '\n' || content[sliceEnd] === '\r'))
1824
+ sliceEnd += 1;
1825
+ const before = content.slice(0, sliceStart);
1826
+ const after = content.slice(sliceEnd);
1827
+ // Blank-line swallowing above consumes every newline flanking the block,
1828
+ // including the one required to keep the surrounding user sections on
1829
+ // separate lines. If the block sat BETWEEN two user sections (content
1830
+ // survives on both sides), concatenating `before` + `after` directly would
1831
+ // glue the last line of the earlier section onto the first line of the
1832
+ // later one. Reinsert a blank-line separator in that case; when only one
1833
+ // side has content (block at file start or EOF), no separator is needed —
1834
+ // that matches the pre-existing idempotent behavior for those shapes.
1835
+ const result = before.trim() !== '' && after.trim() !== ''
1836
+ ? `${before}\n\n${after}`
1837
+ : before + after;
1838
+ return result.trim() === '' ? null : result;
1839
+ }
1840
+ /**
1841
+ * Idempotently (re)write kimi's GSD-owned [[hooks]] block into its native
1842
+ * config.toml at `configPath` (resolved by the caller via
1843
+ * resolveKimiHooksTomlDir). No-ops (`{changed:false}`) when the computed
1844
+ * block is byte-identical to what's already on disk, so reinstalls don't
1845
+ * touch the file's mtime for no reason.
1846
+ */
1847
+ function writeKimiHooksToml(configPath, targetDir, opts) {
1848
+ const existing = node_fs_1.default.existsSync(configPath) ? node_fs_1.default.readFileSync(configPath, 'utf8') : '';
1849
+ const stripped = stripKimiHooksTomlBlock(existing) ?? '';
1850
+ const block = buildKimiHooksTomlBlock(targetDir, opts);
1851
+ const entryCount = block ? (block.match(/\[\[hooks\]\]/g) || []).length : 0;
1852
+ if (!block) {
1853
+ if (stripped === existing)
1854
+ return { changed: false, path: configPath, entryCount: 0 };
1855
+ if (stripped.trim() === '') {
1856
+ if (node_fs_1.default.existsSync(configPath))
1857
+ node_fs_1.default.unlinkSync(configPath);
1858
+ }
1859
+ else {
1860
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
1861
+ atomicWriteFileSync(configPath, stripped, 'utf8');
1862
+ }
1863
+ return { changed: true, path: configPath, entryCount: 0 };
1864
+ }
1865
+ const separator = stripped.trim() === '' ? '' : (stripped.endsWith('\n') ? '\n' : '\n\n');
1866
+ const next = stripped.trim() === '' ? `${block}\n` : `${stripped}${separator}${block}\n`;
1867
+ if (next === existing)
1868
+ return { changed: false, path: configPath, entryCount };
1869
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
1870
+ atomicWriteFileSync(configPath, next, 'utf8');
1871
+ return { changed: true, path: configPath, entryCount };
1872
+ }
1873
+ /**
1874
+ * Uninstall-time counterpart to writeKimiHooksToml: strips the GSD block and
1875
+ * deletes the file if nothing but GSD's own block was ever in it.
1876
+ */
1877
+ function removeKimiHooksToml(configPath) {
1878
+ if (!node_fs_1.default.existsSync(configPath))
1879
+ return { changed: false };
1880
+ const existing = node_fs_1.default.readFileSync(configPath, 'utf8');
1881
+ const stripped = stripKimiHooksTomlBlock(existing);
1882
+ if (stripped === existing)
1883
+ return { changed: false };
1884
+ if (stripped === null || stripped.trim() === '') {
1885
+ node_fs_1.default.unlinkSync(configPath);
1886
+ }
1887
+ else {
1888
+ atomicWriteFileSync(configPath, stripped, 'utf8');
1889
+ }
1890
+ return { changed: true };
1891
+ }
1892
+ // ---------------------------------------------------------------------------
1415
1893
  // referencesHook
1416
1894
  //
1417
1895
  // Pure predicate — checks whether a hook entry object references a managed
@@ -1458,6 +1936,18 @@ module.exports = {
1458
1936
  GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
1459
1937
  GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
1460
1938
  GSD_CURSOR_HOOK_MARKER,
1939
+ // Windsurf/Cascade
1940
+ buildWindsurfHookEntry,
1941
+ isManagedWindsurfHookEntry,
1942
+ reconcileWindsurfHooksJson,
1943
+ writeWindsurfHooksJson,
1944
+ removeWindsurfHooksJson,
1945
+ WINDSURF_HOOK_EVENTS,
1946
+ WINDSURF_EVENT_SCRIPT_MAP,
1947
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1948
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1949
+ GSD_WINDSURF_HOOK_SCRIPTS,
1950
+ GSD_WINDSURF_HOOK_MARKER,
1461
1951
  // Copilot
1462
1952
  buildCopilotHookConfig,
1463
1953
  writeCopilotHookConfig,
@@ -1473,6 +1963,13 @@ module.exports = {
1473
1963
  // Codex TOML
1474
1964
  buildCodexHookBlock,
1475
1965
  rewriteLegacyCodexHookBlock,
1966
+ // Kimi hooks.toml
1967
+ buildKimiHooksTomlBlock,
1968
+ stripKimiHooksTomlBlock,
1969
+ writeKimiHooksToml,
1970
+ removeKimiHooksToml,
1971
+ KIMI_HOOKS_TOML_MARKER_BEGIN,
1972
+ KIMI_HOOKS_TOML_MARKER_END,
1476
1973
  // Shared
1477
1974
  buildHookCommand,
1478
1975
  applySettingsJsonHooks,