Merge pull request #2120 from open-gsd/feat/2089-cursor-eos-migration

feat(#2089): migrate Cursor onto EoS imperative adapter + hook-bus/dispatch upgrades (ADR-1239)
This commit is contained in:
Tom Boucher
2026-07-09 14:17:23 -04:00
committed by GitHub
30 changed files with 1005 additions and 69 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
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)

1
.gitignore vendored
View File

@@ -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

View File

@@ -255,12 +255,30 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
// Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
// ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
// 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,15 @@ 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 <targetDir>/hooks/ and referenced by hooks.json.
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
// 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, {
managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
});
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 +11421,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,

View File

@@ -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"
]
}
}
}

View File

@@ -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",

View File

@@ -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 |

View File

@@ -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

View File

@@ -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"
]
}
}
},

View File

@@ -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({}));
});

48
hooks/gsd-cursor-stop.js Normal file
View File

@@ -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({}));
}
});

View File

@@ -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({}));
}
});

View File

@@ -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({}));
}
});

View File

@@ -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',

View File

@@ -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.

View File

@@ -0,0 +1,155 @@
/**
* Imperative hook-bus adapter — descriptor-driven hooks.json binding
* (ADR-1239 Phase D / #2089).
*
* Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
* into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
* axis + the host's documented hook-event list (from
* `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
* `sessionStart`/`postToolUse` pair.
*
* This module is PURE (no I/O): it resolves the event→script mapping and builds
* the hooks.json entry manifest. The actual file I/O (copying scripts, writing
* hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
* functions exported here. This separation makes the binding testable without a
* filesystem.
*
* Cursor hook-event universe (closed vocabulary per ADR-1239,
* https://cursor.com/docs/hooks):
* sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
* subagentStop, beforeShellExecution, afterShellExecution,
* afterMCPExecution, afterFileEdit, preCompact, stop,
* beforeTabFileRead, afterTabFileEdit, workspaceOpen
*
* GSD registers for the 6 events in the portable floor + subagent lifecycle
* (AC4a upgrade, #2089):
* sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
*/
'use strict';
/**
* The full set of Cursor hook events GSD can register for.
* Frozen closed vocabulary — adding an event requires updating both this set
* and the event→script mapping below.
*/
export const CURSOR_HOOK_EVENTS = Object.freeze([
'sessionStart',
'postToolUse',
'preToolUse',
'stop',
'subagentStart',
'subagentStop',
] as const);
export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number];
/**
* Event → hook-script mapping. Each event maps to a standalone `.js` script
* under `hooks/` that Cursor invokes via `hooks.json`.
*
* Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
* `hooks/` and copied to `<configDir>/hooks/` during install by
* `runtime-hooks-surface.cts`.
*/
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
sessionStart: 'gsd-cursor-session-start.js',
postToolUse: 'gsd-cursor-post-tool.js',
preToolUse: 'gsd-cursor-pre-tool.js',
stop: 'gsd-cursor-stop.js',
subagentStart: 'gsd-cursor-subagent-start.js',
subagentStop: 'gsd-cursor-subagent-stop.js',
});
/**
* The GSD-managed marker written into each hooks.json entry so the
* reconcile pass can distinguish GSD-owned entries from user-owned ones.
*/
export const GSD_HOOK_MARKER = 'gsd-managed';
/**
* Resolve the managed hook events from a runtime descriptor's
* `hostBehaviors.managedHookEvents` list. Falls back to the full
* `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
* (backward-compat for descriptors predating #2089).
*
* Pure: no I/O, never throws. Unknown event names are silently filtered
* (fail-closed — an unrecognized event is never registered). 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
*/
export function resolveManagedHookEvents(
managedHookEvents: readonly string[] | null | undefined,
): readonly string[] {
if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
return CURSOR_HOOK_EVENTS;
}
const valid = new Set<string>(CURSOR_HOOK_EVENTS);
const seen = new Set<string>();
const result: string[] = [];
for (const ev of managedHookEvents) {
if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
seen.add(ev);
result.push(ev);
}
}
return result.length > 0 ? result : CURSOR_HOOK_EVENTS;
}
/**
* Build the list of hook script files that need to be copied for the given
* managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
*
* Pure: returns a deduplicated array of script filenames.
*
* @param events - the managed event names (validated by `resolveManagedHookEvents`)
* @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
*/
export function resolveHookScripts(
events: readonly string[],
): readonly string[] {
const scripts: string[] = [];
const seen = new Set<string>();
for (const ev of events) {
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
if (script && !seen.has(script)) {
seen.add(script);
scripts.push(script);
}
}
return scripts;
}
/**
* Build the hooks.json managed-entry manifest for the given events.
* Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
*
* The `command` string is built by the caller (it requires platform-specific
* node-runner resolution from `runtime-hooks-surface.cts`). This function
* receives a pre-built `event → command` map and attaches the marker.
*
* Pure: no I/O.
*
* @param events - the managed event names
* @param commands - a map of event → command string (built by the caller)
* @returns a map of event → managed entry, ready for hooks.json reconciliation
*/
export function buildHookBusEntries(
events: readonly string[],
commands: Readonly<Record<string, string | null>>,
): Record<string, { type: string; command: string; [key: string]: unknown }> {
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
for (const ev of events) {
const cmd = commands[ev];
if (cmd) {
entries[ev] = {
type: 'command',
command: cmd,
[GSD_HOOK_MARKER]: true,
};
}
}
return entries;
}

View File

@@ -32,7 +32,11 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = 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',

View File

@@ -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 {
@@ -80,8 +87,20 @@ 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 — 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
// ---------------------------------------------------------------------------
@@ -976,9 +995,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) {
const eventKeys = ['sessionStart', 'postToolUse'];
const lifted: Record<string, unknown> = {};
for (const k of eventKeys) {
for (const k of CURSOR_MANAGED_EVENTS) {
if (Array.isArray(parsed[k])) {
lifted[k] = parsed[k];
delete parsed[k];
@@ -989,10 +1007,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
if (!parsed['version']) parsed['version'] = 1;
const hookTable = parsed['hooks'] as Record<string, unknown>;
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
const entries = managedEntries || {};
for (const event of MANAGED_EVENTS) {
for (const event of CURSOR_MANAGED_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
const newEntry = entries[event] || null;
@@ -1020,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 } {
@@ -1027,7 +1045,11 @@ 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];
// 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<string>();
for (const script of hookScripts) {
@@ -1043,28 +1065,16 @@ 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,
};
const commands: Record<string, string | null> = {};
for (const ev of events) {
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
if (script && installedScripts.has(script)) {
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);
@@ -1729,6 +1739,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

View File

@@ -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');
});

View File

@@ -0,0 +1,173 @@
'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));
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`);
}
});
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));
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);
});

View File

@@ -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 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);
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', '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(', ')}`);
}
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -12,28 +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',
timeout: 5000,
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.