diff --git a/.changeset/eager-herons-forage.md b/.changeset/eager-herons-forage.md new file mode 100644 index 000000000..f1adf4731 --- /dev/null +++ b/.changeset/eager-herons-forage.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 777 +--- +Cursor now receives GSD lifecycle hooks via `.cursor/hooks.json` — a sessionStart hook injects the current workflow state as context at session start, and a postToolUse hook nudges the agent to update `.planning/` after write-class operations, bringing Cursor to baseline hook parity with Gemini and Claude Code. diff --git a/CONTEXT.md b/CONTEXT.md index ae909288b..b6641f085 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -122,7 +122,7 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. ### Runtime Config Adapter Registry -Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. +Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. ### Claude Code Plugin Manifest Module Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. diff --git a/bin/install.js b/bin/install.js index c706ff23c..a3c0408eb 100755 --- a/bin/install.js +++ b/bin/install.js @@ -190,6 +190,19 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; +// #777 — Cursor CLI lifecycle hook constants. +// Cursor reads hook configs from /.cursor/hooks.json (local) or +// ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { : [...] } }. +// Events use camelCase: sessionStart, postToolUse, preToolUse, etc. +// A `command` hook entry runs an external script. GSD registers two managed hooks: +// sessionStart → gsd-cursor-session-start.js (context injection) +// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// 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'; +// Marker comment embedded in managed hook entries so GSD can find+remove them. +const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; + // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks). // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does. const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh']; @@ -5738,6 +5751,248 @@ function writeClineArtifacts(targetDir, isGlobalInstall) { return written; } +// ── Cursor hooks.json reconciler (issue #777) ──────────────────────────────── +// +// Cursor v2.4+ supports a hooks.json lifecycle hook system. GSD registers two +// managed command hooks: +// sessionStart → gsd-cursor-session-start.js (context injection) +// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// +// hooks.json schema: +// { "version": 1, "hooks": { "": [ { "type": "command", "command": "" } ] } } +// +// Location: +// Global: ~/.cursor/hooks.json +// Local: /.cursor/hooks.json +// +// GSD entries are identified by a top-level `"gsd-managed": true` field on +// each hook entry. Non-GSD entries are preserved. The reconciler is idempotent +// (safe to re-run) and preserves user-owned entries in the file. +// +// References: https://cursor.com/docs/hooks + +/** + * Build a managed Cursor hook entry for a given hook script path. + * + * @param {string} scriptPath - Absolute path to the hook script + * @returns {object} Cursor hook entry object + */ +function buildCursorHookEntry(scriptPath) { + return { + type: 'command', + command: scriptPath.replace(/\\/g, '/'), + [GSD_CURSOR_HOOK_MARKER]: true, + }; +} + +/** + * Return true if a Cursor hook entry is GSD-managed. + * Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field. + * + * @param {object} entry - A hooks array element from hooks.json + * @returns {boolean} + */ +function isManagedCursorHookEntry(entry) { + return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]); +} + +/** + * Reconcile the GSD-managed entries in a Cursor hooks.json file. + * + * Supports both known hooks.json shapes: + * 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } } + * 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object) + * + * Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then + * re-added if managedEntries is non-null/non-empty. User-owned entries are + * preserved. File is written atomically only when content changes. + * + * @param {string} hooksJsonPath - Absolute path to the hooks.json file + * @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries + * Map from event name to the new hook entry to register (or null to remove). + * Pass null for the whole param to remove all managed entries. + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function reconcileCursorHooksJson(hooksJsonPath, managedEntries) { + let parsed = {}; + let currentContent = null; + + if (fs.existsSync(hooksJsonPath)) { + const raw = fs.readFileSync(hooksJsonPath, 'utf8'); + currentContent = raw; + if (raw.trim()) { + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`); + } + } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {}; + + // Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }. + // GSD always writes (and migrates to) the nested shape so Cursor reads it correctly. + // The flat shape { "sessionStart": [...] } is accepted on read for backwards compat + // with manually-written files, but the output always uses the nested form. + const hasNestedHooksObject = + parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks); + if (!hasNestedHooksObject) { + // Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}. + const eventKeys = ['sessionStart', 'postToolUse']; + const lifted = {}; + for (const k of eventKeys) { + if (Array.isArray(parsed[k])) { + lifted[k] = parsed[k]; + delete parsed[k]; + } + } + parsed.hooks = lifted; + } + if (!parsed.version) parsed.version = 1; + const hookTable = parsed.hooks; + + // Events GSD manages. + const MANAGED_EVENTS = ['sessionStart', 'postToolUse']; + const entries = managedEntries || {}; + + for (const event of MANAGED_EVENTS) { + const existing = Array.isArray(hookTable[event]) ? hookTable[event] : []; + // Strip all prior GSD-managed entries for this event. + const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e)); + const newEntry = entries[event] || null; + if (newEntry) { + hookTable[event] = [...userOwned, newEntry]; + } else { + // Remove-only: keep user entries, or delete the key if it would be empty. + if (userOwned.length > 0) { + hookTable[event] = userOwned; + } else { + delete hookTable[event]; + } + } + } + + // hookTable is parsed.hooks (always nested now); no reassignment needed. + // Write only if content changed or if we're creating the file for the first time. + const nextContent = `${JSON.stringify(parsed, null, 2)}\n`; + const changed = currentContent !== nextContent; + const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0); + if (shouldWrite) { + atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8'); + } + + return { changed: changed, wrote: shouldWrite, path: hooksJsonPath }; +} + +/** + * #777 — Write GSD-managed Cursor lifecycle hooks into /hooks.json. + * + * Both managed hook scripts (gsd-cursor-session-start.js, gsd-cursor-post-tool.js) + * are copied from the GSD hooks/ source to /hooks/ first, so the + * hooks.json entries never reference a script that wasn't installed. + * + * @param {string} targetDir - The Cursor config dir (global: ~/.cursor; local: .cursor) + * @param {string} src - The GSD install source root (for copying hook scripts) + * @param {{ absoluteRunner?: string|null }} opts + * @returns {{ hooksJsonPath: string, changed: boolean }} + */ +function writeCursorHooksJson(targetDir, src, opts) { + opts = opts || {}; + const hooksDir = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + // Copy the two GSD-managed hook scripts from the GSD source hooks/ directory. + // Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor + // JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b). + // Track which scripts were successfully installed so we never register a hook entry + // that references a script that wasn't copied (dangling command guard). + const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]; + const srcHooksDir = path.join(src, 'hooks'); + const installedScripts = new Set(); + for (const script of hookScripts) { + const srcPath = path.join(srcHooksDir, script); + const destPath = path.join(hooksDir, script); + if (fs.existsSync(srcPath)) { + let content = fs.readFileSync(srcPath, 'utf8'); + // Rewrite /gsd: → gsd- so installed hook scripts are consistent + // with the Cursor convention (no colon-form slash commands in agent context). + content = content.replace(/gsd:/gi, 'gsd-'); + fs.writeFileSync(destPath, content); + try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ } + installedScripts.add(script); + } + } + + // Build command strings using the same buildHookCommand helper used by other runtimes. + // buildHookCommand resolves the node runner + emits "" "/hooks/". + const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform }; + // buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection + // Only register the hook entry if the script was actually installed (dangling guard). + const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js') + ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts) + : null; + // buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor + const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js') + ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts) + : null; + + // Build managed entries; skip events whose command couldn't be resolved (e.g. no node). + const managedEntries = {}; + 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 hooksJsonPath = path.join(targetDir, 'hooks.json'); + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + return { hooksJsonPath, changed: result.changed }; +} + +/** + * Remove all GSD-managed Cursor lifecycle hook entries from hooks.json. + * User-owned entries are preserved. If the file becomes empty, it is removed. + * + * @param {string} targetDir - The Cursor config dir + * @returns {{ changed: boolean }} + */ +function removeCursorHooksJson(targetDir) { + const hooksJsonPath = path.join(targetDir, 'hooks.json'); + if (!fs.existsSync(hooksJsonPath)) return { changed: false }; + const result = reconcileCursorHooksJson(hooksJsonPath, null); + // If the resulting file has no meaningful hook content, remove it. + // A file is "empty" if it contains only the scaffolding (version, empty hooks + // object, or a bare {}) with no user-authored hook entries. + if (result.changed) { + try { + const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8'); + const parsed = JSON.parse(contentRaw); + // reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape. + // The file is "empty" when there are no remaining hook events with entries. + const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : {}; + const hasAnyEvents = Object.keys(hookTable).some( + (k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0, + ); + if (!hasAnyEvents) { + fs.unlinkSync(hooksJsonPath); + return { changed: true }; + } + } catch { /* best-effort: leave the file */ } + } + return { changed: result.changed }; +} + /** * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object. * @@ -7804,6 +8059,8 @@ const GSD_UNINSTALL_HOOKS = [ 'gsd-check-update.js', 'gsd-check-update.cmd', 'gsd-context-monitor.js', + 'gsd-cursor-session-start.js', + 'gsd-cursor-post-tool.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', @@ -8039,6 +8296,33 @@ function uninstall(isGlobal, runtime = 'claude') { } } + // 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) { + 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). + const hooksDir = path.join(targetDir, 'hooks'); + for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) { + const p = path.join(hooksDir, script); + try { + if (fs.existsSync(p)) { + fs.unlinkSync(p); + removedCount++; + } + } catch { /* best-effort */ } + } + // Prune hooks/ if empty. + try { + if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) { + fs.rmdirSync(hooksDir); + } + } catch { /* best-effort */ } + } + // 1c. Claude local: remove commands/gsd/ (primary local install location). // The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to // flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside @@ -10137,8 +10421,10 @@ function install(isGlobal, runtime = 'claude', options = {}) { } // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702). - // Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not - // receive the hooks/lib/ helpers either — otherwise the Codex comment downstream + // 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 + // hooks.json directly; the others skip hooks entirely), so they must not receive + // the hooks/lib/ 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 && fs.existsSync(hooksLibSrc)) { @@ -10653,8 +10939,23 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } + if (configIntent.installSurface === 'cursor-hooks-json') { + // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse. + // Hook scripts are copied to /hooks/ and referenced by hooks.json. + const cursorHookResult = writeCursorHooksJson(targetDir, src, {}); + if (cursorHookResult.changed) { + console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`); + } else { + console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`); + } + // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked. + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); + persistActiveProfileMarker(); + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; + } + if (configIntent.installSurface === 'profile-marker-only') { - // Cursor/Windsurf/Trae use skills — no config.toml, no settings.json hooks needed + // Windsurf/Trae use skills — no config.toml, no settings.json hooks needed persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -12262,6 +12563,14 @@ module.exports = { buildClinePreToolUseHook, writeClineArtifacts, mergeGsdAgentsMd, + GSD_CURSOR_SESSION_HOOK_SCRIPT, + GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_HOOK_MARKER, + buildCursorHookEntry, + isManagedCursorHookEntry, + reconcileCursorHooksJson, + writeCursorHooksJson, + removeCursorHooksJson, stripGsdFromAgentsMd, GSD_AGENTS_MD_MARKER, GSD_AGENTS_MD_CLOSE_MARKER, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c555656f..b2b1b4e08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -802,7 +802,7 @@ The migration-specific ownership and source snapshots live in | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | | GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | | Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | -| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; `hooks.json` with sessionStart context injection and postToolUse STATE.md monitor (#777) | | Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | | Augment Code | `~/.augment` | `./.augment` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | No GSD hooks or statusline | | Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 046b487c2..d3def7871 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -360,6 +360,8 @@ "gsd-check-update-worker.js", "gsd-check-update.js", "gsd-context-monitor.js", + "gsd-cursor-post-tool.js", + "gsd-cursor-session-start.js", "gsd-graphify-update.sh", "gsd-phase-boundary.sh", "gsd-prompt-guard.js", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index bb422cd18..d7bc3964b 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -471,7 +471,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. --- -## Hooks (14 shipped) +## Hooks (16 shipped) Full listing: `hooks/`. @@ -482,6 +482,8 @@ Full listing: `hooks/`. | `gsd-check-update.js` | `SessionStart` | Background check for new GSD versions | | `gsd-check-update-worker.js` | (worker) | Background worker helper for check-update | | `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-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) | | `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) | | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 0cacbc98b..2d8c33197 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -370,7 +370,7 @@ for the new shape before changing migration behavior. | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | | Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. | -| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 | +| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/`; lifecycle hooks via `hooks.json` (sessionStart + postToolUse, #777) | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents, GSD rule files or references, and GSD-managed `hooks.json` entries (sentinel `gsd-managed:true`); no statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); [Cursor hooks](https://docs.cursor.com/context/hooks); docs not versioned, checked 2026-06-07 | | Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules | | Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 | | Trae | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `TRAE_CONFIG_DIR` or `~/.trae`; local `./.trae` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Public Trae docs expose AI settings and `.rules` announcements, but no stable skills/config API was found as of 2026-05-11; migrations must treat this row as source-limited | diff --git a/hooks/gsd-cursor-post-tool.js b/hooks/gsd-cursor-post-tool.js new file mode 100644 index 000000000..7f7dec769 --- /dev/null +++ b/hooks/gsd-cursor-post-tool.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-post-tool.js — Cursor postToolUse hook (issue #777) +// +// Cursor invokes this script after each tool call completes. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor postToolUse): +// { tool_name, tool_input, tool_output, duration, +// conversation_id, generation_id, model, hook_event_name, +// cursor_version, workspace_roots, user_email, transcript_path } +// +// Output schema (cursor postToolUse): +// { additional_context?: string } ← injected as context after the tool use +// +// Behaviour: +// - After a write-class tool that targets .planning/, reminds the agent +// to keep STATE.md current. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +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(() => { + // Timeout guard: exit silently rather than hanging. + 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; } + + // Collect only PATH-bearing field values (not free-form content). + 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/ artifact updated — ensure STATE.md reflects the latest phase and progress.', + })); + return; + } + } catch { /* fall through to empty response */ } + + process.stdout.write(JSON.stringify({})); +}); diff --git a/hooks/gsd-cursor-session-start.js b/hooks/gsd-cursor-session-start.js new file mode 100644 index 000000000..dbc8c97ef --- /dev/null +++ b/hooks/gsd-cursor-session-start.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-session-start.js — Cursor sessionStart hook (issue #777) +// +// Cursor invokes this script at the start of each agent session. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor sessionStart): +// { session_id, is_background_agent, composer_mode, conversation_id, +// generation_id, model, hook_event_name, cursor_version, +// workspace_roots, user_email, transcript_path } +// +// Output schema (cursor sessionStart): +// { additional_context?: string } ← injected into the session as context +// +// Behaviour: +// - If .planning/STATE.md is present, injects a brief state reminder. +// - If absent, nudges the user toward /gsd:new-project. +// - 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 MSG_PRESENT = + 'GSD: .planning/STATE.md is present — review the current phase and any blockers before acting.'; +const MSG_ABSENT = + 'GSD: no .planning/ workflow found — run /gsd:new-project to start a tracked workflow.'; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + // Timeout guard: exit silently rather than hanging. + 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 { + // Fail open — never block a Cursor session because of a GSD hook error. + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index e29c7bb3c..77aa4982b 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -19,6 +19,8 @@ const MANAGED_HOOKS = [ 'gsd-check-update-worker.js', 'gsd-check-update.js', 'gsd-context-monitor.js', + 'gsd-cursor-post-tool.js', + 'gsd-cursor-session-start.js', 'gsd-graphify-update.sh', 'gsd-phase-boundary.sh', 'gsd-prompt-guard.js', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 3072a5e50..c566a18cd 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -30,6 +30,9 @@ 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 + 'gsd-cursor-session-start.js', + 'gsd-cursor-post-tool.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index 536de56f1..3e41512fa 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -30,6 +30,8 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set 'hooks/gsd-check-update-worker.js', 'hooks/gsd-check-update.js', 'hooks/gsd-context-monitor.js', + 'hooks/gsd-cursor-post-tool.js', + 'hooks/gsd-cursor-session-start.js', 'hooks/gsd-graphify-update.sh', 'hooks/gsd-phase-boundary.sh', 'hooks/gsd-prompt-guard.js', diff --git a/src/installer-migrations/000-first-time-baseline.cts b/src/installer-migrations/000-first-time-baseline.cts index c81f30e6a..a7c68ace2 100644 --- a/src/installer-migrations/000-first-time-baseline.cts +++ b/src/installer-migrations/000-first-time-baseline.cts @@ -23,7 +23,7 @@ const RUNTIME_SURFACES: Record = { kilo: ['gsd-core', 'command', 'skills', 'agents'], copilot: ['gsd-core', 'skills', 'agents'], antigravity: ['gsd-core', 'skills', 'agents'], - cursor: ['gsd-core', 'skills', 'agents'], + cursor: ['gsd-core', 'skills', 'agents', 'hooks', 'hooks.json'], windsurf: ['gsd-core', 'skills', 'agents', 'rules'], augment: ['gsd-core', 'skills', 'agents'], trae: ['gsd-core', 'skills', 'agents', 'rules'], diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index 3bb690471..68d1b4d90 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -11,6 +11,7 @@ * 'codex-toml' → early-return after writing codex.toml. * 'copilot-instructions' → early-return after writing .github/copilot-instructions.md. * 'cline-rules' → early-return after writing .clinerules. + * 'cursor-hooks-json' → early-return after writing .cursor/hooks.json (issue #777). * 'profile-marker-only' → early-return after writing only the profile marker. * - `writesSharedSettings` is the finishInstall writeSettings gate: * false for codex / copilot / kilo / cursor / windsurf / trae / cline (legacy exclusion list). @@ -30,6 +31,7 @@ type ConfigInstallSurface = | 'codex-toml' | 'copilot-instructions' | 'cline-rules' + | 'cursor-hooks-json' | 'profile-marker-only'; type FinishPermissionWriter = 'opencode' | 'kilo' | null; @@ -64,7 +66,7 @@ const REGISTRY: Record> = Object.freeze({ codex: Object.freeze({ installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null } as const), copilot: Object.freeze({ installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null } as const), cline: Object.freeze({ installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null } as const), - cursor: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + cursor: Object.freeze({ installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null } as const), windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), }); @@ -82,6 +84,7 @@ const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ 'codex-toml', 'copilot-instructions', 'cline-rules', + 'cursor-hooks-json', 'profile-marker-only', ]); diff --git a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs index 12d66b7b1..22ea002be 100644 --- a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs +++ b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs @@ -32,6 +32,20 @@ const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Ensure hooks/dist is populated before any suite that reads it. + * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. + * In CI the scoped/windows test jobs do NOT run build:hooks before running + * tests, so the first test that needs hooks/dist would fail. This mirrors + * the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} // --------------------------------------------------------------------------- // Helpers @@ -92,6 +106,12 @@ function colonRefs(content) { // Prerequisite: hooks/dist must exist (built by `npm run build:hooks`) // --------------------------------------------------------------------------- describe('bug #376 — prerequisite: hooks/dist is present', () => { + before(() => { + // hooks/dist is gitignored; build it on demand so this test is + // deterministic in CI scoped/windows jobs that don't pre-run build:hooks. + ensureHooksDist(); + }); + test('hooks/dist directory exists (run npm run build:hooks if missing)', () => { assert.ok( fs.existsSync(HOOKS_DIST_DIR), @@ -189,11 +209,13 @@ describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook // --------------------------------------------------------------------------- // Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing) // -// Note: Cursor does NOT install hooks/dist files (Cursor skips the hooks -// install step entirely — see install.js gate around line 8829). The Cursor -// /gsd: rewrite applies in `copyWithPathReplacement` to JS files under the -// agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's -// installed .js files under .cursor/ have no /gsd: colon refs. +// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and +// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777). +// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh +// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files +// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's +// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/ +// directory contains only the Cursor-specific managed hooks. // --------------------------------------------------------------------------- describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => { let tmpDir; @@ -242,15 +264,32 @@ describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (r ); }); - test('2c: Cursor install does not install a hooks/ directory (hooks are cursor-skipped)', () => { - // Cursor intentionally skips the hooks/dist copy step — verify this contract - // is still honored so we know the regression only concerns hook .js files - // for runtimes that DO install hooks (claude, qwen, hermes etc.). + test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => { + // Since issue #777, Cursor installs gsd-cursor-session-start.js and + // gsd-cursor-post-tool.js into /hooks/. These are Cursor-native + // hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.). + // Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks. const hooksDir = path.join(tmpDir, '.cursor', 'hooks'); - assert.strictEqual( + assert.ok( fs.existsSync(hooksDir), - false, - 'Cursor install must NOT create a hooks/ directory — Cursor skips the hooks install step', + 'Cursor install must create a hooks/ directory for its managed hook scripts (#777)', + ); + const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js']; + for (const hook of CLAUDE_BUNDLED_HOOKS) { + assert.strictEqual( + fs.existsSync(path.join(hooksDir, hook)), + false, + `Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`, + ); + } + // The two Cursor-specific managed hooks must be present. + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')), + 'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)', + ); + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')), + 'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)', ); }); }); @@ -262,6 +301,9 @@ describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', ( let snapshotBefore; before(() => { + // Ensure hooks/dist is built before snapshotting; it may be absent in CI + // scoped/windows jobs that don't pre-run build:hooks (#777 fix). + ensureHooksDist(); // Snapshot hooks/dist JS files before any install in this suite snapshotBefore = {}; if (fs.existsSync(HOOKS_DIST_DIR)) { diff --git a/tests/cursor-hooks.test.cjs b/tests/cursor-hooks.test.cjs new file mode 100644 index 000000000..fcbd0fc34 --- /dev/null +++ b/tests/cursor-hooks.test.cjs @@ -0,0 +1,462 @@ +/** + * Tests for Cursor hooks.json lifecycle hook registration (issue #777). + * + * Cursor v2.4+ supports a hooks.json system with events including sessionStart + * and postToolUse. GSD registers two managed command hooks so Cursor users get + * baseline parity with Claude Code and Gemini. + * + * Test plan: + * T1 reconcileCursorHooksJson — creates new hooks.json with both events + * T2 reconcileCursorHooksJson — idempotent (re-run writes same content) + * T3 reconcileCursorHooksJson — preserves user-owned entries in sessionStart + * T4 reconcileCursorHooksJson — preserves user-owned entries in postToolUse + * T5 reconcileCursorHooksJson — remove-only (managedEntries=null) strips managed, keeps user + * T6 reconcileCursorHooksJson — handles nested { version, hooks: {...} } shape + * T7 reconcileCursorHooksJson — handles flat (no version) shape + * T8 reconcileCursorHooksJson — corrupted JSON throws descriptive error + * T9 isManagedCursorHookEntry — returns true for GSD-marked entries + * T10 isManagedCursorHookEntry — returns false for user entries + * T11 buildCursorHookEntry — emits correct shape with marker + * T12 removeCursorHooksJson — removes hooks.json when it becomes empty + * T13 removeCursorHooksJson — preserves file when user entries remain + * T14 runtime-config-adapter — cursor now has 'cursor-hooks-json' surface + * T15 INSTALL_SURFACES — 'cursor-hooks-json' in the valid surfaces list + * T16 Hook scripts exist in hooks/ + * T17 Hook script content — sessionStart script emits JSON with additional_context + * T18 Hook script content — postToolUse script emits JSON {} for non-write tools + * T19 GSD_CURSOR_HOOK_MARKER constant is exported + * T20 GSD_CURSOR_SESSION_HOOK_SCRIPT / GSD_CURSOR_POST_TOOL_HOOK_SCRIPT constants + */ + +// allow-test-rule: source-text-is-the-product +// Hook script text IS what Cursor loads. Testing script content tests the deployed contract. + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = 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 { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + reconcileCursorHooksJson, + isManagedCursorHookEntry, + buildCursorHookEntry, + removeCursorHooksJson, + GSD_CURSOR_HOOK_MARKER, + GSD_CURSOR_SESSION_HOOK_SCRIPT, + GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, +} = require('../bin/install.js'); + +const { + resolveRuntimeConfigIntent, + INSTALL_SURFACES, +} = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeHooksJson(obj) { + return JSON.stringify(obj, null, 2) + '\n'; +} + +function readHooksJson(dir) { + const p = path.join(dir, 'hooks.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function managedEntry(command) { + return { type: 'command', command, [GSD_CURSOR_HOOK_MARKER]: true }; +} + +function userEntry(command) { + return { type: 'command', command }; +} + +// --------------------------------------------------------------------------- +// T1: Creates new hooks.json with both events +// --------------------------------------------------------------------------- +describe('reconcileCursorHooksJson', () => { + test('T1: creates new hooks.json with sessionStart and postToolUse', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + const result = reconcileCursorHooksJson(hooksJsonPath, { + sessionStart: managedEntry('/node /path/gsd-cursor-session-start.js'), + postToolUse: managedEntry('/node /path/gsd-cursor-post-tool.js'), + }); + + assert.equal(result.wrote, true); + assert.equal(result.changed, true); + + const parsed = readHooksJson(dir); + assert.ok(parsed, 'hooks.json must exist'); + + // Cursor requires the canonical nested { "version": 1, "hooks": { ... } } shape. + assert.equal(parsed.version, 1, 'hooks.json must have version: 1'); + assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'hooks.json must have top-level hooks object'); + const hookTable = parsed.hooks; + assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must be an array'); + assert.ok(Array.isArray(hookTable.postToolUse), 'postToolUse must be an array'); + assert.equal(hookTable.sessionStart.length, 1); + assert.equal(hookTable.postToolUse.length, 1); + assert.equal(hookTable.sessionStart[0][GSD_CURSOR_HOOK_MARKER], true); + assert.equal(hookTable.postToolUse[0][GSD_CURSOR_HOOK_MARKER], true); + }); + + // --------------------------------------------------------------------------- + // T2: Idempotent + // --------------------------------------------------------------------------- + test('T2: idempotent — second run produces same content (no write)', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + const entries = { + sessionStart: managedEntry('/node /gsd-cursor-session-start.js'), + postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'), + }; + + reconcileCursorHooksJson(hooksJsonPath, entries); + const result2 = reconcileCursorHooksJson(hooksJsonPath, entries); + + assert.equal(result2.wrote, false, 'second run must not write (idempotent)'); + assert.equal(result2.changed, false, 'content must not change on second run'); + }); + + // --------------------------------------------------------------------------- + // T3: Preserves user-owned entries in sessionStart + // --------------------------------------------------------------------------- + test('T3: preserves user-owned entries in sessionStart', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + // Pre-populate with user-owned entry. + fs.writeFileSync(hooksJsonPath, makeHooksJson({ + version: 1, + hooks: { + sessionStart: [userEntry('/my-team/hook.sh')], + }, + })); + + reconcileCursorHooksJson(hooksJsonPath, { + sessionStart: managedEntry('/node /gsd-cursor-session-start.js'), + postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'), + }); + + const parsed = readHooksJson(dir); + const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed; + assert.ok(Array.isArray(hookTable.sessionStart)); + // Both user and GSD entries must survive. + assert.equal(hookTable.sessionStart.length, 2, 'user + GSD entry must coexist'); + const userStays = hookTable.sessionStart.some((e) => e.command === '/my-team/hook.sh'); + const gsdAdded = hookTable.sessionStart.some((e) => e[GSD_CURSOR_HOOK_MARKER]); + assert.ok(userStays, 'user-owned entry must be preserved'); + assert.ok(gsdAdded, 'GSD managed entry must be present'); + }); + + // --------------------------------------------------------------------------- + // T4: Preserves user-owned entries in postToolUse + // --------------------------------------------------------------------------- + test('T4: preserves user-owned entries in postToolUse', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, makeHooksJson({ + hooks: { + postToolUse: [userEntry('/user/post-tool.sh')], + }, + })); + + reconcileCursorHooksJson(hooksJsonPath, { + sessionStart: managedEntry('/node /gsd-cursor-session-start.js'), + postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'), + }); + + const parsed = readHooksJson(dir); + const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed; + assert.ok(Array.isArray(hookTable.postToolUse)); + assert.equal(hookTable.postToolUse.length, 2); + assert.ok(hookTable.postToolUse.some((e) => e.command === '/user/post-tool.sh')); + }); + + // --------------------------------------------------------------------------- + // T5: Remove-only (managedEntries=null) strips managed, keeps user entries + // --------------------------------------------------------------------------- + test('T5: remove-only (null managedEntries) strips GSD entries, preserves user', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, makeHooksJson({ + version: 1, + hooks: { + sessionStart: [ + userEntry('/user/session.sh'), + managedEntry('/node /gsd-cursor-session-start.js'), + ], + postToolUse: [ + managedEntry('/node /gsd-cursor-post-tool.js'), + ], + }, + })); + + reconcileCursorHooksJson(hooksJsonPath, null); + + const parsed = readHooksJson(dir); + const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed; + // sessionStart user entry survives; postToolUse key should be absent. + assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must remain (user entry)'); + assert.equal(hookTable.sessionStart.length, 1, 'only user entry remains'); + assert.equal(hookTable.sessionStart[0].command, '/user/session.sh'); + assert.equal(hookTable.postToolUse, undefined, 'postToolUse key must be removed (was GSD-only)'); + }); + + // --------------------------------------------------------------------------- + // T6: Handles nested { version, hooks: {...} } shape + // --------------------------------------------------------------------------- + test('T6: handles nested { version, hooks } shape', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, makeHooksJson({ version: 1, hooks: {} })); + + reconcileCursorHooksJson(hooksJsonPath, { + sessionStart: managedEntry('/node /gsd-cursor-session-start.js'), + }); + + const parsed = readHooksJson(dir); + assert.ok(parsed.hooks, 'top-level hooks key must be preserved'); + assert.equal(parsed.version, 1, 'version must be preserved'); + assert.ok(Array.isArray(parsed.hooks.sessionStart)); + }); + + // --------------------------------------------------------------------------- + // T7: Handles flat (no version) shape + // --------------------------------------------------------------------------- + test('T7: handles flat shape (no wrapper object)', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, makeHooksJson({ sessionStart: [] })); + + reconcileCursorHooksJson(hooksJsonPath, { + sessionStart: managedEntry('/node /gsd-cursor-session-start.js'), + }); + + const parsed = readHooksJson(dir); + // Flat input is migrated to nested canonical shape: { version: 1, hooks: { ... } }. + assert.equal(parsed.version, 1, 'migrated flat shape must get version: 1'); + assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'migrated shape must have hooks object'); + assert.ok(Array.isArray(parsed.hooks.sessionStart), 'sessionStart must be in hooks object after migration'); + }); + + // --------------------------------------------------------------------------- + // T8: Corrupted JSON throws descriptive error + // --------------------------------------------------------------------------- + test('T8: throws descriptive error for corrupted hooks.json', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, '{ not valid json }'); + + assert.throws( + () => reconcileCursorHooksJson(hooksJsonPath, { sessionStart: managedEntry('/cmd') }), + (err) => { + assert.match(err.message, /Cursor hooks\.json parse failed/i); + return true; + } + ); + }); +}); + +// --------------------------------------------------------------------------- +// T9-T11: Entry helpers +// --------------------------------------------------------------------------- +describe('isManagedCursorHookEntry / buildCursorHookEntry', () => { + test('T9: isManagedCursorHookEntry returns true for GSD-marked entry', () => { + const entry = { type: 'command', command: '/x', [GSD_CURSOR_HOOK_MARKER]: true }; + assert.equal(isManagedCursorHookEntry(entry), true); + }); + + test('T10: isManagedCursorHookEntry returns false for user-owned entry', () => { + const entry = { type: 'command', command: '/user/hook.sh' }; + assert.equal(isManagedCursorHookEntry(entry), false); + assert.equal(isManagedCursorHookEntry(null), false); + assert.equal(isManagedCursorHookEntry({}), false); + }); + + test('T11: buildCursorHookEntry emits correct shape', () => { + const entry = buildCursorHookEntry('/usr/local/bin/node /path/to/hook.js'); + assert.equal(entry.type, 'command'); + assert.equal(entry[GSD_CURSOR_HOOK_MARKER], true); + assert.ok(typeof entry.command === 'string'); + // Forward slashes only. + assert.ok(!entry.command.includes('\\'), 'command must use forward slashes'); + }); +}); + +// --------------------------------------------------------------------------- +// T12-T13: removeCursorHooksJson +// --------------------------------------------------------------------------- +describe('removeCursorHooksJson', () => { + test('T12: removes hooks.json when it becomes empty after GSD removal', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + // GSD-only file. + fs.writeFileSync(hooksJsonPath, makeHooksJson({ + version: 1, + hooks: { + sessionStart: [managedEntry('/node /gsd-cursor-session-start.js')], + postToolUse: [managedEntry('/node /gsd-cursor-post-tool.js')], + }, + })); + + const result = removeCursorHooksJson(dir); + assert.equal(result.changed, true); + // File should be removed (was GSD-only). + assert.equal(fs.existsSync(hooksJsonPath), false, 'empty hooks.json must be removed'); + }); + + test('T13: preserves hooks.json when user entries remain', (t) => { + const dir = createTempDir(); + t.after(() => cleanup(dir)); + + const hooksJsonPath = path.join(dir, 'hooks.json'); + fs.writeFileSync(hooksJsonPath, makeHooksJson({ + version: 1, + hooks: { + sessionStart: [ + managedEntry('/node /gsd-cursor-session-start.js'), + userEntry('/user/session.sh'), + ], + }, + })); + + removeCursorHooksJson(dir); + + assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must remain (user entries present)'); + const parsed = readHooksJson(dir); + const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed; + assert.equal(hookTable.sessionStart.length, 1); + assert.equal(hookTable.sessionStart[0].command, '/user/session.sh'); + }); +}); + +// --------------------------------------------------------------------------- +// T14: runtime-config-adapter — cursor now has 'cursor-hooks-json' surface +// --------------------------------------------------------------------------- +test('T14: cursor runtime has installSurface cursor-hooks-json', () => { + const intent = resolveRuntimeConfigIntent('cursor'); + assert.equal(intent.installSurface, 'cursor-hooks-json'); + assert.equal(intent.writesSharedSettings, false); + assert.equal(intent.finishPermissionWriter, null); +}); + +// --------------------------------------------------------------------------- +// T15: INSTALL_SURFACES includes 'cursor-hooks-json' +// --------------------------------------------------------------------------- +test('T15: INSTALL_SURFACES includes cursor-hooks-json', () => { + assert.ok( + INSTALL_SURFACES.includes('cursor-hooks-json'), + "'cursor-hooks-json' must be in INSTALL_SURFACES" + ); +}); + +// --------------------------------------------------------------------------- +// T16: Hook script files exist in hooks/ +// --------------------------------------------------------------------------- +test('T16: Cursor hook script files exist in hooks/', () => { + const hooksDir = path.join(__dirname, '..', 'hooks'); + const sessionStart = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT); + const postTool = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT); + assert.ok(fs.existsSync(sessionStart), `${GSD_CURSOR_SESSION_HOOK_SCRIPT} must exist in hooks/`); + assert.ok(fs.existsSync(postTool), `${GSD_CURSOR_POST_TOOL_HOOK_SCRIPT} must exist in hooks/`); +}); + +// --------------------------------------------------------------------------- +// T17: sessionStart script emits JSON with additional_context on stdin close +// --------------------------------------------------------------------------- +test('T17: gsd-cursor-session-start.js emits JSON with additional_context', (t, done) => { + const hooksDir = path.join(__dirname, '..', 'hooks'); + const scriptPath = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT); + const { execFile } = require('child_process'); + + const input = JSON.stringify({ session_id: 'test-123', composer_mode: 'agent' }); + const child = execFile(process.execPath, [scriptPath], { + timeout: 10000, + cwd: os.tmpdir(), // no .planning/ dir here — should get MSG_ABSENT + }, (err, stdout) => { + if (err && !stdout) { done(err); return; } + let parsed; + try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; } + assert.ok('additional_context' in parsed, 'output must have additional_context field'); + assert.ok(typeof parsed.additional_context === 'string', 'additional_context must be a string'); + assert.ok(parsed.additional_context.length > 0, 'additional_context must not be empty'); + done(); + }); + child.stdin.write(input); + child.stdin.end(); +}); + +// --------------------------------------------------------------------------- +// T18: postToolUse script emits {} for non-write tools +// --------------------------------------------------------------------------- +test('T18: gsd-cursor-post-tool.js emits {} for non-write tool names', (t, done) => { + const hooksDir = path.join(__dirname, '..', 'hooks'); + const scriptPath = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT); + const { execFile } = require('child_process'); + + const input = JSON.stringify({ + tool_name: 'Read', + tool_input: { path: '/some/file.js' }, + tool_output: 'contents', + duration: 42, + }); + + const child = execFile(process.execPath, [scriptPath], { + timeout: 10000, + cwd: os.tmpdir(), + }, (err, stdout) => { + if (err && !stdout) { done(err); return; } + let parsed; + try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; } + // Non-write tool → empty response (no additional_context). + assert.ok(typeof parsed === 'object', 'output must be an object'); + // additional_context should be absent for non-write, non-planning tool. + assert.equal(parsed.additional_context, undefined, 'no additional_context for non-write tool'); + done(); + }); + child.stdin.write(input); + child.stdin.end(); +}); + +// --------------------------------------------------------------------------- +// T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string +// --------------------------------------------------------------------------- +test('T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string', () => { + assert.equal(typeof GSD_CURSOR_HOOK_MARKER, 'string'); + assert.ok(GSD_CURSOR_HOOK_MARKER.length > 0); +}); + +// --------------------------------------------------------------------------- +// T20: Script name constants are exported and correct +// --------------------------------------------------------------------------- +test('T20: GSD_CURSOR_SESSION_HOOK_SCRIPT and GSD_CURSOR_POST_TOOL_HOOK_SCRIPT are exported', () => { + assert.equal(GSD_CURSOR_SESSION_HOOK_SCRIPT, 'gsd-cursor-session-start.js'); + assert.equal(GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, 'gsd-cursor-post-tool.js'); +}); diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index a288a6ccf..10ed2dc68 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -31,7 +31,7 @@ const EXPECTED_TABLE = [ { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, ]; @@ -160,8 +160,8 @@ describe('installSurface correctness', () => { assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules'); }); - test('cursor -> "profile-marker-only"', () => { - assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only'); + test('cursor -> "cursor-hooks-json"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'cursor-hooks-json'); }); test('windsurf -> "profile-marker-only"', () => { @@ -236,10 +236,11 @@ describe('INSTALL_SURFACES export', () => { 'codex-toml', 'copilot-instructions', 'cline-rules', + 'cursor-hooks-json', 'profile-marker-only', ]); - test('INSTALL_SURFACES contains exactly the 5 surface strings', () => { + test('INSTALL_SURFACES contains exactly the 6 surface strings', () => { assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES); }); });