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
11k72 skills64 agents72 commands7 hooks

Claude Code

> /plugin marketplace add open-gsd/gsd-core
> /plugin install gsd-core@gsd-core

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-path-export.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"
  • MatchesRead|Grep|Bashnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-secret-read-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` > the per-install
    // `.gsd-runtime` marker, #3566 — 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';
    
    // #5183: Node's on-disk compile cache, so later runs reuse V8's compiled code
    // for unchanged modules. Only as the entry (a test require() is unaffected),
    // skipped under V8 coverage, optional (Bun stubs it), and never fatal.
    // NODE_DISABLE_COMPILE_CACHE (any value) turns it off.
    if (require.main === module && !process.env.NODE_V8_COVERAGE) {
      try { require('node:module').enableCompileCache?.(); } catch { /* optimization only */ }
    }
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
    const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
    const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
    const { readConfigFileBounded } = require('./lib/config-read.js');
    
    // Required at module top, alongside the other ./lib requires — NOT behind
    // ensureRuntimeBuild() below. Terminating on a parse/timeout failure must
    // never depend on the gitignored build artifacts this hook self-heals for
    // its own registry lookups (#3911).
    //
    // This guard's outer catch (main(), below) has always exited 0 (fail open):
    // that outer catch only covers payload PARSING failing before applicability
    // could even be determined (malformed stdin JSON, etc.) — the guard's real
    // fail-closed logic (a GSD project whose dispatch-isolation configuration
    // cannot be verified) is handled separately, inside evaluateDispatch/
    // resolveIsolationState, and already returns a 'block' decision through the
    // normal exit-2 path rather than through this catch. So an unparseable
    // payload has nothing to enforce; allowing it preserves today's behavior
    // exactly.
    const ON_CRASH = HOOK_ON_CRASH.ALLOW;
    // #3582: gsd-core/bin/lib/*.cjs (runtime-name-policy.cjs, capability-registry.cjs
    // below) are tsc build artifacts (ADR-457), gitignored and absent on a raw
    // plugin-marketplace / git-clone install that never ran `npm run build:lib`.
    // Self-heal before the first such require (resolveRegistryIsolation, below) —
    // see ensureRuntimeBuild's own header for the full rationale. This module
    // itself (gsd-core/bin/ensure-runtime-build.cjs) depends on nothing under
    // ./lib, so requiring it here is alway
  • 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
    
    // #5183: Node's on-disk compile cache, so later runs reuse V8's compiled code
    // for unchanged modules. Only as the entry (a test require() is unaffected),
    // skipped under V8 coverage, optional (Bun stubs it), and never fatal.
    // NODE_DISABLE_COMPILE_CACHE (any value) turns it off.
    if (require.main === module && !process.env.NODE_V8_COVERAGE) {
      try { require('node:module').enableCompileCache?.(); } catch { /* optimization only */ }
    }
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const { spawn } = require('child_process');
    
    // #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact
    // (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
    // install that never ran `npm run build:lib`. This SessionStart hook must
    // DEGRADE (fall back to a generic cache filename) rather than crash session
    // start. gsd-check-update-worker.js — the process this hook spawns — degrades
    // identically and independently, so the shared fallback literal keeps the
    // cache path consistent between writer and reader even in the (rare)
    // doubly-degraded case. This try/require/ensureRuntimeBuild/require/catch
    // shape is deliberately duplicated (not extracted to hooks/lib/) — see
    // gsd-check-update-worker.js's identical #3582 comment for why.
    let updateCacheFileName = 'gsd-update-check.json';
    try {
      const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
      ensureRuntimeBuild();
      ({ updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'));
    } catch (e) {
      // Runtime library missing/broken and could not self-build — degrade to the
      // fallback filename above rather than crash the SessionStart hook.
    }
    
    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');
    
    // This background check is advisory. Cache or worker failures must not make
    // the SessionStart hook fail. Recursive mkdir is safe if another session
    // creates the directory concurrently.
    try {
      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,
        },
      });
    
      // A spawn failure can also arrive asynchronously on the child process.
      child.on('error', () => {});
      child.unref();
    } catch {
      // Update hints are best effort; a failed check must not interrupt startup.
    }
    
  • 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 path = require('path');
    const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
    const { readConfigFileBounded } = require('./lib/config-read.js');
    
    // This hook only injects an advisory config-reload summary; a crash mid-parse
    // must not block the session or the FileChanged event that triggered it — the
    // agent simply keeps using the config context it already had (#3911).
    const ON_CRASH = HOOK_ON_CRASH.ALLOW;
    
    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(() => allow(undefined), 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') {
          allow(undefined);
        }
        const expectedPath = path.resolve(cwd, '.planning', 'config.json');
        if (path.resolve(filePath) !== expectedPath) {
          allow(undefined);
        }
    
        // On unlink (deletion) emit a brief notice and exit
        if (event === 'unlink') {
          allow({
            hookSpecificOutput: {
              hookEventName: 'FileChanged',
              additionalContext:
                'GSD config (.planning/config.json) was deleted. ' +
                'Falling back to built-in defaults for this session.',
            },
          });
        }
    
        // Read the updated config file
        let config;
        try {
          const raw = readConfigFileBounded(filePath, { required: true });
          config = JSON.parse(raw);
        } catch (e) {
          if (e && e.code === 'ENOENT') allow(undefined);
          // Malformed JSON — inform the agent without crashing
          allow({
            hookSpecificOutput: {
              hookEventName: 'FileChanged',
              additionalContext:
                'GSD config (.planning/config.json) was modified but could not be parsed. ' +
                'Check the file for JSON syntax errors.',
            },
          });
        }
    
        // 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.
        // ON_CRASH is declared ALLOW at module top: this preserves today's
        // exit(0) fail-open behavior exactly (#3911).
        crash(ON_CRASH, undefined);
      }
    });
    
  • 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
    // "remaining" is of the auto-compact threshold — the statusline puts the
    // bridge on that scale (#4985), so both fire before Claude Code compacts.
    // Both fire-points are overridable per project via .planning/config.json
    // (hooks.context_warning_threshold / hooks.context_critical_threshold, #4285);
    // the values above are the defaults used when the keys are absent or unusable.
    //
    // 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 { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
    const { readConfigFileBounded } = require('./lib/config-read.js');
    
    // This hook only injects an advisory context-usage warning; it never blocks
    // the tool call it rides in on. A crash here (e.g. a malformed bridge file)
    // must not turn a PostToolUse advisory into a blocked tool call — losing a
    // context warning is far cheaper than stalling the agent's work (#3911).
    const ON_CRASH = HOOK_ON_CRASH.ALLOW;
    
    const WARNING_THRESHOLD = 35;  // remaining_percentage <= 35% (default, see resolveThresholds)
    const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25% (default, see resolveThresholds)
    const STALE_SECONDS = 60;      // ignore metrics older than 60s
    const DEBOUNCE_CALLS = 5;      // min tool uses between warnings
    // How long after a PreCompact readings stay suspect. The watermark records the
    // compaction's START; the compaction keeps running after it, and a statusline
    // render during it stamps the PRE-compaction reading with a CURRENT timestamp
    // (Codex review of #3808, round 3) — so "newer than the watermark" alone still
    // admits it. Everything inside this window is dropped instead. The cost is
    // bounded: a healthy reading dropped here behaves identically to an accepted
    // one (it would exit above-threshold anyway). A genuine exhaustion reading
    // inside the window is SKIPPED, not queued — its warning and its #1974
    // breadcrumb both fire on the next reading after the window, so they are
    // delayed by at most this window plus the accepted skew below when a later
    // reading comes, and lost when
    // none does, i.e. when the session ends inside the window (review of #3808,
    // round 9). That loss is accepted over the alternative, which is trusting a
    // reading that may be the pre-compaction value under a fresh timestamp.
    const COMPACT_GRACE_SECONDS = 60;
    // How far AHEAD of this process's clock a watermark may be and still be
    // honored. PreCompact stamps it from the same clock as the reader, so the
    // legitimate skew is 0; this tolerance only absorbs a clock step. It is a
    // THRESHOLD, so it is named rather than inlined and carries its own boundary
    // trio (Codex review of #3808, round 4). Note it also extends the mute: a
    // watermark this far ahead pushes first recovery from +61 to +66 (measured).
    const WATERMARK_SKEW_SECONDS = 5;
    
    // Resolve the two fire-points from the project's `.planning/config.json`
    // (#4285). The constants above are the DEFAULTS; a project overrides either one
    // through `hooks.context_warning_threshold` / `hooks.context_critical_threshold`,
    // which is what keeps a tuned fire-point alive across updates — this file is in
    // the MANAGED registry, so an edit to the constants is re-staged away by the
    // next install.
    //
    // TOTAL and never-throwing: this hook must not block the tool call it rides in
    // on, so every unusable input degrades to the default instead of raising.
    // Unusable is decided by Number.isFinite, which is type-strict (the string
    // "30" and true are both rejected, unlike the global isFinite), plus the 0-100
    // domain of the remaining_percentage these are compared against.
    //
    // The PAIR is validated too, and falls back TOGETHER. `critical >= warning` has
    // no coherent reading — critical fires deeper into the window than warning —
    // and honouring one side of an inconsistent pair silently picks which of the
    // operator's two numbers to discard. This also rejects a single override that
    // contradicts the OTHER key's default (warning 20 with critical absent, i.e.
    // 25); the resulting pair is the same nonsense either way. Set-time validation
    // cannot stand in for this check: `config-set` writes one key per call, so
    // tuning both (warning first, then critical) is transiently inconsistent on
    // disk, and refusing it there would block a legitimate configuration.
    function resolveThresholds(hooks) {
      const defaults = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD };
      if (!hooks || typeof hooks !== 'object') return defaults;
    
      const usable = (value, fallback) =>
        (Number.isFinite(value) && value >= 0 && value <= 100) ? value : fallback;
    
      const warning = usable(hooks.context_warning_threshold, WARNING_THRESHOLD);
      const critical = usable(hooks.context_critical_threshold, CRITICAL_THRESHOLD);
    
      return critical < warning ? { warning, critical } : defaults;
    }
    
    // One DEFINITION of what counts as a lifecycle event name, shared by the #3709
    // PreCompact reset and the #2289 output-envelope allowlist. Two call sites, one
    // rule — so the two cannot drif
  • 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');
    const { allow } = require('./lib/hook-exit.js');
    
    // 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).
     *
     * 
  • hooks/gsd-graphify-update.shGitHub
  • hooks/gsd-node-runner.shGitHub
  • hooks/gsd-path-export.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // gsd-hook-version: {{GSD_VERSION}}
    // SessionStart hook: put the installed gsd-core `bin/` directory (which holds `gsd_run`) on the
    // PATH of every later Bash tool call in this Claude Code session (#5465).
    //
    // Why a hook: the resolver snippet (gsd-core/workflows/_runtime-launcher.snippet.sh) persists its
    // PATH export through $CLAUDE_ENV_FILE, but Claude Code provides that variable only to SessionStart,
    // Setup, CwdChanged and FileChanged hooks, never to a workflow Bash block. So the write never fired
    // and a later block that called `gsd_run` without re-including the resolver got "command not found"
    // (hidden by `2>/dev/null || echo <fallback>`). This hook writes the same line from the one place
    // the variable exists. The snippet's in-block write stays for users who export the variable
    // themselves.
    //
    // Fail-open and side-effect-minimal by design: it only ever appends one line to the file Claude Code
    // hands to SessionStart hooks, only for a directory that contains an installed `gsd_run`, and any
    // problem (no variable, no gsd_run, unwritable file) exits 0 without output.
    
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    const { allow } = require('./lib/hook-exit.js');
    
    /**
     * The `bin/` directory next to this hook's install root: hooks live in `<root>/hooks/` and the
     * runtime in `<root>/gsd-core/` for global, project-local and plugin layouts alike. Returns the
     * real path, or null when `gsd_run` is not there.
     */
    function resolveBinDir(hookDir) {
      try {
        const bin = fs.realpathSync(path.join(hookDir, '..', 'gsd-core', 'bin'));
        return fs.statSync(path.join(bin, 'gsd_run')).isFile() ? bin : null;
      } catch {
        return null;
      }
    }
    
    /**
     * The directory as the bash that sources $CLAUDE_ENV_FILE spells it. On Windows the env file is
     * sourced by Git Bash, where `:` separates PATH entries and a drive letter is `/c/...`, which is
     * also what the resolver snippet writes (it derives the directory from bash's `$GSD_TOOLS`). A
     * native `C:\Users\x\...` would split at the colon and never resolve.
     */
    function shellPath(dir, platform = process.platform) {
      if (platform !== 'win32') return dir;
      const m = /^([A-Za-z]):[\\/](.*)$/.exec(dir);
      const rest = (m ? m[2] : dir.replace(/^[\\/]+/, '')).replace(/\\/g, '/');
      return m ? `/${m[1].toLowerCase()}/${rest}` : dir.replace(/\\/g, '/');
    }
    
    /** The exact line the resolver snippet writes: `export PATH='<dir>':"$PATH"` with `'` escaped. */
    function exportLine(binDir, platform = process.platform) {
      return `export PATH='${shellPath(binDir, platform).replace(/'/g, "'\\''")}':"$PATH"`;
    }
    
    /**
     * Append the export to `envFile` unless it is already there. Returns true when it wrote.
     * Never throws.
     */
    function persistPath(envFile, binDir) {
      try {
        const line = exportLine(binDir);
        let existing = '';
        try {
          existing = fs.readFileSync(envFile, 'utf8');
        } catch (e) {
          if (e && e.code !== 'ENOENT') return false;
        }
        if (existing.split(/\r?\n/).includes(line)) return false;
        const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
        fs.appendFileSync(envFile, `${prefix}${line}\n`, 'utf8');
        return true;
      } catch {
        return false;
      }
    }
    
    function main() {
      const envFile = process.env.CLAUDE_ENV_FILE;
      if (typeof envFile !== 'string' || envFile.trim() === '') return;
      const binDir = resolveBinDir(__dirname);
      if (binDir === null) return;
      persistPath(envFile, binDir);
    }
    
    if (require.main === module) {
      try {
        main();
      } catch {
        // A PATH convenience must never fail session start.
      }
      allow();
    }
    
    module.exports = { resolveBinDir, shellPath, exportLine, persistPath };
    
  • hooks/gsd-phase-boundary.shGitHub
  • hooks/gsd-prompt-guard.jsRunsGitHub
  • hooks/gsd-read-guard.jsRunsGitHub
  • hooks/gsd-read-injection-scanner.jsRunsGitHub
  • hooks/gsd-secret-read-guard.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 30 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.

@open-gsd
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.

11k72 skills64 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
11.4k
Star history of open-gsd/gsd-core on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3112k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin
myclaude

myclaude

Bundles skills for developers using Claude Code to run AI coding tasks across Codex, Claude, Gemini, and OpenCode backends. It also covers structured feature development, multi-session agent work with checkpoints, and browser automation via Chrome DevTools.

11 skills+32.8k
@cexll@cexll
Install Plugin

Questions about the hooks in gsd-core.

Which coding agents can use the hooks in gsd-core?
The hooks in gsd-core install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.