Skip to content
Development
Hook

Hooks

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

From plugin
sd0x-dev-flow
195102 skills16 agents5 hooks

Claude Code

> /plugin marketplace add sd0xdev/sd0x-harness
> /plugin install sd0x-dev-flow@sd0xdev-marketplace

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|clear|compact${CLAUDE_PLUGIN_ROOT}/scripts/namespace-hint.sh
  • Matchescompact${CLAUDE_PLUGIN_ROOT}/hooks/post-compact-auto-loop.sh

PreToolUse

  • MatchesEdit|Write|NotebookEdit${CLAUDE_PLUGIN_ROOT}/hooks/pre-edit-guard.sh
  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-codex-launch-guard.sh

PostToolUse

  • MatchesEdit|Write|NotebookEdit${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-format.sh
  • MatchesSkill${CLAUDE_PLUGIN_ROOT}/hooks/post-skill-auto-loop.sh

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/stop-guard.sh

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-review-guard.sh
Read hooks/hooks.json

Where it lives

  • hooks/post-compact-auto-loop.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # SessionStart(compact) hook: git context + the same gates-owed nudge as Stop,
    # so a compacted session re-reads its ground truth instead of trusting the
    # summary. Markdown out, exit 0 on every path.
    # Contract: docs/features/hook-lightweighting/2-tech-spec.md §3.2.
    
    set -euo pipefail
    
    [[ -n "${HOOK_BYPASS:-}" ]] && exit 0
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    _SELF_NAME="$(basename "$0")"
    # Identity, not filename: `basename "$0"` says WHICH hook this is, never WHICH COPY. Without the
    # comparison below the local copy satisfies every condition and defers to ITSELF — both copies
    # exit 0 and the hook never runs at all (zero-fire, the opposite of the double-fire this block
    # exists to prevent; issue #9). An unresolvable side leaves the guard false and does NOT defer:
    # double-fire is visible, zero-fire is silent.
    #
    # Deferral is decided by ORIGIN, not by path identity. `hooks/hooks.json` registers the plugin copy
    # under `${CLAUDE_PLUGIN_ROOT}` while settings register the local one under `$CLAUDE_PROJECT_DIR`,
    # so the invoking spelling is what separates them — and it stays separate when `.claude/hooks` is a
    # SYMLINK to the plugin's own hooks dir, the case where both copies are one file and a path
    # comparison says "I am local" for both, so neither defers and every reminder prints twice.
    #
    # The origin test is deliberately LEXICAL. `pwd -P` on that symlinked layout resolves the local
    # copy INTO the plugin directory, which would make it look like the plugin's and restore the exact
    # zero-fire this block was written to fix. The resolved comparison stays as the fallback for hosts
    # that do not export CLAUDE_PLUGIN_ROOT, and an invocation matching neither runs rather than defers.
    #
    # It matches the plugin hooks directory EXACTLY, never a descendant of the plugin root. Every
    # `hooks/hooks.json` entry is spelled `${CLAUDE_PLUGIN_ROOT}/hooks/<name>.sh`, so that one
    # directory IS the registered surface, while a `${CLAUDE_PLUGIN_ROOT}/*` prefix also swallows a
    # project nested under the plugin root — calling the LOCAL copy the plugin's and deferring it to
    # itself, which is zero-fire again. Layouts and failure directions: the request doc, issue #9.
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    cat >/dev/null 2>&1 || true  # drain stdin; nothing in it is inspected
    
    # Git context: branch + uncommitted file list. Unreadable git → skip silently.
    _git_clean() ( for v in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$v"; done; git -C "$PWD" "$@" )
    if _git_clean rev-parse --git-dir >/dev/null 2>&1; then
      _BRANCH=$(_git_clean rev-parse --abbrev-ref HEAD 2>/dev/null) || _BRANCH="(unknown)"
      printf '%s\n' "compaction 後基準重讀:branch=${_BRANCH}"
      _FILES=$(_git_clean status --porcelain=v1 -uall 2>/dev/null | head -20) || _FILES=""
      if [[ -n "$_FILES" ]]; then
        printf '%s\n' "未提交變更(前 20 筆):"
        printf '%s\n' "$_FILES"
      fi
    fi
    
    # Same gates-owed nudge as Stop (checker → git fallback), bounded the same way.
    _CHECKER=""
    [[ -n "$_SELF_DIR" && -f "$_SELF_DIR/../scripts/review-state.js" ]] && _CHECKER="$_SELF_DIR/../scripts/review-state.js"
    [[ -z "$_CHECKER" && -n "${CLAUDE_PROJECT_DIR:-}" && -f "${CLAUDE_PROJECT_DIR}/.claude/scripts/review-state.js" ]] \
      && _CHECKER="${CLAUDE_PROJECT_DIR}/.claude/scripts/review-state.js"
    
    _OUT=""; _SOURCE=""
    if [[ -n "$_CHECKER" ]] && command -v node >/dev/null 2>&1; then
      _T="${AUTO_LOOP_CHECK_TIMEOUT:-10}"; case "$_T" in '' | *[!0-9]*) _T=10 ;; esac
      [ "$_T" -gt 0 ] || _T=10  # 0 would DISABLE `timeout`/`alarm`, not bound them
      if command -v timeout >/dev/null 2>&1; then
        _OUT=$(timeout "$_T" node "$_CHECKER" check --format=md 2>/dev/null) && _SOURCE=state || _OUT=""
      elif command -v gtimeout >/dev/null 2>&1; then
        _OUT=$(gtimeout "$_T" node "$_CHECKER" check --format=md 2>/dev/null) && _SOURCE=state || _OUT=""
      elif command -v perl >/dev/null 2>&1; then
        _OUT=$(perl -e 'alarm shift; exec @ARGV or exit 127' "$_T" node "$_CHECKER" check --format=md 2>/dev/null) && _SOURCE=state || _OUT=""
      fi
    fi
    
    if [[ "$_SOURCE" == "state" ]]; then
      [[ -n "$_OUT" ]] && printf '%s\n' "$_OUT"
      exit 0
    fi
    
    _CODE_DIRTY=false; _DOC_DIRTY=false
    _classify() { case "$1" in *.md|*.mdx) _DOC_DIRTY=true ;; *) _CODE_DIRTY=true ;; esac; }
    while IFS= read -r -d '' _rec; do
      [[ -n "$_rec" ]] || continue
      _classify "${_rec:3}"
      case "${_rec:0:1}" in R|C) IFS= read -r -d '' _orig && _classify "$_orig" ;; esac
    done < <(_git_clean status --porcelain=v1 -z -uall 2>/dev/null || true)
    
    [[ "$_CODE_DIRTY"
  • hooks/post-edit-format.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PostToolUse hook: auto-format edited files. Formatter ONLY — this hook
    # creates and mutates no file beyond the formatted target (no state, no
    # sidecar, no lock; the state-tracking half died with the enforcement layer —
    # docs/features/hook-lightweighting/2-tech-spec.md §2).
    #
    # Safety:
    # - Only runs prettier if the project has it installed (package.json or .prettierrc)
    # - Skips gracefully if prettier is not available
    # - Set HOOK_NO_FORMAT=1 to disable auto-formatting
    
    set -euo pipefail
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    _SELF_NAME="$(basename "$0")"
    # Identity, not filename: `basename "$0"` says WHICH hook this is, never WHICH COPY. Without the
    # comparison below the local copy satisfies every condition and defers to ITSELF — both copies
    # exit 0 and the hook never runs at all (zero-fire, the opposite of the double-fire this block
    # exists to prevent; issue #9). An unresolvable side leaves the guard false and does NOT defer:
    # double-fire is visible, zero-fire is silent.
    #
    # Deferral is decided by ORIGIN, not by path identity. `hooks/hooks.json` registers the plugin copy
    # under `${CLAUDE_PLUGIN_ROOT}` while settings register the local one under `$CLAUDE_PROJECT_DIR`,
    # so the invoking spelling is what separates them — and it stays separate when `.claude/hooks` is a
    # SYMLINK to the plugin's own hooks dir, the case where both copies are one file and a path
    # comparison says "I am local" for both, so neither defers and every edit is formatted twice.
    #
    # The origin test is deliberately LEXICAL. `pwd -P` on that symlinked layout resolves the local
    # copy INTO the plugin directory, which would make it look like the plugin's and restore the exact
    # zero-fire this block was written to fix. The resolved comparison stays as the fallback for hosts
    # that do not export CLAUDE_PLUGIN_ROOT, and an invocation matching neither runs rather than defers.
    #
    # It matches the plugin hooks directory EXACTLY, never a descendant of the plugin root. Every
    # `hooks/hooks.json` entry is spelled `${CLAUDE_PLUGIN_ROOT}/hooks/<name>.sh`, so that one
    # directory IS the registered surface, while a `${CLAUDE_PLUGIN_ROOT}/*` prefix also swallows a
    # project nested under the plugin root — calling the LOCAL copy the plugin's and deferring it to
    # itself, which is zero-fire again. Layouts and failure directions: the request doc, issue #9.
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    INPUT=$(cat)
    
    # Check if jq is available
    if ! command -v jq &> /dev/null; then
      exit 0
    fi
    
    # Use printf to avoid echo interpretation issues
    # NotebookEdit matches the Edit|Write hook matcher but carries notebook_path,
    # not file_path — without the fallback, notebook edits silently skip formatting.
    file_path=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null || true)
    
    if [[ -z "$file_path" ]]; then
      exit 0
    fi
    
    # Security: Reject paths with shell metacharacters that could enable injection
    # Block: ; & | ` $()
    # Note: $ alone is NOT blocked as it's valid in some filenames
    # Note: Null bytes cannot be reliably detected in bash (variables truncate at \0)
    if [[ "$file_path" =~ [\;\&\|\`] ]] || [[ "$file_path" =~ \$\( ]]; then
      echo "[Edit Hook] Rejected suspicious file path: contains shell metacharacters" >&2
      exit 0
    fi
    
    # === Skip vendor/generated paths ===
    # Normalize to repo-relative path so we only match root-level vendor dirs
    # (avoids false positives like src/build/helpers.ts matching "build/")
    rel_path="$file_path"
    if [[ "$file_path" = /* ]]; then
      local_prefix="${PWD%/}/"
      if [[ "$file_path" = "$local_prefix"* ]]; then
        rel_path="${file_path#"$local_prefix"}"
      fi
    fi
    if echo "$rel_path" | grep -Eq '^(node_modules|vendor|dist|build|out|target|\.next|\.nuxt|__pycache__|\.pytest_cache|venv|\.venv|\.git)/'; then
      exit 0
    fi
    
    # === Auto-format supported file types ===
    if [[ "${HOOK_NO_FORMAT:-}" != "1" ]]; then
      if echo "$file_path" | grep -Eq '\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|json|md|mdx|yaml|yml)$'; then
        # Require an installed prettier binary. Config files alone used to route
        # through `npx prettier`, which on a config-only repo downloads prettier
        # from the network on every single edit (no timeout, one fetch per file).
        # Local node_modules binary = project opted in via dependency; a global
        # binary still needs a config file as the opt-in signal.
        prettier_bin=""
        if [[ -x "node_modules/.bin/prettier" ]]; then
          prettier_bin="node_modules/.bin/prettier"
        
  • hooks/post-skill-auto-loop.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PostToolUse(Skill) hook: one static gate-sequence reminder. The zero-read
    # design: the hook protocol does expose the skill name and result on stdin,
    # but this hook DELIBERATELY does not inspect them — the wording is
    # unconditional, the model knows where it is. Markdown out, exit 0 always.
    # Contract: docs/features/hook-lightweighting/2-tech-spec.md §3.2.
    
    set -euo pipefail
    
    [[ -n "${HOOK_BYPASS:-}" ]] && exit 0
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    _SELF_NAME="$(basename "$0")"
    # Identity, not filename: `basename "$0"` says WHICH hook this is, never WHICH COPY. Without the
    # comparison below the local copy satisfies every condition and defers to ITSELF — both copies
    # exit 0 and the hook never runs at all (zero-fire, the opposite of the double-fire this block
    # exists to prevent; issue #9). An unresolvable side leaves the guard false and does NOT defer:
    # double-fire is visible, zero-fire is silent.
    #
    # Deferral is decided by ORIGIN, not by path identity. `hooks/hooks.json` registers the plugin copy
    # under `${CLAUDE_PLUGIN_ROOT}` while settings register the local one under `$CLAUDE_PROJECT_DIR`,
    # so the invoking spelling is what separates them — and it stays separate when `.claude/hooks` is a
    # SYMLINK to the plugin's own hooks dir, the case where both copies are one file and a path
    # comparison says "I am local" for both, so neither defers and every reminder prints twice.
    #
    # The origin test is deliberately LEXICAL. `pwd -P` on that symlinked layout resolves the local
    # copy INTO the plugin directory, which would make it look like the plugin's and restore the exact
    # zero-fire this block was written to fix. The resolved comparison stays as the fallback for hosts
    # that do not export CLAUDE_PLUGIN_ROOT, and an invocation matching neither runs rather than defers.
    #
    # It matches the plugin hooks directory EXACTLY, never a descendant of the plugin root. Every
    # `hooks/hooks.json` entry is spelled `${CLAUDE_PLUGIN_ROOT}/hooks/<name>.sh`, so that one
    # directory IS the registered surface, while a `${CLAUDE_PLUGIN_ROOT}/*` prefix also swallows a
    # project nested under the plugin root — calling the LOCAL copy the plugin's and deferring it to
    # itself, which is zero-fire again. Layouts and failure directions: the request doc, issue #9.
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    cat >/dev/null 2>&1 || true  # drain stdin WITHOUT inspecting it — the zero-read design
    
    printf '%s\n' "review → precommit → doc-sync 的閘門順序見 rules/auto-loop.md;本行不知道你剛跑了哪個 skill"
    exit 0
    
  • hooks/pre-bash-codex-launch-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook (Bash): Guard the launch shape of a Codex dispatch
    # Exit code 2 = reject the tool call
    #
    # `codex-transport.md` § Progress step 1: a `start`/`resume` dispatch is launched with
    # `Bash(run_in_background: true)` and NOTHING redirected — stderr left attached is what the
    # task panel shows, so the adapter's 60 s `[CODEX_EXEC_PROGRESS]` lines appear there live.
    # A launch that redirects or pipes (`> log 2>&1`, `| tail`), wraps in `nohup`/`setsid`, or
    # backgrounds with a trailing `&` leaves the panel reading "No output yet" for the whole run
    # — and a foreground launch (`run_in_background` unset) meets the tool ceiling (measured
    # 2026-09-05, repeated 2026-09-22 — lessons L19). The adapter cannot tell a harness task
    # file from a caller's redirection (fd1 and fd2 are the same regular file either way,
    # measured), so the guard sits on the launch command, where the shape is still visible.
    #
    # Only `start` and `resume` are guarded: `alloc` and `cleanup` are short and may be piped
    # or redirected freely, and any command that does not name the adapter passes untouched.
    
    set -euo pipefail
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    # Origin, not path identity, decides deferral — the rationale is written once, in
    # hooks/pre-edit-guard.sh, and this block is the same block.
    _SELF_NAME="$(basename "$0")"
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    # Read stdin once and store it
    stdin_data=$(cat)
    
    # Without jq the command cannot be extracted: fail-open, exactly as pre-edit-guard does,
    # and docs/hooks.md says so.
    command_text=$(printf '%s' "$stdin_data" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
    background=$(printf '%s' "$stdin_data" | jq -r '.tool_input.run_in_background // false' 2>/dev/null || true)
    
    if [[ -z "$command_text" ]]; then
      exit 0
    fi
    
    # Cheap pre-filter: a command that never names the adapter has nothing to guard.
    [[ "$command_text" == *codex-exec.js* ]] || exit 0
    
    # The judgement runs in node — the runtime every dispatcher already needs for the adapter itself —
    # because the unit judged is a SIMPLE COMMAND, and finding one needs a lexer that respects quotes:
    # a review measured a bash-regex form both blocking `grep 'example; node …codex-exec.js start 2>&1'`
    # (the `;` inside quotes split it) and passing `2> err node …` (spaced prefix redirect). The lexer
    # below handles single/double quotes, backslashes, `#` comments, the list operators (`&&`, `||`,
    # `;`, newline), pipes, a trailing `&`, subshell parens and redirection words (`>`, `>>`, `<`,
    # `2>`, `2>&1`, `&>`, `<<`). Every top-level simple command whose command word is `node` running a
    # path ending in `codex-exec.js` with a standalone `start` or `resume` word is a launch, and every
    # launch is checked — not only the first, and a script word written through a shell variable is
    # resolved from the assignments this same input makes. Known boundary, stated in docs/hooks.md: a
    # launch hidden inside `bash -c '…'`, `eval`, `$(…)` or a backtick is a quoted word to this lexer
    # and passes, and so does a locator held in a variable that an EARLIER tool call assigned; the
    # guard is aimed at the launch shapes that were measured, not at parsing arbitrary shell.
    # Without node the command cannot be judged: fail-open, like the jq case above.
    GUARD_JS='
    const src = require("fs").readFileSync(0, "utf8");
    const background = process.argv[1] === "true";
    // Every simple command records its own output handling and the GROUP it sits in: a redirect,
    // pipe or `&` written after a closing `)` / `}` applies to every command inside that group, so a
    // launch inherits its ancestors flags (a review measured `(node … start) 2>err` slipping past a
    // per-command read). Operators inside quotes stay text; quoted command words still count, since
    // bash runs `"node" "scripts/codex-exec.js" start` exactly like the bare spelling.
    const cmds = [];
    const root = { parent: null, redirect: false, pipe: false, bg: false };
    const stack = [root];
    let cur = null, word = "", inWord = false, quoted = false, closed = null;
    const fresh = () => ({ words: [], redirect: false, pipe: false, bg: false, group: stack[stack.length - 1] });
    const push = () => { if (inWord) { cur.words.push({ text: word, quoted }); word = ""; inWord = false; quoted = false; } };
    const end = () => { push(); if (cur.words.length) { cmds.push(cur); closed = null; } cur = fresh(); };
    const target = () => (cur.words.length === 0 && closed) ? closed : cur;   // a `) 2>x` belongs to the group
    cur = fresh();
    let
  • hooks/pre-edit-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook: Guard against editing sensitive files
    # Exit code 2 = reject the tool call
    #
    # Protected paths (always):
    # - .env files (secrets)
    # - .git/ directory (git internals)
    #
    # Custom protected paths (optional):
    # Set GUARD_EXTRA_PATTERNS to add project-specific patterns (pipe-separated regex)
    # Example: GUARD_EXTRA_PATTERNS="src/locales/.*\.json$|generated/.*"
    # WARNING: This env var is used as a grep regex. Only set by trusted project admin.
    
    set -euo pipefail
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    _SELF_NAME="$(basename "$0")"
    # Identity, not filename: `basename "$0"` says WHICH hook this is, never WHICH COPY. Without the
    # comparison below the local copy satisfies every condition and defers to ITSELF — both copies
    # exit 0 and the hook never runs at all (zero-fire, the opposite of the double-fire this block
    # exists to prevent; issue #9). An unresolvable side leaves the guard false and does NOT defer:
    # double-fire is visible, zero-fire is silent.
    #
    # Deferral is decided by ORIGIN, not by path identity. `hooks/hooks.json` registers the plugin copy
    # under `${CLAUDE_PLUGIN_ROOT}` while settings register the local one under `$CLAUDE_PROJECT_DIR`,
    # so the invoking spelling is what separates them — and it stays separate when `.claude/hooks` is a
    # SYMLINK to the plugin's own hooks dir, the case where both copies are one file and a path
    # comparison says "I am local" for both, so neither defers and the ledger counts every round twice.
    #
    # The origin test is deliberately LEXICAL. `pwd -P` on that symlinked layout resolves the local
    # copy INTO the plugin directory, which would make it look like the plugin's and restore the exact
    # zero-fire this block was written to fix. The resolved comparison stays as the fallback for hosts
    # that do not export CLAUDE_PLUGIN_ROOT, and an invocation matching neither runs rather than defers.
    #
    # It matches the plugin hooks directory EXACTLY, never a descendant of the plugin root. Every
    # `hooks/hooks.json` entry is spelled `${CLAUDE_PLUGIN_ROOT}/hooks/<name>.sh`, so that one
    # directory IS the registered surface, while a `${CLAUDE_PLUGIN_ROOT}/*` prefix also swallows a
    # project nested under the plugin root — calling the LOCAL copy the plugin's and deferring it to
    # itself, which is zero-fire again. Layouts and failure directions: the request doc, issue #9.
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    # Read stdin once and store it
    stdin_data=$(cat)
    
    # Read file_path from stdin JSON (use printf to avoid echo interpretation issues)
    # NotebookEdit carries notebook_path instead of file_path — fall back so
    # notebook edits get the same guard instead of silently passing through.
    file_path=$(printf '%s' "$stdin_data" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null || true)
    
    if [[ -z "$file_path" ]]; then
      exit 0
    fi
    
    # Security: Reject paths with shell metacharacters that could enable injection
    # Block: ; & | ` $()
    # Note: $ alone is NOT blocked as it's valid in some filenames
    # Note: Null bytes cannot be reliably detected in bash (variables truncate at \0)
    if [[ "$file_path" =~ [\;\&\|\`] ]] || [[ "$file_path" =~ \$\( ]]; then
      echo "[Edit Guard] Rejected suspicious file path: contains shell metacharacters" >&2
      exit 2
    fi
    
    # Block sensitive paths (universal, always safe to block)
    if echo "$file_path" | grep -Eq '(\.env|\.git/)'; then
      echo "[Edit Guard] Blocked sensitive file: $file_path" >&2
      exit 2
    fi
    
    # Block custom paths (project-specific, opt-in via env var)
    # P0 fix: Validate regex pattern before use
    if [[ -n "${GUARD_EXTRA_PATTERNS:-}" ]]; then
      # Test that the pattern is valid regex before using it
      if echo "" | grep -Eq "$GUARD_EXTRA_PATTERNS" 2>/dev/null || [[ $? -le 1 ]]; then
        if echo "$file_path" | grep -Eq "$GUARD_EXTRA_PATTERNS"; then
          echo "[Edit Guard] Blocked by custom pattern: $file_path" >&2
          exit 2
        fi
      else
        echo "[Edit Guard] Invalid GUARD_EXTRA_PATTERNS regex, skipping" >&2
      fi
    fi
    
    exit 0
    
  • hooks/stop-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Stop hook: gates-owed reminder. Markdown out, exit 0 on every path — this
    # hook blocks nothing, records nothing, discharges nothing. The obligations
    # live in rules/auto-loop.md; contract: docs/features/hook-lightweighting/2-tech-spec.md §3.2.
    
    set -euo pipefail
    
    [[ -n "${HOOK_BYPASS:-}" ]] && exit 0
    
    # === Plugin-defers-to-local arbitration ===
    # When running as a plugin hook, detect if identical local hook is installed
    # and registered in project settings — if so, exit 0 to avoid double-fire.
    # Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
    _SELF_NAME="$(basename "$0")"
    # Identity, not filename: `basename "$0"` says WHICH hook this is, never WHICH COPY. Without the
    # comparison below the local copy satisfies every condition and defers to ITSELF — both copies
    # exit 0 and the hook never runs at all (zero-fire, the opposite of the double-fire this block
    # exists to prevent; issue #9). An unresolvable side leaves the guard false and does NOT defer:
    # double-fire is visible, zero-fire is silent.
    #
    # Deferral is decided by ORIGIN, not by path identity. `hooks/hooks.json` registers the plugin copy
    # under `${CLAUDE_PLUGIN_ROOT}` while settings register the local one under `$CLAUDE_PROJECT_DIR`,
    # so the invoking spelling is what separates them — and it stays separate when `.claude/hooks` is a
    # SYMLINK to the plugin's own hooks dir, the case where both copies are one file and a path
    # comparison says "I am local" for both, so neither defers and every reminder prints twice.
    #
    # The origin test is deliberately LEXICAL. `pwd -P` on that symlinked layout resolves the local
    # copy INTO the plugin directory, which would make it look like the plugin's and restore the exact
    # zero-fire this block was written to fix. The resolved comparison stays as the fallback for hosts
    # that do not export CLAUDE_PLUGIN_ROOT, and an invocation matching neither runs rather than defers.
    #
    # It matches the plugin hooks directory EXACTLY, never a descendant of the plugin root. Every
    # `hooks/hooks.json` entry is spelled `${CLAUDE_PLUGIN_ROOT}/hooks/<name>.sh`, so that one
    # directory IS the registered surface, while a `${CLAUDE_PLUGIN_ROOT}/*` prefix also swallows a
    # project nested under the plugin root — calling the LOCAL copy the plugin's and deferring it to
    # itself, which is zero-fire again. Layouts and failure directions: the request doc, issue #9.
    _SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
    _LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
    _IS_PLUGIN_COPY=false
    if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
      case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
    elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
      _IS_PLUGIN_COPY=true
    fi
    if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
       && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
       && [[ "$_IS_PLUGIN_COPY" == "true" ]] \
       && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
      _SETTINGS_MATCH=false
      for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
                 "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
        if [[ -f "$_sf" ]]; then
          if command -v jq &>/dev/null; then
            jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
              && _SETTINGS_MATCH=true && break
          else
            grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
              && _SETTINGS_MATCH=true && break
          fi
        fi
      done
      if [[ "$_SETTINGS_MATCH" == "true" ]]; then
        exit 0  # Defer to local hook
      fi
    fi
    
    # stdin: only the session id is read (git-autonomy R6 prints the custom-flow line once per session);
    # anything that is not a plain id is dropped, never passed on.
    _IN=$(cat 2>/dev/null || true)
    _SESSION=$(printf '%s' "$_IN" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9._-]\{1,128\}\)".*/\1/p' | head -n 1)
    
    # The checker lives beside this hook's own copy (plugin: hooks/ + scripts/;
    # installed: .claude/hooks/ + .claude/scripts/).
    _CHECKER=""
    [[ -n "$_SELF_DIR" && -f "$_SELF_DIR/../scripts/review-state.js" ]] && _CHECKER="$_SELF_DIR/../scripts/review-state.js"
    [[ -z "$_CHECKER" && -n "${CLAUDE_PROJECT_DIR:-}" && -f "${CLAUDE_PROJECT_DIR}/.claude/scripts/review-state.js" ]] \
      && _CHECKER="${CLAUDE_PROJECT_DIR}/.claude/scripts/review-state.js"
    
    # Bounded by the timeout/gtimeout/perl ladder; with none of the three present
    # the checker is skipped entirely and the git fallback runs — the ladder's
    # last rung would be unbounded (§3.2).
    _OUT=""; _SOURCE=""
    _T="${AUTO_LOOP_CHECK_TIMEOUT:-10}"; case "$_T" in '' | *[!0-9]*) _T=10 ;; esac
    [ "$_T" -gt 0 ] || _T=10  # 0 would DISABLE `timeout`/`alarm`, not bound them
    # One bounded checker call; with no bounding tool it fails, so nothing unbounded ever runs.
    _bounded() {
      if command -v timeout >/dev/null 2>&1; then timeout "$_T" node "$_CHECKER" "$@" 2>/dev/null
      elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$_T" node "$_CHECKER" "$@" 2>/dev/null
      elif command -v perl >/dev/null 2>&1; then perl -e 'alarm shift; exec @ARGV or exit 127' "$_T" node "$_CHECKER" "$@" 2>/dev/null
      else return 1
      fi
    }
    if [[ -n "$_CHECKER" ]] && command -v node >/dev/null 2>&1; then
      _OUT=$(_bounded check --format=md) && _SOURCE=state || _OUT=""
    fi
    
    if [[ "$_SOURCE" == "state" ]]; then
      [[ -n "$_OUT" ]] && printf '%s\n' "$_OUT"
      # git-autonomy R4: one reminder line when a commit/push menu is due (rules/git-workflow.md
      # § Proactive Offer). Empty unless `offer` is true; a failure prints nothing and never blocks.
      _OFFER=$(_bounded offer --format=md) || _OFFER=""
      [[ -n "$_OFFER" ]] && printf '%s\n' "$_OFFER"
      # git-autonomy R6: one line, once per session, when a custom git flow has no override.
      if [[ -n "$_SESSION" ]]; then
        _FLOW=$(_bounded flow-detect --format=md --session "$_SESSION") || _FLO
  • hooks/user-prompt-review-guard.shRunsGitHub

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

@sd0xdev
Ships withsd0x-dev-flow

Language: English | 繁體中文 | 简体中文 | 日本語 | 한국어 | Español The harness layer for Claude Code. Let the model choose the path. Keep "done" verifiable. Claude has discretion inside a closed, test-pinned anchor set.

195102 skills16 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
195
Star history of sd0xdev/sd0x-dev-flow 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 hook318k
@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 hooks191k
@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+3117k
@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+312k
@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 sd0x-dev-flow.

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