feat(#2089): migrate cursor onto imperative adapter + hook-bus/dispatch upgrades
This commit is contained in:
@@ -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,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 <targetDir>/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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
76
hooks/gsd-cursor-pre-tool.js
Normal file
76
hooks/gsd-cursor-pre-tool.js
Normal 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
48
hooks/gsd-cursor-stop.js
Normal 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({}));
|
||||
}
|
||||
});
|
||||
50
hooks/gsd-cursor-subagent-start.js
Normal file
50
hooks/gsd-cursor-subagent-start.js
Normal 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({}));
|
||||
}
|
||||
});
|
||||
40
hooks/gsd-cursor-subagent-stop.js
Normal file
40
hooks/gsd-cursor-subagent-stop.js
Normal 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({}));
|
||||
}
|
||||
});
|
||||
153
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
153
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Imperative hook-bus adapter — descriptor-driven hooks.json binding
|
||||
* (ADR-1239 Phase D / #2089).
|
||||
*
|
||||
* Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
|
||||
* into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
|
||||
* axis + the host's documented hook-event list (from
|
||||
* `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
|
||||
* `sessionStart`/`postToolUse` pair.
|
||||
*
|
||||
* This module is PURE (no I/O): it resolves the event→script mapping and builds
|
||||
* the hooks.json entry manifest. The actual file I/O (copying scripts, writing
|
||||
* hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
|
||||
* functions exported here. This separation makes the binding testable without a
|
||||
* filesystem.
|
||||
*
|
||||
* Cursor hook-event universe (closed vocabulary per ADR-1239,
|
||||
* https://cursor.com/docs/hooks):
|
||||
* sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
|
||||
* subagentStop, beforeShellExecution, afterShellExecution,
|
||||
* afterMCPExecution, afterFileEdit, preCompact, stop,
|
||||
* beforeTabFileRead, afterTabFileEdit, workspaceOpen
|
||||
*
|
||||
* GSD registers for the 6 events in the portable floor + subagent lifecycle
|
||||
* (AC4a upgrade, #2089):
|
||||
* sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The full set of Cursor hook events GSD can register for.
|
||||
* Frozen closed vocabulary — adding an event requires updating both this set
|
||||
* and the event→script mapping below.
|
||||
*/
|
||||
export const CURSOR_HOOK_EVENTS = Object.freeze([
|
||||
'sessionStart',
|
||||
'postToolUse',
|
||||
'preToolUse',
|
||||
'stop',
|
||||
'subagentStart',
|
||||
'subagentStop',
|
||||
] as const);
|
||||
|
||||
export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number];
|
||||
|
||||
/**
|
||||
* Event → hook-script mapping. Each event maps to a standalone `.js` script
|
||||
* under `hooks/` that Cursor invokes via `hooks.json`.
|
||||
*
|
||||
* Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
|
||||
* `hooks/` and copied to `<configDir>/hooks/` during install by
|
||||
* `runtime-hooks-surface.cts`.
|
||||
*/
|
||||
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
sessionStart: 'gsd-cursor-session-start.js',
|
||||
postToolUse: 'gsd-cursor-post-tool.js',
|
||||
preToolUse: 'gsd-cursor-pre-tool.js',
|
||||
stop: 'gsd-cursor-stop.js',
|
||||
subagentStart: 'gsd-cursor-subagent-start.js',
|
||||
subagentStop: 'gsd-cursor-subagent-stop.js',
|
||||
});
|
||||
|
||||
/**
|
||||
* The GSD-managed marker written into each hooks.json entry so the
|
||||
* reconcile pass can distinguish GSD-owned entries from user-owned ones.
|
||||
*/
|
||||
export const GSD_HOOK_MARKER = 'gsd-managed';
|
||||
|
||||
/**
|
||||
* Resolve the managed hook events from a runtime descriptor's
|
||||
* `hostBehaviors.managedHookEvents` list. Falls back to the full
|
||||
* `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
|
||||
* (backward-compat for descriptors predating #2089).
|
||||
*
|
||||
* Pure: no I/O, never throws. Unknown event names are silently filtered
|
||||
* (fail-closed — an unrecognized event is never registered).
|
||||
*
|
||||
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
|
||||
* @returns a deduplicated, validated array of event names
|
||||
*/
|
||||
export function resolveManagedHookEvents(
|
||||
managedHookEvents: readonly string[] | null | undefined,
|
||||
): readonly string[] {
|
||||
if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
|
||||
return CURSOR_HOOK_EVENTS;
|
||||
}
|
||||
const valid = new Set<string>(CURSOR_HOOK_EVENTS);
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const ev of managedHookEvents) {
|
||||
if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
|
||||
seen.add(ev);
|
||||
result.push(ev);
|
||||
}
|
||||
}
|
||||
return result.length > 0 ? result : CURSOR_HOOK_EVENTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of hook script files that need to be copied for the given
|
||||
* managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
|
||||
*
|
||||
* Pure: returns a deduplicated array of script filenames.
|
||||
*
|
||||
* @param events - the managed event names (validated by `resolveManagedHookEvents`)
|
||||
* @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
|
||||
*/
|
||||
export function resolveHookScripts(
|
||||
events: readonly string[],
|
||||
): readonly string[] {
|
||||
const scripts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||
if (script && !seen.has(script)) {
|
||||
seen.add(script);
|
||||
scripts.push(script);
|
||||
}
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the hooks.json managed-entry manifest for the given events.
|
||||
* Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
|
||||
*
|
||||
* The `command` string is built by the caller (it requires platform-specific
|
||||
* node-runner resolution from `runtime-hooks-surface.cts`). This function
|
||||
* receives a pre-built `event → command` map and attaches the marker.
|
||||
*
|
||||
* Pure: no I/O.
|
||||
*
|
||||
* @param events - the managed event names
|
||||
* @param commands - a map of event → command string (built by the caller)
|
||||
* @returns a map of event → managed entry, ready for hooks.json reconciliation
|
||||
*/
|
||||
export function buildHookBusEntries(
|
||||
events: readonly string[],
|
||||
commands: Readonly<Record<string, string | null>>,
|
||||
): Record<string, { type: string; command: string; [key: string]: unknown }> {
|
||||
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
|
||||
for (const ev of events) {
|
||||
const cmd = commands[ev];
|
||||
if (cmd) {
|
||||
entries[ev] = {
|
||||
type: 'command',
|
||||
command: cmd,
|
||||
[GSD_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -80,8 +80,25 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
|
||||
// ---------------------------------------------------------------------------
|
||||
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
|
||||
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
|
||||
const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
|
||||
const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
|
||||
const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
|
||||
const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
|
||||
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
||||
|
||||
// The full set of Cursor hook events GSD manages (AC4a upgrade, #2089).
|
||||
// Sourced from the descriptor-driven adapter module
|
||||
// (src/host-integration-adapters/imperative-hook-bus.cts). This replaces the
|
||||
// hardcoded ['sessionStart', 'postToolUse'] pair with the 6-event managed set.
|
||||
const CURSOR_MANAGED_EVENTS = [
|
||||
'sessionStart',
|
||||
'postToolUse',
|
||||
'preToolUse',
|
||||
'stop',
|
||||
'subagentStart',
|
||||
'subagentStop',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cline / AGENTS.md constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -976,9 +993,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
||||
const hasNestedHooksObject =
|
||||
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
||||
if (!hasNestedHooksObject) {
|
||||
const eventKeys = ['sessionStart', 'postToolUse'];
|
||||
const lifted: Record<string, unknown> = {};
|
||||
for (const k of eventKeys) {
|
||||
for (const k of CURSOR_MANAGED_EVENTS) {
|
||||
if (Array.isArray(parsed[k])) {
|
||||
lifted[k] = parsed[k];
|
||||
delete parsed[k];
|
||||
@@ -989,10 +1005,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
||||
if (!parsed['version']) parsed['version'] = 1;
|
||||
const hookTable = parsed['hooks'] as Record<string, unknown>;
|
||||
|
||||
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
||||
const entries = managedEntries || {};
|
||||
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
for (const event of CURSOR_MANAGED_EVENTS) {
|
||||
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
|
||||
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
||||
const newEntry = entries[event] || null;
|
||||
@@ -1027,7 +1042,20 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
||||
// AC4a (#2089): install all managed hook scripts, not just sessionStart/postToolUse.
|
||||
// The event→script mapping is sourced from the descriptor-driven adapter
|
||||
// (src/host-integration-adapters/imperative-hook-bus.cts).
|
||||
const eventScriptMap: Record<string, string> = {
|
||||
sessionStart: GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||||
postToolUse: GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||||
preToolUse: GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
|
||||
stop: GSD_CURSOR_STOP_HOOK_SCRIPT,
|
||||
subagentStart: GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
||||
subagentStop: GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
||||
};
|
||||
const hookScripts = CURSOR_MANAGED_EVENTS
|
||||
.map((ev) => eventScriptMap[ev])
|
||||
.filter((s): s is string => Boolean(s));
|
||||
const srcHooksDir = path.join(src, 'hooks');
|
||||
const installedScripts = new Set<string>();
|
||||
for (const script of hookScripts) {
|
||||
@@ -1043,27 +1071,19 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
||||
}
|
||||
|
||||
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
||||
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
||||
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
||||
: null;
|
||||
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
||||
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
||||
: null;
|
||||
|
||||
const managedEntries: CursorManagedEntries = {};
|
||||
if (sessionStartCmd) {
|
||||
managedEntries['sessionStart'] = {
|
||||
type: 'command',
|
||||
command: sessionStartCmd,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
if (postToolCmd) {
|
||||
managedEntries['postToolUse'] = {
|
||||
type: 'command',
|
||||
command: postToolCmd,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
for (const ev of CURSOR_MANAGED_EVENTS) {
|
||||
const script = eventScriptMap[ev];
|
||||
if (script && installedScripts.has(script)) {
|
||||
const cmd = buildHookCommand(targetDir, script, hookOpts);
|
||||
if (cmd) {
|
||||
managedEntries[ev] = {
|
||||
type: 'command',
|
||||
command: cmd,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
@@ -1729,6 +1749,10 @@ export = {
|
||||
removeCursorHooksJson,
|
||||
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||||
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||||
GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
|
||||
GSD_CURSOR_STOP_HOOK_SCRIPT,
|
||||
GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
||||
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
|
||||
// Copilot
|
||||
|
||||
Reference in New Issue
Block a user