Skip to content
Development
Hook

Hooks

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

From plugin
scaffolding
1538 skills13 agents19 commands20 hooks
Install
> /plugin marketplace add komluk/scaffolding
> /plugin install scaffolding@komluk-scaffolding

Ships with scaffolding. Installing the plugin gets these hooks.

Where it lives

  • hooks/auto-init-check.shGitHub
    Read the script
    #!/usr/bin/env bash
    # auto-init-check.sh — SessionStart soft auto-init (startup/resume).
    #
    # Detects whether the current project is initialized for scaffolding:
    #   1. a project-root CLAUDE.md that contains the routing protocol, AND
    #   2. .claude/settings.json
    # If either is MISSING, injects additionalContext advising the user/Claude to
    # run /init-scaffolding.
    #
    # STRICTLY IDEMPOTENT & NON-DESTRUCTIVE:
    #   - NEVER overwrites or creates CLAUDE.md or settings.json.
    #   - NEVER clobbers user content.
    #   - Only best-effort creates the safe .scaffolding/ skeleton (|| true), the
    #     same harmless directory memory-project-id.sh already relies on.
    # Always exits 0; SessionStart JSON additionalContext like session-start-protocol.sh.
    set +e
    
    dir="${CLAUDE_PROJECT_DIR:-$PWD}"
    
    # --- Detection (read-only) ---
    has_routing=0
    if [ -f "$dir/CLAUDE.md" ] && grep -q 'subagent_type="scaffolding:' "$dir/CLAUDE.md" 2>/dev/null; then
        has_routing=1
    fi
    
    has_settings=0
    if [ -f "$dir/.claude/settings.json" ]; then
        has_settings=1
    fi
    
    # Already initialized -> nothing to advise. Still emit a valid (empty-context)
    # SessionStart object so the hook is well-formed.
    if [ "$has_routing" = "1" ] && [ "$has_settings" = "1" ]; then
        exit 0
    fi
    
    # --- Safe skeleton only (never touches CLAUDE.md / settings.json) ---
    mkdir -p "$dir/.scaffolding/agent-memory/shared" 2>/dev/null || true
    mkdir -p "$dir/.scaffolding/agent-memory/agents" 2>/dev/null || true
    mkdir -p "$dir/.scaffolding/conversations" 2>/dev/null || true
    mkdir -p "$dir/.scaffolding/worktrees" 2>/dev/null || true
    
    # --- Build advisory message ---
    missing=""
    [ "$has_routing" = "0" ] && missing="${missing}CLAUDE.md routing protocol, "
    [ "$has_settings" = "0" ] && missing="${missing}.claude/settings.json, "
    missing="${missing%, }"
    
    ctx="scaffolding: this project is not fully initialized (missing: ${missing}). Run /init-scaffolding to install the routing protocol and hooks so agent delegation works here. This is advisory only — no files were overwritten; the safe .scaffolding/ skeleton was created if absent."
    
    # SessionStart additionalContext injection (JSON-escape via python3, with fallback).
    printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' \
      "$(printf '%s' "$ctx" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$ctx")"
    
    exit 0
    
  • hooks/block-destructive-rm.shGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook: Block destructive rm -rf on critical paths.
    # Exit 2 = abort (block the tool call), Exit 0 = allow.
    
    set -euo pipefail
    
    # Reason: try/except ensures empty string on malformed JSON so hook exits 0
    # (allow) instead of exit 1 (warn-but-proceed) which would let the op execute.
    CMD=$(python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        print(d.get('tool_input', {}).get('command', ''))
    except Exception:
        print('')
    ")
    
    # Reason: Block piped-to-rm patterns (xargs rm, xargs -I{} rm, etc.)
    # before the main rm flag analysis, since these bypass flag detection.
    if echo "$CMD" | grep -qE 'xargs\s+(-[^\s]*\s+)*rm\b'; then
        echo "BLOCKED: Piping to rm via xargs is not allowed." >&2
        exit 2
    fi
    
    # Only check rm commands
    if ! echo "$CMD" | grep -q 'rm '; then
        exit 0
    fi
    
    # Reason: Independently detect recursive AND force flags anywhere in the command.
    # This catches combined flags (-rf, -fr), separated flags (-r -f), long flags
    # (--recursive --force), and any mix thereof.
    HAS_RECURSIVE=false
    HAS_FORCE=false
    
    # Check for -r/-R in combined short flags or standalone, or --recursive
    if echo "$CMD" | grep -qE '(^| )-[a-zA-Z]*[rR]'; then
        HAS_RECURSIVE=true
    fi
    if echo "$CMD" | grep -qE '(^| )--recursive( |$)'; then
        HAS_RECURSIVE=true
    fi
    
    # Check for -f in combined short flags or standalone, or --force
    if echo "$CMD" | grep -qE '(^| )-[a-zA-Z]*f'; then
        HAS_FORCE=true
    fi
    if echo "$CMD" | grep -qE '(^| )--force( |$)'; then
        HAS_FORCE=true
    fi
    
    # Not a destructive rm unless both recursive and force are present
    if ! $HAS_RECURSIVE || ! $HAS_FORCE; then
        exit 0
    fi
    
    # Reason: Use fixed-string prefix matching via python to avoid regex escaping
    # issues (e.g. unescaped dots in paths like /.). Python startswith() is exact.
    BLOCKED=$(python3 -c "
    import sys
    cmd = sys.argv[1]
    blocked_prefixes = ['/', '/etc', '/var', '/usr', '.']
    # Reason: Extract all path-like arguments after rm and its flags
    parts = cmd.split()
    in_rm = False
    for p in parts:
        if p == 'rm':
            in_rm = True
            continue
        if not in_rm:
            continue
        # Skip flags
        if p.startswith('-'):
            continue
        # Check if this argument matches or is under a blocked prefix
        for bp in blocked_prefixes:
            if bp == '/':
                # Only block exact '/' not every absolute path
                if p == '/':
                    print(p)
                    sys.exit(0)
            elif bp == '.':
                if p == '.' or p == '..':
                    print(p)
                    sys.exit(0)
            else:
                # Prefix match: block '/etc' and '/etc/foo' etc.
                if p == bp or p.startswith(bp + '/'):
                    print(p)
                    sys.exit(0)
    print('')
    " "$CMD")
    
    if [ -n "$BLOCKED" ]; then
        echo "BLOCKED: Destructive rm -rf on '$BLOCKED' is not allowed." >&2
        exit 2
    fi
    
    exit 0
    
  • hooks/block-env-write.shGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook: Block writes to .env files.
    # Exit 2 = abort (block the tool call), Exit 0 = allow.
    
    set -euo pipefail
    
    # Reason: try/except ensures empty string on malformed JSON so hook exits 0
    # (allow) instead of exit 1 (warn-but-proceed) which would let the op execute.
    TOOL_INPUT=$(python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        ti = d.get('tool_input', {})
        # Output file_path (for Edit/Write) and command (for Bash) separated by newline
        print(ti.get('file_path', ''))
        print(ti.get('command', ''))
    except Exception:
        print('')
        print('')
    ")
    
    FILE_PATH=$(echo "$TOOL_INPUT" | head -1)
    CMD=$(echo "$TOOL_INPUT" | tail -1)
    
    # Check Edit/Write tool: block if file_path targets .env
    if [ -n "$FILE_PATH" ]; then
        BASENAME=$(basename "$FILE_PATH")
        # Block if basename is exactly ".env" or starts with ".env."
        if [ "$BASENAME" = ".env" ] || echo "$BASENAME" | grep -q '^\.env\.'; then
            echo "BLOCKED: Writing to $BASENAME is not allowed. Environment files must be edited manually." >&2
            exit 2
        fi
    fi
    
    # Check Bash tool: block commands that redirect output to .env files
    # Reason: Catch patterns like "echo ... > .env", "cat ... > .env", "tee .env"
    if [ -n "$CMD" ]; then
        if echo "$CMD" | grep -qE '(>|>>)\s*\.env(\s|$|\.)|tee\s+(-a\s+)?\.env(\s|$|\.)'; then
            echo "BLOCKED: Writing to .env via shell redirect/tee is not allowed. Environment files must be edited manually." >&2
            exit 2
        fi
    fi
    
    exit 0
    
  • hooks/block-force-push.shGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook: Block git push with --force, -f, or --force-with-lease.
    # Exit 2 = abort (block the tool call), Exit 0 = allow.
    
    set -euo pipefail
    
    # Reason: try/except ensures empty string on malformed JSON so hook exits 0
    # (allow) instead of exit 1 (warn-but-proceed) which would let the op execute.
    CMD=$(python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        print(d.get('tool_input', {}).get('command', ''))
    except Exception:
        print('')
    ")
    
    # Only check git push commands
    if ! echo "$CMD" | grep -q 'git push'; then
        exit 0
    fi
    
    # Block --force, --force-with-lease, or standalone -f flag
    if echo "$CMD" | grep -qE '(--force-with-lease|--force)\b'; then
        echo "BLOCKED: Force push is not allowed. Remove --force or --force-with-lease flag." >&2
        exit 2
    fi
    
    # Reason: Match -f anywhere in a combined flag group (e.g. -fu, -uf) not just standalone.
    if echo "$CMD" | grep -qE '(^| )-[a-zA-Z]*f'; then
        echo "BLOCKED: Force push (-f) is not allowed." >&2
        exit 2
    fi
    
    exit 0
    
  • hooks/block-subagent.shGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse hook (matcher: Task): Block denylisted subagent types.
    # Exit 2 = block the tool call, Exit 0 = allow.
    # Denylist is EXACT-match only: general-purpose, explore.
    # scaffolding:<agent> and all other custom agents are allowed.
    
    set +e
    
    # Reason: try/except yields empty string on malformed JSON so the hook exits 0
    # (allow) rather than exit 1 (warn-but-proceed). Read tool_input.subagent_type.
    SUBAGENT_TYPE=$(python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        print(d.get('tool_input', {}).get('subagent_type', ''))
    except Exception:
        print('')
    " 2>/dev/null)
    
    case "$SUBAGENT_TYPE" in
      general-purpose|explore)
        echo "BLOCKED: subagent_type '$SUBAGENT_TYPE' is denylisted by the scaffolding protocol. Use a scaffolding:<agent> (e.g. scaffolding:developer, scaffolding:analyst) instead. 'general-purpose' conflicts with the custom agents; 'explore' is for quick file searches only, never planning/analysis." >&2
        exit 2
        ;;
    esac
    
    exit 0
    
  • hooks/completion-nudge.shGitHub
    Read the script
    #!/usr/bin/env bash
    # completion-nudge.sh — optional Stop hook.
    #
    # Before the agent yields, surfaces a short completion-verification checklist so
    # work isn't reported done prematurely. References the existing
    # pre-commit-validation hook rather than re-running tests — stays cheap (no
    # network, no test execution, sub-millisecond).
    #
    # Loop prevention: reads `stop_hook_active` from the Stop event stdin JSON. When
    # true (the stop was already triggered by a prior hook pass), the hook exits 0
    # immediately — this makes infinite loops impossible.
    #
    # Modes (opt-in, same gating style as notify.sh):
    #   unset                            -> fast-path no-op, exit 0
    #   SCAFFOLDING_COMPLETION_NUDGE=1   -> advisory: checklist to stderr, exit 0 (never blocks)
    #   SCAFFOLDING_COMPLETION_NUDGE=block -> strict: emit {"decision":"block",...} once, exit 0
    #
    # Always exits 0.
    
    set +e
    
    # Read the Stop event payload from stdin (may be empty).
    INPUT=$(cat 2>/dev/null)
    
    # Loop guard (mandatory): if the stop was already triggered by a prior hook pass,
    # do nothing. This is what makes a block-mode nudge fire at most once.
    STOP_ACTIVE=$(printf '%s' "$INPUT" | python3 -c \
        'import json,sys
    try:
        d = json.load(sys.stdin)
        print("true" if d.get("stop_hook_active") else "false")
    except Exception:
        print("false")' 2>/dev/null)
    
    if [ "$STOP_ACTIVE" = "true" ]; then
        exit 0
    fi
    
    # Opt-in gate — no-op fast-path when not enabled.
    MODE="${SCAFFOLDING_COMPLETION_NUDGE:-}"
    if [ -z "$MODE" ]; then
        exit 0
    fi
    
    CHECKLIST="Completion check before yielding:
      - Did you actually run the tests, or just assume they pass?
      - Did validation pass (the pre-commit-validation hook runs at git commit — let it run; do not bypass it)?
      - Is the task truly complete, or only partially done?
      - Any BLOCKED items left unreported?"
    
    case "$MODE" in
        block)
            # Strict mode: force one more turn. stop_hook_active guard above ensures
            # this fires at most once (the re-prompt's Stop event has it set true).
            REASON="$CHECKLIST"
            printf '%s' "$REASON" | python3 -c \
                'import json,sys
    print(json.dumps({"decision": "block", "reason": sys.stdin.read()}))' 2>/dev/null
            exit 0
            ;;
        *)
            # Advisory mode (=1 or any other non-empty value): never blocks.
            printf '%s\n' "$CHECKLIST" >&2
            exit 0
            ;;
    esac
    
  • hooks/file-size-warn.shGitHub
  • hooks/file-staleness-check.shGitHub
  • hooks/file-staleness-update.shGitHub
  • hooks/memory-ingest-mark.shGitHub
  • hooks/memory-ingest.shGitHub
  • hooks/memory-project-id.shGitHub
  • hooks/notify.shGitHub
  • hooks/post-edit-format.shGitHub
  • hooks/post-edit-review.shGitHub
  • hooks/pre-commit-validation.shGitHub
  • hooks/refresh-mcp-token.shGitHub
  • hooks/session-start-protocol.shGitHub
  • hooks/worktree-create.shGitHub
  • hooks/worktree-remove.shGitHub

All 20 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 withscaffolding

Spec-driven multi-agent orchestration for Claude Code — pure markdown, zero backend, runs on the stock runtime. 13 agents, 38 skills, 19 commands, 17 hooks, per-phase model tiers, opt-in lifecycle hooks, optional cross-device semantic memory.

Get the whole plugin