Skip to content
Development
Hook

Hooks

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

From plugin
agent-starter
768 skills5 hooks
Install
> /plugin marketplace add sneg55/agent-starter
> /plugin install agent-starter@agent-starter

Ships with agent-starter. Installing the plugin gets these hooks.

What fires, and when

PostToolUse

  • MatchesWrite|Edit${CLAUDE_PLUGIN_ROOT}/hooks/check-file-size.sh
  • MatchesWrite|Edit${CLAUDE_PLUGIN_ROOT}/hooks/lint-on-edit.sh
  • MatchesWrite|Edit${CLAUDE_PLUGIN_ROOT}/hooks/check-silent-errors.sh

PreToolUse

  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/block-dangerous-commands.sh
  • MatchesBashpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/rm-scope-guard.py"

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.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/check-codebase-health.sh .
  • ${CLAUDE_PLUGIN_ROOT}/hooks/worktree-session-prompt.sh

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/worktree-exit-offer.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/suggest-loop-improvements.sh
Read hooks/hooks.json

Where it lives

  • hooks/block-dangerous-commands.shRunsGitHub
    Read the script
    #!/bin/bash
    # Claude Code hook: block destructive shell commands before they run.
    # PreToolUse on Bash - exit 2 blocks with stderr, exit 0 allows.
    #
    # Blocks:
    #   - git push --force / -f            (use --force-with-lease instead)
    #   - git reset --hard / --merge
    #   - git clean -f...
    #   - git checkout -- . / git restore . (discards all uncommitted work)
    #   - recursive rm on /, /*, ~ or $HOME
    #   - chmod -R 777 /
    #
    # Escape hatch: CLAUDE_ALLOW_DANGEROUS=1 disables the hook.
    #
    # Install: copy to ~/.claude/hooks/, chmod +x, wire in settings.json (see README),
    # or run install.sh from the repo root.
    
    set -u
    
    [ "${CLAUDE_ALLOW_DANGEROUS:-0}" = "1" ] && exit 0
    
    LIB="$(dirname "$0")/lib"
    . "$LIB/hook-input.sh"
    . "$LIB/redact.sh"
    
    hook_input_init "${1:-}"
    CMD=$(hook_input_command)
    # No command in the payload means there is nothing to police. That is not a
    # malformed event (hook_input_init already rejected those, loudly); it is an
    # event shape this hook does not apply to.
    [ -z "$CMD" ] && exit 0
    
    check() { echo "$CMD" | grep -qE "$1"; }
    
    # REASON is prose for the agent; CODE is the stable machine-readable token that
    # goes to the ledger. /reflect should branch on the code, not match the prose,
    # so wording can be improved without invalidating past events.
    REASON=""
    CODE=""
    if check 'git[[:space:]]+push[[:space:]]+[^|;&]*--force([[:space:]]|$)'; then
      CODE="force_push"
      REASON="'git push --force' rewrites remote history - use --force-with-lease"
    elif check 'git[[:space:]]+push[[:space:]]+([^|;&]*[[:space:]])?-f([[:space:]]|$)'; then
      CODE="force_push"
      REASON="'git push -f' rewrites remote history - use --force-with-lease"
    elif check 'git[[:space:]]+reset[[:space:]]+[^|;&]*--(hard|merge)'; then
      CODE="destructive_reset"
      REASON="'git reset --hard/--merge' destroys uncommitted work - stash first, or restore specific paths"
    elif check 'git[[:space:]]+clean[[:space:]]+[^|;&]*-[A-Za-z]*f'; then
      CODE="untracked_delete"
      REASON="'git clean -f' permanently deletes untracked files"
    elif check 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.([[:space:]]|$)'; then
      CODE="worktree_discard"
      REASON="'git checkout -- .' discards every uncommitted change in the working tree"
    elif check 'git[[:space:]]+restore[[:space:]]+\.([[:space:]]|$)'; then
      CODE="worktree_discard"
      REASON="'git restore .' discards every uncommitted change in the working tree"
    elif check 'rm[[:space:]]+(-[A-Za-z]+[[:space:]]+)+(/|/\*|~|~/|\$HOME|\$HOME/)([[:space:]]|$)'; then
      CODE="dangerous_recursive_delete"
      REASON="recursive rm on /, ~ or \$HOME is unrecoverable"
    elif check 'chmod[[:space:]]+-R[[:space:]]+777[[:space:]]+/([[:space:]]|$)'; then
      CODE="permission_destruction"
      REASON="'chmod -R 777 /' destroys system permissions"
    fi
    
    if [ -n "$REASON" ]; then
      cat >&2 <<EOF
    Blocked dangerous command:
      $CMD
    
    Why: $REASON.
    
    If this is genuinely intended, ask the developer to run it themselves, or
    re-run with CLAUDE_ALLOW_DANGEROUS=1 after they approve.
    EOF
      # Ledger gets the reason code plus the command's SHAPE, never the command.
      # The blocked command is the one most likely to be carrying a token, a signed
      # URL, or customer data, and the ledger is durable on-disk state.
      [ -x "$LIB/log-event.sh" ] && "$LIB/log-event.sh" dangerous-command block "" "$CODE: $(redact_command "$CMD")"
      exit 2
    fi
    
    exit 0
    
  • hooks/check-codebase-health.shRunsGitHub
    Read the script
    #!/bin/bash
    # Claude Code hook: codebase health check on session start
    # SessionStart - reports file size distribution and flags violations
    #
    # Install: add to settings.json under SessionStart event
    
    SRC_DIR="${1:-.}"
    
    # Find code files (skip node_modules, dist, .git, etc.)
    CODE_FILES=$(find "$SRC_DIR" -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.py" -o -name "*.rs" -o -name "*.go" \) \
      ! -path "*/node_modules/*" ! -path "*/.git/*" ! -path "*/dist/*" ! -path "*/build/*" ! -path "*/__pycache__/*" ! -path "*/target/*" 2>/dev/null)
    
    if [ -z "$CODE_FILES" ]; then
      exit 0
    fi
    
    TOTAL=0
    UNDER_50=0
    UNDER_100=0
    UNDER_200=0
    UNDER_300=0
    UNDER_500=0
    OVER_500=0
    VIOLATIONS=""
    
    while IFS= read -r file; do
      [ -z "$file" ] && continue
      lines=$(wc -l < "$file" | tr -d ' ')
      TOTAL=$((TOTAL + 1))
      
      if [ "$lines" -le 50 ]; then
        UNDER_50=$((UNDER_50 + 1))
      elif [ "$lines" -le 100 ]; then
        UNDER_100=$((UNDER_100 + 1))
      elif [ "$lines" -le 200 ]; then
        UNDER_200=$((UNDER_200 + 1))
      elif [ "$lines" -le 300 ]; then
        UNDER_300=$((UNDER_300 + 1))
      elif [ "$lines" -le 500 ]; then
        UNDER_500=$((UNDER_500 + 1))
      else
        OVER_500=$((OVER_500 + 1))
        VIOLATIONS="$VIOLATIONS\n  ⛔ $file ($lines lines)"
      fi
      
      if [ "$lines" -gt 300 ]; then
        VIOLATIONS="$VIOLATIONS"
      fi
    done <<< "$CODE_FILES"
    
    # Calculate percentage under 200
    UNDER_200_TOTAL=$((UNDER_50 + UNDER_100 + UNDER_200))
    if [ "$TOTAL" -gt 0 ]; then
      PCT=$((UNDER_200_TOTAL * 100 / TOTAL))
    else
      PCT=100
    fi
    
    # Only output if there are issues
    if [ "$PCT" -lt 64 ] || [ -n "$VIOLATIONS" ]; then
      cat >&2 <<EOF
    📊 Codebase Health Report
    ━━━━━━━━━━━━━━━━━━━━━━━━
    Files under 200 lines: $PCT% (target: 64%)
    Total code files: $TOTAL
    
    Distribution:
      ≤50 lines:   $UNDER_50
      51-100:       $UNDER_100
      101-200:      $UNDER_200
      201-300:      $UNDER_300
      301-500:      $UNDER_500
      500+:         $OVER_500
    EOF
    
      if [ -n "$VIOLATIONS" ]; then
        echo -e "\nFiles over 500 lines (need splitting):$VIOLATIONS" >&2
      fi
      
      if [ "$PCT" -lt 64 ]; then
        echo -e "\n⚠️ Below 64% target. Prioritize splitting large files." >&2
      fi
    fi
    
    exit 0
    
  • hooks/check-em-dash.pyGitHub
  • hooks/check-file-size.shRunsGitHub
    Read the script
    #!/bin/bash
    # Claude Code hook: enforce file size limits
    # PostToolUse on Write|Edit - exit 2 to block, exit 0 to pass
    # (wire to both: files can grow past the limit through repeated Edits)
    #
    # Install: copy to ~/.claude/hooks/ and add to settings.json
    # The hook receives the tool payload as JSON on stdin
    
    . "$(dirname "$0")/lib/hook-input.sh"
    hook_input_init "${1:-}"
    FILE_PATH=$(hook_input_file)
    
    # Skip if we can't determine the file
    if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
      exit 0
    fi
    
    case "$FILE_PATH" in
      *.md|*.mdx|*.markdown|*.json|*.yaml|*.yml|*.toml|*.lock|*.svg|*.png|*.jpg|*.csv|*.txt)
        exit 0
        ;;
    esac
    
    LINE_COUNT=$(wc -l < "$FILE_PATH" | tr -d ' ')
    
    # Stylesheets are not modules. They hold no types, constants, or helper functions to
    # extract, so the module advice below is noise for them, and 200 lines is tight for a
    # language whose unit is one declaration per line. They split by layer instead, and
    # they get their own, looser thresholds.
    case "$FILE_PATH" in
      *.css|*.scss|*.sass|*.less)
        WARN_THRESHOLD=250
        BLOCK_THRESHOLD=400
        SPLIT_ADVICE="Split by layer - extract into separate stylesheets, imported in order:
    - tokens.css - custom properties only (color, space, type, elevation)
    - base.css - reset, document rhythm, app shell
    - components.css - one block per component
    - states.css - empty / error / loading states and their keyframes
    
    Keep each layer under the warn threshold. Do NOT split in the middle of a component."
        ;;
      *.astro|*.vue|*.svelte)
        WARN_THRESHOLD=250
        BLOCK_THRESHOLD=400
        SPLIT_ADVICE="Split by concern, keeping the single-file-component unit intact:
    - extract a child component (with its own scoped <style>) for a distinct region
    - move a long client <script> into src/scripts/*.ts and import it
    - lift genuinely shared rules into src/styles/*.css; keep component-scoped CSS in place
    - move data arrays and helpers into src/lib/*.ts
    
    Do NOT flatten scoped <style> into a global stylesheet to win back lines."
        ;;
      *)
        WARN_THRESHOLD=200
        BLOCK_THRESHOLD=300
        SPLIT_ADVICE="Split by concern - extract into separate files:
    - types.ts / types.py - type definitions and interfaces
    - constants.ts / constants.py - named constants and config values
    - validation.ts / validation.py - input validation logic
    - utils.ts / utils.py - pure helper functions
    - [Name].test.ts - tests (always separate)
    
    Each extracted file should handle a single responsibility.
    Do NOT just move code around - ensure clean imports and no circular dependencies."
        ;;
    esac
    
    CONF_DIR=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)
    CONF="${CONF_DIR}/.harness/file-size.conf"
    if [ -n "$CONF_DIR" ] && [ -f "$CONF" ]; then
      PROJECT_WARN=$(sed -n 's/^WARN_THRESHOLD=\([0-9][0-9]*\).*/\1/p' "$CONF" | tail -1)
      PROJECT_BLOCK=$(sed -n 's/^BLOCK_THRESHOLD=\([0-9][0-9]*\).*/\1/p' "$CONF" | tail -1)
      [ -n "$PROJECT_WARN" ] && WARN_THRESHOLD="$PROJECT_WARN"
      [ -n "$PROJECT_BLOCK" ] && BLOCK_THRESHOLD="$PROJECT_BLOCK"
    fi
    
    if [ "$LINE_COUNT" -gt "$BLOCK_THRESHOLD" ]; then
      cat >&2 <<EOF
    ⛔ FILE TOO LARGE: $FILE_PATH has $LINE_COUNT lines (limit: $BLOCK_THRESHOLD)
    
    This file exceeds the maximum size. You MUST split it before proceeding.
    
    $SPLIT_ADVICE
    EOF
      [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" file-size block "$FILE_PATH" "$LINE_COUNT lines (limit $BLOCK_THRESHOLD)"
      exit 2
    
    elif [ "$LINE_COUNT" -gt "$WARN_THRESHOLD" ]; then
      cat >&2 <<EOF
    ⚠️ FILE GETTING LARGE: $FILE_PATH has $LINE_COUNT lines (target: <$WARN_THRESHOLD)
    
    Consider splitting soon.
    
    $SPLIT_ADVICE
    EOF
      [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" file-size warn "$FILE_PATH" "$LINE_COUNT lines (target <$WARN_THRESHOLD)"
      exit 0
    fi
    
    exit 0
    
  • hooks/check-new-comments.pyGitHub
  • hooks/check-silent-errors.shRunsGitHub
    Read the script
    #!/bin/bash
    # Claude Code hook: block writes that introduce silent error handling.
    # PostToolUse on Write|Edit - exit 2 to block with stderr, exit 0 to pass.
    #
    # Catches: bare `except:`, `except: pass`, `except: ...`, empty `catch {}`,
    # and `catch` blocks whose only body is console.log (swallows errors with
    # a low-severity log).
    #
    # Rationale: LLMs routinely wrap code in try/except to make a test go green.
    # The code still breaks in prod - just silently. Every handler must either
    # re-raise, return a sentinel, or log with context via console.error/warn.
    #
    # Exempt a single site with an inline comment:
    #   Python: `# silent-ok`
    #   JS/TS:  `// silent-ok`
    #
    # Install: copy to ~/.claude/hooks/, chmod +x, wire in settings.json (see README).
    
    set -u
    
    . "$(dirname "$0")/lib/hook-input.sh"
    hook_input_init "${1:-}"
    FILE_PATH=$(hook_input_file)
    [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
    
    case "$FILE_PATH" in
      */scratchpad/*) exit 0 ;;
    esac
    
    VIOLATIONS=""
    
    case "$FILE_PATH" in
      *.py)
        # Bare `except:` (no type). Portable ERE - BSD grep has no -P (PCRE).
        if grep -nE '^[[:space:]]*except[[:space:]]*:' "$FILE_PATH" | grep -v 'silent-ok' > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then
          VIOLATIONS="${VIOLATIONS}  Bare except:
    $(cat /tmp/silerr.$$)
    "
        fi
        # except/pass, except/continue, except/... - heuristic two-line scan.
        # POSIX classes only - BSD/one-true awk doesn't grok \s or \b.
        # Exempt via '# silent-ok' on either the except or the body line.
        if awk '
          /^[[:space:]]*except([[:space:]]|:|$)/ { e=NR; eline=$0; next }
          e && NR==e+1 && /^[[:space:]]*(pass|continue|\.\.\.)[[:space:]]*$/ {
            if (eline !~ /silent-ok/ && $0 !~ /silent-ok/) { print e":"eline; print NR":"$0 }
            e=0; next
          }
          { e=0 }
        ' "$FILE_PATH" > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then
          VIOLATIONS="${VIOLATIONS}  except/pass or except/continue or except/...:
    $(cat /tmp/silerr.$$)
    "
        fi
        rm -f /tmp/silerr.$$
        ;;
      *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs)
        # Empty catch block: catch {} or catch (e) {}. Portable ERE - no -P on BSD grep.
        if grep -nE 'catch[[:space:]]*(\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' "$FILE_PATH" | grep -v 'silent-ok' > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then
          VIOLATIONS="${VIOLATIONS}  Empty catch block:
    $(cat /tmp/silerr.$$)
    "
        fi
        # catch block whose next non-empty line is only console.log(...)
        # POSIX classes only - BSD/one-true awk doesn't grok \s or \S.
        # Exempt via '// silent-ok' on either the catch or the console.log line.
        if awk '
          /catch[[:space:]]*(\([^)]*\))?[[:space:]]*\{/ { c=NR; cline=$0; next }
          c && /^[[:space:]]*console\.log\(/ {
            if (cline !~ /silent-ok/ && $0 !~ /silent-ok/) { print c":"cline; print NR":"$0 }
            c=0; next
          }
          c && /^[[:space:]]*[^[:space:]]/ { c=0 }
        ' "$FILE_PATH" > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then
          VIOLATIONS="${VIOLATIONS}  catch with only console.log (use console.error):
    $(cat /tmp/silerr.$$)
    "
        fi
        rm -f /tmp/silerr.$$
        ;;
      *)
        exit 0 ;;
    esac
    
    if [ -n "$VIOLATIONS" ]; then
      cat >&2 <<EOF
    Blocked: silent error handling in ${FILE_PATH}:
    ${VIOLATIONS}
    Fix: log with context via console.error/logger, then re-raise or return a
    sentinel. Exempt a single site with '// silent-ok' (JS/TS) or '# silent-ok' (Py).
    
    See guides/hooks-reference.md § "Block silent error patterns".
    EOF
      [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" silent-error block "$FILE_PATH" "silent error handler"
      exit 2
    fi
    
    exit 0
    
  • hooks/comment_syntax.pyGitHub
  • hooks/harness-ledger-stats.shGitHub
  • hooks/lint-on-edit.shRunsGitHub
    Read the script
    #!/bin/bash
    # Claude Code hook: lint + typecheck files the agent just wrote.
    # PostToolUse on Write|Edit - exit 2 to block with stderr, exit 0 to pass.
    #
    # Philosophy: rules only shape agent behavior if the agent sees failures.
    # JS/TS: runs Biome (format + fast rules, with --write), then ESLint
    # (type-aware + plugin rules), then optionally tsc --noEmit. Python: runs
    # ruff check --fix, then ruff format, when a ruff binary is available
    # (.venv/bin/ruff or PATH).
    # The agent gets structured errors back in its next turn and self-corrects.
    #
    # Install: copy to ~/.claude/hooks/ and add to settings.json (see README).
    
    set -u
    
    . "$(dirname "$0")/lib/hook-input.sh"
    hook_input_init "${1:-}"
    FILE_PATH=$(hook_input_file)
    
    if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
      exit 0
    fi
    
    # Resolve to an absolute path. The branches below `cd` into the project root,
    # after which a repo-relative path (how CI invokes this hook, e.g.
    # "app/src/foo.ts") would no longer resolve and the linters would report
    # "no files matching". An absolute path resolves regardless of cwd.
    FILE_PATH="$(cd "$(dirname "$FILE_PATH")" && pwd)/$(basename "$FILE_PATH")"
    
    case "$FILE_PATH" in
      *.py)
        # Python path: ruff check --fix, when a project root and ruff binary exist.
        PROJECT_ROOT=""
        DIR=$(cd "$(dirname "$FILE_PATH")" && pwd)
        while [ "$DIR" != "/" ]; do
          if [ -f "$DIR/pyproject.toml" ] || [ -f "$DIR/setup.py" ] || [ -f "$DIR/requirements.txt" ] || [ -d "$DIR/.git" ]; then
            PROJECT_ROOT="$DIR"
            break
          fi
          DIR=$(dirname "$DIR")
        done
        [ -z "$PROJECT_ROOT" ] && exit 0
        HOOK_DIR=$(cd "$(dirname "$0")" && pwd)
        cd "$PROJECT_ROOT" || exit 0
        RUFF=""
        if [ -x .venv/bin/ruff ]; then
          RUFF=.venv/bin/ruff
        elif command -v ruff >/dev/null 2>&1; then
          RUFF=ruff
        fi
        # Ruff is best-effort: run it when present, but don't exit early if it's
        # missing - the mypy type-check below must still run for mypy-only projects.
        if [ -n "$RUFF" ]; then
          if ! RUFF_OUT=$("$RUFF" check --fix "$FILE_PATH" 2>&1); then
            printf 'Ruff errors in %s:\n%s\n' "$FILE_PATH" "$RUFF_OUT" >&2
            [ -x "$HOOK_DIR/lib/log-event.sh" ] && "$HOOK_DIR/lib/log-event.sh" lint block "$FILE_PATH" "ruff check failed"
            exit 2
          fi
          # Formatting parity with Biome's --write. Best-effort: format only fails on
          # syntax errors, which check already reported, so never block on it.
          "$RUFF" format --quiet "$FILE_PATH" >/dev/null 2>&1 || true
        fi
        # Type-check with mypy (the type-aware step, like tsc on the TS path).
        # Whole-file mypy resolves imports and can be slow, so gate it behind the
        # same opt-in marker as tsc: touch .claude/enable-typecheck-on-edit.
        if [ -f .claude/enable-typecheck-on-edit ]; then
          MYPY=""
          if [ -x .venv/bin/mypy ]; then
            MYPY=.venv/bin/mypy
          elif command -v mypy >/dev/null 2>&1; then
            MYPY=mypy
          else
            # The marker is the user's consent to type-check, so install mypy when
            # it's missing rather than silently skipping. Prefer the project venv.
            if [ -x .venv/bin/pip ]; then
              .venv/bin/pip install --quiet mypy >/dev/null 2>&1
              [ -x .venv/bin/mypy ] && MYPY=.venv/bin/mypy
            elif command -v pip3 >/dev/null 2>&1; then
              pip3 install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy
            elif command -v pip >/dev/null 2>&1; then
              pip install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy
            elif command -v python3 >/dev/null 2>&1; then
              python3 -m pip install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy
            fi
            # Install can fail (no network, no pip). Warn without blocking the edit.
            [ -z "$MYPY" ] && printf 'mypy not installed and auto-install failed; skipping type-check for %s. Install mypy to enable (e.g. `pip install mypy`).\n' "$FILE_PATH" >&2
          fi
          if [ -n "$MYPY" ]; then
            if ! MYPY_OUT=$("$MYPY" "$FILE_PATH" 2>&1); then
              printf 'mypy errors in %s:\n%s\n' "$FILE_PATH" "$MYPY_OUT" >&2
              [ -x "$HOOK_DIR/lib/log-event.sh" ] && "$HOOK_DIR/lib/log-event.sh" lint block "$FILE_PATH" "mypy typecheck failed"
              exit 2
            fi
          fi
        fi
        exit 0
        ;;
      *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) ;;
      *) exit 0 ;;
    esac
    
    # Walk up to find the project root (nearest package.json).
    PROJECT_ROOT=""
    DIR=$(cd "$(dirname "$FILE_PATH")" && pwd)
    while [ "$DIR" != "/" ]; do
      if [ -f "$DIR/package.json" ]; then
        PROJECT_ROOT="$DIR"
        break
      fi
      DIR=$(dirname "$DIR")
    done
    
    if [ -z "$PROJECT_ROOT" ]; then
      exit 0
    fi
    
    cd "$PROJECT_ROOT" || exit 0
    
    HAS_ESLINT_CONFIG=0
    for cfg in eslint.config.mjs eslint.config.js eslint.config.cjs .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yaml .eslintrc.yml; do
      if [ -f "$cfg" ]; then HAS_ESLINT_CONFIG=1; break; fi
    done
    
    HAS_BIOME_CONFIG=0
    for cfg in biome.json biome.jsonc; do
      if [ -f "$cfg" ]; then HAS_BIOME_CONFIG=1; break; fi
    done
    
    HAS_TSCONFIG=0
    [ -f tsconfig.json ] && HAS_TSCONFIG=1
    
    FAIL=0
    OUT=""
    
    # Biome first: fast, autofixes formatting + syntactic rules.
    if [ "$HAS_BIOME_CONFIG" -eq 1 ] && [ -x node_modules/.bin/biome ]; then
      if ! BIOME_OUT=$(node_modules/.bin/biome check --write --no-errors-on-unmatched "$FILE_PATH" 2>&1); then
        OUT="${OUT}Biome errors in ${FILE_PATH}:
    ${BIOME_OUT}
    "
        FAIL=1
      fi
    fi
    
    # ESLint second: type-aware + plugin rules (import resolution, sonarjs, security).
    if [ "$HAS_ESLINT_CONFIG" -eq 1 ] && [ -x node_modules/.bin/eslint ]; then
      if ! LINT_OUT=$(node_modules/.bin/eslint --fix --cache --cache-location node_modules/.cache/eslint/ --max-warnings 0 "$FILE_PATH" 2>&1); then
        OUT="${OUT}ESLint errors in ${FILE_PATH}:
    ${LINT_OUT}
    "
        FAIL=1
      fi
    fi
    
    # Type-check only when the file is TS and tsconfig exists. --noEmit is whole-project,
    # which is slow on huge repos; gate
  • hooks/require-read-before-edit.shGitHub
  • hooks/rm-scope-guard.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    import json
    import os
    import re
    import shlex
    import subprocess
    import sys
    
    SEPARATORS = {";", "&", "&&", "|", "||", "\n"}
    REDIRECTIONS = {">", ">>", "<", "<<", "<<<", "&>", ">&", "2>", "2>>", "1>", "1>>"}
    WRAPPERS = {"sudo", "command", "env", "builtin", "exec", "time", "nice", "ionice", "doas"}
    RM_NAMES = {"rm", "\\rm", "/bin/rm", "/usr/bin/rm"}
    RM_PATTERN = re.compile(r"(^|[^\w./-])\\?/?(?:usr/)?(?:bin/)?rm(\s|$|;|&|\|)")
    MAX_REPORTED = 8
    
    
    def log_event(target, detail):
        script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib", "log-event.sh")
        if not os.access(script, os.X_OK):
            return
        try:
            subprocess.run(
                [script, "rm-scope", "block", target, detail],
                timeout=5,
                check=False,
                capture_output=True,
            )
        except (OSError, subprocess.SubprocessError):
            return
    
    
    def tokenize(command):
        lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
        lexer.whitespace_split = True
        try:
            return list(lexer)
        except ValueError:
            return command.split()
    
    
    def strip_redirections(tokens):
        out = []
        skip_next = False
        for token in tokens:
            if skip_next:
                skip_next = False
                continue
            if token in REDIRECTIONS:
                skip_next = True
                continue
            if len(token) > 1 and token[0] in "<>" or (
                len(token) > 2 and token[0].isdigit() and token[1] in "<>"
            ):
                continue
            out.append(token)
        return out
    
    
    def rm_arguments(segment):
        for index, token in enumerate(segment):
            if token in RM_NAMES:
                return segment[index + 1 :]
        return None
    
    
    def next_directory(segment, cwd):
        if not segment or segment[0] != "cd":
            return cwd
        if "-" in segment[1:]:
            return None
        targets = [t for t in segment[1:] if not t.startswith("-")]
        if not targets:
            return os.path.realpath(os.path.expanduser("~"))
        if cwd is None:
            return None
        return os.path.realpath(os.path.join(cwd, os.path.expanduser(targets[0])))
    
    
    def rm_invocations(command, cwd):
        segment = []
        for token in tokenize(command) + [";"]:
            if token in SEPARATORS:
                clean = strip_redirections(segment)
                arguments = rm_arguments(clean)
                if arguments is not None:
                    yield arguments, cwd
                cwd = next_directory(clean, cwd)
                segment = []
                continue
            segment.append(token)
    
    
    def escapes(argument, resolve_dir, boundary):
        expanded = os.path.expanduser(argument)
        for form in ("${HOME}", "$HOME"):
            expanded = expanded.replace(form, os.path.expanduser("~"))
        if resolve_dir is None and not os.path.isabs(expanded):
            return True
        if os.path.isabs(expanded):
            target = expanded
        else:
            target = os.path.join(resolve_dir, expanded)
        target = os.path.realpath(target)
        if target == boundary:
            return False
        return not target.startswith(boundary + os.sep)
    
    
    def main():
        if os.environ.get("CLAUDE_ALLOW_DANGEROUS") == "1":
            return 0
        try:
            payload = json.load(sys.stdin)
        except (json.JSONDecodeError, ValueError):
            return 0
    
        command = (payload.get("tool_input", {}) or {}).get("command", "") or ""
        if not command or not RM_PATTERN.search(command):
            return 0
    
        boundary = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
        boundary = os.path.realpath(boundary)
    
        saw_rm = False
        escaping = []
        for arguments, resolve_dir in rm_invocations(command, boundary):
            saw_rm = True
            for argument in arguments:
                if argument == "--" or argument.startswith("-"):
                    continue
                if escapes(argument, resolve_dir, boundary):
                    escaping.append(argument)
    
        if not saw_rm or not escaping:
            return 0
    
        log_event(escaping[0], f"{len(escaping)} target(s) outside cwd")
        print(
            f"Blocked: rm target outside the working directory ({boundary}).",
            file=sys.stderr,
        )
        for argument in escaping[:MAX_REPORTED]:
            print(f"  {argument}", file=sys.stderr)
        if len(escaping) > MAX_REPORTED:
            print(f"  ... and {len(escaping) - MAX_REPORTED} more", file=sys.stderr)
        print(
            "\nDeleting outside the project is unrecoverable and is almost never what the task "
            "asked for. Move the path into a project-local .trash/ directory instead, re-run the "
            "rm from a shell outside Claude, or set CLAUDE_ALLOW_DANGEROUS=1 for a session that "
            "genuinely needs it.",
            file=sys.stderr,
        )
        return 2
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/suggest-loop-improvements.shRunsGitHub
  • hooks/track-reads.shGitHub
  • hooks/worktree-exit-offer.shRunsGitHub
  • hooks/worktree-session-prompt.shRunsGitHub

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

Ships withagent-starter

Skills, hooks, templates, and engineering guides for bootstrapping AI-agent-friendly projects, with a per-project self-improvement loop.

Get the whole plugin, auto-invoked
Stats
75
Stars
4
Forks
Active
Maintenance
Shell
Language
MIT
License
9d ago
Last commit
5mo ago
Created

Repo: sneg55/agent-starter