Skip to content
Development
Hook

Hooks

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

From plugin
claude-forge
84133 skills16 agents35 commands22 hooks
+1
Install
> /plugin marketplace add sangrokjung/claude-forge
> /plugin install claude-forge@claude-forge

Ships with claude-forge. Installing the plugin gets these hooks.

Where it lives

  • hooks/api-error-auto-resume.shGitHub
    Read the script
    #!/bin/bash
    # api-error-auto-resume.sh — StopFailure hook. When a session dies on a
    #     retryable API error (529 overload, 5xx, dropped stream), schedule an
    #     unattended resume a couple of minutes later.
    #
    # Event:  StopFailure — fires after Claude Code's own retries are exhausted.
    # Blocks: no. StopFailure output cannot steer control flow, so this hook is
    #         informational and does its real work in a detached runner.
    # Action: classify the failure, then (if every guard passes) detach-spawn
    #         scripts/api-error-resume-runner.sh, which waits and resumes.
    #
    # Analogy: the caretaker who waits two minutes after a blackout, flips the
    # breaker back on, and says "carry on where you left off". Unpaid bills and
    # lost keys are left alone, and the breaker is never flipped more than a few
    # times in a row.
    #
    # Retryable:     overloaded / internal_error / connection / timeout errors,
    #                HTTP 408·5xx·529, a stream that stalled mid-response, and rate
    #                limits the server marks as its own ("not your usage limit").
    # Not retryable: billing, authentication, context window, invalid request,
    #                permission, and your own plan usage limit.
    #
    # Fork-bomb guards (a global outage means every session fires at once):
    #   1) per-session cap — 3 resumes per rolling 6h (CLAUDE_AUTO_RESUME_MAX_RETRIES),
    #      reset when the session actually made progress after the last resume
    #   2) per-session lock — the runner holds an mkdir lock, so a duplicate fire of
    #      the same StopFailure is dropped without burning a cap slot
    #   3) global stagger — the runner serializes real resumes 20s apart
    #
    # Environment (full table: rules/api-error-recovery.md):
    #   CLAUDE_AUTO_RESUME_DISABLED=1         kill switch (so is the DISABLED file below)
    #   CLAUDE_AUTO_RESUME_DELAY=120          seconds to wait before resuming
    #   CLAUDE_AUTO_RESUME_MAX_RETRIES=3      per-session resumes per 6h
    #   CLAUDE_AUTO_RESUME_RETRY_RATELIMIT=1  also retry plain rate_limit (off by default)
    #   CLAUDE_AUTO_RESUME_DRY_RUN=1          runner logs its decision and resumes nothing
    #
    # Log:   ~/.claude/logs/api-error-auto-resume.log
    #        Verdict words: RETRY (scheduled) / SKIP (left alone) / CAP / DUP / ERROR.
    # State: ~/.claude/cache/auto-resume/{counts,locks,runs}
    # Kill:  touch ~/.claude/cache/auto-resume/DISABLED
    #
    # Platform: developed and tested on macOS. Linux notes in rules/api-error-recovery.md.
    
    set -uo pipefail
    
    BASE="$HOME/.claude/cache/auto-resume"
    LOG_DIR="$HOME/.claude/logs"
    LOG="$LOG_DIR/api-error-auto-resume.log"
    RUNNER="$HOME/.claude/scripts/api-error-resume-runner.sh"
    
    mkdir -p "$BASE/counts" "$BASE/locks" "$BASE/runs" "$LOG_DIR" 2>/dev/null
    
    log() { echo "[$(date '+%F %T')] [hook] $*" >> "$LOG" 2>/dev/null; }
    
    # stat(1) is not portable: BSD/macOS takes -f, GNU/Linux takes -c.
    #
    # Branch on the OUTPUT, never on the exit status. On GNU coreutils `-f` means
    # --file-system, where %m is not a valid directive: it can be reported as "?"
    # with rc=0, so a status-only fallback would never reach the -c form and the
    # caller would silently receive 0 — which is exactly the failure this helper
    # exists to prevent (a 0 mtime makes the cap window dead and every lock stale).
    # Accept a form only once its output is a plain integer.
    _mtime() {
        local v
        v=$(stat -f %m "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
        v=$(stat -c %Y "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
        printf '0'
    }
    _fsize() {
        local v
        v=$(stat -f %z "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
        v=$(stat -c %s "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
        printf '0'
    }
    
    # ── Kill switch ───────────────────────────────────────────
    if [[ "${CLAUDE_AUTO_RESUME_DISABLED:-0}" == "1" || -f "$BASE/DISABLED" ]]; then
        exit 0
    fi
    
    # A resume this hook spawned can fail again and re-enter here. That is allowed —
    # the counters below are what bounds the recursion.
    INPUT=$(cat)
    
    # ── Log rotation (over 1MB, keep the last 256KB; temp name is PID-scoped) ──
    if [[ -f "$LOG" ]]; then
        _size=$(_fsize "$LOG")
        if (( _size > 1048576 )); then
            tail -c 262144 "$LOG" > "$LOG.tmp.$$" 2>/dev/null && mv "$LOG.tmp.$$" "$LOG" 2>/dev/null
            rm -f "$LOG.tmp.$$" 2>/dev/null
        fi
    fi
    
    # ── Classification (python3): trust the stdin payload first, and fall back to
    #    scanning the tail of the transcript when the payload is inconclusive ──
    VERDICT_LINE=$(printf '%s' "$INPUT" | python3 -c '
    import sys, json, os, re
    
    raw = sys.stdin.read()
    try:
        data = json.loads(raw) if raw.strip() else {}
    except Exception:
        data = {}
    
    session_id = str(data.get("session_id") or "")
    transcript = str(data.get("transcript_path") or "")
    cwd = str(data.get("cwd") or os.environ.get("PWD", ""))
    if not session_id and transcript:
        b = os.path.basename(transcript)
        session_id = b[:-6] if b.endswith(".jsonl") else b
    
    RETRYABLE = {"overloaded", "internal_error", "api_error", "server_error",
                 "connection_error", "timeout", "network_error"}
    FATAL = {"billing_error", "authentication_failed", "context_window_exceeded",
             "invalid_request", "permission_error"}
    RETRY_STATUS = {408, 500, 502, 503, 504, 522, 524, 529}
    
    etype = str(data.get("stop_reason") or data.get("error_type") or "").strip()
    emsg = str(data.get("error") or data.get("message") or "")
    data_text = json.dumps(data, ensure_ascii=False) if data else ""
    
    
    def server_side(text):
        # Phrases by which the server says the throttle is its own, not your plan
        # usage. Only these two: an unqualified 429 stays a SKIP, because resuming
        # into your own exhausted usage limit only burns it faster.
        t = (text or "").lower()
        return ("not your usage limit" in t
                or "temporarily limiting requests" in t)
    
    
    def stalled(text):
        # "API Error: Response stalled mid-stream" — the upstream stream was cut
        # part-way through a 
  • hooks/auto-verify-fix.shGitHub
    Read the script
    #!/bin/bash
    # auto-verify-fix.sh - PostToolUse Hook (Edit/Write)
    # Lightweight type/syntax verification after a code file edit → surface fixable errors.
    # exit 0 required (PostToolUse cannot block).
    
    INPUT=$(cat)
    
    echo "$INPUT" | python3 -c "
    import sys, json, os, subprocess, hashlib, re, time
    
    try:
        d = json.load(sys.stdin)
    except:
        sys.exit(0)
    
    tool = d.get('tool_name', '')
    if tool not in ('Edit', 'Write'):
        sys.exit(0)
    
    inp = d.get('tool_input', {})
    file_path = inp.get('file_path', '')
    if not file_path or not os.path.isfile(file_path):
        sys.exit(0)
    
    # Extension filter: only .ts, .tsx, .js, .jsx, .py
    ext = os.path.splitext(file_path)[1].lower()
    if ext not in ('.ts', '.tsx', '.js', '.jsx', '.py'):
        sys.exit(0)
    
    # Walk up to find the project root (tsconfig.json or package.json)
    search_dir = os.path.dirname(os.path.abspath(file_path))
    project_root = search_dir
    for _ in range(10):
        if os.path.exists(os.path.join(project_root, 'tsconfig.json')) or \
           os.path.exists(os.path.join(project_root, 'package.json')):
            break
        parent = os.path.dirname(project_root)
        if parent == project_root:
            project_root = search_dir
            break
        project_root = parent
    
    # Detect project type + run the matching verification command
    is_python = (ext == '.py')
    has_tsconfig = os.path.exists(os.path.join(project_root, 'tsconfig.json'))
    
    if is_python:
        try:
            r = subprocess.run(
                ['python3', '-m', 'py_compile', file_path],
                capture_output=True, text=True, timeout=8
            )
        except:
            sys.exit(0)
    elif has_tsconfig:
        try:
            r = subprocess.run(
                ['bash', '-c', 'npx tsc --noEmit --pretty \"' + file_path + '\" 2>&1 | head -30'],
                capture_output=True, text=True, timeout=8,
                cwd=project_root
            )
        except:
            sys.exit(0)
    else:
        # JS without tsconfig, or no recognized project type — no-op
        sys.exit(0)
    
    output = (r.stdout + r.stderr).strip()
    if r.returncode == 0 or not output:
        sys.exit(0)
    
    # --- repeat-suppression marker ---
    # The session id must come from the payload. Claude Code does not export
    # SESSION_ID to hook processes, so the old env lookup always fell through to
    # the constant 'unknown': every concurrent session shared one marker, and the
    # count>3 suppression below then silenced the hook machine-wide and forever.
    sid = (d.get('session_id')
           or os.environ.get('CLAUDE_SESSION_ID')
           or os.environ.get('SESSION_ID')
           or 'unknown')
    sid = re.sub(r'[^A-Za-z0-9_-]', '_', str(sid))
    path_hash = hashlib.md5(file_path.encode()).hexdigest()
    marker = f'/tmp/auto-verify-fix-{sid}-{path_hash}'
    
    # A marker older than this is treated as absent, so a stale count (or a
    # fallback key shared across sessions) cannot suppress the hook indefinitely.
    MARKER_TTL_SEC = 6 * 3600
    
    count = 0
    if os.path.exists(marker):
        try:
            if (time.time() - os.path.getmtime(marker)) <= MARKER_TTL_SEC:
                count = int(open(marker).read().strip())
        except Exception:
            count = 0
    count += 1
    try:
        open(marker, 'w').write(str(count))
    except:
        pass
    
    basename = os.path.basename(file_path)
    if count > 3:
        # Same error repeating: go quiet, stop nagging.
        sys.exit(0)
    
    # --- error classification ---
    FIXABLE = [
        r'Cannot find name',
        r'is declared but',
        r'React Hook.*cannot be called',
        r'Module.*not found',
        r'Cannot find module',
        r'has no exported member',
    ]
    
    messages = []
    seen = set()
    for line in output.split('\n')[:30]:
        line = line.strip()
        if not line or line in seen:
            continue
        has_err = 'error' in line.lower() or 'SyntaxError' in line
        if not has_err:
            continue
        seen.add(line)
        if any(re.search(p, line) for p in FIXABLE):
            messages.append(f'[auto-fix] {line} -> fix this file.')
        else:
            messages.append(f'[verify-warn] {line} -> needs manual check.')
    
    # Nothing meaningful found: go quiet.
    if not messages:
        sys.exit(0)
    
    # PostToolUse: plain stdout/stderr is NOT injected into Claude's context.
    # Must use hookSpecificOutput.additionalContext (JSON on stdout) instead.
    ctx = (
        f'[verify hook — auto-verify-fix] Verification errors found in the file you just edited ({basename}):\n'
        + '\n'.join(messages[:10])
        + '\nNon-blocking signal — review and fix the errors above.'
    )
    out = {'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': ctx}}
    print(json.dumps(out, ensure_ascii=False))
    " 2>/dev/null
    
    exit 0
    
  • hooks/code-quality-reminder.shGitHub
    Read the script
    #!/bin/bash
    # code-quality-reminder.sh - PostToolUse Hook (Edit/Write)
    # 코드 수정 후 품질 체크 리마인더를 stderr로 출력
    # Claude에게 셀프 체크를 유도하는 간결한 메시지
    # exit 0 필수 (세션 방해 금지)
    
    INPUT=$(cat)
    
    TOOL_NAME=$(echo "$INPUT" | python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        print(d.get('tool_name', ''))
    except:
        pass
    " 2>/dev/null)
    
    if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
        exit 0
    fi
    
    FILE_PATH=$(echo "$INPUT" | python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        inp = d.get('tool_input', {})
        print(inp.get('file_path', ''))
    except:
        pass
    " 2>/dev/null)
    
    # 코드 파일만 대상 (md, txt, json, yaml 등 제외)
    case "$FILE_PATH" in
        *.ts|*.tsx|*.js|*.jsx|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.sh)
            ;;
        *)
            exit 0
            ;;
    esac
    
    echo "[code-quality] 수정된 파일의 에러 핸들링, 불변성 패턴, 입력 검증을 확인하세요." >&2
    
    exit 0
    
  • hooks/context-sync-suggest.shGitHub
    Read the script
    #!/bin/bash
    # Legacy opt-out flag for hook-guard.sh. Current builds of that helper ignore it,
    # so it is inert either way; kept so older installs keep behaving as before.
    # shellcheck disable=SC2034  # set for the sourced helper, never read by this script
    HOOK_GUARD_SKIP_STDIN=1
    # hook-guard.sh now ships with claude-forge (libs/hook-guard.sh, installed to
    # ~/.claude/libs/hook-guard.sh by install.sh/install.ps1). The guarded source
    # stays regardless: installs that predate this change, or plugin-only installs
    # (Option A — see README "Which option should I pick?", hooks aren't wired at
    # all there) may not have the file. Never hard-fail without it — the only
    # thing this hook needs from it is $PYTHON3, which falls back to the system
    # interpreter below.
    [ -r "$HOME/.claude/libs/hook-guard.sh" ] && . "$HOME/.claude/libs/hook-guard.sh"
    : "${PYTHON3:=$(command -v python3 || echo python3)}"
    # context-sync-suggest.sh - SessionStart Hook
    # 마지막 세션 종료 후 일정 시간이 경과했으면 /context-sync 안내
    # OMC session-start.mjs, project-memory-session.mjs와 독립 공존
    # exit 0 필수
    
    INPUT=$(cat)
    
    MSG=$(echo "$INPUT" | $PYTHON3 -c "
    import sys, json, os, time
    from datetime import datetime, timezone, timedelta
    
    try:
        d = json.load(sys.stdin)
    except:
        sys.exit(0)
    
    sid = d.get('session_id', '')
    if not sid:
        sys.exit(0)
    
    # buffer.jsonl에서 마지막 session_end 찾기
    work_log = os.path.expanduser('~/.claude/work-log/buffer.jsonl')
    if not os.path.exists(work_log):
        sys.exit(0)
    
    last_end = None
    try:
        with open(work_log, 'r') as f:
            for line in f:
                try:
                    ev = json.loads(line.strip())
                    if ev.get('event') == 'session_end':
                        last_end = ev.get('ts', '')
                except:
                    continue
    except:
        sys.exit(0)
    
    if not last_end:
        sys.exit(0)
    
    # 4시간 이상 경과 시 제안
    try:
        last_dt = datetime.fromisoformat(last_end)
        now = datetime.now(timezone(timedelta(hours=9)))
        gap_hours = (now - last_dt).total_seconds() / 3600
        if gap_hours < 4:
            sys.exit(0)
    except:
        sys.exit(0)
    
    gap_display = f'{int(gap_hours)}시간' if gap_hours < 48 else f'{int(gap_hours/24)}일'
    print(f'[Context Sync] 마지막 세션 이후 {gap_display} 경과. /context-sync로 놓친 활동을 확인하세요.')
    " 2>/dev/null)
    
    # 첫 사용자 감지: .forge-onboarded 마커 없으면 /guide 안내
    if [[ ! -f "$HOME/.claude/.forge-onboarded" ]]; then
        if [[ -n "$MSG" ]]; then
            MSG="$MSG
    [Claude Forge] 처음이신가요? /guide 로 시작해보세요."
        else
            MSG="[Claude Forge] 처음이신가요? /guide 로 시작해보세요."
        fi
    fi
    
    if [[ -n "$MSG" ]]; then
        $PYTHON3 -c "
    import json, sys
    msg = sys.argv[1]
    output = {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': msg}}
    print(json.dumps(output, ensure_ascii=False))
    " "$MSG" 2>/dev/null
    fi
    
    exit 0
    
  • hooks/db-guard.shGitHub
    Read the script
    #!/bin/bash
    # DB Guard - PreToolUse Hook
    # Blocks dangerous SQL patterns: DROP, TRUNCATE, DELETE without WHERE, ALTER DROP
    #
    # Hook trigger: PreToolUse, matcher: mcp__(supabase|supabase-db)__execute_sql, mcp__(supabase|supabase-db)__apply_migration
    # Exit codes: 0 = allow, 2 = block
    
    # Read tool call JSON from stdin
    INPUT=$(cat)
    
    QUERY=$(echo "$INPUT" | python3 -c "
    import sys, json
    data = json.load(sys.stdin)
    ti = data.get('tool_input', {})
    # execute_sql uses 'query', apply_migration uses 'sql' or 'statements'
    print(ti.get('query', ti.get('sql', ti.get('statements', ''))))
    " 2>/dev/null)
    
    if [[ -z "$QUERY" ]]; then
        # No query found, allow (might be a different tool input format)
        exit 0
    fi
    
    # Python 기반 SQL 패턴 검사 (CWE-78 방지: grep 파이프라인 대신 Python 사용)
    export _DB_GUARD_QUERY="$QUERY"
    python3 << 'DB_GUARD_SCRIPT'
    import os
    import sys
    import re
    
    query = os.environ.get("_DB_GUARD_QUERY", "")
    if not query:
        sys.exit(0)
    
    query_upper = query.upper()
    safe_preview = query[:200]
    
    # Block DROP TABLE/DATABASE/SCHEMA
    if re.search(r'\bDROP\s+(TABLE|DATABASE|SCHEMA)\b', query_upper):
        print("BLOCKED: DROP statement detected", file=sys.stderr)
        print(f"Query: {safe_preview}", file=sys.stderr)
        sys.exit(2)
    
    # Block TRUNCATE — statement 형태만 (DDL). GRANT/REVOKE의 권한 키워드 TRUNCATE는 허용.
    # DDL: 'TRUNCATE TABLE foo' / 'TRUNCATE foo' / 'TRUNCATE foo, bar'
    # 권한: 'REVOKE INSERT, UPDATE, DELETE, TRUNCATE, ... ON foo FROM ...'
    # 정규식: TRUNCATE 다음에 (TABLE|식별자) 형태의 DDL만 매칭. 콤마 또는 ON이 따라오면 권한 키워드.
    _truncate_ddl = re.search(r'\bTRUNCATE\s+(TABLE\s+|ONLY\s+|[A-Z_][A-Z0-9_]*\s*[(,;]|[A-Z_][A-Z0-9_]*\s*$)', query_upper)
    if _truncate_ddl:
        print("BLOCKED: TRUNCATE statement detected", file=sys.stderr)
        print(f"Query: {safe_preview}", file=sys.stderr)
        sys.exit(2)
    
    # Block DELETE without WHERE
    if re.search(r'\bDELETE\s+FROM\b', query_upper) and not re.search(r'\bWHERE\b', query_upper):
        print("BLOCKED: DELETE without WHERE clause", file=sys.stderr)
        print(f"Query: {safe_preview}", file=sys.stderr)
        sys.exit(2)
    
    # Block ALTER TABLE ... DROP COLUMN (destructive schema change)
    # DROP CONSTRAINT/INDEX는 허용 (중복 인덱스 제거 등)
    if re.search(r'\bALTER\s+TABLE\b.*\bDROP\s+COLUMN\b', query_upper):
        print("BLOCKED: ALTER TABLE DROP COLUMN detected", file=sys.stderr)
        print(f"Query: {safe_preview}", file=sys.stderr)
        sys.exit(2)
    
    # Safe query - allow
    sys.exit(0)
    DB_GUARD_SCRIPT
    
  • hooks/emdash-slop-guard.shGitHub
    Read the script
    #!/bin/bash
    # emdash-slop-guard.sh - PostToolUse hook (Write|Edit)
    # Flags em-dash (—/–) interjection AI-tells in Korean-dominant .md output.
    # Detection logic lives in scripts/emdash_slop_check.py (single source of
    # truth — do not duplicate the regex/heuristic here, edit that file instead).
    # Rule: rules/korean-writing-quality.md §4/§6
    # Non-blocking: always exits 0, emits additionalContext only.
    # Kill switch: CLAUDE_FORGE_EMDASH_GUARD_DISABLED=1
    
    [ "${CLAUDE_FORGE_EMDASH_GUARD_DISABLED:-0}" = "1" ] && exit 0
    
    INPUT=$(cat)
    CHECKER="$HOME/.claude/scripts/emdash_slop_check.py"
    [ -f "$CHECKER" ] || exit 0
    
    echo "$INPUT" | CHECKER="$CHECKER" python3 -c "
    import sys, json, os, subprocess
    
    try:
        d = json.load(sys.stdin)
    except Exception:
        sys.exit(0)
    
    tool = d.get('tool_name', '')
    checker = os.environ['CHECKER']
    
    if tool not in ('Write', 'Edit'):
        sys.exit(0)
    
    fp = d.get('tool_input', {}).get('file_path', '')
    if not fp.endswith('.md') or not os.path.isfile(fp):
        sys.exit(0)
    
    try:
        r = subprocess.run([sys.executable, checker, fp],
                           capture_output=True, text=True, timeout=8)
        out = r.stdout.strip()
    except Exception:
        sys.exit(0)
    
    if out:
        payload = {'hookSpecificOutput': {'hookEventName': 'PostToolUse',
                   'additionalContext': out + chr(10) + '[Emdash Slop] Fix the interjections above before treating this doc as final (advisory, not a hard block — rules/korean-writing-quality.md §4).'}}
        print(json.dumps(payload, ensure_ascii=False))
    " 2>/dev/null
    
    exit 0
    
  • hooks/expensive-mcp-warning.shGitHub
  • hooks/forge-update-check.shGitHub
  • hooks/loop-detection.shGitHub
  • hooks/mcp-usage-tracker.shGitHub
  • hooks/output-secret-filter.shGitHub
  • hooks/post-compact-restore.shGitHub
  • hooks/pre-compact-snapshot.shGitHub
  • hooks/rate-limiter.shGitHub
  • hooks/remote-command-guard.shGitHub
  • hooks/security-auto-trigger.shGitHub
  • hooks/session-time-report.shGitHub
  • hooks/session-wrap-suggest.shGitHub
  • hooks/task-completed.shGitHub
  • hooks/work-tracker-prompt.shGitHub
  • hooks/work-tracker-stop.shGitHub
  • hooks/work-tracker-tool.shGitHub

All 22 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 withclaude-forge

oh-my-zsh for Claude Code — 16 agents, 35 commands, 32 skills, 21 safety hooks in one install. v4.0 adds an adversarial review loop: a second agent that never sees the first one's reasoning. MIT.

Get the whole plugin