diff --git a/.changeset/770-claude-lifecycle-hooks.md b/.changeset/770-claude-lifecycle-hooks.md new file mode 100644 index 000000000..c69857eba --- /dev/null +++ b/.changeset/770-claude-lifecycle-hooks.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 821 +--- + +Added: register newly-available Claude Code lifecycle hooks — SubagentStop, Stop, PreCompact (all wired to gsd-context-monitor for context-headroom warnings), and FileChanged (matcher: `config.json`, wired to new gsd-config-reload.js hook that hot-reloads `.planning/config.json` context mid-session). Also updates hooks/hooks.json (plugin manifest) and managed-hooks-registry for drift-guard coverage (#770). diff --git a/CONTEXT.md b/CONTEXT.md index b6641f085..871b64625 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -125,7 +125,7 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl 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. +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). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). 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. ### Gemini Extension Package The repo-root `gemini-extension.json` + `GEMINI.md` pair that projects gsd-core onto the Gemini CLI extension contract, enabling one-step lifecycle management via `gemini extensions install ` / `update` / `remove` (and `gemini extensions link ` for dev). The Gemini-CLI sibling of the Claude Code Plugin Manifest Module — same additive idea, different runtime package format. Defined mapping: `name`=`binName` (`gsd-core`; lowercase-dashes per Gemini's extension naming rule), `version` tracks `package.json` (Gemini's `gemini extensions update` keys off the manifest `version` field), `description` (required by the manifest schema), `contextFileName`=`GEMINI.md` (the extension's context payload, loaded into every Gemini session). Intentionally minimal: no `mcpServers` (gsd-core ships no MCP server). Slash-command / agent / hook projection into the extension (which would require committing the Gemini-format TOML/agent conversions the Installer Module produces at `--gemini` install time) is deferred — the manual `npx gsd-core --gemini` path remains the way to install the `/gsd:*` commands, and is unchanged (additive, no breaking change). Conformance is guarded by the in-repo drift test `tests/issue-775-gemini-extension.test.cjs` (manifest validity, `version`↔`package.json` parity, `contextFileName` existence, `files[]` publication). _Avoid_: "the Gemini plugin" (Gemini calls them extensions, not plugins). See #775, ADR-766, Claude Code Plugin Manifest Module, and Runtime Artifact Layout Module. diff --git a/bin/install.js b/bin/install.js index a3c0408eb..0ae9bdcf6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8058,6 +8058,7 @@ const GSD_UNINSTALL_HOOKS = [ 'gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.cmd', + 'gsd-config-reload.js', 'gsd-context-monitor.js', 'gsd-cursor-session-start.js', 'gsd-cursor-post-tool.js', @@ -8523,10 +8524,12 @@ function uninstall(isGlobal, runtime = 'claude') { // Remove GSD hooks from settings — per-hook granularity to preserve // user hooks that share an entry with a GSD hook (#1755 followup). // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop, - // PreCompact) and the 3 Gemini-only events added in #776 (BeforeAgent, - // AfterAgent, BeforeModel) — safe to iterate for all runtimes; non-Qwen - // and non-Gemini installs simply find no entries and skip. - for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { + // PreCompact, also registered for Claude in #770), the 3 Gemini-only + // events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the + // Claude-only FileChanged event added in #770 — safe to iterate for all + // runtimes; installs that don't register these events simply find no + // entries and skip. + for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'FileChanged']) { if (settings.hooks && settings.hooks[eventName]) { const before = JSON.stringify(settings.hooks[eventName]); settings.hooks[eventName] = settings.hooks[eventName] @@ -11118,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) { const readInjectionScannerCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts) : localCmd('gsd-read-injection-scanner.js'); + const configReloadCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-config-reload.js', hookOpts) + : localCmd('gsd-config-reload.js'); // #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook // command is null too. Emit one warning here so the operator sees the cause @@ -11472,36 +11478,33 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); } - // ── Qwen-only extended hook events (#788) ──────────────────────────────── - // Qwen Code exposes 15 hook events — a superset of Claude Code. Three - // additional events are registered for Qwen installs: + // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ── + // Claude Code (since #770) and Qwen Code (since #788) both support these + // three lifecycle events. Wire gsd-context-monitor so agents get context- + // headroom warnings at subagent completion, model stop, and pre-compaction + // (the most critical moment to surface headroom info). + // // SubagentStop — subagent lifecycle completion (context headroom tracking) // Stop — model stop / final-response moment (context headroom) // PreCompact — fires before conversation compaction (most critical // moment to surface context headroom warnings) // - // Wire gsd-context-monitor to all three — the same hook already used for - // PostToolUse — so no new hook files are needed. - // // Note: UserPromptSubmit is NOT wired here. That event carries the raw // user prompt text, not a tool invocation, so gsd-prompt-guard (which // exits unless tool_name is Write/Edit) would be a silent no-op. A // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. - // - // Guard: isQwen is defined at the top of install() (line ~8254). - if (isQwen) { - // SubagentStop, Stop, PreCompact — route through the context monitor so - // agents get context-headroom warnings at subagent completion, model stop, - // and pre-compaction (the most critical moment to surface headroom info). - for (const qwenEvent of ['SubagentStop', 'Stop', 'PreCompact']) { - if (!settings.hooks[qwenEvent]) { - settings.hooks[qwenEvent] = []; + if (isQwen || runtime === 'claude') { + const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code'; + // SubagentStop, Stop, PreCompact — route through the context monitor. + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + if (!settings.hooks[event]) { + settings.hooks[event] = []; } - const alreadyHasContextMonitor = settings.hooks[qwenEvent].some(entry => + const alreadyHasContextMonitor = settings.hooks[event].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')) ); if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { - settings.hooks[qwenEvent].push({ + settings.hooks[event].push({ hooks: [ { type: 'command', @@ -11510,13 +11513,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { } ] }); - console.log(` ${green}✓${reset} Configured ${qwenEvent} context monitor hook (Qwen Code)`); + console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`); } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { - console.warn(` ${yellow}⚠${reset} Skipped ${qwenEvent} hook — gsd-context-monitor.js not found at target`); + console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`); } } } - // ── end Qwen-only extended hook events ──────────────────────────────────── + // ── end SubagentStop / Stop / PreCompact events ──────────────────────────── // ── Gemini-only extended hook events (#776) ─────────────────────────────── // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that @@ -11566,6 +11569,45 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } // ── end Gemini-only extended hook events ────────────────────────────────── + + // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─ + // Claude Code fires FileChanged when a watched file changes on disk. Wire + // gsd-config-reload.js to reload the gsd config context whenever the user + // edits .planning/config.json mid-session, eliminating the need to restart. + // + // The matcher "config.json" watches for changes to any file named config.json + // (Claude Code matches by filename, not full path). The hook exits silently + // when the changed file is not the gsd config. + // + // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet + // verified; extend in a follow-on if empirically confirmed. + if (runtime === 'claude') { + if (!settings.hooks.FileChanged) { + settings.hooks.FileChanged = []; + } + const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js'); + const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload')) + ); + if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) { + settings.hooks.FileChanged.push({ + matcher: 'config.json', + hooks: [ + { + type: 'command', + command: configReloadCommand, + timeout: 8 + } + ] + }); + console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`); + } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) { + console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`); + } else if (!alreadyHasConfigReload && !configReloadCommand) { + console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`); + } + } + // ── end FileChanged hook ──────────────────────────────────────────────────── } // ── Gemini hooksConfig.enabled check (#776) ─────────────────────────────── diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d3def7871..5d7854d75 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -359,6 +359,7 @@ "hooks": [ "gsd-check-update-worker.js", "gsd-check-update.js", + "gsd-config-reload.js", "gsd-context-monitor.js", "gsd-cursor-post-tool.js", "gsd-cursor-session-start.js", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index d7bc3964b..ebce42500 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -471,7 +471,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. --- -## Hooks (16 shipped) +## Hooks (17 shipped) Full listing: `hooks/`. @@ -489,6 +489,7 @@ Full listing: `hooks/`. | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | | `gsd-read-injection-scanner.js` | `PostToolUse` | Scans tool Read results for prompt-injection patterns (v1.36+, PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | Hard-blocks Edit/Write/MultiEdit with absolute paths outside the worktree root (PR #579, #260) | +| `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) | | `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | | `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 595d45f0b..b2f53f5d5 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -42,6 +42,22 @@ Skills land in `~/.claude/`. Commands appear as `/gsd-*` slash commands in your CLAUDE_CONFIG_DIR=~/.claude-alt npx @opengsd/gsd-core@latest --claude --global ``` +**Hook coverage** + +GSD registers the following Claude Code hook events automatically on install: + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | +| `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | +| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | +| `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction | +| `FileChanged` (matcher: `config.json`) | `gsd-config-reload.js` | Hot-reloads `.planning/config.json` context mid-session when you edit your GSD config — no session restart required | + +The `FileChanged` hook is always-on and a no-op when `.planning/config.json` does not exist in the project. Editing that file while a session is running injects an `additionalContext` summary of the new configuration so the agent picks up model overrides, workflow toggles, and hook settings immediately. + --- ### Claude Code — native plugin install diff --git a/hooks/gsd-config-reload.js b/hooks/gsd-config-reload.js new file mode 100644 index 000000000..184bebf73 --- /dev/null +++ b/hooks/gsd-config-reload.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-config-reload.js — FileChanged hook: hot-reload GSD config context +// Fires when .planning/config.json is modified, created, or deleted. +// +// When the user edits .planning/config.json mid-session, this hook reads the +// updated config and injects a summary as additionalContext so the agent knows +// the new configuration without requiring a session restart. +// +// Input (from Claude Code): +// { session_id, cwd, hook_event_name: "FileChanged", +// file_path: "/abs/path/.planning/config.json", event: "change"|"add"|"unlink" } +// +// Output: +// { hookSpecificOutput: { hookEventName: "FileChanged", additionalContext: "..." } } +// or exits 0 silently (if config absent, unreadable, or event is "unlink"). +// +// Enabled for all Claude Code installs. This hook is always-on — it is a +// no-op when .planning/config.json is absent (ENOENT → exit 0). + +const fs = require('fs'); +const path = require('path'); + +let input = ''; +// Timeout guard: if stdin does not close within 8s exit silently rather than +// hanging until Claude Code kills the process and reports "hook error". +const stdinTimeout = setTimeout(() => process.exit(0), 8000); +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => (input += chunk)); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const data = JSON.parse(input); + const event = data.event; // "change" | "add" | "unlink" + const filePath = data.file_path || ''; + const cwd = data.cwd || process.cwd(); + + // Only handle the GSD planning config — verify both basename and that the + // resolved path is .planning/config.json relative to cwd. The hook + // matcher ('config.json') fires on any watched config.json; this guard + // ensures an unrelated config.json in node_modules/ or elsewhere does not + // inject spurious additionalContext. + const basename = path.basename(filePath); + if (basename !== 'config.json') { + process.exit(0); + } + const expectedPath = path.resolve(cwd, '.planning', 'config.json'); + if (path.resolve(filePath) !== expectedPath) { + process.exit(0); + } + + // On unlink (deletion) emit a brief notice and exit + if (event === 'unlink') { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'FileChanged', + additionalContext: + 'GSD config (.planning/config.json) was deleted. ' + + 'Falling back to built-in defaults for this session.', + }, + })); + process.exit(0); + } + + // Read the updated config file + let config; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + config = JSON.parse(raw); + } catch (e) { + if (e && e.code === 'ENOENT') process.exit(0); + // Malformed JSON — inform the agent without crashing + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'FileChanged', + additionalContext: + 'GSD config (.planning/config.json) was modified but could not be parsed. ' + + 'Check the file for JSON syntax errors.', + }, + })); + process.exit(0); + } + + // Build a concise summary of key config fields the agent cares about + const lines = ['GSD config reloaded (.planning/config.json updated):']; + + if (config.runtime) lines.push(` runtime: ${config.runtime}`); + if (config.mode) lines.push(` mode: ${config.mode}`); + + // hooks section (opt-in toggles agents act on) + if (config.hooks && typeof config.hooks === 'object') { + const hookKeys = Object.entries(config.hooks) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + if (hookKeys) lines.push(` hooks: { ${hookKeys} }`); + } + + // workflow section (key toggles) + if (config.workflow && typeof config.workflow === 'object') { + const wfKeys = Object.entries(config.workflow) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + if (wfKeys) lines.push(` workflow: { ${wfKeys} }`); + } + + // model overrides (agents use these) + if (config.models && typeof config.models === 'object') { + const modelKeys = Object.entries(config.models) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + if (modelKeys) lines.push(` models: { ${modelKeys} }`); + } + + if (lines.length === 1) { + // No notable fields — still confirm the reload happened + lines.push(' (no notable keys changed)'); + } + + const additionalContext = lines.join('\n'); + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'FileChanged', + additionalContext, + }, + })); + } catch (e) { + // Silent fail — never block the session on a config reload error + process.exit(0); + } +}); diff --git a/hooks/hooks.json b/hooks/hooks.json index 9d99579dc..09c8dc0aa 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -35,6 +35,35 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js\"", "timeout": 5 } ] } + ], + "SubagentStop": [ + { + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 } + ] + } + ], + "FileChanged": [ + { + "matcher": "config.json", + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-config-reload.js\"", "timeout": 8 } + ] + } ] } } diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 77aa4982b..12715ed38 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -18,6 +18,7 @@ const MANAGED_HOOKS = [ 'gsd-check-update-worker.js', 'gsd-check-update.js', + 'gsd-config-reload.js', 'gsd-context-monitor.js', 'gsd-cursor-post-tool.js', 'gsd-cursor-session-start.js', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index c566a18cd..121a73afb 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -33,6 +33,10 @@ const HOOKS_TO_COPY = [ // Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor 'gsd-cursor-session-start.js', 'gsd-cursor-post-tool.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. + 'gsd-config-reload.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 3e41512fa..81e5b8d59 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -29,6 +29,7 @@ const VALID_CHOICES: ReadonlyArray = ['keep', 'remove']; export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set([ 'hooks/gsd-check-update-worker.js', 'hooks/gsd-check-update.js', + 'hooks/gsd-config-reload.js', 'hooks/gsd-context-monitor.js', 'hooks/gsd-cursor-post-tool.js', 'hooks/gsd-cursor-session-start.js', diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 309872381..4de9d9639 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -145,6 +145,7 @@ export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record> = { 'settings-json': new Set([ 'gsd-check-update.js', + 'gsd-config-reload.js', 'gsd-statusline.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', @@ -161,6 +162,7 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record> = { const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record> = { 'settings-json': new Set([ 'gsd-check-update.js', + 'gsd-config-reload.js', 'gsd-statusline.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 1180f5222..7457767e2 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -39,6 +39,37 @@ async function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { return last; } +/** + * Seed `.planning/config.json` for a test and guarantee it lands on disk + * before the test body runs. + * + * `config-ensure-section` is invoked through a spawned `gsd-tools.cjs` child. + * On the scoped CI lane (`--test-concurrency=4`, config.test.cjs scheduled + * alongside the heavy install/tarball suites) that child can be transiently + * killed under resource pressure — surfacing as a non-zero exit with empty + * stderr (an OS-level kill, not a gsd-tools application error; see the + * `runGsdTools` catch). A bare `runGsdTools('config-ensure-section')` in + * `beforeEach` swallows that failure, leaving config.json absent so the first + * subtest's `readConfig()` throws a confusing ENOENT (#770 scoped-lane flake). + * + * This retries on ANY failure or missing file (not just the EPERM/EBUSY class + * `runConfigEnsureSectionWithRetry` covers) and throws a clear diagnostic if it + * still cannot create the file, so setup is deterministic under load. + */ +async function ensureConfigReady(tmpDir, attempts = 5) { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + let last; + for (let i = 0; i < attempts; i += 1) { + last = runGsdTools('config-ensure-section', tmpDir); + if (last.success && fs.existsSync(configPath)) return last; + if (i < attempts - 1) await delay(150 * (i + 1)); + } + throw new Error( + `config-ensure-section failed to create ${configPath} after ${attempts} attempts: ` + + `${(last && last.error) || 'unknown error'}`, + ); +} + // ─── config-ensure-section ─────────────────────────────────────────────────── describe('config-ensure-section command', () => { @@ -1106,10 +1137,12 @@ describe('config-path command (#2282)', () => { describe('config-set prototype-pollution guard (#663)', () => { let tmpDir; - beforeEach(() => { + beforeEach(async () => { tmpDir = createTempProject(); - // Initialise config so there is a config.json to write to. - runGsdTools('config-ensure-section', tmpDir); + // Initialise config so there is a config.json to write to. Retry + assert + // so a transient config-ensure-section child failure under scoped-lane load + // cannot leave config.json absent (#770). + await ensureConfigReady(tmpDir); }); afterEach(() => { @@ -1163,10 +1196,12 @@ describe('config-set prototype-pollution guard (#663)', () => { describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => { let tmpDir; - beforeEach(() => { + beforeEach(async () => { tmpDir = createTempProject(); - // Initialise config so there is a config.json to write to. - runGsdTools('config-ensure-section', tmpDir); + // Initialise config so there is a config.json to write to. Retry + assert + // so a transient config-ensure-section child failure under scoped-lane load + // cannot leave config.json absent (#770). + await ensureConfigReady(tmpDir); }); afterEach(() => { diff --git a/tests/enh-770-claude-hook-events.test.cjs b/tests/enh-770-claude-hook-events.test.cjs new file mode 100644 index 000000000..6352bfa98 --- /dev/null +++ b/tests/enh-770-claude-hook-events.test.cjs @@ -0,0 +1,379 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #770: Register Claude Code lifecycle hooks (SubagentStop / Stop / + * PreCompact / FileChanged). + * + * Claude Code now supports the same SubagentStop, Stop, and PreCompact events + * that were wired for Qwen Code in #788. This suite asserts: + * + * 1. Claude Code installs register SubagentStop, Stop, and PreCompact, each + * wired to gsd-context-monitor.js (same as Qwen). + * 2. Claude Code installs register a FileChanged hook for .planning/config.json + * wired to gsd-config-reload.js (new hook; hot-reloads gsd config). + * 3. All four registrations are idempotent (reinstall does not duplicate). + * 4. Uninstall removes all four event registrations. + * 5. The gsd-config-reload.js hook script exists in hooks/ and has the + * expected structure (reads on stdin, emits additionalContext or exits 0). + * 6. The hooks/hooks.json plugin manifest includes the new events. + * + * Source: https://code.claude.com/docs/en/hooks + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** Extract all matchers registered under `eventName` from settings. */ +function matchersForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName] + .map(entry => entry && entry.matcher) + .filter(Boolean); +} + +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +// Hooks the installer existsSync-checks before registering; must be present +// in targetDir/hooks/ so the registration guards pass. +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', + 'gsd-config-reload.js', +]; + +/** + * Pre-populate targetDir/hooks/ with stub hook files so the installer's + * fs.existsSync guards pass even when hooks/dist/ is absent (e.g. CI without + * a build step). Each test suite passes its own per-test tmpDir/.claude path + * so stubs are isolated to that test's temp directory — no shared filesystem + * state, no cross-test races. + * + * When hooks/dist/ DOES exist (local dev with npm run build:hooks), the + * installer copies real files over these stubs during install() — that is + * fine and correct. + */ +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Claude — new context monitor events are registered ────────────── + +describe('enh-770: Claude install registers SubagentStop / Stop / PreCompact context hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-claude-ctx-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Claude install must return a non-null settings object'); + }); + + test('SubagentStop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('Stop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('PreCompact event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Claude — FileChanged hook for config hot-reload ───────────────── + +describe('enh-770: Claude install registers FileChanged hook for .planning/config.json', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-filechanged-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('FileChanged event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.ok(cmds.length > 0, + `Expected FileChanged hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('FileChanged hook uses gsd-config-reload', () => { + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.ok( + cmds.some(c => c.includes('gsd-config-reload')), + `FileChanged should use gsd-config-reload; got commands: ${JSON.stringify(cmds)}` + ); + }); + + test('FileChanged hook has a matcher targeting .planning/config.json', () => { + const matchers = matchersForEvent(settings, 'FileChanged'); + assert.ok( + matchers.some(m => m && m.includes('config.json')), + `FileChanged matcher should target config.json; got matchers: ${JSON.stringify(matchers)}` + ); + }); +}); + +// ─── Suite 3: Idempotency ───────────────────────────────────────────────────── + +describe('enh-770: Claude install is idempotent for the new hook events', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate context monitor hooks', () => { + const result1 = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result1.settingsPath, result1.settings); + + process.chdir(tmpDir); + const result2 = install(false, 'claude', { installerMigrations: [] }); + const s2 = result2.settings; + + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); + + test('re-running after persisted first install does not duplicate FileChanged hook', () => { + const result1 = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result1.settingsPath, result1.settings); + + process.chdir(tmpDir); + const result2 = install(false, 'claude', { installerMigrations: [] }); + const s2 = result2.settings; + + const cmds = hooksForEvent(s2, 'FileChanged'); + assert.strictEqual(cmds.length, 1, + `FileChanged should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 4: Uninstall removes registrations ───────────────────────────────── + +describe('enh-770: Uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'claude', { installerMigrations: [] }); + const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 5: gsd-config-reload.js hook script exists and has correct shape ─── + +describe('enh-770: gsd-config-reload.js hook script', () => { + const reloadScript = path.join(__dirname, '..', 'hooks', 'gsd-config-reload.js'); + + test('gsd-config-reload.js exists in hooks/', () => { + assert.ok(fs.existsSync(reloadScript), + `gsd-config-reload.js must exist at ${reloadScript}`); + }); + + test('gsd-config-reload.js contains the gsd-hook-version stamp', () => { + // allow-test-rule: runtime-contract-is-the-product — the stamp template token + // IS the product surface that the installer must find and replace with the + // real version at copy time; asserting its presence is required. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('gsd-hook-version'), + 'gsd-config-reload.js must contain the gsd-hook-version stamp for installer stamping' + ); + }); + + test('gsd-config-reload.js reads from stdin and emits JSON output', () => { + // allow-test-rule: runtime-contract-is-the-product — the stdin-read and + // JSON-emit pattern IS the hook contract; asserting its presence is required. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('process.stdin') && content.includes('JSON.stringify'), + 'gsd-config-reload.js must read stdin and emit JSON output per hook protocol' + ); + }); + + test('gsd-config-reload.js targets the FileChanged hook event', () => { + // allow-test-rule: runtime-contract-is-the-product — the hookEventName is + // the protocol surface; asserting its presence verifies the contract. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('FileChanged'), + 'gsd-config-reload.js must reference FileChanged in its hookSpecificOutput' + ); + }); +}); + +// ─── Suite 6: hooks.json plugin manifest includes new events ────────────────── + +describe('enh-770: hooks/hooks.json plugin manifest includes new hook events', () => { + const hooksJsonPath = path.join(__dirname, '..', 'hooks', 'hooks.json'); + + test('hooks.json exists', () => { + assert.ok(fs.existsSync(hooksJsonPath), `hooks.json must exist at ${hooksJsonPath}`); + }); + + test('hooks.json contains SubagentStop event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.SubagentStop, + 'hooks.json must contain SubagentStop' + ); + }); + + test('hooks.json contains Stop event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.Stop, + 'hooks.json must contain Stop' + ); + }); + + test('hooks.json contains PreCompact event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.PreCompact, + 'hooks.json must contain PreCompact' + ); + }); + + test('hooks.json contains FileChanged event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.FileChanged, + 'hooks.json must contain FileChanged' + ); + }); +}); + +// ─── Suite 7: managed-hooks-registry includes gsd-config-reload.js ─────────── + +describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => { + test('MANAGED_HOOKS array includes gsd-config-reload.js', () => { + const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs'); + assert.ok( + MANAGED_HOOKS.includes('gsd-config-reload.js'), + `MANAGED_HOOKS must include gsd-config-reload.js; got: ${JSON.stringify(MANAGED_HOOKS)}` + ); + }); +}); diff --git a/tests/enh-788-qwen-hook-events.test.cjs b/tests/enh-788-qwen-hook-events.test.cjs index 39d2e0d05..c391a9b88 100644 --- a/tests/enh-788-qwen-hook-events.test.cjs +++ b/tests/enh-788-qwen-hook-events.test.cjs @@ -52,6 +52,7 @@ const STUB_HOOKS = [ 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-check-update.js', + 'gsd-config-reload.js', // Added in #770 ]; function stubHooksIntoTarget(targetDir) { @@ -148,11 +149,21 @@ describe('enh-788: Qwen install registers 3 new hook events', () => { ); } }); + + test('FileChanged is NOT registered for Qwen (Claude-only event)', () => { + // gsd-config-reload / FileChanged is a Claude Code-only registration. + // Qwen does not support the FileChanged hook event at all. + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.strictEqual(cmds.length, 0, + `FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`); + }); }); -// ─── Suite 2: Claude install does NOT get the new events ───────────────────── +// ─── Suite 2: Claude install DOES get the context events (since #770) ─────── +// Note: Prior to #770, these were Qwen-only events. #770 extended them to +// Claude Code. This suite is updated to match the new expected behavior. -describe('enh-788: Claude install does NOT register Qwen-only hook events', () => { +describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => { let tmpDir; let previousCwd; let settings; @@ -161,8 +172,9 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () = tmpDir = createTempDir('gsd-788-claude-'); previousCwd = process.cwd(); process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); - const result = install(false, 'claude'); + const result = install(false, 'claude', { installerMigrations: [] }); settings = result && result.settings; }); @@ -171,22 +183,22 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () = cleanup(tmpDir); }); - test('Claude install does not register SubagentStop', () => { + test('Claude install registers SubagentStop (since #770)', () => { const cmds = hooksForEvent(settings, 'SubagentStop'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`); + assert.ok(cmds.length > 0, + `Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`); }); - test('Claude install does not register Stop', () => { + test('Claude install registers Stop (since #770)', () => { const cmds = hooksForEvent(settings, 'Stop'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`); + assert.ok(cmds.length > 0, + `Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`); }); - test('Claude install does not register PreCompact', () => { + test('Claude install registers PreCompact (since #770)', () => { const cmds = hooksForEvent(settings, 'PreCompact'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`); + assert.ok(cmds.length > 0, + `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`); }); }); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 14f131fbd..93f1dc977 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -26,6 +26,7 @@ const EXPECTED_SH_HOOKS = [ const EXPECTED_ALL_HOOKS = [ 'gsd-check-update.js', + 'gsd-config-reload.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 870e4aa23..3854499bd 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -114,9 +114,15 @@ describe('B: hooks/hooks.json', () => { ); }); - test('every event name is one of: SessionStart, PreToolUse, PostToolUse', (t) => { + test('every event name is a known Claude Code lifecycle event', (t) => { if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; } - const validEvents = new Set(['SessionStart', 'PreToolUse', 'PostToolUse']); + // Complete set of Claude Code hook events as of #770 (SubagentStop, Stop, + // PreCompact, FileChanged added in #770; prior set was SessionStart, + // PreToolUse, PostToolUse from #766). + const validEvents = new Set([ + 'SessionStart', 'PreToolUse', 'PostToolUse', + 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', + ]); for (const eventName of Object.keys(hooksConfig.hooks)) { assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`); }