Files
msd-core/hooks/gsd-config-reload.js
Tom Boucher 1b6bd66f2c 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>
2026-06-07 20:36:11 -04:00

134 lines
4.8 KiB
JavaScript

#!/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);
}
});