feat(#2089): migrate cursor onto imperative adapter + hook-bus/dispatch upgrades

This commit is contained in:
Tom Boucher
2026-07-09 00:21:39 -04:00
parent c5e5211db0
commit b0d985ccb3
9 changed files with 518 additions and 48 deletions

View File

@@ -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-<kebab-event>.js`. The script files are authored in
* `hooks/` and copied to `<configDir>/hooks/` during install by
* `runtime-hooks-surface.cts`.
*/
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = 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<string>(CURSOR_HOOK_EVENTS);
const seen = new Set<string>();
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<string>();
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: <cmd>, [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<string, string | null>>,
): Record<string, { type: string; command: string; [key: string]: unknown }> {
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
for (const ev of events) {
const cmd = commands[ev];
if (cmd) {
entries[ev] = {
type: 'command',
command: cmd,
[GSD_HOOK_MARKER]: true,
};
}
}
return entries;
}

View File

@@ -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<string, unknown> = {};
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<string, unknown>;
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<string, string> = {
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<string>();
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