* 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>
248 lines
10 KiB
JavaScript
248 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Copy GSD hooks to dist for installation.
|
|
* Validates JavaScript syntax before copying to prevent shipping broken hooks.
|
|
* See #1107, #1109, #1125, #1161 — a duplicate const declaration shipped
|
|
* in dist and caused PostToolUse hook errors for all users.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
|
// Per-process staging directory for atomic writes. Using process.pid in the
|
|
// name eliminates all contention between concurrent builders: each process
|
|
// owns its own staging dir and never races with another builder's cleanup.
|
|
// Lives under hooks/ so it shares a filesystem with DIST_DIR (POSIX
|
|
// rename(2) is only atomic within the same filesystem) but is NOT inside
|
|
// DIST_DIR — so readers that readdirSync(DIST_DIR) (e.g. bin/install.js,
|
|
// install-hooks-copy tests) never observe a transient ".tmp" sibling.
|
|
// The parent pattern hooks/.dist-staging-*/ is gitignored.
|
|
const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
|
|
|
|
// Hooks to copy (pure Node.js, no bundling needed)
|
|
const HOOKS_TO_COPY = [
|
|
'gsd-check-update-worker.js',
|
|
'gsd-check-update.js',
|
|
// Required by gsd-check-update-worker.js at runtime — must ship alongside it
|
|
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
|
|
'managed-hooks-registry.cjs',
|
|
'gsd-context-monitor.js',
|
|
// Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor
|
|
'gsd-cursor-session-start.js',
|
|
'gsd-cursor-post-tool.js',
|
|
// 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',
|
|
'gsd-statusline.js',
|
|
'gsd-update-banner.js',
|
|
'gsd-workflow-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
|
|
// .planning/config.json graphify.auto_update; off by default.
|
|
'gsd-graphify-update.sh'
|
|
];
|
|
|
|
// Subdirectories under hooks/ whose contents must also ship to dist. Each
|
|
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
|
|
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
|
|
// installed runtime path. See #3579.
|
|
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
|
|
|
|
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
|
|
// Used between Windows rename retries; this script is sync end-to-end so
|
|
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
|
|
// is bounded (~400ms) — acceptable for a one-shot build script.
|
|
function sleepSync(ms) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
}
|
|
|
|
/**
|
|
* Atomic-replace via fs.renameSync, with Windows-only retry and fallback.
|
|
*
|
|
* POSIX rename(2) atomically replaces dest even when readers hold open
|
|
* handles on it. Windows MoveFileEx (which fs.renameSync uses with
|
|
* MOVEFILE_REPLACE_EXISTING) cannot — it throws EPERM/EBUSY when another
|
|
* process has the destination open. Concurrent install.js readers and
|
|
* antivirus scanners are the realistic triggers; both release handles
|
|
* within milliseconds, so a short backoff resolves the race. After
|
|
* retries are exhausted, fall back to copy-then-unlink (re-introduces
|
|
* the truncate-then-write race for this single file but keeps the build
|
|
* moving rather than crashing). If even copy fails because dest is hard-
|
|
* locked, log a non-fatal warning and leave the prior dest in place — a
|
|
* subsequent build invocation will retry from a fresh state.
|
|
*/
|
|
function renameAtomicWithRetry(stagedDest, dest, hook) {
|
|
if (process.platform !== 'win32') {
|
|
fs.renameSync(stagedDest, dest);
|
|
return;
|
|
}
|
|
const BACKOFFS_MS = [10, 30, 90, 270];
|
|
for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
|
|
try {
|
|
fs.renameSync(stagedDest, dest);
|
|
return;
|
|
} catch (e) {
|
|
const transient = e && (e.code === 'EPERM' || e.code === 'EBUSY');
|
|
if (!transient) throw e;
|
|
if (attempt < BACKOFFS_MS.length) {
|
|
sleepSync(BACKOFFS_MS[attempt]);
|
|
continue;
|
|
}
|
|
// Retries exhausted; fall back to copy-then-unlink.
|
|
try {
|
|
fs.copyFileSync(stagedDest, dest);
|
|
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
|
|
console.warn(`\x1b[33m! ${hook}: rename failed (${e.code}) after ${BACKOFFS_MS.length} retries; used copy-fallback\x1b[0m`);
|
|
return;
|
|
} catch (fallbackErr) {
|
|
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
|
|
console.warn(`\x1b[33m! ${hook}: rename + copy fallback both failed (${e.code} → ${fallbackErr.code || fallbackErr.message}); leaving prior dest in place\x1b[0m`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate JavaScript syntax without executing the file.
|
|
* Catches SyntaxError (duplicate const, missing brackets, etc.)
|
|
* before the hook gets shipped to users.
|
|
*/
|
|
function validateSyntax(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
try {
|
|
// Use vm.compileFunction to check syntax without executing
|
|
new vm.Script(content, { filename: path.basename(filePath) });
|
|
return null; // No error
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
return e.message;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function build() {
|
|
// Ensure dist and staging directories exist (staging is a sibling of dist
|
|
// used to make writes atomic — see STAGE_DIR comment above).
|
|
if (!fs.existsSync(DIST_DIR)) {
|
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
|
}
|
|
if (!fs.existsSync(STAGE_DIR)) {
|
|
fs.mkdirSync(STAGE_DIR, { recursive: true });
|
|
}
|
|
|
|
let hasErrors = false;
|
|
|
|
// Copy hooks to dist with syntax validation
|
|
for (const hook of HOOKS_TO_COPY) {
|
|
const src = path.join(HOOKS_DIR, hook);
|
|
const dest = path.join(DIST_DIR, hook);
|
|
|
|
if (!fs.existsSync(src)) {
|
|
console.warn(`Warning: ${hook} not found, skipping`);
|
|
continue;
|
|
}
|
|
|
|
// Validate JS syntax before copying (.sh files skip — not Node.js)
|
|
if (hook.endsWith('.js')) {
|
|
const syntaxError = validateSyntax(src);
|
|
if (syntaxError) {
|
|
console.error(`\x1b[31m✗ ${hook}: SyntaxError — ${syntaxError}\x1b[0m`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
console.log(`\x1b[32m✓\x1b[0m Copying ${hook}...`);
|
|
// Atomic write: copy to a per-process staging file in the per-PID sibling
|
|
// STAGE_DIR (same filesystem as DIST_DIR so rename(2) is atomic), then
|
|
// rename into place. Multiple test files invoke this script concurrently
|
|
// from their before() hooks; fs.copyFileSync truncates then writes the
|
|
// destination — readers (install.js subprocesses spawned by parallel
|
|
// install tests) can observe the dest empty or partial mid-write,
|
|
// producing flaky failures such as bug-2136 part 4 where installed .sh
|
|
// hooks lacked their "# gsd-hook-version:" header. POSIX rename(2)
|
|
// makes the swap atomic so readers see either the old file or the new
|
|
// file. The staging file lives outside DIST_DIR so readdirSync(DIST_DIR)
|
|
// (in install.js and tests) never observes a transient ".tmp" sibling.
|
|
// Each process uses its own STAGE_DIR (keyed by PID) so concurrent
|
|
// builders never race on staging-dir creation or cleanup.
|
|
const stagedDest = path.join(STAGE_DIR, `${hook}.${Date.now()}`);
|
|
fs.copyFileSync(src, stagedDest);
|
|
// Preserve executable bit for shell scripts before rename so the
|
|
// installed file is executable from the very first observation.
|
|
if (hook.endsWith('.sh')) {
|
|
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
|
|
}
|
|
renameAtomicWithRetry(stagedDest, dest, hook);
|
|
}
|
|
|
|
// Copy whitelisted hook subdirectories (e.g. hooks/lib/) into dist so the
|
|
// installer's readdir-and-isFile loop in bin/install.js sees them and
|
|
// detached hook helpers resolve from the installed runtime path (#3579).
|
|
for (const subdir of HOOKS_SUBDIRS_TO_COPY) {
|
|
const srcDir = path.join(HOOKS_DIR, subdir);
|
|
if (!fs.existsSync(srcDir)) continue;
|
|
const destDir = path.join(DIST_DIR, subdir);
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
for (const ent of entries) {
|
|
if (!ent.isFile()) continue;
|
|
const srcFile = path.join(srcDir, ent.name);
|
|
const destFile = path.join(destDir, ent.name);
|
|
if (ent.name.endsWith('.js')) {
|
|
const syntaxError = validateSyntax(srcFile);
|
|
if (syntaxError) {
|
|
console.error(`\x1b[31m✗ ${subdir}/${ent.name}: SyntaxError — ${syntaxError}\x1b[0m`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
}
|
|
console.log(`\x1b[32m✓\x1b[0m Copying ${subdir}/${ent.name}...`);
|
|
const stagedDest = path.join(STAGE_DIR, `${subdir}__${ent.name}.${Date.now()}`);
|
|
fs.copyFileSync(srcFile, stagedDest);
|
|
if (ent.name.endsWith('.sh')) {
|
|
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
|
|
}
|
|
renameAtomicWithRetry(stagedDest, destFile, `${subdir}/${ent.name}`);
|
|
}
|
|
}
|
|
|
|
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
|
|
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
|
|
// rmSync with recursive:true is safe and leaves no race window.
|
|
try {
|
|
fs.rmSync(STAGE_DIR, { recursive: true, force: true });
|
|
} catch (e) { /* tolerate ENOENT if the dir was never created (e.g. all hooks skipped) */ }
|
|
|
|
if (hasErrors) {
|
|
console.error('\n\x1b[31mBuild failed: fix syntax errors above before publishing.\x1b[0m');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\nBuild complete.');
|
|
}
|
|
|
|
// Export HOOKS_TO_COPY so tests can require() this file and assert against
|
|
// the typed value instead of regex-parsing the source text (retires
|
|
// pending-migration-to-typed-ir for orphaned-hooks.test.cjs, per #455).
|
|
// Guard the build() call so requiring this file as a module does not trigger
|
|
// a full build run (which copies files and writes to disk).
|
|
if (require.main === module) {
|
|
build();
|
|
}
|
|
|
|
module.exports = { HOOKS_TO_COPY, HOOKS_SUBDIRS_TO_COPY };
|