* 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:
@@ -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) ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user