Skip to content
Development
Hook

Hooks

What gsd-core runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
gsd-core
8k71 skills34 agents71 commands7 hooks
Install
> /plugin marketplace add open-gsd/gsd-core
> /plugin install gsd-core@gsd-core

Ships with gsd-core. Installing the plugin gets these hooks.

What fires, and when

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js"

PreToolUse

  • MatchesWrite|Editnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-prompt-guard.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-guard.js"
  • MatchesWrite|Edit|MultiEditnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js"
  • MatchesWritenode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js"
  • MatchesAgent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-agent-isolation-guard.js"

PostToolUse

  • MatchesBash|Edit|Write|MultiEdit|Agent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"
  • MatchesRead|WebFetch|WebSearchnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js"

SubagentStop

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"

Stop

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"

PreCompact

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"

FileChanged

  • Matchesconfig.jsonnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-config-reload.js"
Read hooks/hooks.json

Where it lives

  • hooks/gsd-agent-isolation-guard.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    // GSD Agent Isolation Dispatch Guard — PreToolUse hook (#3045)
    //
    // Problem: `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md`
    // resolves the project's dispatch isolation correctly
    // (`gsd_run query dispatch-isolation --raw`), but DELIVERY of that value into
    // the model-authored `Agent(subagent_type="gsd-executor", ...)` call is a
    // prose instruction ("substitute $HARNESS_FLAG's value ... on Claude Code it
    // is literally isolation=\"worktree\""). Nothing verifies the model actually
    // copied it. When it is omitted, the executor runs and commits directly in
    // the user's PRIMARY checkout instead of an isolated worktree, with no
    // consent and no warning.
    //
    // A prose backstop cannot fix a prose defect — it is the same class of
    // artifact the model may equally skip. This hook enforces the invariant at
    // the tooling layer instead: HARD-BLOCKING.
    //
    // Applicability (must positively determine all three to act — otherwise
    // inert):
    //   1. this is a GSD project (`.planning/config.json` exists under cwd),
    //   2. the project's resolved dispatch isolation is `harness-worktree`,
    //   3. the dispatch target is an executor (`subagent_type === "gsd-executor"`;
    //      no other executor-shaped subagent type exists in agents/ today).
    //
    // Fail-closed exception (#3050 lesson: a guard that cannot verify must not
    // answer "safe"): if the project IS a GSD project but the hook cannot read
    // or resolve its dispatch-isolation configuration, it DENIES rather than
    // defaulting to the "safe-looking" none/allow value that
    // `gsd-core/bin/gsd-tools.cjs`'s own `routeDispatchIsolation` degrades to on
    // error. That existing query is fail-OPEN by design (sequential execution
    // is always safe for the SCHEDULER); this guard's job is the opposite
    // invariant (never dispatch unisolated when isolation was promised), so it
    // cannot reuse that fail-open default and instead resolves isolation
    // itself, distinguishing "resolved cleanly" from "could not resolve".
    //
    // Isolation resolution (#3045 BLOCKER fix, see hooks/lib/isolation-sentinel.js):
    // prefers the workflow's own PERSISTED per-dispatch decision (a sentinel
    // `record-dispatch-isolation` writes after `executor-isolation-dispatch.md`
    // resolves ISOLATION in shell, applying workflow.use_worktrees, the #2474
    // per-plan submodule degrade, and the #683/#3060 base-check auto-degrade)
    // over re-deriving a host CAPABILITY from the registry. A fresh sentinel is
    // authoritative — `none`/`orchestrator-worktree` ALLOW immediately
    // (sequential/orchestrator-managed dispatch is legitimate, not a bug); an
    // absent/stale sentinel falls back to a conservative registry+config check
    // (GSD_RUNTIME env > .planning/config.json `runtime` — no confident signal
    // degrades to inert rather than guessing 'claude', see resolveRegistryIsolation)
    // gated additionally by `workflow.use_worktrees` — read directly, in-process,
    // no subprocess spawn.
    //
    // Triggers on: Agent/Task tool calls with subagent_type === "gsd-executor"
    //   (both names accepted — #3045 MAJOR 1: only Agent was previously matched,
    //   silently inert on any host/version whose subagent tool is named Task).
    // Action: BLOCK (exit 2) when isolation should be enforced and is not
    // No-op: any tool other than Agent/Task, non-executor targets, GSD projects
    //        whose resolved isolation is not harness-worktree, non-GSD projects,
    //        malformed payloads, or a dispatch that already carries the correct
    //        isolation parameter.
    
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js');
    
    // No other executor-shaped subagent_type exists in agents/ today
    // (verified: only agents/gsd-executor.md). A Set, not a bare string compare,
    // so a future sibling executor role can be added here without touching the
    // matching logic below.
    const EXECUTOR_SUBAGENT_TYPES = new Set(['gsd-executor']);
    
    /**
     * Parse a registry `harnessIsolationFlag` of the shape `key="value"` (the
     * only shape an `Agent()` tool_input kwarg can express) into its parameter
     * name and expected value. Bare CLI-flag shapes (e.g. a hypothetical
     * `--worktree`) have no tool_input kwarg equivalent and are not checkable
     * here — this hook is scoped to the Claude Code `Agent` tool's keyword-arg
     * dispatch surface.
     */
    function parseHarnessFlag(flag) {
      if (typeof flag !== 'string') return null;
      const m = /^([A-Za-z_][\w-]*)="([^"]*)"$/.exec(flag);
      if (!m) return null;
      return { param: m[1], value: m[2] };
    }
    
    /**
     * Resolve this project's declared `runtime` identity WITHOUT defaulting to
     * 'claude' when no explicit signal exists (#3045 MAJOR 2).
     *
     * `gsd-core/templates/config.json` — the actual scaffold used to write every
     * new project's config.json — ships with NO `runtime` key, so "no signal"
     * is the COMMON case, not a corner case. Previously this resolution silently
     * defaulted to 'claude' in that case, which meant every non-Claude runtime
     * that also installs this hook (any `hostIntegration.hooksSurface ===
     * 'settings-json'` runtime, not only Claude) had Claude's
     * `harnessIsolationFlag` ("isolation=\"worktree\"") demanded on its Agent()
     * -equivalent dispatch — a kwarg that runtime's own tool never accepts.
     *
     * Returns `{ runtimeId, confident }`. `confident` is true only when an
     * explicit signal exists (GSD_RUNTIME env override, a `runtime` key literally
     * present in config.json, or a `runtime` persisted to `~/.gsd/defaults.json`
     * by the installer — see below); false means "cannot determine" and callers
     * must NOT silently substitute 'claude' — see resolveRegistryIsolation.
     *
     * #3045 BLOCKER 2 fix: precedence is GSD_RUNTIME env > config.json `runtime`
     * key > `~/.gsd/defaults.json` `runtime`. The first two are unchanged; the
     * third is NEW — `b
  • hooks/gsd-check-update-worker.jsGitHub
  • hooks/gsd-check-update.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    // Check for GSD updates in background, write result to cache
    // Called by SessionStart hook - runs once per session
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const { spawn } = require('child_process');
    
    const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
    
    const homeDir = os.homedir();
    const cwd = process.cwd();
    
    // Detect runtime config directory (supports Claude, OpenCode, Kilo, Gemini)
    // Respects CLAUDE_CONFIG_DIR for custom config directory setups
    function detectConfigDir(baseDir) {
      // Check env override first (supports multi-account setups)
      const envDir = process.env.CLAUDE_CONFIG_DIR;
      if (envDir && fs.existsSync(path.join(envDir, 'gsd-core', 'VERSION'))) {
        return envDir;
      }
      for (const dir of ['.claude', '.gemini', '.config/kilo', '.kilo', '.config/opencode', '.opencode']) {
        if (fs.existsSync(path.join(baseDir, dir, 'gsd-core', 'VERSION'))) {
          return path.join(baseDir, dir);
        }
      }
      return envDir || path.join(baseDir, '.claude');
    }
    
    const globalConfigDir = detectConfigDir(homeDir);
    const projectConfigDir = detectConfigDir(cwd);
    // Use a shared, tool-agnostic cache directory to avoid multi-runtime
    // resolution mismatches where check-update writes to one runtime's cache
    // but statusline reads from another (#1421).
    const cacheDir = path.join(homeDir, '.cache', 'gsd');
    const cacheFile = path.join(cacheDir, updateCacheFileName);
    
    // VERSION file locations (check project first, then global)
    const projectVersionFile = path.join(projectConfigDir, 'gsd-core', 'VERSION');
    const globalVersionFile = path.join(globalConfigDir, 'gsd-core', 'VERSION');
    
    // Ensure cache directory exists
    if (!fs.existsSync(cacheDir)) {
      fs.mkdirSync(cacheDir, { recursive: true });
    }
    
    // Run check in background via a dedicated worker script.
    // Spawning a file (rather than node -e '<inline code>') keeps the worker logic
    // in plain JS with no template-literal regex-escaping concerns, and makes the
    // worker independently testable.
    const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
    const child = spawn(process.execPath, [workerPath], {
      stdio: 'ignore',
      windowsHide: true,
      detached: true,  // Required on Windows for proper process detachment
      env: {
        ...process.env,
        GSD_CACHE_FILE: cacheFile,
        GSD_PROJECT_VERSION_FILE: projectVersionFile,
        GSD_GLOBAL_VERSION_FILE: globalVersionFile,
      },
    });
    
    child.unref();
    
  • hooks/gsd-config-reload.jsRunsGitHub
    Read the script
    #!/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);
      }
    });
    
  • hooks/gsd-context-monitor.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    // Context Monitor - PostToolUse/AfterTool hook (Gemini uses AfterTool)
    // Reads context metrics from the statusline bridge file and injects
    // warnings when context usage is high. This makes the AGENT aware of
    // context limits (the statusline only shows the user).
    //
    // How it works:
    // 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json
    // 2. This hook reads those metrics after each tool use
    // 3. When remaining context drops below thresholds, it injects a warning
    //    as additionalContext, which the agent sees in its conversation
    //
    // Thresholds:
    //   WARNING  (remaining <= 35%): Agent should wrap up current task
    //   CRITICAL (remaining <= 25%): Agent should stop immediately and save state
    //
    // Debounce: 5 tool uses between warnings to avoid spam
    // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
    
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const { spawn } = require('child_process');
    
    const WARNING_THRESHOLD = 35;  // remaining_percentage <= 35%
    const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
    const STALE_SECONDS = 60;      // ignore metrics older than 60s
    const DEBOUNCE_CALLS = 5;      // min tool uses between warnings
    
    let input = '';
    // Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on
    // Windows/Git Bash, or slow Claude Code piping during large outputs),
    // exit silently instead of hanging until Claude Code kills the process
    // and reports "hook error". See #775, #1162.
    const stdinTimeout = setTimeout(() => process.exit(0), 10000);
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => input += chunk);
    process.stdin.on('end', () => {
      clearTimeout(stdinTimeout);
      try {
        const data = JSON.parse(input);
        const sessionId = data.session_id;
    
        if (!sessionId) {
          process.exit(0);
        }
    
        // Reject session IDs that contain path traversal sequences or path separators.
        // session_id is used to construct file paths in /tmp — an unsanitized value
        // could escape the temp directory and read or write arbitrary files.
        if (/[/\\]|\.\./.test(sessionId)) {
          process.exit(0);
        }
    
        // Check if context warnings are disabled via config.
        // Collapsed existsSync+readFileSync into a single read guarded by try/catch
        // (ENOENT or parse error → use defaults, same as old "planningDir absent" branch).
        const cwd = data.cwd || process.cwd();
        try {
          const configPath = path.join(cwd, '.planning', 'config.json');
          const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
          if (config.hooks?.context_warnings === false) {
            process.exit(0);
          }
        } catch (e) {
          // Missing or unparseable config → proceed with defaults (context warnings enabled)
        }
    
        const tmpDir = os.tmpdir();
        const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
    
        // If no metrics file, this is a subagent or fresh session -- exit silently.
        // Collapsed existsSync+readFileSync: ENOENT → exit 0 (identical to old !existsSync branch),
        // other errors rethrow to the outer catch (swallowed → exit 0, as before).
        let metricsRaw;
        try {
          metricsRaw = fs.readFileSync(metricsPath, 'utf8');
        } catch (e) {
          if (e && e.code === 'ENOENT') process.exit(0);
          throw e;
        }
        const metrics = JSON.parse(metricsRaw);
        const now = Math.floor(Date.now() / 1000);
    
        // Ignore stale metrics
        if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) {
          process.exit(0);
        }
    
        const remaining = metrics.remaining_percentage;
        const usedPct = metrics.used_pct;
    
        // No warning needed
        if (remaining > WARNING_THRESHOLD) {
          process.exit(0);
        }
    
        // Debounce: check if we warned recently
        const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
        let warnData = { callsSinceWarn: 0, lastLevel: null };
        let firstWarn = true;
    
        // Collapsed existsSync+readFileSync: ENOENT or parse error → keep default warnData
        // (same as old "file absent" branch). firstWarn tracks whether we read a valid sentinel.
        try {
          warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
          firstWarn = false;
        } catch (e) {
          // Missing or corrupted sentinel → firstWarn stays true, warnData stays at defaults
        }
    
        warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
    
        const isCritical = remaining <= CRITICAL_THRESHOLD;
        const currentLevel = isCritical ? 'critical' : 'warning';
    
        // Emit immediately on first warning, then debounce subsequent ones
        // Severity escalation (WARNING -> CRITICAL) bypasses debounce
        const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
        if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
          // Update counter and exit without warning
          fs.writeFileSync(warnPath, JSON.stringify(warnData));
          process.exit(0);
        }
    
        // Reset debounce counter
        warnData.callsSinceWarn = 0;
        warnData.lastLevel = currentLevel;
        fs.writeFileSync(warnPath, JSON.stringify(warnData));
    
        // Detect if GSD is active (has .planning/STATE.md in working directory)
        const isGsdActive = fs.existsSync(path.join(cwd, '.planning', 'STATE.md'));
    
        // On CRITICAL with active GSD project, auto-record session state as a
        // breadcrumb for /gsd:resume-work (#1974). Fire-and-forget subprocess —
        // doesn't block the hook or the agent. Fires ONCE per CRITICAL session,
        // guarded by warnData.criticalRecorded to prevent repeated overwrites
        // of the "crash moment" record on every debounce cycle.
        if (isCritical && isGsdActive && !warnData.criticalRecorded) {
          try {
            // Runtime-agnostic path: this hook lives at <runtime-config>/hooks/
            // and gsd-tools.cjs lives at <runtime-config>/gsd-core/bin/.
            // Using _
  • hooks/gsd-cursor-post-tool.jsGitHub
  • hooks/gsd-cursor-pre-tool.jsGitHub
  • hooks/gsd-cursor-session-start.jsGitHub
  • hooks/gsd-cursor-stop.jsGitHub
  • hooks/gsd-cursor-subagent-start.jsGitHub
  • hooks/gsd-cursor-subagent-stop.jsGitHub
  • hooks/gsd-ensure-canonical-path.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    //
    // gsd-ensure-canonical-path — SessionStart hook (#997)
    //
    // PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
    // hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
    // templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
    // environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
    // In a classic `bin/install.js` install the canonical path is a real directory
    // holding the bundled tree, so the includes resolve. In a Claude Code
    // *marketplace plugin* install the plugin manager only unpacks the package
    // into the version-pinned plugin cache and never runs `bin/install.js`, so
    // `~/.claude/gsd-core/` is never created and every @-include resolves to
    // nothing — every agent that depends on one fails (e.g. the executor).
    //
    // FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
    // set and a bundled `gsd-core/` tree found beneath it), ensure
    // `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
    // references, templates, workflows) are symlinked to the plugin's bundled tree.
    // This changes ZERO @-references, is a no-op in classic installs (where each
    // subdir is already a real directory), preserves user-generated files
    // (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
    // after `claude plugin update` rotates the version dir, and uses Windows
    // junctions for symlinks on win32.
    //
    // SECURITY: the resolved bundled-tree path and every per-subdir link target are
    // kept strictly inside the resolved plugin root (realpath-normalised, prefix-
    // checked). A real (non-symlink) file or directory already sitting at a managed
    // link target is NEVER clobbered.
    
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    
    // Immutable, bundled subdirectories that the canonical path must expose. These
    // are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
    // User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
    // NOT in this list and are never created, moved, or deleted by this hook.
    const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
    
    /**
     * Resolve the canonical runtime config dir for the active runtime.
     *
     * Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
     * gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
     * canonical GSD tree always lives at `<configDir>/gsd-core`.
     */
    function resolveConfigDir(homeDir, env) {
      const envDir = env.CLAUDE_CONFIG_DIR;
      if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
        return envDir;
      }
      return path.join(homeDir, '.claude');
    }
    
    /**
     * Locate the bundled `gsd-core/` tree beneath a plugin root.
     *
     * Claude Code unpacks the package so the bundled tree sits at
     * `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
     * that directory, or null if it is absent / not a directory. Resolving with
     * realpath collapses symlinks/.. so the subsequent containment check is sound.
     */
    function resolveBundledTree(pluginRoot) {
      if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
        return null;
      }
      let root;
      try {
        root = fs.realpathSync(pluginRoot);
      } catch (_) {
        return null; // plugin root does not exist
      }
      const bundled = path.join(root, 'gsd-core');
      let bundledReal;
      try {
        // The bundled tree must be a real directory (or a symlink to one) that
        // resolves to a path inside the plugin root. realpathSync throws ENOENT/
        // ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
        // check is needed. Reject anything that does not resolve to a directory.
        bundledReal = fs.realpathSync(bundled);
        if (!fs.statSync(bundledReal).isDirectory()) return null;
      } catch (_) {
        return null;
      }
      // SECURITY: the resolved bundled tree must stay inside the resolved plugin
      // root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
      // is rejected — we never link the canonical path at content we do not own.
      const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
      if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
        return null;
      }
      return bundledReal;
    }
    
    /**
     * The fs.symlinkSync `type` to use for a directory link on a given platform.
     *
     * On Windows, unprivileged users cannot create symlinks but CAN create
     * junctions; 'junction' requires an absolute target (we always pass one). On
     * POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
     * without a Windows host.
     */
    function dirLinkType(platform) {
      return platform === 'win32' ? 'junction' : 'dir';
    }
    
    /**
     * Create a directory symlink (junction on win32) from linkPath -> target.
     * Throws on real failure so the caller records it.
     */
    function createDirLink(target, linkPath, platform) {
      fs.symlinkSync(target, linkPath, dirLinkType(platform));
    }
    
    /**
     * Does `linkPath` already correctly point at `expectedTarget`?
     * Used to make the hook idempotent — a correct link is left untouched.
     */
    function linkPointsAt(linkPath, expectedTarget) {
      try {
        if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
        const resolved = fs.realpathSync(linkPath);
        return resolved === fs.realpathSync(expectedTarget);
      } catch (_) {
        return false;
      }
    }
    
    /**
     * Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
     *
     * Pure, dependency-injected core so tests drive it with a fake home, fake
     * plugin root, and explicit platform. Returns a structured result describing
     * exactly what happened (never throws for ordinary conditions — only truly
     * unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
     * a hook failure never blocks a session).
     *
     * @param {object} opts
     * @param {string} [opts.hom
  • hooks/gsd-graphify-update.shGitHub
  • hooks/gsd-phase-boundary.shGitHub
  • hooks/gsd-prompt-guard.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    // GSD Prompt Injection Guard — PreToolUse hook
    // Scans file content being written to .planning/ for prompt injection patterns.
    // Defense-in-depth: catches injected instructions before they enter agent context.
    //
    // Triggers on: Write and Edit tool calls targeting .planning/ files
    // Action: Advisory warning (does not block) — logs detection for awareness
    //
    // Why advisory-only: Blocking would prevent legitimate workflow operations.
    // The goal is to surface suspicious content so the orchestrator can inspect it,
    // not to create false-positive deadlocks.
    
    const path = require('path');
    
    // Prompt injection patterns (subset of security.cjs patterns, inlined for hook independence)
    const INJECTION_PATTERNS = [
      /ignore\s+(all\s+)?previous\s+instructions/i,
      /ignore\s+(all\s+)?above\s+instructions/i,
      /disregard\s+(all\s+)?previous/i,
      /forget\s+(all\s+)?(your\s+)?instructions/i,
      /override\s+(system|previous)\s+(prompt|instructions)/i,
      /you\s+are\s+now\s+(?:a|an|the)\s+/i,
      /act\s+as\s+(?:a|an|the)\s+(?!plan|phase|wave)/i,
      /pretend\s+(?:you(?:'re| are)\s+|to\s+be\s+)/i,
      /from\s+now\s+on,?\s+you\s+(?:are|will|should|must)/i,
      /(?:print|output|reveal|show|display|repeat)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions)/i,
      /<\/?(?:system|assistant|human)>/i,
      /\[SYSTEM\]/i,
      /\[INST\]/i,
      /<<\s*SYS\s*>>/i,
    ];
    
    // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
    // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
    // matcher is registered pre-translated (runtime-hooks-surface.cts
    // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
    // matcher fires but the tool_name check below exits 0 and the guard is dormant
    // on Kimi. The tool_input field names differ as well (kimi-cli
    // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
    // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
    // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
    // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
    // names; unknown names fall through untouched. Inlined per guard (not
    // hooks/lib/): hook scripts are staged as standalone files, and a sibling
    // require is a staging dependency that can fail silently.
    // A Map, not an object literal: bare bracket lookup resolves prototype keys
    // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
    // !mapped fall-through never fires for them; Map.get returns undefined (same
    // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
    const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
    function normalizeKimiPayload(data) {
      // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
      // payloads reached the `data.tool_name` read below and threw — falsifying
      // this function's own "total over the inputs JSON can express" claim, which
      // property (e) now tests directly. Harmless in practice (a null payload has
      // nothing to guard, and the throw landed in the same fail-open catch as the
      // exit-0 it now takes deliberately) but the claim should be true as stated.
      if (data === null || typeof data !== 'object') return data;
      const raw = data.tool_name;
      if (typeof raw !== 'string') return data;
      const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
      if (!mapped) return data;
      data.tool_name = mapped;
      if (data.tool_response === undefined && data.tool_output !== undefined) {
        data.tool_response = data.tool_output;
      }
      const input = data.tool_input;
      if (input && typeof input === 'object') {
        // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
        // not merely fill in when `file_path` happens to be absent. kimi-cli's file
        // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
        // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
        // model's raw json-parsed
        // arguments to PreToolUse verbatim, doing typed validation only later inside
        // tool.call() — after the hook has already decided. So a `file_path` in a
        // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
        // condition it SHADOWED the field kimi-cli actually executes on. A payload
        // pairing a cross-root `path` with a spurious `file_path: ""` left every
        // guard reading an empty string and exiting 0, while the identical write
        // without the extra key blocked — a bypass needing no crash at all. The same
        // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
        // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
        // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
        // elsewhere, reached through the guard's own read rather than through
        // normalization. Overwriting can only ever narrow what a guard inspects to
        // the path that will actually be written, so it cannot under-block.
        if (typeof input.path === 'string') {
          input.file_path = input.path;
        }
        const edits = Array.isArray(input.edit) ? input.edit
          : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
        if (edits.length) {
          // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
          // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
          // here. normalizeKimiPayload runs before any tool dispatch, so that throw
          // reached each guard's outer `catch { process.exit(0) }` and silently
          // downgraded a should-BLOCK call into an allow. (A string/number entry
          // never threw — `('x').old` is a legal read yielding undefined.)
          //
          // The String() coercion is guarded for the same reason: `{"toString":
          // null}` is valid JSON that throws "Cannot convert object to 
  • hooks/gsd-read-guard.jsRunsGitHub
  • hooks/gsd-read-injection-scanner.jsRunsGitHub
  • hooks/gsd-session-state.shGitHub
  • hooks/gsd-statusline.jsGitHub
  • hooks/gsd-update-banner.jsGitHub
  • hooks/gsd-validate-commit.shGitHub
  • hooks/gsd-windsurf-pre-command.jsGitHub
  • hooks/gsd-windsurf-pre-write.jsGitHub
  • hooks/gsd-workflow-guard.jsGitHub
  • hooks/gsd-worktree-path-guard.jsRunsGitHub
  • hooks/gsd-write-guard.jsRunsGitHub
  • hooks/managed-hooks-registry.cjsGitHub

All 27 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withgsd-core

Git. Ship. Done. A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.

Get the whole plugin