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:
5
.changeset/2089-eos-cursor-imperative-adapter.md
Normal file
5
.changeset/2089-eos-cursor-imperative-adapter.md
Normal 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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({}));
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
71
tests/cursor-dispatch-upgrade.test.cjs
Normal file
71
tests/cursor-dispatch-upgrade.test.cjs
Normal 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');
|
||||
});
|
||||
173
tests/cursor-hook-bus-upgrade.test.cjs
Normal file
173
tests/cursor-hook-bus-upgrade.test.cjs
Normal 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);
|
||||
});
|
||||
126
tests/cursor-imperative-reference.test.cjs
Normal file
126
tests/cursor-imperative-reference.test.cjs
Normal 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(', ')}`);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user