From c5e5211db04bb5260435bd50cb25eff0fc453cf2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:28 -0400 Subject: [PATCH 01/11] test(#2089): add cursor EoS migration test scaffolding (red) --- tests/cursor-dispatch-upgrade.test.cjs | 71 ++++++++ tests/cursor-hook-bus-upgrade.test.cjs | 181 +++++++++++++++++++++ tests/cursor-imperative-reference.test.cjs | 126 ++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 tests/cursor-dispatch-upgrade.test.cjs create mode 100644 tests/cursor-hook-bus-upgrade.test.cjs create mode 100644 tests/cursor-imperative-reference.test.cjs diff --git a/tests/cursor-dispatch-upgrade.test.cjs b/tests/cursor-dispatch-upgrade.test.cjs new file mode 100644 index 000000000..f99d8d608 --- /dev/null +++ b/tests/cursor-dispatch-upgrade.test.cjs @@ -0,0 +1,71 @@ +'use strict'; + +/** + * cursor dispatch UPGRADE — ADR-1239 / #2089 AC4b. + * + * Proves GSD's wave-based execution drives Cursor's native named/background + * nested subagent dispatch (background:true, backgroundDispatch:true, + * nested:true, maxDepth:2) instead of flattening to inline sequential calls. + * + * Cite: + * https://cursor.com/docs/subagents — named + background dispatch + * https://cursor.com/docs/sdk/typescript — nested subagent depth-2 constraint + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { shouldFlattenDispatch } = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_DISPATCH = CUR_CAP.runtime.hostIntegration.dispatch; + +// -- AC4b: cursor dispatch axes ---------------------------------------------- + +test('cursor dispatch declares namedDispatch + nested + background + backgroundDispatch', () => { + assert.equal(CUR_DISPATCH.namedDispatch, true, + 'cite https://cursor.com/docs/subagents — named subagent invocation'); + assert.equal(CUR_DISPATCH.nested, true, + 'cite https://cursor.com/docs/sdk/typescript — nested subagents'); + assert.equal(CUR_DISPATCH.background, true, + 'cite https://cursor.com/docs/subagents — background dispatch'); + assert.equal(CUR_DISPATCH.backgroundDispatch, true, + 'cite https://cursor.com/docs/subagents FAQ — subagents can launch child subagents'); +}); + +test('cursor dispatch respects maxDepth: 2 (the documented constraint)', () => { + assert.equal(CUR_DISPATCH.maxDepth, 2, + 'cite https://cursor.com/docs/sdk/typescript — "a subagent launched by another subagent can\'t launch further"'); +}); + +// -- AC4b: shouldFlattenDispatch returns false (NOT force-flattened) ---------- + +test('shouldFlattenDispatch(cursor) is false — GSD uses native background dispatch', () => { + assert.equal(shouldFlattenDispatch(CUR_DISPATCH), false, + 'cursor has background:true + backgroundDispatch:true → GSD must NOT force-flatten'); +}); + +test('pre-upgrade cursor axes (background:false) DID force-flatten', () => { + const preUpgrade = { ...CUR_DISPATCH, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade cursor (no background dispatch) was force-flattened — the behavioral change #2089 lands'); +}); + +test('shouldFlattenDispatch is true when backgroundDispatch is false (depth-2 but no bg dispatch)', () => { + const noBgDispatch = { ...CUR_DISPATCH, backgroundDispatch: false }; + assert.equal(shouldFlattenDispatch(noBgDispatch), true, + 'background without backgroundDispatch still flattens (the #853 rule)'); +}); + +// -- AC4b: boundary — maxDepth 2 is the discriminator vs unbounded ----------- + +test('maxDepth 2 is the documented constraint (not -1 unbounded)', () => { + assert.notEqual(CUR_DISPATCH.maxDepth, -1, + 'cursor is NOT unbounded — depth-2 is the documented hard limit'); + assert.ok(CUR_DISPATCH.maxDepth > 0 && CUR_DISPATCH.maxDepth <= 2, + 'maxDepth must be a positive integer ≤ 2 per cursor docs'); +}); diff --git a/tests/cursor-hook-bus-upgrade.test.cjs b/tests/cursor-hook-bus-upgrade.test.cjs new file mode 100644 index 000000000..23ca22e4d --- /dev/null +++ b/tests/cursor-hook-bus-upgrade.test.cjs @@ -0,0 +1,181 @@ +'use strict'; + +/** + * cursor hook-bus UPGRADE — ADR-1239 / #2089 AC4a. + * + * Proves the expanded hook-bus coverage: GSD registers for subagentStart, + * subagentStop, preToolUse, and stop IN ADDITION to the baseline sessionStart + * and postToolUse. Cite: https://cursor.com/docs/hooks + * + * Tests the descriptor-driven adapter module (pure) + the reconcile behavior + * (all 6 managed events in the generated hooks.json). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { + CURSOR_HOOK_EVENTS, + CURSOR_EVENT_SCRIPT_MAP, + resolveManagedHookEvents, + resolveHookScripts, +} = require('../gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs'); + +const { + reconcileCursorHooksJson, + GSD_CURSOR_HOOK_MARKER, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); + +const EXPECTED_EVENTS = [ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +]; + +// -- AC4a: the adapter declares all 6 managed events ------------------------- + +test('CURSOR_HOOK_EVENTS contains all 6 managed events', () => { + for (const ev of EXPECTED_EVENTS) { + assert.ok(CURSOR_HOOK_EVENTS.includes(ev), + `CURSOR_HOOK_EVENTS must include ${ev}`); + } + assert.equal(CURSOR_HOOK_EVENTS.length, 6, + 'exactly 6 managed events (no extras)'); +}); + +test('CURSOR_EVENT_SCRIPT_MAP maps every event to a script', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + assert.ok(typeof script === 'string' && script.endsWith('.js'), + `${ev} must map to a .js script, got: ${script}`); + } +}); + +test('descriptor managedHookEvents matches the adapter event list', () => { + const declared = CUR_CAP.runtime.hostBehaviors.managedHookEvents; + assert.deepEqual(declared.sort(), [...EXPECTED_EVENTS].sort(), + 'descriptor managedHookEvents must match the 6-event managed set'); +}); + +// -- AC4a: resolveManagedHookEvents + resolveHookScripts --------------------- + +test('resolveManagedHookEvents returns all 6 from the descriptor list', () => { + const resolved = resolveManagedHookEvents(CUR_CAP.runtime.hostBehaviors.managedHookEvents); + assert.equal(resolved.length, 6); + for (const ev of EXPECTED_EVENTS) { + assert.ok(resolved.includes(ev), `resolveManagedHookEvents must include ${ev}`); + } +}); + +test('resolveManagedHookEvents filters unknown events (fail-closed)', () => { + const resolved = resolveManagedHookEvents(['sessionStart', 'bogusEvent', 'stop']); + assert.deepEqual([...resolved].sort(), ['sessionStart', 'stop']); +}); + +test('resolveManagedHookEvents falls back to full set when descriptor is empty', () => { + const resolved = resolveManagedHookEvents(null); + assert.equal(resolved.length, 6); +}); + +test('resolveHookScripts returns a script for every managed event', () => { + const scripts = resolveHookScripts(EXPECTED_EVENTS); + assert.equal(scripts.length, 6); + for (const s of scripts) { + assert.ok(s.startsWith('gsd-cursor-') && s.endsWith('.js'), + `script must follow gsd-cursor-*.js convention: ${s}`); + } +}); + +// -- AC4a: hook scripts exist on disk --------------------------------------- + +test('all 6 hook scripts exist under hooks/', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + const scriptPath = path.join(__dirname, '..', 'hooks', script); + assert.ok(fs.existsSync(scriptPath), + `hook script must exist: hooks/${script} (event: ${ev})`); + } +}); + +// -- AC4a: reconcile generates hooks.json with all 6 events ------------------ + +test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /fake/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + assert.ok(result.changed, 'first write must report changed=true'); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const hookTable = written.hooks; + assert.ok(hookTable && typeof hookTable === 'object'); + for (const ev of EXPECTED_EVENTS) { + assert.ok(Array.isArray(hookTable[ev]), + `hooks.json must have a ${ev} array`); + assert.equal(hookTable[ev].length, 1, + `${ev} must have exactly 1 managed entry`); + assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, + `${ev} entry must carry the GSD managed marker`); + } + } finally { + cleanup(tmpDir); + } +}); + +test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + // Seed with user-owned entries in two events. + const seed = { + version: 1, + hooks: { + sessionStart: [{ type: 'command', command: 'user-start.sh' }], + preToolUse: [{ type: 'command', command: 'user-pre.sh' }], + }, + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); + + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /gsd/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + reconcileCursorHooksJson(hooksJsonPath, managedEntries); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // sessionStart: 1 user + 1 managed + assert.equal(written.hooks.sessionStart.length, 2); + // preToolUse: 1 user + 1 managed + assert.equal(written.hooks.preToolUse.length, 2); + // postToolUse: 1 managed only + assert.equal(written.hooks.postToolUse.length, 1); + } finally { + cleanup(tmpDir); + } +}); diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs new file mode 100644 index 000000000..a13f2970a --- /dev/null +++ b/tests/cursor-imperative-reference.test.cjs @@ -0,0 +1,126 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'cursor'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2089) +'use strict'; + +/** + * cursor imperative reference host — ADR-1239 Phase D / #2089 (EoS/cursor). + * + * Proves cursor is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, that the Context7- + * verified dispatch UPGRADE (named/background nested subagents) changes + * `shouldFlattenDispatch`, and that the migration retired the hardcoded + * `runtime === 'cursor'` / `isCursor` branches (folded into descriptor-driven + * `runtime.hostBehaviors`). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_AXES = CUR_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies cursor as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'cursor' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'cursor'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('cursor axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(CUR_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(CUR_AXES.embeddingMode, 'imperative'); + assert.equal(CUR_AXES.commandSurface, 'slash-file'); + assert.equal(CUR_AXES.modelMode, 'passive'); + assert.equal(CUR_AXES.hookBus, 'host'); + assert.equal(CUR_AXES.stateIO, 'filesystem'); + assert.equal(CUR_AXES.transport, 'mcp'); + assert.equal(CUR_AXES.runtime, 'node'); + const d = CUR_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, true); + assert.equal(d.maxDepth, 2); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'full'); + assert.equal(d.backgroundDispatch, true); +}); + +// -- AC4b: the Context3-verified dispatch UPGRADE (named/background nested) --- + +test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => { + assert.equal(CUR_AXES.dispatch.background, true); + assert.equal(CUR_AXES.dispatch.backgroundDispatch, true); + assert.equal(CUR_AXES.dispatch.nested, true); + assert.equal(CUR_AXES.dispatch.maxDepth, 2, 'cite https://cursor.com/docs/sdk/typescript'); +}); + +test('dispatch UPGRADE changes shouldFlattenDispatch: false now (may background), true for pre-upgrade axes', () => { + assert.equal(shouldFlattenDispatch(CUR_AXES.dispatch), false, + 'with background:true+backgroundDispatch:true, GSD must NOT force-flatten cursor dispatch'); + const preUpgrade = { ...CUR_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade (background:false) cursor was force-flattened — this is the behavioral change #2089 lands'); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for cursor, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty cursor descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('cursor descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CUR_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.reapplyCommand, 'gsd-update --reapply (mention the skill name)'); + assert.equal(hb.frontmatterDialect, 'cursor'); + assert.equal(hb.hooksJsonSurface, true); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.reportCommandsDir, true); + assert.ok(Array.isArray(hb.managedHookEvents) && hb.managedHookEvents.length >= 6, + 'managedHookEvents must list at least 6 events (AC4a)'); +}); + +test('no `runtime === "cursor"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +}); From b0d985ccb3acdf06186cc920ab364990b944ae0b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:39 -0400 Subject: [PATCH 02/11] 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 From 303a796579f1dfbd6a5f829ca07f7c9cd671a6e5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:51 -0400 Subject: [PATCH 03/11] docs(changeset): #2089 cursor host-integration migration + golden fixture --- .changeset/2089-eos-cursor-imperative-adapter.md | 5 +++++ docs/INVENTORY-MANIFEST.json | 4 ++++ docs/INVENTORY.md | 4 ++++ docs/reference/host-integration-capability-matrix.md | 5 +++++ tests/fixtures/golden-install-parity/cursor.json | 4 ++++ 5 files changed, 22 insertions(+) create mode 100644 .changeset/2089-eos-cursor-imperative-adapter.md diff --git a/.changeset/2089-eos-cursor-imperative-adapter.md b/.changeset/2089-eos-cursor-imperative-adapter.md new file mode 100644 index 000000000..32a3d120c --- /dev/null +++ b/.changeset/2089-eos-cursor-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4628c7b77..046d19ce2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -452,7 +452,11 @@ "gsd-config-reload.js", "gsd-context-monitor.js", "gsd-cursor-post-tool.js", + "gsd-cursor-pre-tool.js", "gsd-cursor-session-start.js", + "gsd-cursor-stop.js", + "gsd-cursor-subagent-start.js", + "gsd-cursor-subagent-stop.js", "gsd-ensure-canonical-path.js", "gsd-graphify-update.sh", "gsd-phase-boundary.sh", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index af1075362..63f4d67a9 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -548,6 +548,10 @@ Full listing: `hooks/`. | `gsd-update-banner.js` | `SessionStart` | Opt-in banner surfacing update availability when GSD statusline isn't used (PR #2795) | | `gsd-cursor-session-start.js` | Cursor `sessionStart` | Cursor-native context injection at session start (issue #777) | | `gsd-cursor-post-tool.js` | Cursor `postToolUse` | Cursor-native STATE.md update monitor after tool calls (issue #777) | +| `gsd-cursor-pre-tool.js` | Cursor `preToolUse` | Cursor-native write-path guard for `.planning/` (ADR-1239 / #2089) | +| `gsd-cursor-stop.js` | Cursor `stop` | Cursor-native verify-work reminder on agent stop (ADR-1239 / #2089) | +| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089) | +| `gsd-cursor-subagent-stop.js` | Cursor `subagentStop` | Cursor-native subagent completion reminder (ADR-1239 / #2089) | | `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) | | `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) | | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 45e94be43..8123fc72c 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -187,6 +187,11 @@ Sources consulted: - https://cursor.com/docs/enterprise/llm-safety-and-controls - /websites/cursor (Context7) +**GSD integration status — Phase D dogfood complete (#2089, ADR-1239).** Cursor installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cursor'` / `isCursor` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cursor.json`). Two capability upgrades land, each with a test driving the user-reachable surface: + +- **Expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in `hooks.json` beyond the original `sessionStart`/`postToolUse`: `preToolUse`, `stop`, `subagentStart`, `subagentStop` (AC4a, cite https://cursor.com/docs/hooks). The hook-bus binding is descriptor-driven via `src/host-integration-adapters/imperative-hook-bus.cts` (reads `hostBehaviors.managedHookEvents`), not a hardcoded event pair. +- **Named/background nested subagent dispatch** — `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`; `shouldFlattenDispatch(cursor)` returns `false` so GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent dispatch instead of flattening to inline sequential calls (AC4b, cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript). + --- ## cline diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 62441d144..ef6658d4f 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -382,7 +382,11 @@ "gsd-core/workflows/verify-phase.md": "e0957e153788a222", "gsd-core/workflows/verify-work.md": "e7e7e900c4874490", "hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f", + "hooks/gsd-cursor-pre-tool.js": "fe274720781fcbb5", "hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020", + "hooks/gsd-cursor-stop.js": "902a005c49e7660b", + "hooks/gsd-cursor-subagent-start.js": "693d38d298d2252c", + "hooks/gsd-cursor-subagent-stop.js": "8da03aad6bb05d3f", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", From 24896ddac735f2c431ac4ed84dcbe88d63e40075 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:58:09 -0400 Subject: [PATCH 04/11] fix(#2089): register 4 new cursor hook scripts in build + managed-hooks whitelists --- hooks/managed-hooks-registry.cjs | 4 ++++ scripts/build-hooks.js | 6 +++++- src/installer-migration-report.cts | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 1d392471d..0a05e5841 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -21,7 +21,11 @@ const MANAGED_HOOKS = [ 'gsd-config-reload.js', 'gsd-context-monitor.js', 'gsd-cursor-post-tool.js', + 'gsd-cursor-pre-tool.js', 'gsd-cursor-session-start.js', + 'gsd-cursor-stop.js', + 'gsd-cursor-subagent-start.js', + 'gsd-cursor-subagent-stop.js', 'gsd-ensure-canonical-path.js', 'gsd-graphify-update.sh', 'gsd-phase-boundary.sh', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index a4e220860..9e80d813e 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -37,9 +37,13 @@ const HOOKS_TO_COPY = [ // so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir. 'managed-hooks-registry.cjs', 'gsd-context-monitor.js', - // Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor + // Cursor lifecycle hooks (#777 + ADR-1239/#2089): 6 managed events 'gsd-cursor-session-start.js', 'gsd-cursor-post-tool.js', + 'gsd-cursor-pre-tool.js', + 'gsd-cursor-stop.js', + 'gsd-cursor-subagent-start.js', + 'gsd-cursor-subagent-stop.js', // Claude Code FileChanged hook (#770) — hot-reloads gsd config when // .planning/config.json changes mid-session. Must ship to dist so the // installer can copy it to the target hooks/ dir and register FileChanged. diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index b77dccc27..70b74252a 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -32,7 +32,11 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set 'hooks/gsd-config-reload.js', 'hooks/gsd-context-monitor.js', 'hooks/gsd-cursor-post-tool.js', + 'hooks/gsd-cursor-pre-tool.js', 'hooks/gsd-cursor-session-start.js', + 'hooks/gsd-cursor-stop.js', + 'hooks/gsd-cursor-subagent-start.js', + 'hooks/gsd-cursor-subagent-stop.js', 'hooks/gsd-ensure-canonical-path.js', 'hooks/gsd-graphify-update.sh', 'hooks/gsd-phase-boundary.sh', From c68bca55cf7ec1a36cf709f72eea3826cf6eae42 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 01:19:25 -0400 Subject: [PATCH 05/11] test(#2089): regenerate all golden fixtures for managed-hooks-registry + cursor hook additions --- tests/fixtures/golden-install-parity/antigravity.json | 6 +++++- tests/fixtures/golden-install-parity/augment.json | 6 +++++- tests/fixtures/golden-install-parity/claude-local.json | 6 +++++- tests/fixtures/golden-install-parity/claude.json | 6 +++++- tests/fixtures/golden-install-parity/codebuddy.json | 6 +++++- tests/fixtures/golden-install-parity/hermes.json | 6 +++++- tests/fixtures/golden-install-parity/opencode.json | 6 +++++- tests/fixtures/golden-install-parity/qwen.json | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 4615ba8ec..6f495953e 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "6d81d7326e5b2710", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "64d092d7e4a01211", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "838498aa91619740", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "45b2431992d3d7d2", + "hooks/managed-hooks-registry.cjs": "721d696556b7509f", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index a39d82e99..6f7ae32a5 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "44ff1bbf292747af", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "d569f5f3578e93e5", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "f46a329fcfefa465", + "hooks/managed-hooks-registry.cjs": "e61da0f7a3037c35", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 72a5c64bb..95a1dfb72 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -385,7 +385,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "ecbe9747e4a442e0", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -400,7 +404,7 @@ "hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "ea876b1ec185173e", + "hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index b96c3ccec..e2009eaa3 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -314,7 +314,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "ecbe9747e4a442e0", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -329,7 +333,7 @@ "hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "ea876b1ec185173e", + "hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b47dbf1de..c04aa1bf7 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "f372804867cabe40", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "434887487ae63ec5", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "548fc57131a04fa7", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "0368fd4ac7bb3d1d", + "hooks/managed-hooks-registry.cjs": "0e7a61bde8688e11", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 6757b3c2c..50d7c6a7a 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "880b696458e85e9b", "hooks/gsd-context-monitor.js": "41d28e0db7b20968", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "7d116d7d65c50b4b", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "108ab88ccbafc5d8", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "2218a41c279720c2", + "hooks/managed-hooks-registry.cjs": "a494d1a70ed87690", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 62e04aade..6fab45dd5 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "7a9787868a39b76d", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "2801ae3fef9579bf", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "726fb9afefda5d42", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c", + "hooks/managed-hooks-registry.cjs": "9163e096b74ec4b3", "opencode.json": "2c12c446a88f2f36", "package.json": "dbf8353f77358bc1", "plugins/gsd-core.js": "931ca839dc9eb7f1", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 40444eb7b..f2feab0c5 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "4f52b8a0120bb1b8", "hooks/gsd-context-monitor.js": "437a33e6e3058640", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "2df5e295b36c3334", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "8389e4c9175b2613", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "a57697c1ae4ac163", + "hooks/managed-hooks-registry.cjs": "a5a93d50c4ea7a0c", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", From a53c5462e6dc7bfd14ab041f557e23ffe1b6fbfa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 02:07:08 -0400 Subject: [PATCH 06/11] chore(#2089): gitignore compiled imperative-hook-bus adapter + fix Context7 typo - Add /gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs to .gitignore (tsc-emitted build artifact per ADR-457 convention; matches the sibling adapter entries at .gitignore:70-89). The subagent authored the .cts source but missed this entry, leaving the compiled output untracked. - Fix cosmetic 'Context3' -> 'Context7' typo in test section header comment. --- .gitignore | 1 + tests/cursor-imperative-reference.test.cjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c70665f48..37d552553 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ build/ /tsconfig.build.tsbuildinfo /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs +/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs /gsd-core/bin/lib/handshake-serialized.cjs /gsd-core/bin/lib/install-effort-resolver.cjs /gsd-core/bin/lib/install-engine.cjs diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs index a13f2970a..1a412b579 100644 --- a/tests/cursor-imperative-reference.test.cjs +++ b/tests/cursor-imperative-reference.test.cjs @@ -66,7 +66,7 @@ test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocument assert.equal(d.backgroundDispatch, true); }); -// -- AC4b: the Context3-verified dispatch UPGRADE (named/background nested) --- +// -- AC4b: the Context7-verified dispatch UPGRADE (named/background nested) --- test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => { assert.equal(CUR_AXES.dispatch.background, true); From a8d9dbe02a914284aeebec5daf13385b0a5774f2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 09:16:37 -0400 Subject: [PATCH 07/11] fix(#2089): widen read-injection-scanner property test timeout to avoid node22 race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property test's execFileSync timeout (5000ms) was identical to the scanner's own internal stdin-timeout (hooks/gsd-read-injection-scanner.js:109, also 5000ms). Under concurrent test-chunk load on linux-node22 — which #2089's 3 new cursor test files redistribute — the scanner subprocess's stdin 'end' event can fire late enough that execFileSync's SIGTERM arrives before the scanner's own process.exit(0), producing err.status=null → exitCode=1 → spurious property-test failure. The scanner has no process.exit(N!=0) paths; the only non-zero exit is from the signal-kill race. Doubling the test ceiling to 10000ms gives the scanner's 5000ms internal exit a 5s buffer to win the race deterministically on every node version. --- tests/read-injection-scanner.property.test.cjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/read-injection-scanner.property.test.cjs b/tests/read-injection-scanner.property.test.cjs index 92cf925f9..fb891c9c1 100644 --- a/tests/read-injection-scanner.property.test.cjs +++ b/tests/read-injection-scanner.property.test.cjs @@ -27,7 +27,14 @@ function runHook(payload) { const stdout = execFileSync(process.execPath, [HOOK_PATH], { input: JSON.stringify(payload), encoding: 'utf-8', - timeout: 5000, + // 10s — double the scanner's own 5s internal stdin-timeout + // (hooks/gsd-read-injection-scanner.js:109). Under concurrent test + // load (crowded run-tests.cjs chunks), node22's event-loop scheduling + // can delay the scanner's stdin 'end' handler past 5s, racing the + // scanner's process.exit(0) against this timeout's SIGTERM. A 10s + // ceiling gives the scanner's own 5s exit a 5s buffer to win the race + // deterministically on every node version. (#2089) + timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], }); return { exitCode: 0, stdout: stdout.trim() }; From fad5094587c1d63011d0c89e35d9957d124490ad Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 09:36:33 -0400 Subject: [PATCH 08/11] =?UTF-8?q?fix(#2089):=20redesign=20scanner=20proper?= =?UTF-8?q?ty=20test=20=E2=80=94=20logic/results,=20not=20wall-clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the timing-dependent execFileSync(timeout:5000) approach with a spawnSync-based runHook that tests the scanner's RESULT (exit code + output shape), never how long it takes. Root design flaw in the prior approach: execFileSync's timeout (5000ms) was identical to the scanner's own internal setTimeout(5000ms), creating a non-repeatable race (F.I.R.S.T. violation: not Repeatable). Under concurrent test-chunk load — which #2089's 3 new cursor test files redistribute — node22's event-loop scheduling let execFileSync's SIGTERM win the race, producing err.status=null → exitCode=1 → spurious property-test failure. Redesign (F.I.R.S.T.): - spawnSync (not execFileSync): non-zero exits return a result object, not an exception — cleaner for property tests - Non-serializable payloads (BigInt, circular refs, Symbol) are SKIPPED: the scanner receives JSON via stdin, so these values are outside its protocol — JSON.stringify throwing is a test-harness artifact, not a scanner defect - 30s safety-net timeout is NOT a test assertion: scanner exits in <100ms; 30s only catches a genuinely hung process (6x the scanner's own 5s internal timer → no race possible) - Assertions check exit===0 and output structure, never timing qa-test-architect pipeline: risk=HIGH (security boundary); automation= subprocess (real shipped hook); test-cases cover happy/boundary/negative/ independence; verified via gsd-test. --- .../read-injection-scanner.property.test.cjs | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/read-injection-scanner.property.test.cjs b/tests/read-injection-scanner.property.test.cjs index fb891c9c1..eac847542 100644 --- a/tests/read-injection-scanner.property.test.cjs +++ b/tests/read-injection-scanner.property.test.cjs @@ -12,35 +12,63 @@ * * Invoked as a subprocess (the hook reads a JSON payload on stdin and has no * exported surface), so this exercises the real shipped hook end-to-end. + * + * F.I.R.S.T. design: + * Fast — spawnSync is synchronous; scanner exits in <100ms for any input. + * Isolated — each invocation is a fresh subprocess; no shared state. + * Repeatable — no wall-clock assertion; the 30s safety-net timeout is 6x the + * scanner's own internal 5s timer and is never tested against. + * Tests assert on the scanner's RESULT (exit code + output shape), + * never on timing. + * Self-Val — assertions check exit===0 and output is empty or valid JSON. + * Timely — written alongside the scanner (#1577); hardened for #2089. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const { execFileSync } = require('node:child_process'); +const { spawnSync } = require('node:child_process'); const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-injection-scanner.js'); +/** + * Run the scanner hook with a payload and return its result. + * + * Uses spawnSync (not execFileSync) so non-zero exits return a result object + * rather than throwing — cleaner for property tests that assert on exit code. + * + * Non-serializable payloads (BigInt, circular refs, Symbol, undefined) are + * SKIPPED: the scanner receives JSON via stdin, so these values can never + * reach it. JSON.stringify throwing is a test-harness artifact (fc.anything() + * generates values outside the JSON domain), not a scanner defect. + * + * The 30s safety-net timeout is NOT a test assertion. The scanner exits in + * <100ms for any input; its own internal setTimeout(5000) guarantees exit + * even if stdin never closes (impossible here — spawnSync's `input:` pipes + * and closes stdin). The ceiling only catches a genuinely hung process (a + * real defect) without racing the scanner's internal timer. + */ function runHook(payload) { + let input; try { - const stdout = execFileSync(process.execPath, [HOOK_PATH], { - input: JSON.stringify(payload), - encoding: 'utf-8', - // 10s — double the scanner's own 5s internal stdin-timeout - // (hooks/gsd-read-injection-scanner.js:109). Under concurrent test - // load (crowded run-tests.cjs chunks), node22's event-loop scheduling - // can delay the scanner's stdin 'end' handler past 5s, racing the - // scanner's process.exit(0) against this timeout's SIGTERM. A 10s - // ceiling gives the scanner's own 5s exit a 5s buffer to win the race - // deterministically on every node version. (#2089) - timeout: 10000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { exitCode: 0, stdout: stdout.trim() }; - } catch (err) { - return { exitCode: err.status ?? 1, stdout: (err.stdout || '').toString().trim() }; + input = JSON.stringify(payload); + } catch { + return { exitCode: 0, stdout: '', skipped: true }; } + + const result = spawnSync(process.execPath, [HOOK_PATH], { + input, + encoding: 'utf-8', + timeout: 30000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return { + exitCode: result.status ?? 1, + stdout: (result.stdout || '').trim(), + signal: result.signal, + }; } // Injection-shaped fragments so the regex-matching path is exercised, not just clean text. From 45f3a2a5f945a6fedfcb27aeba304f4c6538bfcd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:17:04 -0400 Subject: [PATCH 09/11] fix(#2089): wire adapter into install path + address all review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM fixes (code review): - Wire resolveManagedHookEvents + resolveHookScripts + buildHookBusEntries from imperative-hook-bus.cts into writeCursorHooksJson — the install path is now truly descriptor-driven (reads hostBehaviors.managedHookEvents), not a hardcoded constant that happens to match the descriptor. bin/install.js passes the descriptor list via opts.managedHookEvents. - buildHookBusEntries is now consumed (was dead code); entry-building is no longer duplicated inline. - Remove try/finally from cursor-hook-bus-upgrade.test.cjs test bodies (violated CONTRIBUTING.md L342; redundant with t.after cleanup). LOW fixes: - Remove dead require('fs')/require('path') from gsd-cursor-pre-tool.js - Fix resolveManagedHookEvents docstring (all-invalid fallback behavior) - Add src/runtime-hooks-surface.cts to the AC2 source-guard file list Security review: no CRITICAL/HIGH/MEDIUM findings (3 LOW are pre-existing #777 baseline patterns, not regressions). --- bin/install.js | 4 +- hooks/gsd-cursor-pre-tool.js | 3 - .../imperative-hook-bus.cts | 4 +- src/runtime-hooks-surface.cts | 64 +++++------ tests/cursor-hook-bus-upgrade.test.cjs | 102 ++++++++---------- tests/cursor-imperative-reference.test.cjs | 2 +- 6 files changed, 81 insertions(+), 98 deletions(-) diff --git a/bin/install.js b/bin/install.js index f36debfd0..16cc410c6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10004,7 +10004,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // 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, {}); + const cursorHookResult = writeCursorHooksJson(targetDir, src, { + managedHookEvents: _hostBehaviors(runtime).managedHookEvents, + }); if (cursorHookResult.changed) { console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`); } else { diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js index a608255c0..d0d96e4c2 100644 --- a/hooks/gsd-cursor-pre-tool.js +++ b/hooks/gsd-cursor-pre-tool.js @@ -22,9 +22,6 @@ '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([\\/]|$)/; diff --git a/src/host-integration-adapters/imperative-hook-bus.cts b/src/host-integration-adapters/imperative-hook-bus.cts index 1638a5481..6716bd9a7 100644 --- a/src/host-integration-adapters/imperative-hook-bus.cts +++ b/src/host-integration-adapters/imperative-hook-bus.cts @@ -73,7 +73,9 @@ export const GSD_HOOK_MARKER = 'gsd-managed'; * (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). + * (fail-closed — an unrecognized event is never registered). Falls back to + * the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries + * are unrecognized (ensures the portable-event floor is always covered). * * @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array * @returns a deduplicated, validated array of event names diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 09fc530bd..dbd1141ad 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -27,6 +27,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { + CURSOR_HOOK_EVENTS, + CURSOR_EVENT_SCRIPT_MAP, + resolveManagedHookEvents, + resolveHookScripts, + buildHookBusEntries, +} from './host-integration-adapters/imperative-hook-bus.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import shellCmdProjection = require('./shell-command-projection.cjs'); const { @@ -86,18 +93,13 @@ 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', -]; +// The full set of Cursor hook events GSD manages — sourced from the adapter +// (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary +// stays closed and first-party. Used by reconcileCursorHooksJson (the +// reconciliation scope is always the full set). The install path +// (writeCursorHooksJson) resolves a descriptor-driven subset via +// resolveManagedHookEvents(opts.managedHookEvents). +const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS; // --------------------------------------------------------------------------- // Cline / AGENTS.md constants @@ -1035,6 +1037,7 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM interface WriteCursorHooksJsonOpts { absoluteRunner?: string | null; platform?: string; + managedHookEvents?: readonly string[]; } function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } { @@ -1042,20 +1045,11 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor const hooksDir = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDir, { recursive: true }); - // 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)); + // Descriptor-driven event resolution (#2089): the managed event set comes + // from the host descriptor's hostBehaviors.managedHookEvents via the pure + // adapter (resolveManagedHookEvents), NOT a hardcoded constant. + const events = resolveManagedHookEvents(opts.managedHookEvents); + const hookScripts = resolveHookScripts(events); const srcHooksDir = path.join(src, 'hooks'); const installedScripts = new Set(); for (const script of hookScripts) { @@ -1071,20 +1065,16 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor } const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform }; - const managedEntries: CursorManagedEntries = {}; - for (const ev of CURSOR_MANAGED_EVENTS) { - const script = eventScriptMap[ev]; + const commands: Record = {}; + for (const ev of events) { + const script = CURSOR_EVENT_SCRIPT_MAP[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, - }; - } + commands[ev] = buildHookCommand(targetDir, script, hookOpts); + } else { + commands[ev] = null; } } + const managedEntries = buildHookBusEntries(events, commands) as CursorManagedEntries; const hooksJsonPath = path.join(targetDir, 'hooks.json'); const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); diff --git a/tests/cursor-hook-bus-upgrade.test.cjs b/tests/cursor-hook-bus-upgrade.test.cjs index 23ca22e4d..881bb1a6e 100644 --- a/tests/cursor-hook-bus-upgrade.test.cjs +++ b/tests/cursor-hook-bus-upgrade.test.cjs @@ -114,68 +114,60 @@ test('all 6 hook scripts exist under hooks/', () => { test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); t.after(() => cleanup(tmpDir)); - try { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - const managedEntries = {}; - for (const ev of EXPECTED_EVENTS) { - managedEntries[ev] = { - type: 'command', - command: `node /fake/${ev}.js`, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); - assert.ok(result.changed, 'first write must report changed=true'); + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /fake/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + assert.ok(result.changed, 'first write must report changed=true'); - const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const hookTable = written.hooks; - assert.ok(hookTable && typeof hookTable === 'object'); - for (const ev of EXPECTED_EVENTS) { - assert.ok(Array.isArray(hookTable[ev]), - `hooks.json must have a ${ev} array`); - assert.equal(hookTable[ev].length, 1, - `${ev} must have exactly 1 managed entry`); - assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, - `${ev} entry must carry the GSD managed marker`); - } - } finally { - cleanup(tmpDir); + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const hookTable = written.hooks; + assert.ok(hookTable && typeof hookTable === 'object'); + for (const ev of EXPECTED_EVENTS) { + assert.ok(Array.isArray(hookTable[ev]), + `hooks.json must have a ${ev} array`); + assert.equal(hookTable[ev].length, 1, + `${ev} must have exactly 1 managed entry`); + assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, + `${ev} entry must carry the GSD managed marker`); } }); test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); t.after(() => cleanup(tmpDir)); - try { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - // Seed with user-owned entries in two events. - const seed = { - version: 1, - hooks: { - sessionStart: [{ type: 'command', command: 'user-start.sh' }], - preToolUse: [{ type: 'command', command: 'user-pre.sh' }], - }, + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + // Seed with user-owned entries in two events. + const seed = { + version: 1, + hooks: { + sessionStart: [{ type: 'command', command: 'user-start.sh' }], + preToolUse: [{ type: 'command', command: 'user-pre.sh' }], + }, + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); + + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /gsd/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, }; - fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); - - const managedEntries = {}; - for (const ev of EXPECTED_EVENTS) { - managedEntries[ev] = { - type: 'command', - command: `node /gsd/${ev}.js`, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - reconcileCursorHooksJson(hooksJsonPath, managedEntries); - - const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - // sessionStart: 1 user + 1 managed - assert.equal(written.hooks.sessionStart.length, 2); - // preToolUse: 1 user + 1 managed - assert.equal(written.hooks.preToolUse.length, 2); - // postToolUse: 1 managed only - assert.equal(written.hooks.postToolUse.length, 1); - } finally { - cleanup(tmpDir); } + reconcileCursorHooksJson(hooksJsonPath, managedEntries); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // sessionStart: 1 user + 1 managed + assert.equal(written.hooks.sessionStart.length, 2); + // preToolUse: 1 user + 1 managed + assert.equal(written.hooks.preToolUse.length, 2); + // postToolUse: 1 managed only + assert.equal(written.hooks.postToolUse.length, 1); }); diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs index 1a412b579..f3e0209a8 100644 --- a/tests/cursor-imperative-reference.test.cjs +++ b/tests/cursor-imperative-reference.test.cjs @@ -118,7 +118,7 @@ test('no `runtime === "cursor"` string-equality branch remains in the install so .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\r\n]*/g, '') .replace(/`[^`]*`/g, ''); - for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) { const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || []; assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`); From c25b212c627a34922bcee79d2468ef2dafc996cc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:39:24 -0400 Subject: [PATCH 10/11] revert: restore gsd-cursor-pre-tool.js dead imports (golden parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the LOW-severity dead-import removal (require('fs')/require('path')) that changed the file hash and broke 9 golden-install-parity fixtures. The golden test computes per-runtime hashes of installed hook files; regenerating all 9 fixtures for a cosmetic cleanup is disproportionate. Dead imports are harmless (Node caches built-in requires) — noted as a follow-up nit. --- hooks/gsd-cursor-pre-tool.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js index d0d96e4c2..a608255c0 100644 --- a/hooks/gsd-cursor-pre-tool.js +++ b/hooks/gsd-cursor-pre-tool.js @@ -22,6 +22,9 @@ '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([\\/]|$)/; From 39116ba00159d02ba76f13a37b294b5182bdb98a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:59:48 -0400 Subject: [PATCH 11/11] docs(changeset): backfill pr 2120 for #2089 --- .changeset/2089-eos-cursor-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2089-eos-cursor-imperative-adapter.md b/.changeset/2089-eos-cursor-imperative-adapter.md index 32a3d120c..870d57748 100644 --- a/.changeset/2089-eos-cursor-imperative-adapter.md +++ b/.changeset/2089-eos-cursor-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 2120 --- **Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089)