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.
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
Stats
841
Stars
178
Forks
Active
Maintenance
Shell
Language
MIT
License
18d ago
Last commit
7mo ago
Created
Repo: sangrokjung/claude-forge

