Skip to content
Development
Hook

Hooks

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

From plugin
session-orchestrator
5352 skills14 agents2 commands10 hooks
+1

Claude Code

> /plugin marketplace add Kanevry/session-orchestrator
> /plugin install session-orchestrator@kanevry

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.

  • Matchesstartup|resume|clear|compactecho '🎯 Session Orchestrator v5.18.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-session-start.mjs"

SessionEnd

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-session-end.mjs"

PreToolUse

  • MatchesSkillexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/skill-invocation-telemetry.mjs"
  • MatchesEdit|Write|MultiEditexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/enforce-scope.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/config-protection.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-state-md-path-guard.mjs"
  • MatchesBash|Monitorexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-destructive-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-staging-fence.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-memory-propose-audit.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-sessions-ledger-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-state-md-path-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-templates-first.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-issue-budget.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/enforce-commands.mjs"
  • MatchesPowerShellexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-destructive-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" --fail-closed "$CLAUDE_PLUGIN_ROOT/hooks/enforce-commands.mjs"
  • MatchesAgentexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-task-scope-disjoint.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-agent-cap.mjs"
  • MatchesAskUserQuestionexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-auq-clarity.mjs"

PostToolUse

  • MatchesEdit|Writeexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-edit-validate.mjs"
  • MatchesEdit|Write|MultiEditexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tooluse-frontend-slop.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-edit-import-probe.mjs"
  • MatchesBash|Monitorexec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs"
  • Matches*exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/loop-guard.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tool-context-threshold.mjs"

Stop

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-stop.mjs"

SubagentStop

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-stop.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/subagent-telemetry.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-subagent-discovery-validator.mjs"

PostToolUseFailure

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tool-failure-corrective-context.mjs"exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-bash-issue-budget-refund.mjs"

PostToolBatch

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tool-batch-wave-signal.mjs"
  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/operator-steer.mjs"

SubagentStart

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/subagent-telemetry.mjs"

CwdChanged

  • exec sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/cwd-change-restore.mjs"
Read hooks/hooks.json

Where it lives

  • hooks/codex-apply-patch-guard.mjsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * codex-apply-patch-guard.mjs — PreToolUse `apply_patch` + `Bash` hook for Codex
     * (GitLab #1913, #1914): runs the Edit/Write guards on every file a Codex patch touches.
     *
     * Codex writes files through its freeform `apply_patch` tool; the PreToolUse
     * payload carries the patch text, not the `tool_input.file_path` the Edit/Write
     * guards read. This adapter parses the patch (`scripts/lib/codex/apply-patch-parse.mjs`,
     * which also records what of the payload shape is measured and what is not),
     * turns each touched file into the Claude-shaped Edit or Write call the guards
     * understand, and runs the four guards on it:
     *
     *   enforce-scope → config-protection → pre-edit-sessions-ledger-guard → pre-tool-state-md-path-guard
     *
     * Mapping per file: Add → Write `{file_path, content}`; Delete → Write with
     * `content: ''`; Update → a Write of the WHOLE file Codex would produce; Move →
     * the source as a Delete AND the destination as a Write of the moved content
     * (see `toToolCalls` for why whole-file Writes, not per-hunk Edits).
     *
     * The guards end through `emitAllow`/`emitDeny`, which call `process.exit`, so
     * each runs as its own child process (`spawnSync(process.execPath, …)`) with
     * the synthesized payload on stdin. The first deny envelope is forwarded with
     * ` [apply_patch: <path>]` appended to its reason.
     *
     * ## Two routes (measured codex-cli 0.160.1, macOS, `codex exec`, 2026-11-02, #1914)
     *
     * 1. Tool route: matcher `apply_patch`, `tool_name: "apply_patch"`,
     *    `tool_input: { command: "<patch>" }` (`extractPatchText`).
     * 2. Shell route: the model writes `apply_patch <<'EOF' … EOF` through its shell
     *    tool. Only the `Bash` matcher fires (`tool_name: "Bash"`, the heredoc in
     *    `tool_input.command`), no PostToolUse; Codex recognises the heredoc itself
     *    (tree-sitter query `(#any-of? @apply_name "apply_patch" "applypatch")`) and
     *    applies the patch without running a shell. So this hook is registered on
     *    `Bash` too: `extractShellApplyPatch` reads the heredoc and the patch runs
     *    the same path as on the tool route. A Bash command that does not name
     *    apply_patch allows at once after a cheap text test, with no guard child
     *    and no further module load. A command that may name it is read with the
     *    shared tokenizer in `scripts/lib/command-blocker.mjs` (loaded only then,
     *    #1917): every statement of the chain, every verb reading behind
     *    assignments and wrappers (`x=1`, `time`, `exec`, `sudo`, `nice -n 5`, both
     *    readings of an unknown option such as `sudo -X val`), `$( )` and backtick
     *    bodies, the string of `bash|sh|zsh|dash|ksh|su -c` and `eval`, and the
     *    literal data a shell reads from stdin (`printf 'S' | sh`, `sh <<< 'S'`,
     *    `printf 'S' | xargs -0 sh -c`, the blocker's `pipedShellData`, #2003). The
     *    command word counts after quotes, backslashes and `$'…'` are undone; a
     *    word left unexpanded (`apply_${p}atch`) counts when it carries the name or
     *    what remains of it. A quoted argument (`git commit -m "fix; apply_patch"`)
     *    or a heredoc body is not an invocation and allows; `command -v` and
     *    `which` look the name up and allow. Only the plain heredoc
     *    `apply_patch <<'EOF'` … `EOF` (optionally behind `command`/`env VAR=x`) is
     *    read; every other invocation (prefixed heredoc, pipe, file redirect,
     *    `cd … &&`, `bash -c`, a quoted or masked name, text after the heredoc)
     *    denies: Codex might still apply it internally, past the guards. A
     *    tokenizer that cannot load or throws denies too.
     *
     *    CEILING (BV-004): the tokenizer reads the command text only. A name that
     *    exists only at run time (`$CMD < p.diff` with `CMD=apply_patch`, a name
     *    read from a file or variable, data piped into a shell from a file), an
     *    expansion pattern other than a
     *    bare `${x}`/`$x`/`$( )` inside the name, and another interpreter that runs
     *    it (`xargs apply_patch`, `find -exec`, `python -c`, `perl -e`) are not
     *    seen and allow. Revisit trigger: one such invocation in a real Codex
     *    session.
     *
     *    MEASURED (codex-cli 0.160.1, 2026-11-02): a `Bash` PreToolUse deny stops
     *    Codex's INTERNAL heredoc application completely (file unchanged, no
     *    file_change event; the model gets "Command blocked by PreToolUse hook:
     *    <reason>"), for all three deny forms (permissionDecision "deny",
     *    `{"decision":"block"}`, exit 2 with stderr).
     *
     *    CEILING: the shell route protects only with the guards the host profile
     *    switches on. Under profile `standard`, `pre-tool-state-md-path-guard` is
     *    off (STANDARD_OFF_HOOKS), so a heredoc patch on `.orchestrator/STATE.md`
     *    goes through, as with Claude Edit/Write.
     *
     * ## Fail closed
     *
     * Unlike the single-purpose guards (which allow on internal error), this hook
     * is the ONLY route by which Codex file writes reach those guards. So every
     * failure denies: unparsable stdin, an unrecognised `tool_input` shape, a patch
     * outside the grammar, a guard that exits non-zero, times out or cannot be
     * spawned, an exhausted time budget, and any internal error. A payload whose
     * `tool_name` is not `apply_patch` allows only when its `tool_input` does not
     * read as a patch (see `main`).
     *
     * Time: Codex gives the hook 5 s (`hooks/hooks-codex.json`). The children share
     * a 4.5 s budget; a patch too large to judge in it is denied, not waved through,
     * and the deny names the way out: split the patch. All `*** Update File:`
     * sections of one path fold into one judged Write (`foldPatchFiles`, #1914),
     * so the child count grows with the files a patch touches, not its hunks.
     *
     * ## Override
     *
     *   SO_DISABLED_HOOKS=codex-apply-patch-guard   (whole adapter)
     *   SO_DISABLED_HOOKS=<guard name>               (one guard; honoured by the child)
     *   SO_HOOK_PROFILE=minimal|off
     */
    
    import { readFileSync, writeSync } from 'node:fs';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
  • hooks/codex-win.cjsGitHub
    Read the script
    'use strict';
    // hooks/codex-win.cjs — native Windows entry for the Codex plugin hooks (#1849).
    //
    // hooks/hooks-codex.json starts every handler through a POSIX hull
    // (`SO_PLATFORM=codex CODEX_PLUGIN_ROOT=… sh run-node.sh …`). Under native
    // Windows that hull fails before the handler runs: Codex starts Windows hooks
    // through `%COMSPEC% /C` (cmd.exe; codex-rs hooks/src/engine/command_runner.rs
    // @26ad281f), and cmd.exe has no env-prefix syntax, so no hook — and no guard —
    // ever runs. Each handler therefore also carries a `commandWindows` override:
    //
    //   set NoDefaultCurrentDirectoryInExePath=1&& node -e "require(require('path').join(require('path').isAbsolute(process.env.PLUGIN_ROOT||'')?process.env.PLUGIN_ROOT:process.exit(1),'hooks','codex-win.cjs'))" <handler>.mjs
    //
    // - The `set` matters: cmd.exe looks for a bare `node` in the current directory
    //   before PATH, and the current directory is the session's repository, which
    //   could ship a `node.exe` or `node.cmd`.
    // - The quoted script has no spaces and no `$`, `%` or `"`, so cmd.exe (and
    //   PowerShell) hand it to node unchanged; the plugin root is read from the
    //   environment inside node, so a root with spaces needs no shell quoting.
    // - Only an absolute root is used: a relative or empty one would make the
    //   join a module path that require() looks up in the session directory's
    //   node_modules — a file the repository could plant.
    //
    // Contract (same as hooks/run-node.sh): set SO_PLATFORM=codex and
    // CODEX_PLUGIN_ROOT, run the handler with stdin/stdout/stderr inherited, exit
    // with the handler's exit code. Node is the running binary (process.execPath);
    // a host without `node` on PATH fails in the shell before this file loads.
    //
    // Unlike the POSIX hull's `exec`, this is a parent plus a child process. On a
    // hook timeout Codex terminates the hook's job object, or runs `taskkill /T`,
    // which takes the child with it (command_runner.rs @26ad281f). Revisit if a
    // Windows run shows a handler outliving its timeout.
    // eslint-disable-next-line no-restricted-syntax -- CommonJS entry; its only start is process.execPath, an absolute path (#1864)
    const { spawnSync } = require('node:child_process');
    const path = require('node:path');
    const process = require('node:process');
    
    // Same grammar as the POSIX wrapper's handler path in scripts/lib/codex/plugin-contract.mjs.
    const HANDLER_RE = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.mjs$/;
    
    const root = process.env.PLUGIN_ROOT;
    const handler = process.argv[1];
    
    if (typeof root !== 'string' || root.trim() === '') {
      process.stderr.write('session-orchestrator: PLUGIN_ROOT is not set; Codex hook not started (#1849)\n');
      process.exit(1);
    }
    if (typeof handler !== 'string' || !HANDLER_RE.test(handler)) {
      process.stderr.write(`session-orchestrator: invalid hook handler name ${JSON.stringify(String(handler))} (#1849)\n`);
      process.exit(1);
    }
    
    const result = spawnSync(process.execPath, [path.join(root, 'hooks', ...handler.split('/'))], {
      stdio: 'inherit',
      env: { ...process.env, SO_PLATFORM: 'codex', CODEX_PLUGIN_ROOT: root },
      windowsHide: true,
    });
    
    if (result.error) {
      process.stderr.write(`session-orchestrator: hook ${handler} failed to start: ${result.error.message}\n`);
      process.exit(1);
    }
    process.exit(result.status ?? 1);
    
  • hooks/config-protection.mjsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * config-protection.mjs — PreToolUse Edit|Write|MultiEdit guard (ecc-analysis / #622).
     *
     * The edit-tool analogue of the test-the-mock gate-cheating anti-pattern.
     * Intercepts Edit/Write on a small allow-list of quality-gate config files
     * (eslint / vitest / tsconfig / prettier / commitlint / gitleaks) and WARNs —
     * or, in `strict` mode, BLOCKS — when an edit LOOSENS a quality gate:
     *   1. a coverage/threshold number is lowered;
     *   2. a disable/ignore directive (eslint-disable, @ts-ignore, prettier-ignore,
     *      …) is ADDED (count increase, not mere presence);
     *   3. a lint rule is removed or turned off (error/warn → off/0/false);
     *   4. a `.gitleaks.toml` allowlist is widened (new allowlist/regex/path/stopword
     *      entries);
     *   5. tsconfig strictness is relaxed (strict flags flipped true→false, removed,
     *      or skipLibCheck added true).
     *
     * First-time creation (no prior file), tightening, neutral/comment-only edits,
     * non-config files, and unparseable content are ALWAYS allowed. The guard is
     * warn-by-default advisory — a low-false-positive line/regex heuristic, NOT an
     * exhaustive AST gate (YAGNI). Fail-open on any internal error: a legit edit is
     * never blocked by a guard bug.
     *
     * Edit/MultiEdit are compared WHOLE-FILE, not slice-only: the on-disk file
     * (still pre-edit at PreToolUse time) is read as old, the supplied slice
     * replacement(s) are applied in order to synthesise new, and detectLoosening
     * runs over the full contents. This defeats slice-boundary bypasses where the
     * key name / rule lives outside the caller-chosen old_string→new_string window
     * (e.g. `old_string:"90" new_string:"10"` or deleting a whole threshold line).
     *
     * Decision precedence:
     *   1. shouldRunHook('config-protection') gate — exit 0 when disabled.
     *   2. config-protection.enabled (false → allow silently).
     *   3. allow-config-weakening: true Session Config bypass → allow + ℹ note.
     *   4. heuristic → warn (stderr + event) | strict-block (event + deny envelope).
     *
     * Exit code is ALWAYS 0 (post-#906). A strict-mode block is signalled solely by
     * the nested PreToolUse deny envelope emitDeny() writes to stdout — the docs
     * forbid the old stdout-JSON + `exit 2` mixed form ("Exit 2 … Claude Code
     * ignores stdout and any JSON in it"), which discarded the reason and looked
     * like a crash to the operator. Do not reintroduce `exit 2` here.
     */
    
    import { shouldRunHook } from './_lib/profile-gate.mjs';
    import { isMainModule } from '../scripts/lib/is-main-module.mjs';
    
    import { promises as fs } from 'node:fs';
    import path from 'node:path';
    
    import { emitAllow, emitDeny } from '../scripts/lib/io.mjs';
    import { emitEvent } from '../scripts/lib/events.mjs';
    import { getProjectDir } from '../scripts/lib/platform.mjs';
    import {
      _parseConfigProtection,
      _isConfigWeakeningAllowed,
    } from '../scripts/lib/config/config-protection.mjs';
    
    // ---------------------------------------------------------------------------
    // Protected-file allow-list (matched against path.basename(file_path))
    // ---------------------------------------------------------------------------
    
    /**
     * Exact basenames that are always protected.
     * package.json + pyproject.toml are deliberately EXCLUDED — they change for
     * many non-gate reasons and would be a false-positive magnet (#622 scope).
     */
    const PROTECTED_EXACT = new Set([
      'vitest.config.base.ts',
      '.gitleaks.toml',
      'gitleaks.toml',
    ]);
    
    /**
     * Regex matchers for the protected basenames with variant extensions.
     * Kept anchored + linear-time (no nested quantifiers) — ReDoS-safe.
     */
    const PROTECTED_PATTERNS = [
      /^eslint\.config\.(?:js|mjs|cjs|ts|mts|cts)$/,
      /^\.eslintrc(?:\..+)?$/, // .eslintrc, .eslintrc.json, .eslintrc.js, .eslintrc.cjs, …
      /^vitest\.config\.(?:ts|js|mjs)$/,
      /^tsconfig.*\.json$/, // tsconfig.json, tsconfig.base.json, tsconfig.build.json, …
      /^\.prettierrc(?:\..+)?$/, // .prettierrc, .prettierrc.json, .prettierrc.js, …
      /^prettier\.config\.(?:js|cjs|mjs)$/,
      /^commitlint(?:\.ci)?\.config\.(?:js|cjs|mjs|ts)$/, // + the strict CI config (commitlint.ci.config.mjs)
    ];
    
    /**
     * Is the given file path a protected quality-gate config file?
     * @param {string} filePath
     * @returns {boolean}
     */
    function isProtectedConfig(filePath) {
      if (typeof filePath !== 'string' || !filePath) return false;
      const base = path.basename(filePath);
      if (PROTECTED_EXACT.has(base)) return true;
      return PROTECTED_PATTERNS.some((re) => re.test(base));
    }
    
    // ---------------------------------------------------------------------------
    // stdin reading (inline null-on-failure — never throw; mirrors the
    // post-tool-batch-wave-signal.mjs:55 pattern so a malformed payload allows)
    // ---------------------------------------------------------------------------
    
    /**
     * Read stdin to EOF (best-effort). Returns parsed JSON or null on failure.
     * @returns {Promise<object|null>}
     */
    function readStdinJson() {
      return new Promise((resolve) => {
        if (process.stdin.readableEnded || process.stdin.closed) {
          resolve(null);
          return;
        }
        const chunks = [];
        const timer = setTimeout(() => { resolve(null); }, 5_000);
        process.stdin.setEncoding('utf8');
        process.stdin.on('data', (c) => chunks.push(c));
        process.stdin.on('end', () => {
          clearTimeout(timer);
          const raw = chunks.join('').trim();
          if (!raw) { resolve(null); return; }
          try { resolve(JSON.parse(raw)); } catch { resolve(null); }
        });
        process.stdin.on('error', () => { clearTimeout(timer); resolve(null); });
        process.stdin.resume();
      });
    }
    
    // ---------------------------------------------------------------------------
    // Loosening heuristic
    // ---------------------------------------------------------------------------
    
    /**
     * Disable/ignore directive TOKENS. Counted (not merely presence-checked) so an
     * edit only flags when it ADDS more directives than the old content had — prose
     * mentioning the word "disable" cannot trip the g
  • hooks/cwd-change-restore.mjsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * cwd-change-restore.mjs — CwdChanged hook handler.
     *
     * Hook event: CwdChanged (issue #342).
     * Fires when the coordinator's working directory changes unexpectedly.
     * Records the event in `.orchestrator/current-session.json` under the
     * `cwd_changes` array so the coordinator and downstream skills can
     * inspect recent directory changes without re-reading the full event log.
     *
     * Note: the actual CWD restoration is a harness-level concern; this
     * handler is informational only. It mirrors the pattern used by
     * post-tool-failure-corrective-context.mjs.
     *
     * Decision flow:
     *   1. shouldRunHook gate — exit 0 immediately when the hook is disabled.
     *   2. Read JSON payload from stdin: { previous_cwd, new_cwd }.
     *   3. Build a compact record: { timestamp, previous_cwd, new_cwd }.
     *   4. Atomic read-modify-write of .orchestrator/current-session.json:
     *      append to `cwd_changes` array (create if absent), keep last 20
     *      entries to bound file growth.
     *   5. Output: nothing on stdout. Diagnostic errors to stderr only.
     *
     * Exit codes: 0 always (informational, never blocking).
     */
    
    import path from 'node:path';
    
    import { shouldRunHook } from './_lib/profile-gate.mjs';
    import { isMainModule } from '../scripts/lib/is-main-module.mjs';
    
    import { getProjectDir } from '../scripts/lib/platform.mjs';
    import { atomicMutateJson } from './_lib/atomic-json.mjs';
    import { resolveOwnLockRoot } from './_lib/hook-root.mjs';
    
    // ---------------------------------------------------------------------------
    // Constants
    // ---------------------------------------------------------------------------
    
    /** Maximum number of cwd_change entries retained per session. */
    const MAX_ENTRIES = 20;
    
    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------
    
    /**
     * Read stdin to EOF (best-effort). Returns parsed JSON or null on failure.
     * Uses a 5 s timeout consistent with Claude Code hook contract.
     *
     * @returns {Promise<object|null>}
     */
    function readStdinJson() {
      return new Promise((resolve) => {
        if (process.stdin.readableEnded || process.stdin.closed) {
          resolve(null);
          return;
        }
        const chunks = [];
        const timer = setTimeout(() => { resolve(null); }, 5_000);
        process.stdin.setEncoding('utf8');
        process.stdin.on('data', (c) => chunks.push(c));
        process.stdin.on('end', () => {
          clearTimeout(timer);
          const raw = chunks.join('').trim();
          if (!raw) { resolve(null); return; }
          try { resolve(JSON.parse(raw)); } catch { resolve(null); }
        });
        process.stdin.on('error', () => { clearTimeout(timer); resolve(null); });
        process.stdin.resume();
      });
    }
    
    // ---------------------------------------------------------------------------
    // Main
    // ---------------------------------------------------------------------------
    
    async function main() {
      const input = await readStdinJson();
    
      const previousCwd =
        typeof input?.previous_cwd === 'string' ? input.previous_cwd : null;
      const newCwd =
        typeof input?.new_cwd === 'string' ? input.new_cwd : null;
    
      const record = {
        timestamp: new Date().toISOString(),
        previous_cwd: previousCwd,
        new_cwd: newCwd,
      };
    
      // #1605 B5 — annotate THIS session's current-session.json where
      // on-session-start.mjs put it (the entered worktree after a /clear there),
      // found by the lock carrying the raw payload id; launch dir otherwise.
      const sessionFile = path.join(resolveOwnLockRoot(input, getProjectDir()), '.orchestrator', 'current-session.json');
    
      const result = await atomicMutateJson(sessionFile, {}, (current) => {
        const existing = Array.isArray(current.cwd_changes)
          ? current.cwd_changes
          : [];
        // Append the new record and cap at MAX_ENTRIES (keep most-recent).
        const updated = [...existing, record].slice(-MAX_ENTRIES);
        return { ...current, cwd_changes: updated };
      }, 'cwd');
      // Nothing else depends on this write — a non-ENOENT read/parse failure
      // just means the record is dropped this turn. Diagnostic only (stderr),
      // matches the hook's own "never blocking" contract.
      if (!result.ok) {
        console.error(`cwd-change-restore: atomicMutateJson skipped write (${result.reason})`);
      }
    }
    
    // Exit 0 always — informational hook must never block Claude.
    // Entry guard (#1393): run only when this file IS the script node was invoked
    // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
    // `import()` (a probe, a test, a curious agent) must neither run main() nor
    // tear the importing process down. The profile gate sits INSIDE the guard for
    // that second reason: at module top level its `process.exit(0)` exited every
    // process that merely imported this hook.
    if (isMainModule(import.meta.url)) {
      if (!shouldRunHook('cwd-change-restore')) process.exit(0);
      main().catch(() => {}).finally(() => process.exit(0));
    }
    
  • hooks/enforce-commands.mjsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * enforce-commands.mjs — PreToolUse hook: blocks dangerous Bash commands.
     *
     * Node.js port of hooks/enforce-commands.sh. Part of v3.0.0 migration
     * (Epic #124, issue #138). ESM, Node 20+, no external dependencies beyond stdlib.
     *
     * Decision flow (8 gates, early-exit):
     *   G1 tool filter — Bash and PowerShell tools are gated
     *   G2 command present + string
     *   G3 wave-scope.json exists
     *   G4 command-guard gate enabled
     *   G5 enforcement != "off"
     *   G6 blocked pattern match against .blockedCommands[], or
     *      fallback safety list when .blockedCommands is empty
     *   G7 strict → deny; warn → stderr + allow; otherwise allow
     *
     * DECISION CHANNEL (post-#906): a deny is signalled by the single nested
     *   PreToolUse JSON envelope emitDeny() writes to stdout, with exit **0** —
     *   NOT by exit 2. The docs forbid the mixed form ("Exit 2 … Claude Code
     *   ignores stdout and any JSON in it"), which silently discarded the reason
     *   and surfaced to the operator as a crash. Do not reintroduce `exit 2` here.
     *   Corollary: exit 0 alone no longer distinguishes allow from deny — the
     *   envelope's presence does, and a malformed envelope fails OPEN.
     *
     * SECURITY-REQ-01: try/catch on main(). emitDeny on any unhandled error —
     *   fail-closed, never a bare exit 1. Null-guard readStdin() return.
     * SECURITY-REQ-07: FALLBACK_BLOCKED includes 'git push -f' and 'drop table'
     *   (short form + case variant gaps in the original Bash fallback list).
     * SECURITY-REQ-08: scope file read exactly once per invocation.
     */
    
    import { shouldRunHook } from './_lib/profile-gate.mjs';
    // Static for the SAME reason profile-gate.mjs is (#993, see the late-binding
    // block below): a leaf predicate with ZERO repo imports (node:fs + node:url
    // only) that decides whether this hook runs at all. Everything carrying a
    // transitive repo graph stays late-bound inside bootstrap().
    import { isMainModule } from '../scripts/lib/is-main-module.mjs';
    
    // Static for the same reason: `command-hash.mjs` imports node builtins only.
    // `command_hash` is a keyed host-local HMAC since #1880 (it was an unsalted
    // sha256 prefix, reversible by dictionary); the foreign_session_ignored
    // payload carries it instead of the raw command since 2026-09-19 (#1140).
    import { hashCommand } from './_lib/command-hash.mjs';
    import path from 'node:path';
    import { pathToFileURL } from 'node:url';
    
    // ---------------------------------------------------------------------------
    // #993 — late-bound repo dependencies
    //
    // These used to be STATIC imports. A SyntaxError in any of them failed at ESM
    // LINK time, before the first statement here ran: node exited 1 with 0 bytes on
    // stdout, and the `main().catch(...)` handler at the bottom of this file was
    // structurally unreachable (it only covers runtime errors inside `main()`).
    // Under the exit-0 PreToolUse protocol (#906) that crash is, on the only
    // decision-bearing channel, INDISTINGUISHABLE from an explicit `emitAllow()` —
    // the guard failed open and SILENTLY. This is the sibling defect #992 fixed for
    // pre-bash-destructive-guard; #993 generalises the same repair here.
    //
    // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
    // link-time crash into a catchable runtime error, which is what makes the
    // GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
    //
    // `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
    // and gates whether this hook runs at all.
    // ---------------------------------------------------------------------------
    /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
    /** @type {typeof import('../scripts/lib/io.mjs').isShellCommandTool} */ let isShellCommandTool;
    /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
    /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
    /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
    let readJson;
    // #1504 point 6 — bound from scope-gate below.
    let resolveScopeContext;
    let extractBashWriteTargets;
    let pathMatchesPattern;
    /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
    let readProcessLocalSessionIds;
    /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').classifyManifestSession} */
    let classifyManifestSession;
    /**
     * The `command-blocker.mjs` namespace, imported DIRECTLY (not via the
     * hardening.mjs barrel — which does not carry the `headFallback` recovery, and
     * which transitively imports command-blocker via scope-gate.mjs, so it fails
     * anyway when command-blocker breaks). Mirrors the direct binding in
     * pre-bash-destructive-guard / sessions-ledger-guard. Held as ONE object so the
     * required-export list lives in exactly one place: the `requires` array on the
     * `blocker` spec passed to `armGuard`.
     *
     * @type {Record<string, Function>|null}
     */
    let blocker = null;
    
    /**
     * Module labels `armGuard` recovered from HEAD because the working-tree copy
     * failed (parse error OR shape check). Non-empty ⇒ this hook is armed against
     * COMMITTED source. Surfaced on the visible stdout channel by {@link flushNotices}
     * (#1001) — the stderr DEGRADED banner alone is discarded under the exit-0
     * protocol, which made a degraded ALLOW indistinguishable from a healthy one.
     *
     * @type {string[]}
     */
    let degradedLabels = [];
    
    const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
    
    /** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
    const HOOK_NAME = 'enforce-commands';
    
    /**
     * The consequence block spliced VERBATIM into the DEGRADED and GUARD INACTIVE
     * banners (#993), naming the enforcement this hook's outage stops applying.
     */
    const GUARD_CONSEQUENCE = {
      degraded: [
        '    Consequence: command enforcement IS still armed, but it is evaluating the',
        '    COMMITTED (HEAD) command-blocker — any uncommitted change to it is NO
  • hooks/enforce-scope.mjsGitHub
  • hooks/loop-guard.mjsGitHub
  • hooks/on-session-end.mjsGitHub
  • hooks/on-session-start.mjsGitHub
  • hooks/on-stop.mjsGitHub
  • hooks/operator-steer.mjsGitHub
  • hooks/post-bash-issue-budget-refund.mjsGitHub
  • hooks/post-bash-write-verify.mjsGitHub
  • hooks/post-edit-import-probe.mjsGitHub
  • hooks/post-edit-validate.mjsGitHub
  • hooks/post-subagent-discovery-validator.mjsGitHub
  • hooks/post-tool-batch-wave-signal.mjsGitHub
  • hooks/post-tool-context-threshold.mjsGitHub
  • hooks/post-tool-failure-corrective-context.mjsGitHub
  • hooks/post-tooluse-frontend-slop.mjsGitHub
  • hooks/pre-agent-cap.mjsGitHub
  • hooks/pre-auq-clarity.mjsGitHub
  • hooks/pre-bash-destructive-guard.mjsGitHub
  • hooks/pre-bash-issue-budget.mjsGitHub
  • hooks/pre-bash-memory-propose-audit.mjsGitHub
  • hooks/pre-bash-sessions-ledger-guard.mjsGitHub
  • hooks/pre-bash-staging-fence.mjsGitHub
  • hooks/pre-bash-templates-first.mjsGitHub
  • hooks/pre-edit-sessions-ledger-guard.mjsGitHub
  • hooks/pre-task-scope-disjoint.mjsGitHub
  • hooks/pre-tool-state-md-path-guard.mjsGitHub
  • hooks/run-node.shRunsGitHub
    Read the script
    #!/bin/sh
    # hooks/run-node.sh — resolve the Node.js binary robustly, then exec a plugin hook.
    #
    # Why this exists (GH Kanevry/session-orchestrator#53): the Claude Code harness
    # executes hook commands via `/bin/sh -c` with the PATH of the harness process
    # itself. That shell does NOT source ~/.zshrc / ~/.bashrc, so Node installed via
    # Homebrew on Apple Silicon (/opt/homebrew/bin), nvm, volta, or asdf may be
    # invisible to hooks even though `node` works fine in a normal terminal. A bare
    # `node ...` command then fails with "node: command not found" on EVERY hook of
    # EVERY tool call — loud, repetitive, and useless to the operator.
    #
    # Contract:
    #   sh run-node.sh [--fail-closed] <hook-script.mjs> [args...]
    #   - Resolution order: $SO_NODE_BIN override > PATH > well-known install dirs
    #     ($SO_NODE_SEARCH_DIRS, colon-separated, overrides the built-in list) > nvm.
    #   - Found:   exec's node — stdin/stdout/stderr and exit code pass through
    #              unchanged (PreToolUse exit-2 blocking still works).
    #   - Missing with --fail-closed: prints a diagnostic every time and exits 2.
    #   - Missing otherwise: prints ONE warning per rate-limit window (marker file in
    #              ${TMPDIR:-/tmp}, 6h TTL) and exits 0 so hooks degrade gracefully
    #              instead of spamming a shell error on every tool call.
    
    fail_closed=false
    if [ "${1-}" = "--fail-closed" ]; then
      fail_closed=true
      shift
    fi
    
    # 1. Explicit operator override wins.
    if [ -n "${SO_NODE_BIN:-}" ] && [ -x "${SO_NODE_BIN:-}" ]; then
      exec "${SO_NODE_BIN:-}" "$@"
    fi
    
    # 2. PATH as inherited from the harness.
    # Git Bash / MSYS / Cygwin (#1969): a relative PATH entry such as `.` taken over
    # from the Windows PATH makes `command -v node` pick a node.exe from the hook's
    # cwd (the repo). There, search only the absolute entries. The check runs only
    # when PATH holds an empty or relative entry, so a clean PATH and every
    # Darwin/Linux run take the unchanged lookup below without an extra process.
    path_lookup=true
    case ":${PATH-}:" in
      *::* | *:[!/]*)
        abs_path=""
        rest="${PATH-}:"
        while [ -n "$rest" ]; do
          dir="${rest%%:*}"
          rest="${rest#*:}"
          case "$dir" in
            /*) abs_path="${abs_path:+$abs_path:}$dir" ;;
          esac
        done
        # uname itself is looked up on the absolute entries, never in cwd.
        case "$(PATH="$abs_path"; uname -s 2>/dev/null)" in
          MINGW* | MSYS* | CYGWIN*)
            node_bin="$(PATH="$abs_path"; command -v node 2>/dev/null)"
            if [ -n "$node_bin" ]; then
              exec "$node_bin" "$@"
            fi
            # No node on an absolute entry: never fall back to the relative ones.
            path_lookup=false
            ;;
        esac
        ;;
    esac
    if [ "$path_lookup" = true ] && command -v node >/dev/null 2>&1; then
      exec node "$@"
    fi
    
    # 3. Well-known install locations (Homebrew arm64/intel, system, volta, asdf).
    search_dirs="${SO_NODE_SEARCH_DIRS:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:${VOLTA_HOME:-${HOME:-}/.volta}/bin:${HOME:-}/.asdf/shims:${HOME:-}/.local/bin}"
    old_ifs="${IFS-}"
    IFS=:
    for dir in $search_dirs; do
      if [ -n "$dir" ] && [ -x "$dir/node" ]; then
        IFS="$old_ifs"
        exec "$dir/node" "$@"
      fi
    done
    IFS="$old_ifs"
    
    # Windows paths are checked separately to preserve spaces and drive-letter colons.
    # An explicit search list disables these built-in locations (#1900).
    if [ "${SO_NODE_SEARCH_DIRS+x}" != x ]; then
      for dir in "${ProgramFiles:+$ProgramFiles/nodejs}" \
        "${PROGRAMFILES:+$PROGRAMFILES/nodejs}" \
        "${LOCALAPPDATA:+$LOCALAPPDATA/Volta/bin}" "${NVM_SYMLINK:-}"; do
        if [ -n "$dir" ] && [ -x "$dir/node" ]; then
          exec "$dir/node" "$@"
        fi
      done
    fi
    
    # 4. nvm keeps versioned dirs; any installed version is good enough for hooks.
    #    (Glob order is lexical, not semver — acceptable for a last-resort fallback.)
    for cand in "${NVM_DIR:-${HOME:-}/.nvm}"/versions/node/*/bin/node; do
      if [ -x "$cand" ]; then
        exec "$cand" "$@"
      fi
    done
    
    if [ "$fail_closed" = true ]; then
      echo "session-orchestrator: Node is missing from the hook PATH; the tool call is blocked. Fix: install Node.js 24+, expose it on the harness PATH, or set SO_NODE_BIN=/abs/path/to/node." >&2
      exit 2
    fi
    
    # 5. Not found anywhere: degrade gracefully. Warn at most once per 6 hours so
    #    the operator gets ONE actionable diagnostic instead of per-tool-call spam.
    #    Deliberately dependency-free: a PATH broken enough to lose `node` may also
    #    lack `touch`/`find`/`id`, so the marker is written via shell redirection
    #    and `find` only upgrades the check to a 6h TTL when it happens to exist.
    marker="${TMPDIR:-/tmp}/session-orchestrator-node-missing-${USER:-uid}"
    if [ -f "$marker" ]; then
      expired="$(find "$marker" -mmin +360 2>/dev/null || true)"
    else
      expired="yes"
    fi
    if [ -n "$expired" ]; then
      : > "$marker" 2>/dev/null || true
      {
        echo "session-orchestrator: 'node' not found on the hook PATH — plugin hooks are skipped."
        echo "  Fix: install Node.js 24+, expose it on the harness PATH, or set SO_NODE_BIN=/abs/path/to/node."
        echo "  (Hook shells do not source ~/.zshrc — Homebrew/nvm/volta installs can be invisible here."
        echo "   Rate-limited to once per 6h. See docs/USER-GUIDE.md § Node is missing from the hook PATH.)"
      } >&2
    fi
    exit 0
    
  • hooks/skill-invocation-telemetry.mjsGitHub
  • hooks/subagent-telemetry.mjsGitHub
  • hooks/wave-scope-commit-guard.mjsGitHub

All 35 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.

@kanevry
Ships withsession-orchestrator

Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session.

5352 skills14 agents1 MCP
Get the whole plugin

Stars on GitHub

Momentum

stars in total
53
Star history of Kanevry/session-orchestrator 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
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
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

Questions about the hooks in session-orchestrator.

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