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
18999 skills16 agents5 hooks
Install
> /plugin marketplace add sd0xdev/sd0x-dev-flow
> /plugin install sd0x-dev-flow@sd0xdev-marketplace

Ships with sd0x-dev-flow. Installing the plugin gets these hooks.

What fires, and when

SessionStart

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

  • Matchesstartup|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

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-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
    
    cat >/dev/null 2>&1 || true  # drain stdin; nothing in it is inspected
    
    # 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=""
    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
    
    # Git fallback: one plain read (-z so spaces, quoting and rename records parse
    # without heuristics). Without state it cannot know what was already reviewed,
    # so the reminder carries the ignore-if-done sentence. Unreadable git → silent.
    _git_clean() ( for v in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$v"; done; git -C "$PWD" "$@" )
    _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" == "true"
  • hooks/user-prompt-review-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # UserPromptSubmit hook: one [AUTO_LOOP_STATE] fact line per prompt. A fact,
    # not a nudge — it claims nothing that could be already-done. 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
    
    _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 checker (timeout/gtimeout/perl ladder; none present → git fallback).
    _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=fact 2>/dev/null) && _SOURCE=state || _OUT=""
      elif command -v gtimeout >/dev/null 2>&1; then
        _OUT=$(gtimeout "$_T" node "$_CHECKER" check --format=fact 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=fact 2>/dev/null) && _SOURCE=state || _OUT=""
      fi
    fi
    
    if [[ "$_SOURCE" == "state" && -n "$_OUT" ]]; then
      printf '%s\n' "$_OUT"
      printf '%s\n' "審核義務見 rules/auto-loop.md(提醒層,非強制)"
      exit 0
    fi
    
    # Git fallback: change classes only, no verdict claims — source=git_status.
    # Unreadable git → print nothing (a fact line over an unread tree would be a
    # claim, and this hook only states facts); still exit 0.
    _git_clean() ( for v in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$v"; done; git -C "$PWD" "$@" )
    _git_clean rev-parse --git-dir >/dev/null 2>&1 || exit 0
    _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)
    
    _CHANGE="none"
    if [[ "$_CODE_DIRTY" == "true" && "$_DOC_DIRTY" == "true" ]]; then _CHANGE="code,doc"
    elif [[ "$_CODE_DIRTY" == "true" ]]; then _CHANGE="code"
    elif [[ "$_DOC_DIRTY" == "true"

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

Ships withsd0x-dev-flow

Language: English | 繁體中文 | 简体中文 | 日本語 | 한국어 | Español The harness layer for Claude Code. Let the model choose the path. Keep "done" verifiable. Full control plane on Claude Code. Skills-only distribution for Codex CLI and other compatible agents.

Get the whole plugin