feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged) (#821)

* feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged)

Wire three new context-tracking events (SubagentStop, Stop, PreCompact) to
gsd-context-monitor so context-headroom warnings surface at model-stop and
subagent-finalisation moments — not just on PostToolUse.  Add a new
FileChanged hook (gsd-config-reload.js) that hot-reloads .planning/config.json
context mid-session when the user edits it, injecting a config summary as
hookSpecificOutput.additionalContext.  Updates plugin manifest hooks.json,
managed-hooks-registry, installer-migration-report allowlist, and
shell-command-projection cleanup tables.  Tests: 21 new assertions in
enh-770-claude-hook-events.test.cjs; enh-788 and issue-766 test suites updated.

Closes #770

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#770): document newly-registered Claude Code lifecycle hooks

Add a Hook coverage table to the Claude Code npm installer section of
docs/how-to/install-on-your-runtime.md describing SubagentStop, Stop,
PreCompact, and the new FileChanged (gsd-config-reload.js) hook that
hot-reloads .planning/config.json mid-session. Also fixes the changeset
frontmatter (adds type: Added + pr: 821) so docs-lint can consume the
fragment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): add gsd-config-reload.js to INVENTORY.md and regenerate manifest

The feat commit added hooks/gsd-config-reload.js but did not bump the
Hooks count in docs/INVENTORY.md (14→15) or add the new row, and did not
regenerate docs/INVENTORY-MANIFEST.json. Both inventory-counts and
inventory-manifest-sync tests failed across the full CI matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): make lifecycle-hook tests deterministic on scoped runner

Replace the shared hooks/dist/ ensemble setup (ensureHooksDist /
teardownHooksDist) in the Claude hook tests with per-test isolation:
pre-populate each test's own tmpDir/.claude/hooks/ with stub files and
pass installerMigrations:[] to install() so the first-time-baseline
migration does not remove the stubs before the copy step can run.

Root cause: hooks/dist/ is gitignored and absent on a fresh npm ci.
ensureHooksDist() created it and teardownHooksDist() deleted it, but
with --test-concurrency=4 both test files ran concurrently as separate
Node.js worker processes sharing the same filesystem.  One file's
afterEach teardown deleted hooks/dist/ while the other file's install()
was copying from it, producing an ENOENT (reproduced 2/10 runs locally).

The additional issue: even with pre-placed stubs surviving the copy race,
the 000-first-time-baseline migration classified hooks/gsd-*.js as
bundled-gsd-hook artifacts, auto-removed them, and the copy step never
re-ran (hooks/dist/ absent) — leaving contextMonitorFile missing and all
hook registrations silently skipped (the 'got: []' symptom).

Fix: pre-populate targetDir/hooks/ per-test (isolated temp dir) AND pass
installerMigrations:[] so the baseline scan is skipped.  The Qwen suites
already used this pattern correctly; the Claude suites are aligned to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): ship gsd-config-reload.js by adding it to build-hooks HOOKS_TO_COPY

The #770 feature added hooks/gsd-config-reload.js and registered it in
MANAGED_HOOKS, the installer, INVENTORY, and the test EXPECTED_ALL_HOOKS
list — but never added it to scripts/build-hooks.js HOOKS_TO_COPY. As a
result the hook was never copied into hooks/dist/ during the build, so:

  - the hook would never ship to users (real production bug — the
    FileChanged config-reload feature was dead-on-arrival), and
  - install-minimal-hooks.test.cjs #1755 ("all expected hooks are copied
    from hooks/dist/ to target", ".js hooks are executable after copy",
    "manifest contains .js hook entries") failed on any environment with
    a clean checkout (no pre-existing hooks/dist/): coverage, full test
    macos-22/macos-24, test ubuntu-24.

The failures were masked locally only by a stale hooks/dist/ left from a
prior build (build-hooks copies into dist without clearing it). On CI's
fresh `npm ci` there is no dist, so the omission surfaced.

Fix: add 'gsd-config-reload.js' to HOOKS_TO_COPY so build-hooks stages it
into hooks/dist/ alongside the other JS hooks. Verified by removing
hooks/dist/ and rerunning the full suite green (0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): make config prototype-pollution beforeEach deterministic on scoped runner

Root cause: the #663 and alert-#26 prototype-pollution describe blocks
seeded .planning/config.json in beforeEach via a bare
runGsdTools('config-ensure-section') whose result was discarded. That
command runs in a spawned gsd-tools child; on the scoped CI lane
(--test-concurrency=4, config.test.cjs scheduled alongside the heavy
install/tarball suites that #770 pulled into the targeted set) the child
can be transiently killed under resource pressure (non-zero exit, empty
stderr — an OS-level kill, not an app error). The swallowed failure left
config.json absent, so the first subtest's readConfig() threw ENOENT
opening <tmp>/.planning/config.json. Only 1 of 4 subtests failed,
confirming a per-invocation transient, not a deterministic miss; the full
suite schedules files differently so config.test.cjs did not collide with
those heavy neighbors → passed there.

Fix: add ensureConfigReady(tmpDir) which retries config-ensure-section on
ANY failure or missing file and throws a clear diagnostic if it still
cannot create config.json, then use it in both prototype-pollution
beforeEach blocks. Setup is now deterministic under load; the #663/alert-#26
security assertions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 20:36:11 -04:00
committed by GitHub
parent d32b8db635
commit 1b6bd66f2c
17 changed files with 715 additions and 46 deletions

View File

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

View File

@@ -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. 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 ### 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 ### 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 <git-url>` / `update` / `remove` (and `gemini extensions link <path>` 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. 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 <git-url>` / `update` / `remove` (and `gemini extensions link <path>` 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.

View File

@@ -8058,6 +8058,7 @@ const GSD_UNINSTALL_HOOKS = [
'gsd-statusline.js', 'gsd-statusline.js',
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-check-update.cmd', 'gsd-check-update.cmd',
'gsd-config-reload.js',
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-cursor-session-start.js', 'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.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 // Remove GSD hooks from settings — per-hook granularity to preserve
// user hooks that share an entry with a GSD hook (#1755 followup). // user hooks that share an entry with a GSD hook (#1755 followup).
// Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop, // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop,
// PreCompact) and the 3 Gemini-only events added in #776 (BeforeAgent, // PreCompact, also registered for Claude in #770), the 3 Gemini-only
// AfterAgent, BeforeModel) — safe to iterate for all runtimes; non-Qwen // events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the
// and non-Gemini installs simply find no entries and skip. // Claude-only FileChanged event added in #770 — safe to iterate for all
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { // 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]) { if (settings.hooks && settings.hooks[eventName]) {
const before = JSON.stringify(settings.hooks[eventName]); const before = JSON.stringify(settings.hooks[eventName]);
settings.hooks[eventName] = settings.hooks[eventName] settings.hooks[eventName] = settings.hooks[eventName]
@@ -11118,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const readInjectionScannerCommand = isGlobal const readInjectionScannerCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts) ? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
: localCmd('gsd-read-injection-scanner.js'); : 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 // #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
// command is null too. Emit one warning here so the operator sees the cause // 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)`); console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
} }
// ── Qwen-only extended hook events (#788) ──────────────────────────────── // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
// Qwen Code exposes 15 hook events — a superset of Claude Code. Three // Claude Code (since #770) and Qwen Code (since #788) both support these
// additional events are registered for Qwen installs: // 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) // SubagentStop — subagent lifecycle completion (context headroom tracking)
// Stop — model stop / final-response moment (context headroom) // Stop — model stop / final-response moment (context headroom)
// PreCompact — fires before conversation compaction (most critical // PreCompact — fires before conversation compaction (most critical
// moment to surface context headroom warnings) // 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 // Note: UserPromptSubmit is NOT wired here. That event carries the raw
// user prompt text, not a tool invocation, so gsd-prompt-guard (which // 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 // 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. // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
// if (isQwen || runtime === 'claude') {
// Guard: isQwen is defined at the top of install() (line ~8254). const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
if (isQwen) { // SubagentStop, Stop, PreCompact — route through the context monitor.
// SubagentStop, Stop, PreCompact — route through the context monitor so for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
// agents get context-headroom warnings at subagent completion, model stop, if (!settings.hooks[event]) {
// and pre-compaction (the most critical moment to surface headroom info). settings.hooks[event] = [];
for (const qwenEvent of ['SubagentStop', 'Stop', 'PreCompact']) {
if (!settings.hooks[qwenEvent]) {
settings.hooks[qwenEvent] = [];
} }
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')) entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
); );
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
settings.hooks[qwenEvent].push({ settings.hooks[event].push({
hooks: [ hooks: [
{ {
type: 'command', 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)) { } 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-only extended hook events (#776) ───────────────────────────────
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that // 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 ────────────────────────────────── // ── 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) ─────────────────────────────── // ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────

View File

@@ -359,6 +359,7 @@
"hooks": [ "hooks": [
"gsd-check-update-worker.js", "gsd-check-update-worker.js",
"gsd-check-update.js", "gsd-check-update.js",
"gsd-config-reload.js",
"gsd-context-monitor.js", "gsd-context-monitor.js",
"gsd-cursor-post-tool.js", "gsd-cursor-post-tool.js",
"gsd-cursor-session-start.js", "gsd-cursor-session-start.js",

View File

@@ -471,7 +471,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
--- ---
## Hooks (16 shipped) ## Hooks (17 shipped)
Full listing: `hooks/`. 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-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-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-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-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes |
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | | `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions |

View File

@@ -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 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 ### Claude Code — native plugin install

133
hooks/gsd-config-reload.js Normal file
View File

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

View File

@@ -35,6 +35,35 @@
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js\"", "timeout": 5 } { "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 }
]
}
] ]
} }
} }

View File

@@ -18,6 +18,7 @@
const MANAGED_HOOKS = [ const MANAGED_HOOKS = [
'gsd-check-update-worker.js', 'gsd-check-update-worker.js',
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-cursor-post-tool.js', 'gsd-cursor-post-tool.js',
'gsd-cursor-session-start.js', 'gsd-cursor-session-start.js',

View File

@@ -33,6 +33,10 @@ const HOOKS_TO_COPY = [
// Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor // Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor
'gsd-cursor-session-start.js', 'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.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-prompt-guard.js',
'gsd-read-guard.js', 'gsd-read-guard.js',
'gsd-read-injection-scanner.js', 'gsd-read-injection-scanner.js',

View File

@@ -29,6 +29,7 @@ const VALID_CHOICES: ReadonlyArray<string> = ['keep', 'remove'];
export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set([ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set([
'hooks/gsd-check-update-worker.js', 'hooks/gsd-check-update-worker.js',
'hooks/gsd-check-update.js', 'hooks/gsd-check-update.js',
'hooks/gsd-config-reload.js',
'hooks/gsd-context-monitor.js', 'hooks/gsd-context-monitor.js',
'hooks/gsd-cursor-post-tool.js', 'hooks/gsd-cursor-post-tool.js',
'hooks/gsd-cursor-session-start.js', 'hooks/gsd-cursor-session-start.js',

View File

@@ -145,6 +145,7 @@ export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime
const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = { const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'settings-json': new Set([ 'settings-json': new Set([
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-statusline.js', 'gsd-statusline.js',
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-prompt-guard.js', 'gsd-prompt-guard.js',
@@ -161,6 +162,7 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = { const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'settings-json': new Set([ 'settings-json': new Set([
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-statusline.js', 'gsd-statusline.js',
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-prompt-guard.js', 'gsd-prompt-guard.js',

View File

@@ -39,6 +39,37 @@ async function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) {
return last; 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 ─────────────────────────────────────────────────── // ─── config-ensure-section ───────────────────────────────────────────────────
describe('config-ensure-section command', () => { describe('config-ensure-section command', () => {
@@ -1106,10 +1137,12 @@ describe('config-path command (#2282)', () => {
describe('config-set prototype-pollution guard (#663)', () => { describe('config-set prototype-pollution guard (#663)', () => {
let tmpDir; let tmpDir;
beforeEach(() => { beforeEach(async () => {
tmpDir = createTempProject(); tmpDir = createTempProject();
// Initialise config so there is a config.json to write to. // Initialise config so there is a config.json to write to. Retry + assert
runGsdTools('config-ensure-section', tmpDir); // so a transient config-ensure-section child failure under scoped-lane load
// cannot leave config.json absent (#770).
await ensureConfigReady(tmpDir);
}); });
afterEach(() => { afterEach(() => {
@@ -1163,10 +1196,12 @@ describe('config-set prototype-pollution guard (#663)', () => {
describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => { describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => {
let tmpDir; let tmpDir;
beforeEach(() => { beforeEach(async () => {
tmpDir = createTempProject(); tmpDir = createTempProject();
// Initialise config so there is a config.json to write to. // Initialise config so there is a config.json to write to. Retry + assert
runGsdTools('config-ensure-section', tmpDir); // so a transient config-ensure-section child failure under scoped-lane load
// cannot leave config.json absent (#770).
await ensureConfigReady(tmpDir);
}); });
afterEach(() => { afterEach(() => {

View File

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

View File

@@ -52,6 +52,7 @@ const STUB_HOOKS = [
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-prompt-guard.js', 'gsd-prompt-guard.js',
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-config-reload.js', // Added in #770
]; ];
function stubHooksIntoTarget(targetDir) { 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 tmpDir;
let previousCwd; let previousCwd;
let settings; let settings;
@@ -161,8 +172,9 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () =
tmpDir = createTempDir('gsd-788-claude-'); tmpDir = createTempDir('gsd-788-claude-');
previousCwd = process.cwd(); previousCwd = process.cwd();
process.chdir(tmpDir); process.chdir(tmpDir);
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
const result = install(false, 'claude'); const result = install(false, 'claude', { installerMigrations: [] });
settings = result && result.settings; settings = result && result.settings;
}); });
@@ -171,22 +183,22 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () =
cleanup(tmpDir); cleanup(tmpDir);
}); });
test('Claude install does not register SubagentStop', () => { test('Claude install registers SubagentStop (since #770)', () => {
const cmds = hooksForEvent(settings, 'SubagentStop'); const cmds = hooksForEvent(settings, 'SubagentStop');
assert.strictEqual(cmds.length, 0, assert.ok(cmds.length > 0,
`Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`); `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'); const cmds = hooksForEvent(settings, 'Stop');
assert.strictEqual(cmds.length, 0, assert.ok(cmds.length > 0,
`Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`); `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'); const cmds = hooksForEvent(settings, 'PreCompact');
assert.strictEqual(cmds.length, 0, assert.ok(cmds.length > 0,
`Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`); `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`);
}); });
}); });

View File

@@ -26,6 +26,7 @@ const EXPECTED_SH_HOOKS = [
const EXPECTED_ALL_HOOKS = [ const EXPECTED_ALL_HOOKS = [
'gsd-check-update.js', 'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-context-monitor.js', 'gsd-context-monitor.js',
'gsd-prompt-guard.js', 'gsd-prompt-guard.js',
'gsd-read-guard.js', 'gsd-read-guard.js',

View File

@@ -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; } 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)) { for (const eventName of Object.keys(hooksConfig.hooks)) {
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`); assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
} }