Skip to content

Instantly share code, notes, and snippets.

@tomfuertes
Created February 22, 2026 15:08
Show Gist options
  • Select an option

  • Save tomfuertes/a6d751e509d4d4c1ec841e9970bd65f4 to your computer and use it in GitHub Desktop.

Select an option

Save tomfuertes/a6d751e509d4d4c1ec841e9970bd65f4 to your computer and use it in GitHub Desktop.
Claude Code Hooks - Battle-tested hooks for security, team orchestration, and knowledge management

Claude Code Hooks - Greatest Hits

Battle-tested hooks from ~6 months of daily Claude Code usage. These run globally via ~/.claude/settings.json and protect every project.

Quick Start

# 1. Copy hooks to ~/.claude/hooks/
mkdir -p ~/.claude/hooks/lib
cp *.sh ~/.claude/hooks/
cp secret-patterns.sh ~/.claude/hooks/lib/

# 2. Wire them in ~/.claude/settings.json (see settings-wiring.jsonc)

# 3. Restart Claude Code (hooks snapshot at startup)

Hooks

File Event What it does
Security
detect-secrets.sh PreToolUse (Edit|Write) Blocks secrets (API keys, tokens, private keys) from being written to files
bash-secrets-warn.sh PreToolUse (Bash) Blocks secrets from appearing in shell commands
secret-patterns.sh (shared lib) 25+ regex patterns for AWS, GitHub, Stripe, OpenAI, Anthropic, Slack, npm, DB URLs, private keys
npm-malware-scan.sh SessionStart + PreToolUse (Bash) Detects Shai-Hulud supply chain malware in node_modules (Sep-Oct 2025 campaign)
git-clean-guard.sh PreToolUse (Bash) Blocks git clean -f which destroys untracked files (especially dangerous on orphan branches)
Team Orchestration
force-background-tasks.sh PreToolUse (Task) Auto-backgrounds named teammate spawns so the lead stays non-blocking
task-completion-gate.sh TaskCompleted Enforces structured metadata (changes, learnings, risks) before workers can mark tasks complete
ceo-stop-guard.sh Stop Prevents the lead from stopping while workers are still running
Knowledge Pipeline
session-end-promote.sh SessionEnd Scans completed task metadata, auto-promotes learnings appearing in 2+ tasks to CLAUDE.md
Diagnostic
task-call-logger.sh PreToolUse (Task) Logs every Task tool call to ~/.claude/debug/task-calls.log for debugging

Key Lessons

  • updatedInput in PreToolUse hooks REPLACES tool_input, not merges. To add a field, pass through the full original: .tool_input + { new_field: value }. See force-background-tasks.sh for the pattern.
  • Hooks snapshot at session start. Edits mid-session require a restart. The ConfigChange event fires but doesn't reload hooks.
  • $TMPDIR varies across sessions. Use $HOME/.claude/debug/ for log files that need to persist.
  • Fail open by default. trap 'exit 0' ERR at the top of every hook. A broken hook shouldn't block your workflow.
  • Exit 2 = block with feedback. stderr text is fed back to Claude as an error message.

Directory Structure

~/.claude/
  hooks/
    lib/
      secret-patterns.sh     # Shared regex patterns
    detect-secrets.sh         # PreToolUse (Edit|Write)
    bash-secrets-warn.sh      # PreToolUse (Bash)
    npm-malware-scan.sh       # SessionStart + PreToolUse (Bash)
    git-clean-guard.sh        # PreToolUse (Bash)
    force-background-tasks.sh # PreToolUse (Task)
    task-completion-gate.sh   # TaskCompleted
    ceo-stop-guard.sh         # Stop
    session-end-promote.sh    # SessionEnd
    task-call-logger.sh       # PreToolUse (Task)
  settings.json               # Wire hooks here (see settings-wiring.jsonc)

CEO Mode (CLAUDE.md excerpt)

The team orchestration hooks (force-background-tasks.sh, task-completion-gate.sh, ceo-stop-guard.sh) support a "CEO mode" workflow where the lead agent never writes code directly - it only triages, creates tasks, spawns workers, and merges results.

See CLAUDE-ceo-mode.md for the full CLAUDE.md section that teaches the model this workflow.

License

MIT. Use however you want.

#!/bin/bash
# PreToolUse (Bash): Warn about secrets in shell commands
# Exit 2 = block, user can approve to continue
set -euo pipefail
# Check dependencies
command -v jq &>/dev/null || { echo "Error: jq required but not installed" >&2; exit 1; }
HOOK_DIR="$(dirname "$0")"
source "$HOOK_DIR/lib/secret-patterns.sh" || { echo "Error: Cannot load $HOOK_DIR/lib/secret-patterns.sh" >&2; exit 1; }
PAYLOAD=$(cat)
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""')
[[ -z "$COMMAND" ]] && exit 0
if pattern=$(check_for_secrets "$COMMAND"); then
redacted=$(get_redacted_match "$COMMAND" "$pattern")
cat >&2 << EOF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ SECRET DETECTED IN BASH COMMAND
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Found: $redacted
To allow this command:
→ Approve in the permission prompt
Better approach:
→ export TOKEN="..." then use \$TOKEN
→ Commands with secrets are logged
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EOF
exit 2
fi
exit 0
#!/usr/bin/env bash
# Stop hook: prevent the lead from stopping when active work exists.
# Only blocks on in_progress tasks with owners (active workers).
# Ownerless in_progress tasks get a warning but don't block (error state, not active work).
# Pending backlog items don't trigger.
# Fails open on any error.
trap 'exit 0' ERR
INPUT=$(cat)
# Prevent infinite loops
stop_hook_active=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0
[[ "$stop_hook_active" == "true" ]] && exit 0
# Locate task directory
task_list_id="${CLAUDE_CODE_TASK_LIST_ID:-}"
[[ -z "$task_list_id" ]] && exit 0
task_dir="$HOME/.claude/tasks/$task_list_id"
[[ ! -d "$task_dir" ]] && exit 0
shopt -s nullglob
task_files=("$task_dir"/*.json)
shopt -u nullglob
[[ ${#task_files[@]} -eq 0 ]] && exit 0
# Process files individually (one bad file shouldn't blind us to all tasks)
all_tasks="["
first=true
for f in "${task_files[@]}"; do
task=$(jq '.' "$f" 2>/dev/null) || continue
$first || all_tasks+=","
all_tasks+="$task"
first=false
done
all_tasks+="]"
[[ "$all_tasks" == "[]" ]] && exit 0
decision=$(echo "$all_tasks" | jq -c '
# In-progress tasks with owners = workers still running (BLOCKS)
[ .[] |
select(.status == "in_progress" and .owner != null and .owner != "") |
.subject // "unnamed task"
] as $active |
# In-progress tasks without owners = abandoned work (WARN only)
[ .[] |
select(.status == "in_progress") |
select(.owner == null or .owner == "") |
.subject // "unnamed task"
] as $abandoned |
if ($active | length) > 0
then {
decision: "block",
reason: (($active | length | tostring) + " active worker(s): " + ($active | join(", ")) + ". Wait for workers or reassign.")
}
elif ($abandoned | length) > 0
then {
decision: "warn",
reason: (($abandoned | length | tostring) + " abandoned task(s): " + ($abandoned | join(", ")) + ". Consider resolving or deleting these.")
}
else null
end
' 2>/dev/null) || exit 0
[[ -z "$decision" || "$decision" == "null" ]] && exit 0
# Warnings don't block - just print to stderr
decision_type=$(echo "$decision" | jq -r '.decision' 2>/dev/null)
if [[ "$decision_type" == "warn" ]]; then
reason=$(echo "$decision" | jq -r '.reason' 2>/dev/null)
echo "[ceo-stop-guard] WARNING: $reason" >&2
exit 0
fi
echo "$decision"

CEO Mode - CLAUDE.md Excerpt

Add this to your project or user-level CLAUDE.md to teach Claude the coordinator-worker pattern. Activate by saying "CEO mode", "delegate", "swarm", or "team".


CEO Mode

Activated by: "CEO mode", "delegate", "swarm", "team". Requires TeamCreate + TaskList. No exceptions.

  • Zero implementation. The lead never touches source files. Even a 1-line fix gets delegated. If you reach for Edit/Write on a source file, stop and delegate.
  • Always TeamCreate. Non-trivial sessions get a team immediately. One team per session, reuse it.
  • Always TaskList. Every unit of work is a task. Lead's job: triage, TaskCreate, spawn teammates, merge results.
  • Never block the lead. force-background-tasks.sh hook injects run_in_background: true into named teammate Task calls via updatedInput. The lead is the event loop - user input is the async event source.
  • Lead is autonomous between tasks. Never ask "should we proceed?" or "want to QA?" Default: if the next task is unblocked, spawn a worker immediately. Workers self-verify. The lead only pauses for one-way doors (push to remote, production config, branch deletion).
  • Always mode: "bypassPermissions" for spawned agents unless destructive actions need user approval. OS sandbox is the safety boundary.
  • Enter plan mode for 3+ step tasks or architectural decisions.

Worker Lifecycle

Include this when spawning workers. Workers start cold with just CLAUDE.md + the spawn prompt.

  1. Read before writing. Understand what exists before changing it.
  2. Stay in scope. Don't refactor adjacent code or add unrequested features.
  3. Verify before marking complete. Typecheck, lint, or run relevant tests.
  4. Commit to the feature branch. Do not open a PR.
  5. Report (two calls, in order):
    • TaskUpdate with metadata: { "changes": [...], "learnings": [...], "failed_approaches": [...], "loose_ends": [...], "risks": [...] }. For trivial tasks: { "clean": true }.
    • Then TaskUpdate status completed + SendMessage the lead with a brief summary. The task-completion-gate.sh hook blocks completion if metadata is missing.
  6. Stuck? Escalate. SendMessage the lead immediately. Don't retry the same failing command.

Triage Incoming Reports

When a worker completes:

  • Default: acknowledge and scan. Check if the worker's learnings or failed_approaches match any other completed task's metadata.
  • Auto-promote (2+ occurrences): If a learning appears across 2+ completed tasks, append it to CLAUDE.md immediately.
  • Auto-create tasks: If loose_ends are present, create TaskList items for each one.
  • Act immediately on: risks items, ship-blockers, anything that blocks another in-progress task.
  • Defer to session end: single-occurrence learnings, failed approaches. The SessionEnd hook auto-promotes patterns.

Model Selection

  • opus: architecture, complex decisions, ambiguous debugging. Delegates exploration to sonnet/haiku subagents.
  • sonnet: default workhorse. Implementation, exploration, test writing, research.
  • haiku: mechanical zero-reasoning (bulk renames, known-path lookups). Stuck after 2 check-ins -> kill, re-spawn as sonnet.
  • Workers are ephemeral. Don't reuse teammates across tasks - spawn fresh, get clean context.
#!/bin/bash
# PreToolUse (Edit|Write): Block secrets in file content
# Exit 2 = block with prompt, user can approve to continue
set -euo pipefail
# Check dependencies
command -v jq &>/dev/null || { echo "Error: jq required but not installed" >&2; exit 1; }
HOOK_DIR="$(dirname "$0")"
source "$HOOK_DIR/lib/secret-patterns.sh" || { echo "Error: Cannot load $HOOK_DIR/lib/secret-patterns.sh" >&2; exit 1; }
PAYLOAD=$(cat)
CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // ""')
[[ -z "$CONTENT" ]] && exit 0
if pattern=$(check_for_secrets "$CONTENT"); then
redacted=$(get_redacted_match "$CONTENT" "$pattern")
cat >&2 << EOF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚫 BLOCKED: Secret detected in file content
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Found: $redacted
Use environment variables instead:
→ Store in .env (gitignored)
→ Reference via process.env.VAR or \$VAR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EOF
exit 2
fi
exit 0
#!/usr/bin/env bash
# PreToolUse hook (Task): silently inject run_in_background: true
# Only forces background for named teammate spawns (name param present).
# KEY-DECISION 2026-02-22: updatedInput REPLACES tool_input (not merge).
# Must pass through ALL original fields with run_in_background added.
set -eo pipefail
trap 'exit 0' ERR
INPUT=$(cat)
# Already background - pass through
echo "$INPUT" | jq -e '.tool_input.run_in_background == true' &>/dev/null && exit 0
# Only force background for named agents (teammates)
# Unnamed Task calls (ad-hoc subagents) stay foreground for synchronous results
agent_name=$(echo "$INPUT" | jq -r '.tool_input.name // empty' 2>/dev/null) || exit 0
[[ -z "$agent_name" ]] && exit 0
# updatedInput REPLACES tool_input - must include all original fields
echo "$INPUT" | jq '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: (.tool_input + { run_in_background: true })
}
}'
#!/bin/bash
# PreToolUse (Bash): Block git clean with force flags
# Exit 2 = block, user can approve to continue
# git clean -fd destroys untracked files — especially dangerous on orphan branches
# where .env and other gitignored files aren't protected by the index.
set -euo pipefail
command -v jq &>/dev/null || { echo "Error: jq required but not installed" >&2; exit 1; }
PAYLOAD=$(cat)
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""')
[[ -z "$COMMAND" ]] && exit 0
# Match git clean with any force flag (-f, -fd, -fx, -fxd, --force, etc.)
if echo "$COMMAND" | grep -qE 'git\s+clean\s+.*-[a-zA-Z]*f'; then
cat >&2 << EOF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ git clean -f BLOCKED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Command: $COMMAND
git clean with force flags destroys untracked
files permanently. On orphan branches (pr-assets)
this will delete .env and other gitignored files.
Safer alternatives:
→ git rm -rf . (clears index only)
→ git checkout -- (restore tracked files)
→ git stash (save changes temporarily)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EOF
exit 2
fi
exit 0
#!/bin/bash
# Dual-mode: SessionStart (proactive scan) + PreToolUse/Bash (gate npm/node commands)
# Exit 2 = block with error to Claude
# Sources:
# - Shai-Hulud campaign (Sep 2025): global['!']=X-YYYY fingerprint
# - Muhammad Usman incident report (Oct 2025): blockchain stealer + ransomware loader
# - CISA alert: https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem
set -uo pipefail
command -v jq &>/dev/null || { echo "Error: jq required" >&2; exit 1; }
PAYLOAD=$(cat)
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // ""')
# Detect mode: PreToolUse has tool_input.command, SessionStart does not
if [[ -n "$COMMAND" ]]; then
MODE="pre_tool"
# Only intercept npm/npx/bun/node execution
if ! echo "$COMMAND" | grep -qE '^\s*(npm (run|test|exec)|npx|bun (run|x)|node )'; then
exit 0
fi
else
MODE="session_start"
fi
NODE_MODULES="${CWD}/node_modules"
[[ ! -d "$NODE_MODULES" ]] && exit 0
# Alert helper - warns on session start, blocks on pre-tool
alert() {
local title="$1" body="$2"
cat >&2 <<EOF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚨 $title
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
$body
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EOF
# PreToolUse: block the command. SessionStart: warn only (let user use Claude to clean up)
[[ "$MODE" == "pre_tool" ]] && exit 2
}
# ── Known payload filenames (fast check) ──────────────────────────────────────
PAYLOAD_FILES=("setup_bun.js" "bun_environment.js")
for f in "${PAYLOAD_FILES[@]}"; do
found_path=$(find "$NODE_MODULES" -maxdepth 4 -name "$f" -type f 2>/dev/null | head -1)
[[ -z "$found_path" ]] && continue
alert "NPM SUPPLY-CHAIN MALWARE DETECTED" "$(cat <<BODY
Found Shai-Hulud payload file: $f
Location: $found_path
${COMMAND:+Command blocked: $COMMAND}
Immediate steps:
1. Run: command rm -rf "$NODE_MODULES"
2. Rotate GitHub, npm, Cloudflare, OpenAI credentials NOW
3. Run: ps aux | grep node (kill any running infected processes)
4. Report: support@npmjs.com
5. Use: npm ci --ignore-scripts (safer reinstall)
BODY
)"
done
# ── JS content fingerprints (grep with timeout) ──────────────────────────────
FINGERPRINTS=(
"global\[.!.\]\s*=\s*.[0-9]+-[0-9]+"
"_\\\$_[0-9a-f]\{4\}"
)
for pattern in "${FINGERPRINTS[@]}"; do
match=$(timeout 15 grep -rl --include="*.js" -m 1 -E "$pattern" "$NODE_MODULES" 2>/dev/null | head -1)
[[ -z "$match" ]] && continue
alert "NPM SUPPLY-CHAIN MALWARE DETECTED" "$(cat <<BODY
Found obfuscation fingerprint matching: $pattern
Infected file: $match
${COMMAND:+Command blocked: $COMMAND}
This matches the Shai-Hulud blockchain wallet-stealing malware (Sep-Oct 2025).
It harvests: GitHub tokens, SSH keys, npm tokens, crypto wallet seeds.
Immediate steps:
1. Run: command rm -rf "$NODE_MODULES"
2. Rotate ALL credentials: GitHub, npm, Cloudflare, OpenAI, SSH keys
3. Check: ps aux | grep node (kill infected processes)
4. Check: lsof -i | grep ESTABLISHED | grep node (look for exfil connections)
5. Report: support@npmjs.com
BODY
)"
done
# ── Source file targets: check config/route files for injected imports ────────
TARGET_FILES=(
"routes/dashboard.js" "tailwind.config.js" "webpack.config.js"
"next.config.js" "nuxt.config.js" "vite.config.js"
)
for target in "${TARGET_FILES[@]}"; do
filepath="${CWD}/${target}"
[[ ! -f "$filepath" ]] && continue
grep -qE "global\[.!.\]\s*=" "$filepath" 2>/dev/null || continue
alert "MALICIOUS CODE IN PROJECT SOURCE FILE" "$(cat <<BODY
Found malware fingerprint in: $target
This matches the Oct 2025 SSR injection attack pattern.
${COMMAND:+Command blocked: $COMMAND}
This means attacker had repo write access. Check immediately:
1. git log --all --since="2025-09-01" --pretty=format:"%h %an %ae %ad %s"
2. git log -p $target (see what was injected)
3. git revert <bad-commit> --no-edit
4. Revoke ALL GitHub personal access tokens
5. Check force-push history: git reflog | grep force
BODY
)"
done
exit 0
# Shared secret detection patterns
# Source this file: source "$(dirname "$0")/lib/secret-patterns.sh"
SECRET_PATTERNS=(
# AWS
'AKIA[0-9A-Z]{16}'
'aws_secret_access_key\s*[=:]\s*[A-Za-z0-9/+=]{40}'
# GitHub
'ghp_[a-zA-Z0-9]{36}'
'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}'
'gho_[a-zA-Z0-9]{36}'
'ghu_[a-zA-Z0-9]{36}'
'ghs_[a-zA-Z0-9]{36}'
'ghr_[a-zA-Z0-9]{36}'
# Stripe
'sk_live_[a-zA-Z0-9]{24,}'
'sk_test_[a-zA-Z0-9]{24,}'
'rk_live_[a-zA-Z0-9]{24,}'
'rk_test_[a-zA-Z0-9]{24,}'
# OpenAI
'sk-[a-zA-Z0-9]{48}'
'sk-proj-[a-zA-Z0-9_-]{80,}'
# Anthropic
'sk-ant-[a-zA-Z0-9_-]{80,}'
# Slack
'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}'
# Private keys
'-----BEGIN RSA PRIVATE KEY-----'
'-----BEGIN OPENSSH PRIVATE KEY-----'
'-----BEGIN EC PRIVATE KEY-----'
'-----BEGIN PGP PRIVATE KEY BLOCK-----'
# Database URLs with credentials
'mongodb(\+srv)?://[^:]+:[^@]+@'
'postgres(ql)?://[^:]+:[^@]+@'
'mysql://[^:]+:[^@]+@'
'redis://[^:]+:[^@]+@'
# npm
'npm_[a-zA-Z0-9]{36}'
# Heroku
'[hH]eroku.*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
# Twilio
'SK[a-fA-F0-9]{32}'
# SendGrid
'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}'
# Mailchimp
'[a-f0-9]{32}-us[0-9]{1,2}'
)
# Helper: check content for secrets, returns matched pattern or empty
check_for_secrets() {
local content="$1"
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$content" | grep -qE -- "$pattern"; then
echo "$pattern"
return 0
fi
done
return 1
}
# Helper: get redacted match
get_redacted_match() {
local content="$1"
local pattern="$2"
local matched=$(echo "$content" | grep -oE -- "$pattern" | head -1)
echo "${matched:0:10}..."
}
# Helper: combined pattern for grep -E
get_combined_pattern() {
local IFS='|'
echo "${SECRET_PATTERNS[*]}"
}
#!/usr/bin/env bash
# SessionEnd hook: auto-promote cross-task learnings to ~/.claude/CLAUDE.md
# Fail open - always exits 0. SessionEnd hooks cannot block session exit.
# Reads JSON from stdin: { session_id, transcript_path, cwd, permission_mode, hook_event_name, reason }
INPUT=$(cat)
CLAUDE_MD="$HOME/.claude/CLAUDE.md"
TASK_LIST_ID="${CLAUDE_CODE_TASK_LIST_ID:-}"
# Guards
command -v jq &>/dev/null || exit 0
[[ -f "$CLAUDE_MD" ]] || exit 0
PROMOTED=0
# Skip text that looks like secrets or credential values
is_suspicious() {
echo "$1" | grep -qiE '(password|secret|api[_-]?key|private[_-]?key|\.dev\.vars|BEGIN (RSA|EC|OPENSSH)|id_rsa|id_ed25519|token|credential|bearer|authorization|ghp_|sk-|cf_)'
}
# Append learning to CLAUDE.md if not already present; return 0 if added
promote_learning() {
local text="$1"
[[ -z "$text" ]] && return 1
is_suspicious "$text" && return 1
# Dedup: skip if first 60 chars already appear in file
grep -qF "${text:0:60}" "$CLAUDE_MD" 2>/dev/null && return 1
if grep -q "^## Learnings" "$CLAUDE_MD" 2>/dev/null; then
# Insert bullet at end of ## Learnings section (before next ## heading or EOF)
# Use same-dir tmpfile for atomic mv + flock to prevent concurrent write races
local tmpfile lockfile
tmpfile=$(mktemp "$CLAUDE_MD.XXXXXX") || return 1
lockfile="$CLAUDE_MD.lock"
(
flock -n 200 || { echo "[session-end-promote] skipped: another session is writing" >&2; rm -f "$tmpfile"; return 1; }
local input_lines output_lines
input_lines=$(wc -l < "$CLAUDE_MD")
LEARNING_TEXT="$text" awk '
/^## Learnings/ { in_section=1 }
in_section && /^## / && !/^## Learnings/ { print "- " ENVIRON["LEARNING_TEXT"]; in_section=0 }
{ print }
END { if (in_section) print "- " ENVIRON["LEARNING_TEXT"] }
' "$CLAUDE_MD" > "$tmpfile" || { rm -f "$tmpfile"; return 1; }
output_lines=$(wc -l < "$tmpfile")
if [[ "$output_lines" -lt "$input_lines" ]]; then
rm -f "$tmpfile"
return 1
fi
mv "$tmpfile" "$CLAUDE_MD" || { rm -f "$tmpfile"; return 1; }
) 200>"$lockfile"
else
printf '\n## Learnings\n- %s\n' "$text" >> "$CLAUDE_MD" || return 1
fi
echo "[session-end-promote] promoted: ${text:0:70}"
return 0
}
# --- Scan task metadata ---
if [[ -n "$TASK_LIST_ID" && -d "$HOME/.claude/tasks/$TASK_LIST_ID" ]]; then
TASK_DIR="$HOME/.claude/tasks/$TASK_LIST_ID"
# Promote learnings appearing in 2+ completed tasks (cross-task signal)
while IFS= read -r line; do
count=$(echo "$line" | awk '{print $1}')
text=$(echo "$line" | awk '{$1=""; sub(/^ /, ""); print}')
[[ "${count:-0}" -ge 2 ]] || continue
promote_learning "$text" && PROMOTED=$((PROMOTED + 1)) || true
done < <(
find "$TASK_DIR" -name "*.json" -exec jq -r \
'select(.status=="completed" and .metadata!=null and .metadata.clean!=true) | (.metadata.learnings // []) | .[]' \
{} \; 2>/dev/null | sed 's/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sort | uniq -c | sort -rn
)
# Promote failed approaches appearing in 2+ tasks
while IFS= read -r line; do
count=$(echo "$line" | awk '{print $1}')
text=$(echo "$line" | awk '{$1=""; sub(/^ /, ""); print}')
[[ "${count:-0}" -ge 2 ]] || continue
promote_learning "Avoid: $text" && PROMOTED=$((PROMOTED + 1)) || true
done < <(
find "$TASK_DIR" -name "*.json" -exec jq -r \
'select(.status=="completed" and .metadata!=null and .metadata.clean!=true) | (.metadata.failed_approaches // []) | .[]' \
{} \; 2>/dev/null | sed 's/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sort | uniq -c | sort -rn
)
# Print loose ends to stdout (TaskCreate unavailable from shell hooks)
while IFS= read -r line; do
[[ -z "$line" ]] && continue
echo "[session-end-promote] LOOSE END: $line"
done < <(
find "$TASK_DIR" -name "*.json" -exec jq -r \
'select(.status=="completed" and .metadata!=null and .metadata.clean!=true) | (.metadata.loose_ends // []) | .[]' \
{} \; 2>/dev/null | sort -u
)
fi
# --- Scan transcript for high correction rate ---
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) || true
if [[ -n "${TRANSCRIPT_PATH:-}" && -f "$TRANSCRIPT_PATH" ]]; then
corrections=$(grep -c '\bactually\b\|I was wrong\|let me correct' "$TRANSCRIPT_PATH" 2>/dev/null) || corrections=0
[[ "${corrections:-0}" -gt 5 ]] && \
echo "[session-end-promote] high correction rate ($corrections) - consider /hookify or CLAUDE.md update"
fi
# Summary
if [[ "$PROMOTED" -gt 0 ]]; then
echo "[session-end-promote] promoted $PROMOTED learning(s) to $CLAUDE_MD"
else
echo "[session-end-promote] no cross-task learnings to promote this session"
fi
exit 0
// Add this to your ~/.claude/settings.json under "hooks": {}
// Only showing the hooks section - merge into your existing settings.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/npm-malware-scan.sh\"",
"statusMessage": "Scanning node_modules for supply-chain malware..."
}
]
}
],
"PreToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/task-call-logger.sh\""
},
{
"type": "command",
"command": "\"$HOME/.claude/hooks/force-background-tasks.sh\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/detect-secrets.sh\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/bash-secrets-warn.sh\""
},
{
"type": "command",
"command": "\"$HOME/.claude/hooks/git-clean-guard.sh\""
},
{
"type": "command",
"command": "\"$HOME/.claude/hooks/npm-malware-scan.sh\"",
"statusMessage": "Scanning for supply-chain malware..."
}
]
}
],
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/task-completion-gate.sh\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/ceo-stop-guard.sh\""
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/session-end-promote.sh\""
}
]
}
]
}
}
#!/usr/bin/env bash
# PreToolUse hook (Task): diagnostic logger for debugging Task call failures.
# Logs to ~/.claude/debug/task-calls.log (survives $TMPDIR differences).
# Remove when diagnosis is complete.
trap 'exit 0' ERR
INPUT=$(cat)
LOGDIR="$HOME/.claude/debug"
mkdir -p "$LOGDIR" 2>/dev/null || exit 0
LOGFILE="$LOGDIR/task-calls.log"
{
echo "--- $(date -u +%Y-%m-%dT%H:%M:%SZ) ---"
echo "$INPUT" | jq '{
tool_name: .tool_name,
subagent_type: .tool_input.subagent_type,
name: .tool_input.name,
team_name: .tool_input.team_name,
model: .tool_input.model,
mode: .tool_input.mode,
run_in_background: .tool_input.run_in_background,
isolation: .tool_input.isolation,
description: .tool_input.description,
has_prompt: (.tool_input.prompt != null)
}' 2>/dev/null || echo "jq parse failed on input"
echo ""
} >> "$LOGFILE" 2>/dev/null
exit 0
#!/usr/bin/env bash
# TaskCompleted hook: enforce structured metadata before task completion
# Team mode only - solo tasks pass through. Fail open on infra issues.
set -euo pipefail
trap 'exit 0' ERR
INPUT=$(cat)
# Skip if not in a team (solo tasks don't need reports)
team_name=$(echo "$INPUT" | jq -r '.team_name // empty')
[[ -z "$team_name" ]] && exit 0
# Locate task file on disk
task_id=$(echo "$INPUT" | jq -r '.task_id // empty')
task_list_id="${CLAUDE_CODE_TASK_LIST_ID:-}"
[[ -z "$task_id" || -z "$task_list_id" ]] && exit 0 # fail open
task_file="$HOME/.claude/tasks/$task_list_id/$task_id.json"
[[ ! -f "$task_file" ]] && exit 0 # fail open
# --- End of fail-open guards. Validation below should block on errors. ---
trap - ERR
# Check metadata
metadata=$(jq '.metadata // empty' "$task_file" 2>/dev/null) || {
echo "Task completion blocked: cannot parse task file metadata." >&2
exit 2
}
[[ -z "$metadata" || "$metadata" == "null" ]] && {
echo "Task completion blocked: missing structured metadata." >&2
echo "Before marking complete, call TaskUpdate with metadata:" >&2
echo ' { "changes": [...], "learnings": [...], "failed_approaches": [...], "loose_ends": [...], "risks": [...] }' >&2
echo "Include only relevant categories. For trivial tasks: { \"clean\": true }" >&2
exit 2
}
# Trivial task shortcut
echo "$metadata" | jq -e '.clean == true' &>/dev/null && exit 0
# At least one non-empty array required
for field in changes learnings failed_approaches loose_ends risks; do
len=$(echo "$metadata" | jq -r ".$field // [] | length" 2>/dev/null) || continue
[[ "$len" -gt 0 ]] && exit 0
done
echo "Task completion blocked: metadata present but all report arrays are empty." >&2
echo "Set at least one of: changes, learnings, failed_approaches, loose_ends, risks" >&2
echo "Or use { \"clean\": true } for trivial tasks." >&2
exit 2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment