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.
> /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.
- Matches
startup|compact${CLAUDE_PLUGIN_ROOT}/scripts/namespace-hint.sh - Matches
compact${CLAUDE_PLUGIN_ROOT}/hooks/post-compact-auto-loop.sh
PreToolUse
- Matches
Edit|Write|NotebookEdit${CLAUDE_PLUGIN_ROOT}/hooks/pre-edit-guard.sh
PostToolUse
- Matches
Edit|Write|NotebookEdit${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-format.sh - Matches
Skill${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
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.
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.
Repo: sd0xdev/sd0x-dev-flow

