Hooks
What scaffolding runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /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.
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.
Repo: komluk/scaffolding

