@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6

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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -28,9 +28,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
28
28
  const node_fs_1 = __importDefault(require("node:fs"));
29
29
  const node_path_1 = __importDefault(require("node:path"));
30
30
  const node_os_1 = __importDefault(require("node:os"));
31
+ const imperative_hook_bus_cjs_1 = require("./host-integration-adapters/imperative-hook-bus.cjs");
31
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
33
  const shellCmdProjection = require("./shell-command-projection.cjs");
33
- const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
34
+ const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, escapeTomlDoubleQuotedString, } = shellCmdProjection;
34
35
  // ---------------------------------------------------------------------------
35
36
  // Terminal color constants (mirrors install.js for console output parity)
36
37
  // ---------------------------------------------------------------------------
@@ -52,18 +53,68 @@ const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' +
52
53
  const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
53
54
  `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
54
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}"}'`;
55
73
  // ---------------------------------------------------------------------------
56
74
  // Cursor hook constants
57
75
  // ---------------------------------------------------------------------------
58
76
  const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
59
77
  const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
78
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
79
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
80
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
81
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
60
82
  const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
83
+ // The full set of Cursor hook events GSD manages — sourced from the adapter
84
+ // (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary
85
+ // stays closed and first-party. Used by reconcileCursorHooksJson (the
86
+ // reconciliation scope is always the full set). The install path
87
+ // (writeCursorHooksJson) resolves a descriptor-driven subset via
88
+ // resolveManagedHookEvents(opts.managedHookEvents).
89
+ const CURSOR_MANAGED_EVENTS = imperative_hook_bus_cjs_1.CURSOR_HOOK_EVENTS;
61
90
  // ---------------------------------------------------------------------------
62
91
  // Cline / AGENTS.md constants
63
92
  // ---------------------------------------------------------------------------
64
93
  const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
65
94
  const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
66
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
+ // ---------------------------------------------------------------------------
67
118
  // atomicWriteFileSync — shared canonical implementation.
68
119
  //
69
120
  // __atomicWrittenTmps is exported so bin/install.js can merge it into its
@@ -819,9 +870,8 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
819
870
  parsed = {};
820
871
  const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
821
872
  if (!hasNestedHooksObject) {
822
- const eventKeys = ['sessionStart', 'postToolUse'];
823
873
  const lifted = {};
824
- for (const k of eventKeys) {
874
+ for (const k of CURSOR_MANAGED_EVENTS) {
825
875
  if (Array.isArray(parsed[k])) {
826
876
  lifted[k] = parsed[k];
827
877
  delete parsed[k];
@@ -832,9 +882,8 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
832
882
  if (!parsed['version'])
833
883
  parsed['version'] = 1;
834
884
  const hookTable = parsed['hooks'];
835
- const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
836
885
  const entries = managedEntries || {};
837
- for (const event of MANAGED_EVENTS) {
886
+ for (const event of CURSOR_MANAGED_EVENTS) {
838
887
  const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
839
888
  const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
840
889
  const newEntry = entries[event] || null;
@@ -862,7 +911,11 @@ function writeCursorHooksJson(targetDir, src, opts) {
862
911
  opts = opts || {};
863
912
  const hooksDir = node_path_1.default.join(targetDir, 'hooks');
864
913
  node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
865
- const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
914
+ // Descriptor-driven event resolution (#2089): the managed event set comes
915
+ // from the host descriptor's hostBehaviors.managedHookEvents via the pure
916
+ // adapter (resolveManagedHookEvents), NOT a hardcoded constant.
917
+ const events = (0, imperative_hook_bus_cjs_1.resolveManagedHookEvents)(opts.managedHookEvents);
918
+ const hookScripts = (0, imperative_hook_bus_cjs_1.resolveHookScripts)(events);
866
919
  const srcHooksDir = node_path_1.default.join(src, 'hooks');
867
920
  const installedScripts = new Set();
868
921
  for (const script of hookScripts) {
@@ -880,27 +933,17 @@ function writeCursorHooksJson(targetDir, src, opts) {
880
933
  }
881
934
  }
882
935
  const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
883
- const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
884
- ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
885
- : null;
886
- const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
887
- ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
888
- : null;
889
- const managedEntries = {};
890
- if (sessionStartCmd) {
891
- managedEntries['sessionStart'] = {
892
- type: 'command',
893
- command: sessionStartCmd,
894
- [GSD_CURSOR_HOOK_MARKER]: true,
895
- };
896
- }
897
- if (postToolCmd) {
898
- managedEntries['postToolUse'] = {
899
- type: 'command',
900
- command: postToolCmd,
901
- [GSD_CURSOR_HOOK_MARKER]: true,
902
- };
936
+ const commands = {};
937
+ for (const ev of events) {
938
+ const script = imperative_hook_bus_cjs_1.CURSOR_EVENT_SCRIPT_MAP[ev];
939
+ if (script && installedScripts.has(script)) {
940
+ commands[ev] = buildHookCommand(targetDir, script, hookOpts);
941
+ }
942
+ else {
943
+ commands[ev] = null;
944
+ }
903
945
  }
946
+ const managedEntries = (0, imperative_hook_bus_cjs_1.buildHookBusEntries)(events, commands);
904
947
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
905
948
  const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
906
949
  return { hooksJsonPath, changed: result.changed };
@@ -928,6 +971,193 @@ function removeCursorHooksJson(targetDir) {
928
971
  return { changed: result.changed };
929
972
  }
930
973
  // ---------------------------------------------------------------------------
974
+ // Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
975
+ //
976
+ // Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
977
+ // { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
978
+ // Each entry carries a bare `command` STRING (a shell command line) — not
979
+ // Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
980
+ // top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
981
+ // https://docs.devin.ai/desktop/cascade/hooks
982
+ //
983
+ // Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
984
+ // `{ block: true, reason }` form — so the two hook scripts installed here
985
+ // (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
986
+ // different protocol than the Cursor scripts, even though the surrounding
987
+ // install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
988
+ //
989
+ // Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
990
+ // BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
991
+ // context-injection channel (no `additional_context`-style advisory
992
+ // response), so the 4 advisory events GSD registers on Cursor (sessionStart,
993
+ // postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
994
+ // ---------------------------------------------------------------------------
995
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
996
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
997
+ const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
998
+ /** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
999
+ const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command']);
1000
+ /** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
1001
+ const WINDSURF_EVENT_SCRIPT_MAP = Object.freeze({
1002
+ pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1003
+ pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1004
+ });
1005
+ /** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
1006
+ const GSD_WINDSURF_HOOK_SCRIPTS = [
1007
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1008
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1009
+ ];
1010
+ /**
1011
+ * Build a single Cascade hooks.json managed entry. Cascade's entry shape has
1012
+ * no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
1013
+ * bare `command` shell string plus the GSD marker.
1014
+ */
1015
+ function buildWindsurfHookEntry(command) {
1016
+ return {
1017
+ command,
1018
+ [GSD_WINDSURF_HOOK_MARKER]: true,
1019
+ };
1020
+ }
1021
+ function isManagedWindsurfHookEntry(entry) {
1022
+ return Boolean(entry && typeof entry === 'object' && entry[GSD_WINDSURF_HOOK_MARKER]);
1023
+ }
1024
+ /**
1025
+ * Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
1026
+ * preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
1027
+ * merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
1028
+ * `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
1029
+ * top-level-array lift — Cascade's hooks.json is a brand-new surface with no
1030
+ * prior shape to migrate from).
1031
+ */
1032
+ function reconcileWindsurfHooksJson(hooksJsonPath, managedEntries) {
1033
+ let parsed = {};
1034
+ let currentContent = null;
1035
+ if (node_fs_1.default.existsSync(hooksJsonPath)) {
1036
+ const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
1037
+ currentContent = raw;
1038
+ if (raw.trim()) {
1039
+ try {
1040
+ parsed = JSON.parse(raw);
1041
+ }
1042
+ catch (err) {
1043
+ throw new Error(`Windsurf hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
1044
+ }
1045
+ }
1046
+ }
1047
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1048
+ parsed = {};
1049
+ const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
1050
+ if (!hasNestedHooksObject)
1051
+ parsed['hooks'] = {};
1052
+ const hookTable = parsed['hooks'];
1053
+ const entries = managedEntries || {};
1054
+ for (const event of WINDSURF_HOOK_EVENTS) {
1055
+ const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
1056
+ const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
1057
+ const newEntry = entries[event] || null;
1058
+ if (newEntry) {
1059
+ hookTable[event] = [...userOwned, newEntry];
1060
+ }
1061
+ else if (userOwned.length > 0) {
1062
+ hookTable[event] = userOwned;
1063
+ }
1064
+ else {
1065
+ delete hookTable[event];
1066
+ }
1067
+ }
1068
+ // Avoid writing an empty `{ "hooks": {} }` artifact.
1069
+ if (Object.keys(hookTable).length === 0)
1070
+ delete parsed['hooks'];
1071
+ const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
1072
+ const changed = currentContent !== nextContent;
1073
+ const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
1074
+ if (shouldWrite) {
1075
+ atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
1076
+ }
1077
+ return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
1078
+ }
1079
+ /**
1080
+ * Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
1081
+ * Both managed hook scripts (gsd-windsurf-pre-write.js,
1082
+ * gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
1083
+ * `<targetDir>/hooks/` first, so the hooks.json entries never reference a
1084
+ * script that wasn't installed. Mirrors writeCursorHooksJson's structure;
1085
+ * `buildHookCommand` is runtime-agnostic (it already returns a plain shell
1086
+ * command string), so it is reused as-is with `runtime: 'windsurf'` — only
1087
+ * the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
1088
+ * function differ from Cursor's.
1089
+ *
1090
+ * @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
1091
+ * @param src - The GSD install source root (for copying hook scripts)
1092
+ * @param opts - `{ platform? }`
1093
+ * @returns `{ hooksJsonPath, changed }`
1094
+ */
1095
+ function writeWindsurfHooksJson(targetDir, src, opts) {
1096
+ opts = opts || {};
1097
+ const hooksDir = node_path_1.default.join(targetDir, 'hooks');
1098
+ node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
1099
+ const srcHooksDir = node_path_1.default.join(src, 'hooks');
1100
+ const installedScripts = new Set();
1101
+ for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
1102
+ const srcPath = node_path_1.default.join(srcHooksDir, script);
1103
+ const destPath = node_path_1.default.join(hooksDir, script);
1104
+ if (node_fs_1.default.existsSync(srcPath)) {
1105
+ let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
1106
+ content = content.replace(/gsd:/gi, 'gsd-');
1107
+ node_fs_1.default.writeFileSync(destPath, content);
1108
+ try {
1109
+ node_fs_1.default.chmodSync(destPath, 0o755);
1110
+ }
1111
+ catch { /* Windows: ignore chmod */ }
1112
+ installedScripts.add(script);
1113
+ }
1114
+ }
1115
+ const hookOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
1116
+ const commands = {};
1117
+ for (const ev of WINDSURF_HOOK_EVENTS) {
1118
+ const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
1119
+ commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
1120
+ }
1121
+ const managedEntries = {};
1122
+ for (const ev of WINDSURF_HOOK_EVENTS) {
1123
+ const cmd = commands[ev];
1124
+ if (cmd)
1125
+ managedEntries[ev] = buildWindsurfHookEntry(cmd);
1126
+ }
1127
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
1128
+ const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
1129
+ return { hooksJsonPath, changed: result.changed };
1130
+ }
1131
+ /**
1132
+ * Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
1133
+ * entries are preserved. If the file becomes empty, it is removed.
1134
+ *
1135
+ * @param targetDir - The Windsurf config dir
1136
+ * @returns `{ changed }`
1137
+ */
1138
+ function removeWindsurfHooksJson(targetDir) {
1139
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
1140
+ if (!node_fs_1.default.existsSync(hooksJsonPath))
1141
+ return { changed: false };
1142
+ const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
1143
+ if (result.changed) {
1144
+ try {
1145
+ const contentRaw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
1146
+ const parsed = JSON.parse(contentRaw);
1147
+ const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
1148
+ ? parsed['hooks']
1149
+ : {};
1150
+ const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
1151
+ if (!hasAnyEvents) {
1152
+ node_fs_1.default.unlinkSync(hooksJsonPath);
1153
+ return { changed: true };
1154
+ }
1155
+ }
1156
+ catch { /* best-effort: leave the file */ }
1157
+ }
1158
+ return { changed: result.changed };
1159
+ }
1160
+ // ---------------------------------------------------------------------------
931
1161
  // Copilot hook functions
932
1162
  // ---------------------------------------------------------------------------
933
1163
  function buildCopilotHookConfig() {
@@ -942,6 +1172,41 @@ function buildCopilotHookConfig() {
942
1172
  timeoutSec: 10,
943
1173
  },
944
1174
  ],
1175
+ // #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
1176
+ // advisory), postToolUse (context-monitor advisory), userPromptSubmitted
1177
+ // (prompt-guard advisory), sessionEnd (session-finalize advisory).
1178
+ preToolUse: [
1179
+ {
1180
+ type: 'command',
1181
+ bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH,
1182
+ powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH,
1183
+ timeoutSec: 10,
1184
+ },
1185
+ ],
1186
+ postToolUse: [
1187
+ {
1188
+ type: 'command',
1189
+ bash: GSD_COPILOT_POST_TOOL_HOOK_BASH,
1190
+ powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH,
1191
+ timeoutSec: 10,
1192
+ },
1193
+ ],
1194
+ userPromptSubmitted: [
1195
+ {
1196
+ type: 'command',
1197
+ bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
1198
+ powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
1199
+ timeoutSec: 10,
1200
+ },
1201
+ ],
1202
+ sessionEnd: [
1203
+ {
1204
+ type: 'command',
1205
+ bash: GSD_COPILOT_SESSION_END_HOOK_BASH,
1206
+ powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH,
1207
+ timeoutSec: 10,
1208
+ },
1209
+ ],
945
1210
  },
946
1211
  };
947
1212
  }
@@ -966,7 +1231,13 @@ function applySettingsJsonHooks(settings, opts) {
966
1231
  // register settings.json hooks; runtimes with hooksSurface === 'none'
967
1232
  // (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
968
1233
  // the old !isOpencode && !isKilo check.
969
- if (hooksSurface !== 'none') {
1234
+ // #2095: kimi's hooksSurface is 'kimi-hooks-toml' — it registers hooks into
1235
+ // its own native config.toml via writeKimiHooksToml, not settings.json (kimi
1236
+ // never writes settings.json at all: writesSharedSettings stays false). This
1237
+ // guard must also skip kimi's surface so applySettingsJsonHooks doesn't log
1238
+ // misleading "Configured ..." console messages for a settings object that
1239
+ // finishInstall() will never persist for kimi.
1240
+ if (hooksSurface !== 'none' && hooksSurface !== 'kimi-hooks-toml') {
970
1241
  if (!settings.hooks) {
971
1242
  settings.hooks = {};
972
1243
  }
@@ -1268,12 +1539,18 @@ function applySettingsJsonHooks(settings, opts) {
1268
1539
  else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
1269
1540
  console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
1270
1541
  }
1271
- // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
1272
- // Claude Code (since #770) and Qwen Code (since #788) both support these
1273
- // three lifecycle events. Wire gsd-context-monitor so agents get context-
1274
- // headroom warnings at subagent completion, model stop, and pre-compaction
1275
- // (the most critical moment to surface headroom info).
1542
+ // ── Extended hook events: SubagentStop / Stop / PreCompact / SubagentStart
1543
+ // (#788 + #770 + #2092) ────────────────────────────────────────────────
1544
+ // Claude Code (since #770) and Qwen Code (since #788) both support the
1545
+ // SubagentStop / Stop / PreCompact lifecycle events. Qwen Code additionally
1546
+ // supports SubagentStart (#2092 Phase B, Upgrade 2). Wire gsd-context-
1547
+ // monitor so agents get context-headroom warnings at subagent start,
1548
+ // subagent completion, model stop, and pre-compaction (the most critical
1549
+ // moment to surface headroom info).
1276
1550
  //
1551
+ // SubagentStart — subagent lifecycle start (context headroom tracking;
1552
+ // qwen-only today — no other runtime declares it in
1553
+ // extendedHookEvents)
1277
1554
  // SubagentStop — subagent lifecycle completion (context headroom tracking)
1278
1555
  // Stop — model stop / final-response moment (context headroom)
1279
1556
  // PreCompact — fires before conversation compaction (most critical
@@ -1283,11 +1560,15 @@ function applySettingsJsonHooks(settings, opts) {
1283
1560
  // user prompt text, not a tool invocation, so gsd-prompt-guard (which
1284
1561
  // exits unless tool_name is Write/Edit) would be a silent no-op. A
1285
1562
  // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
1286
- // SubagentStop, Stop, PreCompact — route through the context monitor.
1287
- // Guard is now descriptor-driven: only events present in extendedEvents are wired.
1563
+ // SubagentStart, SubagentStop, Stop, PreCompact — route through the context monitor.
1564
+ // Guard is descriptor-driven: only events present in extendedEvents are wired,
1565
+ // so this loop is a no-op for every runtime that doesn't list SubagentStart.
1288
1566
  {
1289
- const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime;
1290
- for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
1567
+ // Descriptor-driven (ADR-1239 / #2092): folded from a hardcoded
1568
+ // `runtime === 'qwen' ? ... : ...` ternary into a capability-title
1569
+ // lookup (see _capabilityTitle above).
1570
+ const runtimeLabel = _capabilityTitle(runtime);
1571
+ for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']) {
1291
1572
  if (!extendedEvents.includes(event))
1292
1573
  continue;
1293
1574
  if (!settings.hooks[event]) {
@@ -1311,7 +1592,7 @@ function applySettingsJsonHooks(settings, opts) {
1311
1592
  }
1312
1593
  }
1313
1594
  }
1314
- // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
1595
+ // ── end SubagentStop / Stop / PreCompact / SubagentStart events ────────────
1315
1596
  // ── Extended hook events (#776; Gemini runtime removed #1928) ──────────────
1316
1597
  // The Gemini-3-backend dialect exposes several hook events beyond
1317
1598
  // BeforeTool/AfterTool. These were added for the now-removed Gemini CLI
@@ -1408,6 +1689,199 @@ function applySettingsJsonHooks(settings, opts) {
1408
1689
  @typescript-eslint/no-unsafe-assignment */
1409
1690
  }
1410
1691
  // ---------------------------------------------------------------------------
1692
+ // Kimi hooks.toml (#2095 EoS/kimi Upgrade 1 — native hook bus)
1693
+ //
1694
+ // Kimi CLI reads lifecycle hooks from a flat `[[hooks]]` array in its own
1695
+ // config.toml (moonshotai.github.io/kimi-cli/en/customization/hooks.html),
1696
+ // not from settings.json. Unlike every other hooksSurface writer above, this
1697
+ // file lives OUTSIDE the runtime's GSD configDir: kimi's configDir is the
1698
+ // generic Agent-Skills root (~/.config/agents by default), while config.toml
1699
+ // is a sibling at ~/.kimi (KIMI_SHARE_DIR override), resolved by
1700
+ // resolveKimiHooksTomlDir in runtime-homes.cts. Callers resolve that path and
1701
+ // pass it in explicitly — this module never reaches into runtime-homes.cjs
1702
+ // itself, keeping the same configDir/targetDir-passed-in shape every other
1703
+ // writer in this file uses.
1704
+ //
1705
+ // GSD-owned [[hooks]] entries are wrapped in marker comments so a reinstall
1706
+ // can find-and-replace only GSD's own block, leaving any user-authored
1707
+ // [[hooks]] entries elsewhere in the file untouched — mirrors the marker
1708
+ // approach stripStaleGsdHookBlocks uses for Codex's config.toml, simplified
1709
+ // to plain string slicing since this block is a flat, self-contained span
1710
+ // (no nested per-key structural TOML parsing is needed).
1711
+ // ---------------------------------------------------------------------------
1712
+ const KIMI_HOOKS_TOML_MARKER_BEGIN = '# GSD Hooks BEGIN — managed by GSD, do not edit between these markers';
1713
+ const KIMI_HOOKS_TOML_MARKER_END = '# GSD Hooks END';
1714
+ function buildKimiHookEntryToml(spec) {
1715
+ if (!spec.command)
1716
+ return null;
1717
+ const lines = ['[[hooks]]', `event = "${spec.event}"`];
1718
+ if (spec.matcher) {
1719
+ lines.push(`matcher = "${escapeTomlDoubleQuotedString(spec.matcher)}"`);
1720
+ }
1721
+ lines.push(`command = "${escapeTomlDoubleQuotedString(spec.command)}"`);
1722
+ if (typeof spec.timeout === 'number') {
1723
+ lines.push(`timeout = ${spec.timeout}`);
1724
+ }
1725
+ return lines.join('\n');
1726
+ }
1727
+ /**
1728
+ * Build the full marker-delimited GSD [[hooks]] block for kimi's config.toml,
1729
+ * or null when no GSD hook resolved to a usable command (hooks/ missing, or
1730
+ * the node/bash runner could not be resolved — mirrors the #1754/#3002
1731
+ * defensive guards applySettingsJsonHooks applies per-hook above).
1732
+ *
1733
+ * Event -> hook mapping mirrors applySettingsJsonHooks' settings.json wiring
1734
+ * 1:1 by GSD hook script (update check, session-state, phase-boundary,
1735
+ * graphify, context monitor, prompt/read/workflow/worktree guards, commit
1736
+ * validation). Kimi's 13 lifecycle events include exact-name equivalents for
1737
+ * every Claude-dialect event GSD currently wires (SessionStart, PreToolUse,
1738
+ * PostToolUse, Stop, PreCompact, SubagentStart, SubagentStop) — see
1739
+ * moonshotai.github.io/kimi-cli/en/customization/hooks.html.
1740
+ *
1741
+ * Matcher translation (best-effort — Kimi's tool-name vocabulary is
1742
+ * confirmed distinct from Claude's by the upstream hooks doc's own examples):
1743
+ * Bash -> Shell, Write -> WriteFile, Edit/MultiEdit -> StrReplaceFile.
1744
+ * Read -> ReadFile follows the same WriteFile/StrReplaceFile naming
1745
+ * convention but is not independently doc-confirmed. Claude's Agent|Task
1746
+ * (subagent-dispatch) matcher segment has no confirmed Kimi tool name and is
1747
+ * dropped rather than guessed — gsd-context-monitor's PostToolUse entry runs
1748
+ * unmatched (all tools) instead, which only widens when it fires, it never
1749
+ * narrows incorrectly.
1750
+ */
1751
+ function buildKimiHooksTomlBlock(targetDir, opts) {
1752
+ const { hookOpts } = opts;
1753
+ const cmd = (hookName) => {
1754
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(targetDir, 'hooks', hookName)))
1755
+ return null;
1756
+ return buildHookCommand(targetDir, hookName, hookOpts);
1757
+ };
1758
+ const specs = [
1759
+ // SessionStart — unmatched (session-level; no tool_name to filter on).
1760
+ { event: 'SessionStart', command: cmd('gsd-check-update.js') },
1761
+ { event: 'SessionStart', command: cmd('gsd-session-state.sh') },
1762
+ // PreToolUse
1763
+ { event: 'PreToolUse', command: cmd('gsd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1764
+ { event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1765
+ { event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1766
+ { event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
1767
+ { event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
1768
+ // PostToolUse
1769
+ { event: 'PostToolUse', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1770
+ { event: 'PostToolUse', command: cmd('gsd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
1771
+ { event: 'PostToolUse', command: cmd('gsd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
1772
+ { event: 'PostToolUse', command: cmd('gsd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
1773
+ // Extended lifecycle events — context-headroom tracking (unmatched).
1774
+ { event: 'Stop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1775
+ { event: 'PreCompact', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1776
+ { event: 'SubagentStart', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1777
+ { event: 'SubagentStop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
1778
+ ];
1779
+ const entries = specs
1780
+ .map(buildKimiHookEntryToml)
1781
+ .filter((entry) => entry !== null);
1782
+ if (entries.length === 0)
1783
+ return null;
1784
+ return [KIMI_HOOKS_TOML_MARKER_BEGIN, '', entries.join('\n\n'), '', KIMI_HOOKS_TOML_MARKER_END].join('\n');
1785
+ }
1786
+ /**
1787
+ * Strip a previously-written GSD [[hooks]] block from kimi's config.toml
1788
+ * content. Pure string function (no fs access) so install, uninstall, and
1789
+ * tests share one strip implementation. Returns null when stripping leaves
1790
+ * nothing but whitespace (the file was GSD-only), so the caller can unlink
1791
+ * it instead of writing an empty file.
1792
+ */
1793
+ function stripKimiHooksTomlBlock(content) {
1794
+ const beginIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_BEGIN);
1795
+ if (beginIdx === -1) {
1796
+ return content.trim() === '' ? null : content;
1797
+ }
1798
+ const endMarkerIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_END, beginIdx);
1799
+ if (endMarkerIdx === -1) {
1800
+ // Malformed marker pair — BEGIN present but no END after it (missing END,
1801
+ // or an END that only appears earlier in the file, before BEGIN). Never
1802
+ // fall back to content.length here: that would slice to EOF and destroy
1803
+ // every user section that follows. Leave the content untouched instead;
1804
+ // a subsequent writeKimiHooksToml call will append a fresh, well-formed
1805
+ // block rather than silently deleting user data.
1806
+ return content;
1807
+ }
1808
+ const endIdx = endMarkerIdx + KIMI_HOOKS_TOML_MARKER_END.length;
1809
+ // Swallow blank lines immediately surrounding the block so repeated
1810
+ // strip+rewrite cycles never accumulate blank lines.
1811
+ let sliceStart = beginIdx;
1812
+ while (sliceStart > 0 && (content[sliceStart - 1] === '\n' || content[sliceStart - 1] === '\r'))
1813
+ sliceStart -= 1;
1814
+ let sliceEnd = endIdx;
1815
+ while (sliceEnd < content.length && (content[sliceEnd] === '\n' || content[sliceEnd] === '\r'))
1816
+ sliceEnd += 1;
1817
+ const before = content.slice(0, sliceStart);
1818
+ const after = content.slice(sliceEnd);
1819
+ // Blank-line swallowing above consumes every newline flanking the block,
1820
+ // including the one required to keep the surrounding user sections on
1821
+ // separate lines. If the block sat BETWEEN two user sections (content
1822
+ // survives on both sides), concatenating `before` + `after` directly would
1823
+ // glue the last line of the earlier section onto the first line of the
1824
+ // later one. Reinsert a blank-line separator in that case; when only one
1825
+ // side has content (block at file start or EOF), no separator is needed —
1826
+ // that matches the pre-existing idempotent behavior for those shapes.
1827
+ const result = before.trim() !== '' && after.trim() !== ''
1828
+ ? `${before}\n\n${after}`
1829
+ : before + after;
1830
+ return result.trim() === '' ? null : result;
1831
+ }
1832
+ /**
1833
+ * Idempotently (re)write kimi's GSD-owned [[hooks]] block into its native
1834
+ * config.toml at `configPath` (resolved by the caller via
1835
+ * resolveKimiHooksTomlDir). No-ops (`{changed:false}`) when the computed
1836
+ * block is byte-identical to what's already on disk, so reinstalls don't
1837
+ * touch the file's mtime for no reason.
1838
+ */
1839
+ function writeKimiHooksToml(configPath, targetDir, opts) {
1840
+ const existing = node_fs_1.default.existsSync(configPath) ? node_fs_1.default.readFileSync(configPath, 'utf8') : '';
1841
+ const stripped = stripKimiHooksTomlBlock(existing) ?? '';
1842
+ const block = buildKimiHooksTomlBlock(targetDir, opts);
1843
+ const entryCount = block ? (block.match(/\[\[hooks\]\]/g) || []).length : 0;
1844
+ if (!block) {
1845
+ if (stripped === existing)
1846
+ return { changed: false, path: configPath, entryCount: 0 };
1847
+ if (stripped.trim() === '') {
1848
+ if (node_fs_1.default.existsSync(configPath))
1849
+ node_fs_1.default.unlinkSync(configPath);
1850
+ }
1851
+ else {
1852
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
1853
+ atomicWriteFileSync(configPath, stripped, 'utf8');
1854
+ }
1855
+ return { changed: true, path: configPath, entryCount: 0 };
1856
+ }
1857
+ const separator = stripped.trim() === '' ? '' : (stripped.endsWith('\n') ? '\n' : '\n\n');
1858
+ const next = stripped.trim() === '' ? `${block}\n` : `${stripped}${separator}${block}\n`;
1859
+ if (next === existing)
1860
+ return { changed: false, path: configPath, entryCount };
1861
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
1862
+ atomicWriteFileSync(configPath, next, 'utf8');
1863
+ return { changed: true, path: configPath, entryCount };
1864
+ }
1865
+ /**
1866
+ * Uninstall-time counterpart to writeKimiHooksToml: strips the GSD block and
1867
+ * deletes the file if nothing but GSD's own block was ever in it.
1868
+ */
1869
+ function removeKimiHooksToml(configPath) {
1870
+ if (!node_fs_1.default.existsSync(configPath))
1871
+ return { changed: false };
1872
+ const existing = node_fs_1.default.readFileSync(configPath, 'utf8');
1873
+ const stripped = stripKimiHooksTomlBlock(existing);
1874
+ if (stripped === existing)
1875
+ return { changed: false };
1876
+ if (stripped === null || stripped.trim() === '') {
1877
+ node_fs_1.default.unlinkSync(configPath);
1878
+ }
1879
+ else {
1880
+ atomicWriteFileSync(configPath, stripped, 'utf8');
1881
+ }
1882
+ return { changed: true };
1883
+ }
1884
+ // ---------------------------------------------------------------------------
1411
1885
  // referencesHook
1412
1886
  //
1413
1887
  // Pure predicate — checks whether a hook entry object references a managed
@@ -1449,7 +1923,23 @@ module.exports = {
1449
1923
  removeCursorHooksJson,
1450
1924
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
1451
1925
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
1926
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
1927
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
1928
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
1929
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
1452
1930
  GSD_CURSOR_HOOK_MARKER,
1931
+ // Windsurf/Cascade
1932
+ buildWindsurfHookEntry,
1933
+ isManagedWindsurfHookEntry,
1934
+ reconcileWindsurfHooksJson,
1935
+ writeWindsurfHooksJson,
1936
+ removeWindsurfHooksJson,
1937
+ WINDSURF_HOOK_EVENTS,
1938
+ WINDSURF_EVENT_SCRIPT_MAP,
1939
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1940
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1941
+ GSD_WINDSURF_HOOK_SCRIPTS,
1942
+ GSD_WINDSURF_HOOK_MARKER,
1453
1943
  // Copilot
1454
1944
  buildCopilotHookConfig,
1455
1945
  writeCopilotHookConfig,
@@ -1465,6 +1955,13 @@ module.exports = {
1465
1955
  // Codex TOML
1466
1956
  buildCodexHookBlock,
1467
1957
  rewriteLegacyCodexHookBlock,
1958
+ // Kimi hooks.toml
1959
+ buildKimiHooksTomlBlock,
1960
+ stripKimiHooksTomlBlock,
1961
+ writeKimiHooksToml,
1962
+ removeKimiHooksToml,
1963
+ KIMI_HOOKS_TOML_MARKER_BEGIN,
1964
+ KIMI_HOOKS_TOML_MARKER_END,
1468
1965
  // Shared
1469
1966
  buildHookCommand,
1470
1967
  applySettingsJsonHooks,