From b0d985ccb3acdf06186cc920ab364990b944ae0b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:39 -0400 Subject: [PATCH] feat(#2089): migrate cursor onto imperative adapter + hook-bus/dispatch upgrades --- bin/install.js | 74 ++++++--- capabilities/cursor/capability.json | 17 ++ gsd-core/bin/lib/capability-registry.cjs | 34 ++++ hooks/gsd-cursor-pre-tool.js | 76 +++++++++ hooks/gsd-cursor-stop.js | 48 ++++++ hooks/gsd-cursor-subagent-start.js | 50 ++++++ hooks/gsd-cursor-subagent-stop.js | 40 +++++ .../imperative-hook-bus.cts | 153 ++++++++++++++++++ src/runtime-hooks-surface.cts | 74 ++++++--- 9 files changed, 518 insertions(+), 48 deletions(-) create mode 100644 hooks/gsd-cursor-pre-tool.js create mode 100644 hooks/gsd-cursor-stop.js create mode 100644 hooks/gsd-cursor-subagent-start.js create mode 100644 hooks/gsd-cursor-subagent-stop.js create mode 100644 src/host-integration-adapters/imperative-hook-bus.cts diff --git a/bin/install.js b/bin/install.js index 78af35018..f36debfd0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -255,12 +255,30 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = // Cursor reads hook configs from /.cursor/hooks.json (local) or // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { : [...] } }. // Events use camelCase: sessionStart, postToolUse, preToolUse, etc. -// A `command` hook entry runs an external script. GSD registers two managed hooks: -// sessionStart → gsd-cursor-session-start.js (context injection) -// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// A `command` hook entry runs an external script. GSD registers six managed hooks +// (AC4a upgrade, #2089 — ADR-1239): +// sessionStart → gsd-cursor-session-start.js (context injection) +// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// preToolUse → gsd-cursor-pre-tool.js (write-path guard) +// stop → gsd-cursor-stop.js (verify-work reminder) +// subagentStart → gsd-cursor-subagent-start.js (subagent context injection) +// subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder) // Cursor docs: https://cursor.com/docs/hooks const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; +const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; +const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; +const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; +const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; +// All GSD-managed Cursor hook scripts (used by uninstall cleanup). +const GSD_CURSOR_HOOK_SCRIPTS = [ + GSD_CURSOR_SESSION_HOOK_SCRIPT, + GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, +]; // Marker comment embedded in managed hook entries so GSD can find+remove them. const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; @@ -6969,17 +6987,20 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } } - // 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed - // hook entries from hooks.json and clean up the managed hook scripts. - if (isCursor) { + // 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove + // GSD-managed hook entries from hooks.json and clean up the managed hook + // scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a + // hardcoded `isCursor` branch. + if (_hostBehaviors(runtime).hooksJsonSurface) { const hooksJsonCleanup = removeCursorHooksJson(targetDir); if (hooksJsonCleanup.changed) { removedCount++; console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`); } - // Remove the managed hook scripts (session-start + post-tool). + // Remove all GSD-managed hook scripts (sessionStart, postToolUse, preToolUse, + // stop, subagentStart, subagentStop — AC4a, #2089). const hooksDir = path.join(targetDir, 'hooks'); - for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) { + for (const script of GSD_CURSOR_HOOK_SCRIPTS) { const p = path.join(hooksDir, script); try { if (fs.existsSync(p)) { @@ -8245,11 +8266,9 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) { if (meta.files && meta.files.length > 0) { const reapplyCommand = _hostBehaviors(runtime).reapplyCommand ? _hostBehaviors(runtime).reapplyCommand - : runtime === 'cursor' - ? 'gsd-update --reapply (mention the skill name)' - : runtime === 'kimi' - ? '/skill:gsd-update --reapply' - : '/gsd-update --reapply'; + : runtime === 'kimi' + ? '/skill:gsd-update --reapply' + : '/gsd-update --reapply'; console.log(''); console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):'); for (const f of meta.files) { @@ -8877,8 +8896,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) - if (isCursor) { + // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands). + // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089). + if (_hostBehaviors(runtime).reportCommandsDir) { const commandsDir = path.join(targetDir, 'commands'); if (fs.existsSync(commandsDir)) { const cmdCount = fs.readdirSync(commandsDir) @@ -9184,8 +9204,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeAgentToCopilotAgent(content, isGlobal); } else if (isAntigravity) { content = convertClaudeAgentToAntigravityAgent(content, isGlobal); - } else if (isCursor) { - content = convertClaudeAgentToCursorAgent(content); } else if (isWindsurf) { content = convertClaudeAgentToWindsurfAgent(content); } else if (isAugment) { @@ -9266,7 +9284,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // its native plugin adapter (#1914, installed above under plugins/gsd-core.js) // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both // them and the CommonJS package.json marker written below. - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) { + // #2089: Cursor's exclusion is now descriptor-driven via + // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor). + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -9357,7 +9377,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702). // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers - // (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses + // (Cursor uses standalone .js hook scripts registered via hooks.json — gated + // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Codex uses // hooks.json directly; the others skip hooks entirely); Kilo and ZCode also skip // hooks entirely (hooksSurface:'none' with no plugin surface — #1821). OpenCode // is NOT excluded: its #1914 plugin adapter spawns the staged hooks and requires @@ -9365,7 +9386,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // helpers — otherwise the Codex comment downstream ("we deliberately do *not* // copy hooks/lib/ for Codex") is contradicted in practice. const hooksLibSrc = path.join(src, 'hooks', 'lib'); - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { const hooksLibDest = path.join(targetDir, 'hooks', 'lib'); fs.mkdirSync(hooksLibDest, { recursive: true }); copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES); @@ -9979,11 +10000,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } if (plan.installSurface === 'cursor-hooks-json') { - // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse. - // Hook scripts are copied to /hooks/ and referenced by hooks.json. + // ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus + // adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse, + // stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads + // the event list from the descriptor-driven adapter module. const cursorHookResult = writeCursorHooksJson(targetDir, src, {}); if (cursorHookResult.changed) { - console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`); + console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`); } else { console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`); } @@ -11396,6 +11419,11 @@ module.exports = { mergeGsdAgentsMd, GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, + GSD_CURSOR_HOOK_SCRIPTS, GSD_CURSOR_HOOK_MARKER, buildCursorHookEntry, isManagedCursorHookEntry, diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index b4877e40f..1d58b7100 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -98,6 +98,23 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index dea955a3b..3a2fac1b3 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1059,6 +1059,23 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } }, @@ -4392,6 +4409,23 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } }, diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js new file mode 100644 index 000000000..a608255c0 --- /dev/null +++ b/hooks/gsd-cursor-pre-tool.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-pre-tool.js — Cursor preToolUse hook (ADR-1239 / #2089) +// +// Cursor invokes this script before each tool call executes. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor preToolUse): +// { tool_name, tool_input, conversation_id, generation_id, model, +// hook_event_name, cursor_version, workspace_roots, user_email, +// transcript_path } +// +// Output schema (cursor preToolUse): +// { additional_context?: string, block?: boolean, reason?: string } +// +// Behaviour: +// - If a write-class tool targets .planning/, reminds the agent to keep +// STATE.md current before the write proceeds. +// - Fails open: any error silently exits 0 so a hook bug never wedges Cursor. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; +const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; +const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + let input; + try { input = JSON.parse(raw || '{}'); } catch { process.stdout.write(JSON.stringify({})); return; } + + const toolName = String( + input.tool_name || input.toolName || '' + ).toLowerCase(); + + const isWrite = WRITE_TOOL_RE.test(toolName); + if (!isWrite) { process.stdout.write(JSON.stringify({})); return; } + + const paths = []; + const walk = (v, depth) => { + if (depth > 5 || paths.length > 64) return; + if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; } + if (v && typeof v === 'object') { + for (const k of Object.keys(v)) { + const val = v[k]; + if (typeof val === 'string' && PATH_KEY_RE.test(k)) paths.push(val); + else walk(val, depth + 1); + } + } + }; + walk(input.tool_input || input.toolInput || {}, 0); + + if (paths.some((p) => PLANNING_PATH_RE.test(p))) { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: .planning/ write detected — ensure STATE.md reflects the latest phase and progress after this change.', + })); + return; + } + } catch { /* fall through to empty response */ } + + process.stdout.write(JSON.stringify({})); +}); diff --git a/hooks/gsd-cursor-stop.js b/hooks/gsd-cursor-stop.js new file mode 100644 index 000000000..4c69bbbaf --- /dev/null +++ b/hooks/gsd-cursor-stop.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-stop.js — Cursor stop hook (ADR-1239 / #2089) +// +// Cursor invokes this script when the agent stops responding. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor stop): +// { conversation_id, generation_id, model, hook_event_name, +// cursor_version, workspace_roots, user_email, transcript_path } +// +// Output schema (cursor stop): +// { additional_context?: string } +// +// Behaviour: +// - Reminds the user to verify work if .planning/ is present. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const statePath = path.join(process.cwd(), '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: Agent stopping — run /gsd:verify-work or /gsd:progress to confirm the phase goal is met before ending the session.', + })); + } else { + process.stdout.write(JSON.stringify({})); + } + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/hooks/gsd-cursor-subagent-start.js b/hooks/gsd-cursor-subagent-start.js new file mode 100644 index 000000000..ad1e3ca48 --- /dev/null +++ b/hooks/gsd-cursor-subagent-start.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-subagent-start.js — Cursor subagentStart hook (ADR-1239 / #2089) +// +// Cursor invokes this script when a subagent session starts. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor subagentStart): +// { session_id, is_background_agent, conversation_id, generation_id, +// model, hook_event_name, cursor_version, workspace_roots, +// user_email, transcript_path } +// +// Output schema (cursor subagentStart): +// { additional_context?: string } +// +// Behaviour: +// - Injects a brief GSD state reminder so subagents (planner, executor, +// verifier) have the current phase context. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MSG_PRESENT = + 'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.'; +const MSG_ABSENT = + 'GSD: Subagent session started — no .planning/ workflow found.'; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const statePath = path.join(process.cwd(), '.planning', 'STATE.md'); + const statePresent = fs.existsSync(statePath); + const msg = statePresent ? MSG_PRESENT : MSG_ABSENT; + process.stdout.write(JSON.stringify({ additional_context: msg })); + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/hooks/gsd-cursor-subagent-stop.js b/hooks/gsd-cursor-subagent-stop.js new file mode 100644 index 000000000..fa5bb6826 --- /dev/null +++ b/hooks/gsd-cursor-subagent-stop.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-subagent-stop.js — Cursor subagentStop hook (ADR-1239 / #2089) +// +// Cursor invokes this script when a subagent session completes. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor subagentStop): +// { session_id, conversation_id, generation_id, model, hook_event_name, +// cursor_version, workspace_roots, user_email, transcript_path } +// +// Output schema (cursor subagentStop): +// { additional_context?: string } +// +// Behaviour: +// - Reminds the orchestrating agent to check the subagent's output. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: Subagent completed — review its output and update .planning/STATE.md if the phase progressed.', + })); + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/src/host-integration-adapters/imperative-hook-bus.cts b/src/host-integration-adapters/imperative-hook-bus.cts new file mode 100644 index 000000000..1638a5481 --- /dev/null +++ b/src/host-integration-adapters/imperative-hook-bus.cts @@ -0,0 +1,153 @@ +/** + * Imperative hook-bus adapter — descriptor-driven hooks.json binding + * (ADR-1239 Phase D / #2089). + * + * Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson` + * into a descriptor-driven hook-bus binding that reads the negotiated `hookBus` + * axis + the host's documented hook-event list (from + * `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded + * `sessionStart`/`postToolUse` pair. + * + * This module is PURE (no I/O): it resolves the event→script mapping and builds + * the hooks.json entry manifest. The actual file I/O (copying scripts, writing + * hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure + * functions exported here. This separation makes the binding testable without a + * filesystem. + * + * Cursor hook-event universe (closed vocabulary per ADR-1239, + * https://cursor.com/docs/hooks): + * sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart, + * subagentStop, beforeShellExecution, afterShellExecution, + * afterMCPExecution, afterFileEdit, preCompact, stop, + * beforeTabFileRead, afterTabFileEdit, workspaceOpen + * + * GSD registers for the 6 events in the portable floor + subagent lifecycle + * (AC4a upgrade, #2089): + * sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop + */ +'use strict'; + +/** + * The full set of Cursor hook events GSD can register for. + * Frozen closed vocabulary — adding an event requires updating both this set + * and the event→script mapping below. + */ +export const CURSOR_HOOK_EVENTS = Object.freeze([ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +] as const); + +export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number]; + +/** + * Event → hook-script mapping. Each event maps to a standalone `.js` script + * under `hooks/` that Cursor invokes via `hooks.json`. + * + * Convention: `gsd-cursor-.js`. The script files are authored in + * `hooks/` and copied to `/hooks/` during install by + * `runtime-hooks-surface.cts`. + */ +export const CURSOR_EVENT_SCRIPT_MAP: Readonly> = Object.freeze({ + sessionStart: 'gsd-cursor-session-start.js', + postToolUse: 'gsd-cursor-post-tool.js', + preToolUse: 'gsd-cursor-pre-tool.js', + stop: 'gsd-cursor-stop.js', + subagentStart: 'gsd-cursor-subagent-start.js', + subagentStop: 'gsd-cursor-subagent-stop.js', +}); + +/** + * The GSD-managed marker written into each hooks.json entry so the + * reconcile pass can distinguish GSD-owned entries from user-owned ones. + */ +export const GSD_HOOK_MARKER = 'gsd-managed'; + +/** + * Resolve the managed hook events from a runtime descriptor's + * `hostBehaviors.managedHookEvents` list. Falls back to the full + * `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list + * (backward-compat for descriptors predating #2089). + * + * Pure: no I/O, never throws. Unknown event names are silently filtered + * (fail-closed — an unrecognized event is never registered). + * + * @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array + * @returns a deduplicated, validated array of event names + */ +export function resolveManagedHookEvents( + managedHookEvents: readonly string[] | null | undefined, +): readonly string[] { + if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) { + return CURSOR_HOOK_EVENTS; + } + const valid = new Set(CURSOR_HOOK_EVENTS); + const seen = new Set(); + const result: string[] = []; + for (const ev of managedHookEvents) { + if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) { + seen.add(ev); + result.push(ev); + } + } + return result.length > 0 ? result : CURSOR_HOOK_EVENTS; +} + +/** + * Build the list of hook script files that need to be copied for the given + * managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`. + * + * Pure: returns a deduplicated array of script filenames. + * + * @param events - the managed event names (validated by `resolveManagedHookEvents`) + * @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`) + */ +export function resolveHookScripts( + events: readonly string[], +): readonly string[] { + const scripts: string[] = []; + const seen = new Set(); + for (const ev of events) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + if (script && !seen.has(script)) { + seen.add(script); + scripts.push(script); + } + } + return scripts; +} + +/** + * Build the hooks.json managed-entry manifest for the given events. + * Each entry is `{ type: 'command', command: , [GSD_HOOK_MARKER]: true }`. + * + * The `command` string is built by the caller (it requires platform-specific + * node-runner resolution from `runtime-hooks-surface.cts`). This function + * receives a pre-built `event → command` map and attaches the marker. + * + * Pure: no I/O. + * + * @param events - the managed event names + * @param commands - a map of event → command string (built by the caller) + * @returns a map of event → managed entry, ready for hooks.json reconciliation + */ +export function buildHookBusEntries( + events: readonly string[], + commands: Readonly>, +): Record { + const entries: Record = {}; + for (const ev of events) { + const cmd = commands[ev]; + if (cmd) { + entries[ev] = { + type: 'command', + command: cmd, + [GSD_HOOK_MARKER]: true, + }; + } + } + return entries; +} diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index cb0151da0..09fc530bd 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -80,8 +80,25 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = // --------------------------------------------------------------------------- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; +const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; +const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; +const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; +const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; +// The full set of Cursor hook events GSD manages (AC4a upgrade, #2089). +// Sourced from the descriptor-driven adapter module +// (src/host-integration-adapters/imperative-hook-bus.cts). This replaces the +// hardcoded ['sessionStart', 'postToolUse'] pair with the 6-event managed set. +const CURSOR_MANAGED_EVENTS = [ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +]; + // --------------------------------------------------------------------------- // Cline / AGENTS.md constants // --------------------------------------------------------------------------- @@ -976,9 +993,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']); if (!hasNestedHooksObject) { - const eventKeys = ['sessionStart', 'postToolUse']; const lifted: Record = {}; - for (const k of eventKeys) { + for (const k of CURSOR_MANAGED_EVENTS) { if (Array.isArray(parsed[k])) { lifted[k] = parsed[k]; delete parsed[k]; @@ -989,10 +1005,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM if (!parsed['version']) parsed['version'] = 1; const hookTable = parsed['hooks'] as Record; - const MANAGED_EVENTS = ['sessionStart', 'postToolUse']; const entries = managedEntries || {}; - for (const event of MANAGED_EVENTS) { + for (const event of CURSOR_MANAGED_EVENTS) { const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : []; const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e)); const newEntry = entries[event] || null; @@ -1027,7 +1042,20 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor const hooksDir = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDir, { recursive: true }); - const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]; + // AC4a (#2089): install all managed hook scripts, not just sessionStart/postToolUse. + // The event→script mapping is sourced from the descriptor-driven adapter + // (src/host-integration-adapters/imperative-hook-bus.cts). + const eventScriptMap: Record = { + sessionStart: GSD_CURSOR_SESSION_HOOK_SCRIPT, + postToolUse: GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + preToolUse: GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + stop: GSD_CURSOR_STOP_HOOK_SCRIPT, + subagentStart: GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + subagentStop: GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, + }; + const hookScripts = CURSOR_MANAGED_EVENTS + .map((ev) => eventScriptMap[ev]) + .filter((s): s is string => Boolean(s)); const srcHooksDir = path.join(src, 'hooks'); const installedScripts = new Set(); for (const script of hookScripts) { @@ -1043,27 +1071,19 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor } const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform }; - const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js') - ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts) - : null; - const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js') - ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts) - : null; - const managedEntries: CursorManagedEntries = {}; - if (sessionStartCmd) { - managedEntries['sessionStart'] = { - type: 'command', - command: sessionStartCmd, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - if (postToolCmd) { - managedEntries['postToolUse'] = { - type: 'command', - command: postToolCmd, - [GSD_CURSOR_HOOK_MARKER]: true, - }; + for (const ev of CURSOR_MANAGED_EVENTS) { + const script = eventScriptMap[ev]; + if (script && installedScripts.has(script)) { + const cmd = buildHookCommand(targetDir, script, hookOpts); + if (cmd) { + managedEntries[ev] = { + type: 'command', + command: cmd, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + } } const hooksJsonPath = path.join(targetDir, 'hooks.json'); @@ -1729,6 +1749,10 @@ export = { removeCursorHooksJson, GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, GSD_CURSOR_HOOK_MARKER, // Copilot