Claude Skill

hook-authoring

Guide creating Claude Code hooks with security-first design. Use for validation and enforcement.

LLM Mart · 0 points · 5 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download athola-claude-night-market-plugins_abstract_skills_hook-authoring-9045831.zip · 34 KB
Part of athola/claude-night-market — 46 skills

Install

skills CLI npx skills add https://github.com/athola/claude-night-market/tree/master/plugins/abstract/skills/hook-authoring
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install athola-claude-night-market@llmmart
Git git clone https://github.com/athola/claude-night-market.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole athola/claude-night-market collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

When NOT To Use

  • Auditing a hook that already exists (use abstract:hooks-eval)
  • Choosing where a hook should live (use abstract:hook-scope-guide)
  • Authoring a skill rather than a hook (use abstract:skill-authoring)

Hook Authoring Guide

Overview

Hooks are event interceptors that allow you to extend Claude Code and Claude Agent SDK behavior by executing custom logic at specific points in the agent lifecycle. They enable validation before tool use, logging after actions, context injection, workflow automation, and security enforcement.

This skill teaches you how to write effective, secure, and performant hooks for both declarative JSON (Claude Code) and programmatic Python (Claude Agent SDK) use cases.

Key Capabilities

  • PreToolUse: Validate, filter, or transform tool inputs before execution; inject context (2.1.9+)
  • PostToolUse: Log, analyze, or modify tool outputs after execution
  • UserPromptSubmit: Inject context or filter user messages before processing
  • Stop/SubagentStop: Cleanup, final reporting, or result aggregation
  • TeammateIdle/TaskCompleted: Multi-agent coordination and orchestration (2.1.33+)
  • PreCompact: State preservation before context window compaction

New in 2.1.9: PreToolUse hooks can now return additionalContext to inject information before a tool executes. This enables patterns like cache hints, security warnings, or relevant context injection.

Quick Start

Your First Hook (JSON - Claude Code)

Create a simple logging hook in .claude/settings.json:

{
  "PostToolUse": [
    {
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "echo \"$(date): Executed $(jq -r '.tool_name')\" >> ~/.claude/audit.log"
      }]
    }
  ]
}

Note: Use string matchers ("Bash") not object matchers ({"toolName": "Bash"}).

Verification: Run the command with --help flag to verify availability.

This logs every Bash command execution with a timestamp.

Your First Hook (Python - Claude Agent SDK)

Create a validation hook using the SDK:

from claude_agent_sdk import AgentHooks


class ValidationHooks(AgentHooks):
    async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
        """Validate tool inputs before execution."""
        if tool_name == "Bash":
            command = tool_input.get("command", "")
            if "rm -rf /" in command:
                raise ValueError("Dangerous command blocked by hook")

        # Return None to proceed unchanged, or modified dict to transform
        return None

Verification: Run the command with --help flag to verify availability.

Hook Event Types

Quick reference for all supported hook events:

Event Trigger Point Parameters Common Use Cases
PreToolUse Before tool execution tool_name, tool_input Validation, filtering, input transformation
PostToolUse After tool execution tool_name, tool_input, tool_output Logging, metrics, output transformation
UserPromptSubmit User sends message message Context injection, content filtering
PermissionRequest Permission dialog shown tool_name, tool_input Auto-approve/deny with custom logic
Notification Claude Code sends notification message Custom notification handling
Stop Agent completes reason, result Final cleanup, summary reports
SubagentStop Subagent completes subagent_id, result Result processing, aggregation
TeammateIdle Teammate agent becomes idle agent_id, session_id Work assignment, load balancing (2.1.33+)
TaskCompleted Task finishes execution task_id, result Coordination, chaining, reporting (2.1.33+)
PreCompact Before context compact context_size State preservation, checkpointing
SessionStart Session starts/resumes session_id, source, agent_type Initialization, context loading
SessionEnd Session terminates session_id Cleanup, final logging
WorktreeCreate Agent worktree created worktree_path, session_id Custom VCS setup, symlink .venv, pre-populate caches (2.1.50+)
WorktreeRemove Agent worktree removed worktree_path, session_id Cleanup temp files, teardown worktree-scoped resources (2.1.50+)

SessionStart Input Schema (Claude Code 2.1.2+)

The SessionStart hook receives JSON input via stdin with these fields:

{
  "session_id": "abc123",
  "source": "startup",
  "agent_type": "my-agent"
}

Fields: source is one of "startup", "resume", "clear", or "compact". agent_type is populated when the --agent flag is used.

agent_type field: When Claude Code is launched with --agent my-agent, this field contains the agent name, enabling agent-specific initialization:

# Python example: Agent-aware SessionStart hook
input_data = json.loads(sys.stdin.read())
agent_type = input_data.get("agent_type", "")

if agent_type in ["code-reviewer", "quick-query"]:
    # Skip heavy context injection for lightweight agents
    print(json.dumps({"hookSpecificOutput": {"additionalContext": "Minimal context"}}))
else:
    # Full initialization for implementation agents
    print(json.dumps({"hookSpecificOutput": {"additionalContext": full_context}}))
# Bash example: Agent-aware SessionStart hook
HOOK_INPUT=$(cat)
AGENT_TYPE=$(echo "$HOOK_INPUT" | jq -r '.agent_type // empty')

case "$AGENT_TYPE" in
    code-reviewer|quick-query)
        echo '{"hookSpecificOutput": {"additionalContext": "Minimal context"}}'
        ;;
    *)
        echo '{"hookSpecificOutput": {"additionalContext": "Full context"}}'
        ;;
esac

Hooks in Frontmatter (Claude Code 2.1.0+)

New in 2.1.0: Define hooks directly in skill, command, or agent frontmatter. These hooks are scoped to the component's lifecycle.

Skill/Command/Agent Frontmatter Hooks

---
name: validated-skill
description: Skill with lifecycle hooks
hooks:
  PreToolUse:
    - matcher: "Bash"
      command: "./validate-command.sh"
      once: true  # NEW: Run only once per session
    - matcher: "Write|Edit"
      command: "./pre-edit-check.sh"
  PostToolUse:
    - matcher: "Write|Edit"
      command: "./format-on-save.sh"
  Stop:
    - command: "./cleanup-and-report.sh"
---

The once: true Configuration

New in 2.1.0: Use once: true to execute a hook only once per session, ideal for:

  • One-time setup/initialization
  • Resource allocation that shouldn't repeat
  • Session-level configuration
hooks:
  PreToolUse:
    - matcher: "Bash"
      command: "./setup-environment.sh"
      once: true  # Runs only on first Bash call
  SessionStart:
    - command: "./initialize-session.sh"
      once: true  # Runs only once at session start

Frontmatter vs Settings Hooks

Aspect Frontmatter Hooks Settings Hooks
Scope Component lifecycle Global/project
Location In skill/agent/command settings.json
Persistence Active only when component runs Always active
Use case Component-specific validation Cross-cutting concerns

PreToolUse updatedInput (2.1.0 Fix)

PreToolUse hooks can now return updatedInput when returning ask permission decision, enabling hooks to act as middleware while still requesting user consent:

{
  "decision": "ask",
  "updatedInput": {
    "command": "modified-command --safe-flag"
  }
}

Claude Code vs SDK

JSON Hooks (Claude Code)

Declarative configuration in .claude/settings.json, project .claude/settings.json, or plugin hooks/hooks.json:

{
  "PreToolUse": [
    {
      "matcher": "Edit",
      "hooks": [{
        "type": "command",
        "command": "echo 'WARNING: Editing production file' >&2"
      }]
    }
  ]
}

Important: Use string matchers (regex patterns), not object matchers. The object format {"toolName": "Edit"} is deprecated.

Matcher patterns:

  • "Edit" - Match single tool
  • "Read|Write|Edit" - Match multiple tools (regex OR)
  • ".*" - Match all tools

Verification: Run the command with --help flag to verify availability.

Pros: Simple, no code required, easy to version control Cons: Limited logic capabilities, shell command only

HTTP Hooks (Claude Code 2.1.63+)

New in 2.1.63: Hooks can POST JSON to a URL and receive JSON responses instead of running shell commands. Use "type": "http" with a "url" field:

{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [{
        "type": "http",
        "url": "https://my-service.example.com/hooks/validate-bash"
      }]
    }
  ]
}

The hook POSTs the standard hook input as JSON and expects a standard hook response JSON body.

When to use HTTP hooks over command hooks:

  • Enterprise environments where shell execution is restricted
  • Centralized hook logic shared across teams via a web service
  • Sandboxed or containerized setups without local script access
  • Integration with external validation/logging services

Pros: No local scripts needed, centralized logic, works in sandboxed environments Cons: Network latency, requires running HTTP service, external dependency

Python SDK Hooks

Programmatic callbacks using AgentHooks base class:

from claude_agent_sdk import AgentHooks


class MyHooks(AgentHooks):
    async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
        # Complex validation logic
        if self._is_dangerous(tool_input):
            raise ValueError("Operation blocked")
        return None  # or return modified input

Verification: Run the command with --help flag to verify availability.

Pros: Full Python capabilities, complex logic, state management Cons: Requires Python, more complex setup

Bash Permission Matching Notes

Environment Variable Wrappers (2.1.38+)

Permission rules now correctly match commands prefixed with environment variable assignments. Before 2.1.38, NODE_ENV=production npm test would not match a rule for Bash(npm *).

# These now all match `Bash(npm *)`:
npm test
NODE_ENV=production npm test
FORCE_COLOR=1 CI=true npm test

When writing PreToolUse hooks that inspect bash commands, be aware that the permission system strips env var prefixes for matching, but your hook receives the full command string including prefixes.

Heredoc Delimiter Security (2.1.38+)

Claude Code now validates heredoc delimiters to prevent command smuggling. The recommended pattern <<'EOF' (single-quoted) remains the safest approach. Always use single-quoted delimiters in heredoc patterns to prevent variable expansion.

Security Essentials

Critical Security Rules

  1. Input Validation: Always validate tool inputs before processing
  2. No Secret Logging: Never log API keys, tokens, passwords, or credentials
  3. Sandbox Awareness: Respect sandbox boundaries, don't escape. Note: .claude/skills/ is read-only in sandbox mode (2.1.38+)
  4. Fail-Safe Defaults: Return None on error instead of blocking the agent
  5. Rate Limiting: Prevent hook abuse from malicious or buggy code
  6. Injection Prevention: Sanitize all logged content to prevent log injection

Example: Secure Logging Hook

import re
from claude_agent_sdk import AgentHooks


class SecureLoggingHooks(AgentHooks):
    # Patterns that might contain secrets
    SECRET_PATTERNS = [
        r"api[_-]?key",
        r"password",
        r"token",
        r"secret",
        r"credential",
        r"auth",
    ]

    def _sanitize_output(self, text: str) -> str:
        """Remove potential secrets from log output."""
        for pattern in self.SECRET_PATTERNS:
            text = re.sub(
                rf'({pattern}["\s:=]+)([^\s,}}]+)',
                r"\1***REDACTED***",
                text,
                flags=re.IGNORECASE,
            )
        return text

    async def on_post_tool_use(
        self, tool_name: str, tool_input: dict, tool_output: str
    ) -> str | None:
        """Log tool use with sanitization."""
        safe_output = self._sanitize_output(tool_output)
        # Log safe_output...
        return None  # Don't modify output

Verification: Run the command with --help flag to verify availability.

See modules/testing-hooks.md for detailed security guidance.

Performance Guidelines

Performance Best Practices

  1. Non-Blocking: Use async/await properly, don't block the event loop
  2. Timeout Handling: Hook timeout is 10 minutes (increased from 60s in 2.1.3). For most hooks, aim for < 30s; use extended time only for CI/CD integration, complex validation, or external API calls
  3. Efficient Logging: Batch writes, use async I/O
  4. Memory Management: Don't accumulate unbounded state
  5. Fail Fast: Quick validation, early returns, avoid expensive operations

Example: Efficient Hook

import asyncio
from claude_agent_sdk import AgentHooks


class EfficientHooks(AgentHooks):
    def __init__(self):
        self._log_queue = asyncio.Queue()
        self._log_task = None

    async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
        # Quick validation only
        if not self._is_valid_input(tool_input):
            raise ValueError("Invalid input")
        return None

    async def on_post_tool_use(
        self, tool_name: str, tool_input: dict, tool_output: str
    ) -> str | None:
        # Queue log entry without blocking
        await self._log_queue.put({"tool": tool_name, "timestamp": time.time()})
        return None

    def _is_valid_input(self, tool_input: dict) -> bool:
        """Fast validation check."""
        # Simple checks only, < 10ms
        return len(str(tool_input)) < 1_000_000

Verification: Run the command with --help flag to verify availability.

See modules/performance-guidelines.md for detailed optimization techniques.

Scope Selection

Choose the right location for your hooks based on audience and purpose.

Important: Auto-Loading Behavior

hooks/hooks.json is automatically loaded when a plugin is enabled. Do NOT add "hooks": "./hooks/hooks.json" to plugin.json - this causes duplicate load errors. Only use the hooks field for additional hook files beyond the standard location.

Decision Framework

**Verification:** Run the command with `--help` flag to verify availability.
Is this hook part of a plugin's core functionality?
├─ YES → Plugin hooks (hooks/hooks.json in plugin)
└─ NO ↓

Should all team members on this project have this hook?
├─ YES → Project hooks (.claude/settings.json)
└─ NO ↓

Should this hook apply to all my Claude sessions?
├─ YES → Global hooks (~/.claude/settings.json)
└─ NO → Reconsider if you need a hook at all

Verification: Run the command with --help flag to verify availability.

Scope Comparison

Scope Location Audience Committed? Example Use Case
Plugin hooks/hooks.json Plugin users Yes (with plugin) YAML validation in YAML plugin
Project .claude/settings.json Team members Yes (in repo) Block production config edits
Global ~/.claude/settings.json Only you Never Personal audit logging

See modules/scope-selection.md for detailed scope decision guidance.

Common Patterns

Validation Hook

Block dangerous operations before execution:

async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
    if tool_name == "Bash":
        command = tool_input.get("command", "")

        # Block dangerous patterns
        if any(pattern in command for pattern in ["rm -rf /", ":(){ :|:& };:"]):
            raise ValueError(f"Dangerous command blocked: {command}")

        # Block production access
        if "production" in command and not self._has_approval():
            raise ValueError("Production access requires approval")

    return None

Verification: Run the command with --help flag to verify availability.

Logging Hook

Audit all tool operations:

async def on_post_tool_use(
    self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
    await self._log_entry(
        {
            "timestamp": datetime.now().isoformat(),
            "tool": tool_name,
            "input_size": len(str(tool_input)),
            "output_size": len(tool_output),
            "success": True,
        }
    )
    return None

Verification: Run the command with --help flag to verify availability.

Context Injection Hook

Add relevant context before user prompts:

async def on_user_prompt_submit(self, message: str) -> str | None:
    # Inject project-specific context
    context = await self._load_project_context()
    enhanced_message = f"{context}\n\n{message}"
    return enhanced_message

PreToolUse Context Injection (Claude Code 2.1.9+)

Inject context before a tool executes using additionalContext:

#!/usr/bin/env python3
"""PreToolUse hook that injects context before WebFetch."""

import json
import sys


def main():
    payload = json.load(sys.stdin)
    tool_name = payload.get("tool_name", "")

    if tool_name == "WebFetch":
        url = payload.get("tool_input", {}).get("url", "")
        # Check cache or knowledge base
        cached = lookup_knowledge_base(url)
        if cached:
            print(
                json.dumps(
                    {
                        "hookSpecificOutput": {
                            "hookEventName": "PreToolUse",
                            "additionalContext": f"Relevant cached info: {cached}",
                        }
                    }
                )
            )
    sys.exit(0)


if __name__ == "__main__":
    main()

This pattern is useful for: cache hints before web requests, security warnings before risky operations, and injecting relevant project context before file operations.

Testing Hooks

Unit Testing

import pytest
from my_hooks import ValidationHooks


@pytest.mark.asyncio
async def test_dangerous_command_blocked():
    hooks = ValidationHooks()

    with pytest.raises(ValueError, match="Dangerous command"):
        await hooks.on_pre_tool_use("Bash", {"command": "rm -rf /"})


@pytest.mark.asyncio
async def test_safe_command_allowed():
    hooks = ValidationHooks()
    result = await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
    assert result is None  # Allows execution

Verification: Run pytest -v from to verify.

See modules/testing-hooks.md for detailed testing strategies.

Module References

For detailed guidance on specific topics:

  • Hook Types: modules/hook-types.md - Detailed event signatures and parameters
  • SDK Callbacks: modules/sdk-callbacks.md - Python SDK implementation patterns
  • Security Patterns: modules/testing-hooks.md - detailed security guidance
  • Performance Guidelines: modules/performance-guidelines.md - Optimization techniques
  • Scope Selection: modules/scope-selection.md - Choosing plugin/project/global
  • Testing Hooks: modules/testing-hooks.md - Testing strategies and fixtures
  • Observability Warnings: modules/observability-warnings.md - Copy-pasteable resolution pattern for binary-actionable drift hooks

Tools

  • hook_validator.py: Validate hook structure and syntax (at plugins/abstract/scripts/hook_validator.py)

Related Skills

  • hook-scope-guide: Decision framework for hook placement (existing)
  • modular-skills: Design patterns for skill architecture
  • skills-eval: Quality assessment and improvement framework

Next Steps

  1. Choose your hook type (JSON vs SDK) based on complexity needs
  2. Select the appropriate scope (plugin/project/global)
  3. Implement following security and performance best practices
  4. Test thoroughly with unit and integration tests
  5. Validate using hook_validator.py before deployment

Environment Variables (Claude Code 2.1.2+)

FORCE_AUTOUPDATE_PLUGINS

Forces plugin auto-update even when the main Claude Code auto-updater is disabled.

Use cases:

  • CI/CD pipelines that need latest plugin versions
  • Development environments testing plugin updates
  • Controlled update rollouts in enterprise settings
# Enable forced plugin updates
export FORCE_AUTOUPDATE_PLUGINS=1
claude

# Or inline
FORCE_AUTOUPDATE_PLUGINS=1 claude --agent my-agent

Note: This only affects plugin updates, not Claude Code core updates.

References

Hook Exit Codes

Hooks communicate decisions to Claude Code via exit codes:

Exit Code Meaning stdout stderr
0 Success/allow Shown to Claude as system context Ignored
2 Block/deny Ignored Shown to user as explanation (2.1.39+ fix)
Other Error Ignored Shown to user as error message

Blocking with Exit Code 2 (2.1.39+)

Use exit code 2 to block an action and display a message to the user:

#!/bin/bash
# Example: Block force pushes with user-facing message
command=$(echo "$1" | jq -r '.tool_input.command // empty')
if echo "$command" | grep -q 'push.*--force'; then
  echo "Force push blocked: use --force-with-lease instead" >&2
  exit 2
fi
exit 0

Important: Before Claude Code 2.1.39, stderr from exit code 2 was silently swallowed (#10964). Users would see a generic "hook error" instead of the custom message. This is now fixed: stderr is properly displayed to the user.

Plugin hooks: Before 2.1.39, plugin-installed hooks had a separate code path that also failed to show stderr for exit code 2 (#10412). Both plugin and project hooks now work correctly.

Troubleshooting

Common Issues

Hook not firing Verify hook pattern matches the event. Check hook logs for errors

Syntax errors Validate JSON/Python syntax before deployment

Permission denied Check hook file permissions and ownership

Hook blocking message not shown (pre-2.1.39) If using exit code 2 to block with a user-facing message and the message isn't appearing, upgrade to Claude Code 2.1.39+. In older versions, use exit 0 with stdout as a workaround.

Exit Criteria

  • The authored hook file exists at a valid scope location (hooks/hooks.json, .claude/settings.json, or ~/.claude/settings.json) with correct JSON or Python syntax.
  • The hook fires on the target event: a test invocation of the matching tool call triggers the hook command or callback without error.
  • The hook contains no secret logging: no field names matching api[_-]?key, password, token, secret, or credential appear in log output paths.
  • Blocking hooks exit with code 2 and write the user-facing explanation to stderr (not stdout).
  • If abstract:validate-hook is available, it exits 0 on the authored hook file.
Files (claude-night-market)
  • modules
    • hook-types.md 16.7 KB
      # Hook Types Overview
      
      Claude Code hook lifecycle events and their use cases. Hooks intercept specific moments in the session to inject context, validate actions, or transform outputs.
      
      ## Quick Reference (Claude Code 2.1.50)
      
      ### Lifecycle Hooks
      - **Setup**: One-time plugin initialization
      - **SessionStart**: Session initialization, context setup
      - **SessionEnd**: Session cleanup, metrics collection
      - **Stop**: Graceful shutdown, final logging
      
      ### Tool Execution Hooks
      - **PreToolUse**: Validation, logging, state management
      - **PostToolUse**: Result processing, metrics, cleanup
      - **PostToolUseFailure**: Error handling, fallback (2.1.20+)
      
      ### Permission Hooks
      - **PermissionRequest**: Auto-approve/deny patterns
      
      ### Communication Hooks
      - **UserPromptSubmit**: Input validation, routing
      - **Notification**: System notification forwarding (2.1.20+)
      
      ### Agent Coordination Hooks
      - **SubagentStart**: Track agent spawns (2.1.20+)
      - **SubagentStop**: Collect results, cleanup
      - **TeammateIdle**: Work assignment (2.1.33+).
        Supports `{"continue": false}` to stop teammate
        (2.1.69+).
      - **TaskCompleted**: Task chaining (2.1.33+).
        Supports `{"continue": false}` to stop teammate
        (2.1.69+).
      
      ### Configuration Hooks
      - **ConfigChange**: React to settings changes (2.1.49+)
      - **InstructionsLoaded**: Augment instructions (2.1.33+)
      
      ### Context Hooks
      - **PreCompact**: Preserve critical context before compaction
      
      ### Worktree Hooks (2.1.50+, plugin fix 2.1.69+)
      - **WorktreeCreate**: Initialize worktree state.
        Command-only (no Python SDK callback, no matchers).
        Must print absolute worktree path on stdout.
        Plugin-registered hooks were silently ignored before
        2.1.69; now they fire correctly.
      - **WorktreeRemove**: Cleanup worktree state.
        Command-only (no Python SDK callback, no matchers).
        Receives `worktree_path` in input. Cannot block
        removal. Plugin-registered hooks were silently
        ignored before 2.1.69; now they fire correctly.
      
      ### ExitWorktree Tool (2.1.72+)
      
      New built-in tool to leave an `EnterWorktree` session
      mid-conversation. Parameters:
      
      - `action` (required): `"keep"` (leave worktree on disk)
        or `"remove"` (delete worktree and branch)
      - `discard_changes` (optional, default false): required
        `true` when action is `"remove"` and the worktree has
        uncommitted files or unmerged commits
      
      Restores the session CWD and clears CWD-dependent
      caches (system prompt, memory files, plans). Hooks can
      match on `ExitWorktree` in `PreToolUse`/`PostToolUse`.
      
      **Worktree isolation fixes (2.1.72+)**: Task tool
      resume now correctly restores CWD in worktree sessions.
      Background task notifications include `worktreePath`
      and `worktreeBranch` fields.
      
      ### HTTP Hooks (2.1.63+)
      
      Hooks can POST JSON to a URL instead of running shell
      commands. Use `"type": "http"` with a `"url"` field.
      The hook POSTs the standard hook input as JSON and
      expects a standard hook response JSON body. Useful for
      enterprise/sandboxed environments where shell execution
      is restricted. See `Skill(abstract:hook-authoring)` for
      full configuration details.
      
      ### Hook Event Fields: agent_id and agent_type (2.1.69+)
      
      All hook events now include an `agent_id` field for
      subagent sessions and an `agent_type` field for both
      subagent sessions and `--agent` invocations. Use these
      fields to distinguish which agent triggered the hook
      and to implement agent-specific hook logic.
      
      ```json
      {
        "session_id": "sess_abc",
        "hook_event_name": "PreToolUse",
        "agent_id": "backend@my-team",
        "agent_type": "implementer",
        "tool_name": "Bash",
        "tool_input": { "command": "make test" }
      }
      ```
      
      Status line hooks also gain a `worktree` field
      (2.1.69+) containing `name`, `path`, `branch`, and
      `originalRepoDir` when running in a `--worktree`
      session.
      
      ### Cron Scheduling Tools (2.1.71+)
      
      Three new built-in tools for scheduled tasks:
      `CronCreate`, `CronList`, and `CronDelete`. Hooks can
      match on these tool names in `PreToolUse` and
      `PostToolUse` events. The `/loop` command uses
      `CronCreate` internally.
      
      **CronCreate parameters:**
      
      - `cron` (string, required): Standard 5-field cron
        expression in local timezone
        (`minute hour day-of-month month day-of-week`)
      - `prompt` (string, required): The prompt to enqueue
        at each fire time
      - `recurring` (boolean, default true): true = fire on
        every cron match until deleted or auto-expired.
        false = fire once then auto-delete (one-shot reminders)
      - `durable` (boolean, default false): true = persist to
        `.claude/scheduled_tasks.json` and survive restarts.
        false = in-memory only, dies when session ends
      
      **CronList**: No parameters. Returns all tasks with IDs.
      
      **CronDelete**: Takes `id` (string) from CronCreate.
      
      **Scheduling behavior:**
      
      - Sessions hold up to 50 tasks
      - Recurring tasks auto-expire after 7 days
      - Tasks fire only while the REPL is idle (not mid-query)
      - Jitter: recurring tasks fire up to 10% of their period
        late (max 15 min); one-shot tasks at :00/:30 fire up
        to 90s early. Prefer off-minute scheduling to spread
        API load.
      - Disable entirely with `CLAUDE_CODE_DISABLE_CRON=1`.
        As of 2.1.72, this also stops scheduled jobs
        mid-session (previously only prevented new jobs).
      
      ### Bash Auto-Approval Expansion (2.1.71+)
      
      Added to the default bash auto-approval allowlist:
      `fmt`, `comm`, `cmp`, `numfmt`, `expr`, `test`,
      `printf`, `getconf`, `seq`, `tsort`, and `pr`. These
      are standard POSIX text/math utilities that execute
      without permission prompts. Hooks using
      `PermissionRequest` should account for these commands
      no longer triggering permission events.
      
      ### Heredoc Permission Fix (2.1.71+)
      
      Compound bash commands containing heredoc commit
      messages no longer trigger false-positive permission
      prompts. This fixes the common pattern:
      
      ```bash
      git commit -m "$(cat <<'EOF'
      feat: my commit message
      EOF
      )"
      ```
      
      Previously this could prompt for permission even when
      `Bash(git commit *)` was in the allow list.
      
      ### Bash Auto-Approval Expansion (2.1.72+)
      
      Added to the auto-approval allowlist: `lsof`, `pgrep`,
      `tput`, `ss`, `fd`, and `fdfind`. These read-only
      system inspection and file-finding utilities no longer
      trigger `PermissionRequest` events.
      
      ### Skill Hook Double-Fire Fix (2.1.72+)
      
      Skill hooks no longer fire twice per event when a
      hooks-enabled skill is invoked by the model. Previously,
      both the skill's hooks and the plugin's hooks would
      fire for the same event, producing duplicate log
      entries and potentially double-counting metrics.
      
      ### Hooks Fixes (2.1.72+)
      
      - `transcript_path` now points to the correct directory
        for resumed (`--continue`) and forked (`/fork`)
        sessions. Previously it pointed to the original
        session's transcript.
      - The agent prompt is no longer silently deleted from
        `settings.json` on every settings write.
      - `PostToolUse` block reason no longer displays twice.
      - Async hooks now receive stdin when using
        `bash read -r` (previously stdin was closed).
      
      ### CLAUDE.md Comment Hiding (2.1.72+)
      
      HTML comments (`<!-- ... -->`) in CLAUDE.md files are
      hidden from Claude when auto-injected into context.
      Comments remain visible when read with the Read tool.
      This means CLAUDE.md comments can contain human-only
      notes without consuming context tokens.
      
      ### Permission Rule Matching Fixes (2.1.72+)
      
      - Wildcard rules now match commands with heredocs,
        embedded newlines, or no arguments
      - `sandbox.excludedCommands` works with env var
        prefixes (e.g., `FOO=bar command`)
      - "Always Allow" no longer suggests overly broad
        prefixes for nested CLI tools
      - Deny rules apply to all command forms
      
      ### Parallel Tool Call Cascade Fix (2.1.72+)
      
      Failed `Read`, `WebFetch`, or `Glob` no longer cancels
      sibling parallel tool calls. Only `Bash` errors
      cascade. This improves reliability of parallel agent
      dispatch and multi-file reading operations.
      
      ### Security: Workspace Trust (2.1.51+)
      
      Hook commands that emit `statusLine` or `fileSuggestion`
      now require workspace trust acceptance in interactive
      mode. Untrusted hooks cannot execute these commands
      until the user has accepted workspace trust. If your
      hook outputs status line updates or file suggestions,
      test it in both trusted and untrusted workspace contexts.
      
      ## Hook Source Display (2.1.75+)
      
      When a hook requires user confirmation, the permission
      prompt now displays the source of the hook: `settings`,
      `plugin`, or `skill`. This improves visibility into
      where permission requests originate. Use this to audit
      which plugins trigger which permission prompts.
      
      ## Async Hook Completion Messages Suppressed (2.1.75+)
      
      Async hook completion messages (e.g., "Async hook
      UserPromptSubmit completed") are now suppressed by
      default. Previously, every async hook completion
      generated visible status line noise. Still visible via
      `--verbose` flag, transcript mode, or `Ctrl+O` toggle.
      
      Hooks that relied on visible completion messages for
      debugging should use `--verbose` mode. No per-hook
      `silent` configuration was added; the default behavior
      changed globally.
      
      ## Hook Conditional `if` Field (2.1.85+)
      
      Hooks now support an `if` field using permission rule
      syntax to filter when they run. Only evaluated on tool
      events: PreToolUse, PostToolUse, PostToolUseFailure,
      PermissionRequest. On other events, hooks with `if`
      never fire.
      
      Reduces process spawning: without `if`, a hook with
      `matcher: "Bash"` spawns a process for every Bash call.
      With `if: "Bash(git *)"`, the condition is evaluated
      in-process before any subprocess.
      
      ```json
      {
        "hooks": {
          "PreToolUse": [{
            "matcher": "Bash",
            "hooks": [{
              "type": "command",
              "if": "Bash(rm -rf *)",
              "command": "block-rm.sh",
              "timeout": 10
            }]
          }]
        }
      }
      ```
      
      ## PreToolUse Satisfies AskUserQuestion (2.1.85+)
      
      PreToolUse hooks matching `AskUserQuestion` can return
      `updatedInput` alongside `permissionDecision: "allow"`
      to programmatically answer questions for headless
      integrations:
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "allow",
          "updatedInput": {
            "question": "Use PostgreSQL for the database"
          }
        }
      }
      ```
      
      ## StopFailure Hook (2.1.78+)
      
      Fires when a turn ends due to an API error (rate limit,
      auth failure, etc.). **Non-blockable** (output/exit code
      ignored). Matcher on error type: `rate_limit`,
      `authentication_failed`, `billing_error`,
      `invalid_request`, `server_error`, `max_output_tokens`,
      `unknown`.
      
      ## TaskCreated Hook (2.1.84+)
      
      Fires when a task is created via TaskCreate. **Blockable**
      (exit code 2 or `continue: false`). No matcher support
      (fires on every TaskCreate). Input includes `task_id`,
      `task_subject`, `task_description`, `teammate_name`,
      `team_name`.
      
      ## CwdChanged and FileChanged Hooks (2.1.83+)
      
      **CwdChanged**: Fires on working directory change. Non-
      blockable, no matcher. Input includes `cwd`. Has
      `CLAUDE_ENV_FILE` access for persisting env vars.
      
      **FileChanged**: Fires on watched file change. Non-
      blockable. Matcher on filename (e.g., `.envrc`). Input
      includes `file_path`. Has `CLAUDE_ENV_FILE` access.
      
      ## WorktreeCreate HTTP Hook Support (2.1.84+)
      
      HTTP hooks (`type: "http"`) for WorktreeCreate can now
      return the created worktree path via
      `hookSpecificOutput.worktreePath` in the response JSON.
      Previously only command hooks could return this via
      stdout.
      
      ## PreToolUse "allow" Bypass Fix (2.1.77+)
      
      PreToolUse hooks returning `{"decision": "allow"}` could
      previously bypass `deny` permission rules, including
      enterprise managed settings. A plugin hook could override
      organization-wide security policies.
      
      Fixed: hook `"allow"` decisions are now checked **after**
      deny rules. The permission precedence order is:
      
      1. Managed deny (highest, unbypassable)
      2. Hook deny
      3. Permission deny
      4. Hook allow
      5. Permission allow (lowest)
      
      This is a **security-critical fix**. Enterprise `deny`
      rules in managed settings can no longer be circumvented
      by third-party plugin hooks.
      
      ## MCP Elicitation Hooks (2.1.76+)
      
      Two new hook events for MCP elicitation workflows:
      
      ### Elicitation Hook
      
      Fires when an MCP server sends an `elicitation/create`
      request. **Blockable**: exit code 2 sends `decline` to
      the server. Matcher filters on `mcp_server_name`.
      
      Input includes `mcp_server_name`, `tool_name`, and
      `form_schema` (the MCP `requestedSchema`). Supports
      `hookSpecificOutput` to auto-approve, auto-decline, or
      auto-fill responses without showing the dialog:
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "Elicitation",
          "action": "accept",
          "content": { "env": "staging" }
        }
      }
      ```
      
      ### ElicitationResult Hook
      
      Fires after the user responds to an elicitation, before
      the response reaches the MCP server. **Blockable**: exit
      code 2 converts the action to `decline`. Matcher filters
      on `mcp_server_name`.
      
      Input includes `action` (`accept`/`decline`/`cancel`)
      and `content` (form field values if `accept`). Supports
      `hookSpecificOutput` to override the action and/or
      content before it reaches the server. Use for validation,
      transformation, or audit logging of user responses.
      
      ## PostCompact Hook (2.1.76+)
      
      Fires after context compaction completes (manual
      `/compact` or automatic). **Non-blockable** (compaction
      already completed). Matcher filters on `trigger` value
      (`"manual"` or `"auto"`).
      
      Input includes `trigger` and `compact_summary` (the
      generated conversation summary). Use for post-compaction
      recovery: re-injecting framework instructions that were
      paraphrased during compaction. PreCompact content gets
      summarized (compliance drops ~95% to ~60-70%);
      PostCompact content appears fresh and verbatim.
      
      ## SessionEnd Hooks Timeout Fix (2.1.74+)
      
      SessionEnd hooks were previously killed after 1.5
      seconds on exit regardless of the `hook.timeout` setting.
      Fixed in 2.1.74: the exit timeout is now configurable
      via `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` env var.
      Set this to give SessionEnd hooks enough time to complete
      (e.g., metrics upload, state persistence, notifications).
      
      Example: allow 10 seconds for SessionEnd hooks:
      ```bash
      export CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=10000
      ```
      
      The default remains 1500ms (1.5 seconds). Per-hook
      `timeout` values are **capped** by this budget: a hook
      with `timeout: 30` is still killed at 1.5s unless the
      env var is raised. The budget applies to session exit,
      `/clear`, and switching sessions via `/resume`.
      
      SessionEnd matchers: `clear`, `resume`, `logout`,
      `prompt_input_exit`, `bypass_permissions_disabled`,
      `other`.
      
      ## New Hook Events (2.1.74+ documentation refresh)
      
      The hooks documentation now includes several additional
      event types beyond the 19 previously tracked:
      
      - **CwdChanged**: Fires when the working directory
        changes. Observability-only (no decision control).
      - **FileChanged**: Fires when a watched file changes.
        Observability-only.
      - **PostCompact**: Fires after context compaction
        completes. Complements PreCompact.
      - **TaskCreated**: Fires when a task is created.
        Blockable (can prevent task creation).
      - **StopFailure**: Fires on API error (distinct from
        Stop, which fires on normal completion). No decision
        control.
      - **Elicitation**: Fires when an MCP server requests
        user input. Blockable.
      - **ElicitationResult**: Fires when the user responds
        to an MCP elicitation.
      
      These expand the hook surface for directory tracking,
      file watch integration, compaction response, and MCP
      elicitation interception.
      
      ## SessionStart Resume Double-Fire Fix (2.1.73+)
      
      SessionStart hooks previously fired twice when resuming
      a session via `--resume` or `--continue`. Now they fire
      exactly once. The `source` field in the hook input
      distinguishes session types: `"startup"`, `"resume"`,
      `"clear"`, or `"compact"`. Hooks that tracked
      initialization state (counters, one-time setup) no
      longer need deduplication guards for resumed sessions.
      
      ## JSON-Output Hooks Fix (2.1.73+)
      
      JSON-output hooks previously injected no-op
      system-reminder messages into the model's context on
      every turn, causing token waste. Fixed in 2.1.73: hooks
      using JSON output format no longer produce spurious
      context injections. This is particularly relevant for
      hooks that return `additionalContext` in their JSON
      output, as those hooks were most affected by the
      duplicate injection.
      
      ## Hook Selection Guide
      
      | Use Case | Recommended Hook | Why |
      |----------|------------------|-----|
      | Log all tool calls | PreToolUse | Captures before execution |
      | Track execution time | Pre and PostToolUse | Measure duration |
      | Validate inputs | UserPromptSubmit | Before processing |
      | Handle tool errors | PostToolUseFailure | Error-specific handling |
      | Auto-approve tools | PermissionRequest | Bypass permission dialog |
      | Initialize session | SessionStart | One-time setup |
      | Cleanup resources | SessionEnd/Stop | Guaranteed cleanup |
      | Multi-agent coordination | TeammateIdle/TaskCompleted | Agent team workflows |
      | React to config | ConfigChange | Settings-driven behavior |
      
      ## See Complete Guide
      
      The complete hook types guide includes:
      - Detailed lifecycle diagrams
      - Complete code examples for each hook type
      - Advanced patterns and combinations
      - Performance considerations
      - Migration guides
      
      See `Skill(abstract:hook-authoring)` for the full hook development guide and examples.
      
    • observability-warnings.md 3.6 KB
      # Observability Warning Patterns
      
      When a hook surfaces actionable drift (state the user can resolve
      or keep), emit the **exact shell command** that resolves it.
      Listing paths or describing the resolution in prose forces the
      next agent to compose the command, adding latency, error surface,
      and one more reason to ignore the warning.
      
      ## Reference implementation
      
      `plugins/sanctum/hooks/brainstorm_session_warn.py` (locked by
      `test_warning_includes_batch_rm_command` in
      `plugins/sanctum/tests/test_brainstorm_session_warn.py`) emits:
      
      ````
      - `.superpowers/brainstorm/abc`
      - `.superpowers/brainstorm/def`
      
      To remove all listed sessions in one go, run:
      
      ```
      rm -rf .superpowers/brainstorm/abc .superpowers/brainstorm/def
      ```
      ````
      
      Uses `shlex.quote` per path so session ids with spaces or shell
      metacharacters are handled safely.
      
      ## Three-category classification
      
      When designing an observability hook, classify it into one of
      three categories. Only one of them takes the copy-pasteable
      resolution pattern.
      
      ### Apply pattern: binary-actionable
      
      Hook surfaces drift with a clear resolve-or-keep choice. The
      user inspects the list, decides "yes, clean this up" or "no,
      keep it", and a single command resolves the entire batch.
      
      | Hook | Notes |
      |------|-------|
      | `sanctum/hooks/brainstorm_session_warn.py` | Reference implementation |
      
      The pattern is narrow. After the original audit, no other hook
      in the codebase emitted a multi-item drift warning with a single
      resolve-or-keep decision. Future binary-actionable hooks should
      adopt the pattern from authorship.
      
      ### Skip pattern: observe-only
      
      Hook records a signal for retrospective analysis; no immediate
      action expected.
      
      | Hook | Reason |
      |------|--------|
      | `abstract/hooks/skill_execution_logger.py` | Logs to JSON for daily aggregation; no action expected per invocation |
      | `abstract/hooks/aggregate_learnings_daily.py` | Daily batch run; output is a report, not a prompt |
      | `abstract/hooks/post_learnings_stop.py` | Writes session summary; no per-hook action |
      
      ### Skip pattern: needs-triage
      
      Resolution requires per-item triage (review N items, classify
      each, choose disposition). A single resolve-all command would
      hide the per-item judgment the hook surfaced.
      
      | Hook | Reason |
      |------|--------|
      | `leyline/hooks/fetch-recent-discussions.sh` | Lists discussions; users decide which to read or skip |
      | `conserve/hooks/context_warning.py` | Suggests one of several actions (clear, compact, summarize) based on context state |
      | `leyline/hooks/supply_chain_check.py` | Lists dependency advisories; resolution depends on each one |
      
      ## Counter-examples (when NOT to apply)
      
      The pattern actively harms when:
      
      - Resolution requires inspection of each item (lists of
        discussions, dependencies, lint warnings)
      - Resolution is destructive and the user has not yet decided
        whether to keep
      - The list contains paths the user explicitly chose to keep
        (which would imply the hook should not surface them at all)
      
      ## Authoring checklist for binary-actionable hooks
      
      When adding a new binary-actionable observability hook:
      
      1. Emit a fenced block listing the items
      2. Follow with a single resolve-all command
      3. Use `shlex.quote` per path to handle metacharacters
      4. Add a contract test mirroring
         `test_warning_includes_batch_rm_command`
      5. If the hook is in a category that would normally skip the
         pattern, add a counter-example test confirming the pattern
         was deliberately not adopted (regression guard)
      
      ## References
      
      - Issue #460 (origin)
      - Discussion #447 (retrospective on PR #417)
      - Reference implementation:
        `plugins/sanctum/hooks/brainstorm_session_warn.py`
      - Reference test:
        `plugins/sanctum/tests/test_brainstorm_session_warn.py`
      
    • performance-guidelines.md 16.9 KB
      # Performance Guidelines for Hooks
      
      Optimization techniques for writing fast, efficient hooks that don't degrade agent performance.
      
      ## Performance Principles
      
      ### Core Performance Rules
      
      1. **Non-Blocking**: Use async/await, never block the event loop
      2. **Fast Validation**: < 1s for PreToolUse hooks
      3. **Async I/O**: Use async file/network operations
      4. **Batch Operations**: Queue and batch writes
      5. **Memory Efficient**: Don't accumulate unbounded state
      6. **Fail Fast**: Early returns, quick validation
      
      ## Performance Budgets
      
      ### Hook Timing Targets
      
      | Hook Type | Target | Maximum | Rationale |
      |-----------|--------|---------|-----------|
      | **PreToolUse** | < 100ms | 1s | Blocks tool execution |
      | **PostToolUse** | < 500ms | 5s | Blocks output processing |
      | **UserPromptSubmit** | < 200ms | 2s | Blocks message processing |
      | **Stop** | < 2s | 10s | Final cleanup, less critical |
      | **SubagentStop** | < 1s | 5s | May have multiple instances |
      | **TeammateIdle** | < 1s | 5s | Agent teams coordination |
      | **TaskCompleted** | < 1s | 5s | Task completion handling |
      | **PreCompact** | < 1s | 3s | Blocks context compaction |
      
      ### Measuring Hook Performance
      
      ```python
      import time
      from claude_agent_sdk import AgentHooks
      
      
      class PerformanceMonitoringHooks(AgentHooks):
          """Monitor hook execution time."""
      
          def __init__(self):
              self._hook_timings = []
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Measure validation time."""
              start = time.perf_counter()
      
              try:
                  result = await self._validate(tool_input)
                  return result
      
              finally:
                  duration_ms = (time.perf_counter() - start) * 1000
                  self._hook_timings.append(
                      {"hook": "pre_tool_use", "tool": tool_name, "duration_ms": duration_ms}
                  )
      
                  if duration_ms > 100:  # Warn if over target
                      print(
                          f"[WARN]  Slow hook: {tool_name} validation took {duration_ms:.2f}ms"
                      )
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Report hook performance."""
              if self._hook_timings:
                  avg_time = sum(t["duration_ms"] for t in self._hook_timings) / len(
                      self._hook_timings
                  )
                  max_time = max(t["duration_ms"] for t in self._hook_timings)
      
                  print(f"\nHook Performance:")
                  print(f"  Average: {avg_time:.2f}ms")
                  print(f"  Maximum: {max_time:.2f}ms")
                  print(f"  Total calls: {len(self._hook_timings)}")
      ```
      
      ## Non-Blocking Operations
      
      ### Async I/O
      
      Always use async I/O for file and network operations:
      
      ```python
      import asyncio
      import aiofiles
      from claude_agent_sdk import AgentHooks
      
      
      class AsyncIOHooks(AgentHooks):
          """Use async I/O for performance."""
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Log asynchronously without blocking."""
              #  BLOCKING (slow)
              # with open('log.txt', 'a') as f:
              #     f.write(f"{tool_name}\n")
      
              #  NON-BLOCKING (fast)
              async with aiofiles.open("log.txt", "a") as f:
                  await f.write(f"{tool_name}\n")
      
              return None
      
          async def _fetch_config(self) -> dict:
              """Async HTTP request."""
              import aiohttp
      
              #  BLOCKING
              # import requests
              # return requests.get('http://api.example.com/config').json()
      
              #  NON-BLOCKING
              async with aiohttp.ClientSession() as session:
                  async with session.get("http://api.example.com/config") as resp:
                      return await resp.json()
      ```
      
      ### Background Tasks
      
      Use background tasks for non-critical operations:
      
      ```python
      import asyncio
      from claude_agent_sdk import AgentHooks
      
      
      class BackgroundTaskHooks(AgentHooks):
          """Offload work to background tasks."""
      
          def __init__(self):
              self._log_queue = asyncio.Queue()
              self._background_task = None
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Queue log entry without blocking."""
              # Add to queue (fast, non-blocking)
              await self._log_queue.put(
                  {
                      "tool": tool_name,
                      "timestamp": time.time(),
                      "output_size": len(tool_output),
                  }
              )
      
              # Start background writer if not running
              if self._background_task is None or self._background_task.done():
                  self._background_task = asyncio.create_task(self._write_logs())
      
              return None
      
          async def _write_logs(self) -> None:
              """Background task to write logs."""
              while not self._log_queue.empty():
                  try:
                      entry = await asyncio.wait_for(self._log_queue.get(), timeout=1.0)
      
                      # Write to file (in background)
                      async with aiofiles.open("audit.log", "a") as f:
                          await f.write(json.dumps(entry) + "\n")
      
                  except asyncio.TimeoutError:
                      break
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """validate background tasks complete."""
              if self._background_task and not self._background_task.done():
                  await self._background_task
      ```
      
      ## Batch Operations
      
      ### Batch Writes
      
      Batch multiple writes to reduce I/O overhead:
      
      ```python
      import asyncio
      import aiofiles
      from claude_agent_sdk import AgentHooks
      
      
      class BatchWriteHooks(AgentHooks):
          """Batch writes for efficiency."""
      
          def __init__(self, batch_size: int = 10, flush_interval: float = 5.0):
              self._batch: list[dict] = []
              self._batch_size = batch_size
              self._flush_interval = flush_interval
              self._last_flush = time.time()
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Add to batch, flush when full."""
              self._batch.append({"tool": tool_name, "timestamp": time.time()})
      
              # Flush if batch is full or time elapsed
              should_flush = (
                  len(self._batch) >= self._batch_size
                  or time.time() - self._last_flush >= self._flush_interval
              )
      
              if should_flush:
                  await self._flush_batch()
      
              return None
      
          async def _flush_batch(self) -> None:
              """Write entire batch at once."""
              if not self._batch:
                  return
      
              # Write all entries in one operation
              async with aiofiles.open("audit.log", "a") as f:
                  lines = "\n".join(json.dumps(entry) for entry in self._batch)
                  await f.write(lines + "\n")
      
              # Clear batch
              self._batch.clear()
              self._last_flush = time.time()
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Flush remaining batch."""
              await self._flush_batch()
      ```
      
      ## Memory Management
      
      ### Bounded State
      
      Never accumulate unbounded state:
      
      ```python
      from collections import deque
      from claude_agent_sdk import AgentHooks
      
      
      class BoundedStateHooks(AgentHooks):
          """Maintain bounded state to prevent memory growth."""
      
          def __init__(self, max_history: int = 1000):
              #  UNBOUNDED (memory leak)
              # self._all_operations = []
      
              #  BOUNDED (fixed size)
              self._recent_operations = deque(maxlen=max_history)
              self._tool_counts = {}  # OK - bounded by number of tools
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Track recent operations with bounded memory."""
              # Automatically evicts oldest when full
              self._recent_operations.append({"tool": tool_name, "timestamp": time.time()})
      
              # Update counts (bounded by tool types)
              self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1
      
              return None
      ```
      
      ### Cleanup Old State
      
      Periodically clean up old state:
      
      ```python
      import time
      from claude_agent_sdk import AgentHooks
      
      
      class CleanupHooks(AgentHooks):
          """Periodically clean up old state."""
      
          def __init__(self, max_age_seconds: int = 3600):
              self._operations: list[dict] = []
              self._max_age = max_age_seconds
              self._last_cleanup = time.time()
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Track with periodic cleanup."""
              self._operations.append({"tool": tool_name, "timestamp": time.time()})
      
              # Cleanup every 100 operations
              if len(self._operations) % 100 == 0:
                  self._cleanup_old_operations()
      
              return None
      
          def _cleanup_old_operations(self) -> None:
              """Remove operations older than max_age."""
              cutoff = time.time() - self._max_age
              self._operations = [op for op in self._operations if op["timestamp"] > cutoff]
      ```
      
      ## Fast Validation
      
      ### Early Returns
      
      Return as soon as possible:
      
      ```python
      from claude_agent_sdk import AgentHooks
      
      
      class FastValidationHooks(AgentHooks):
          """Optimize validation with early returns."""
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Fast validation with early returns."""
              # Quick checks first
              if tool_name not in ["Bash", "Edit"]:
                  return None  # No validation needed
      
              # Only validate Bash/Edit
              if tool_name == "Bash":
                  command = tool_input.get("command", "")
      
                  # Fast length check
                  if len(command) > 10_000:
                      raise ValueError("Command too long")
      
                  # Quick pattern check (compiled regex)
                  if self._dangerous_pattern.search(command):
                      raise ValueError("Dangerous command")
      
              return None
      
          # Compile regex once at init
          def __init__(self):
              import re
      
              self._dangerous_pattern = re.compile(
                  r"rm\s+-rf\s+/|:(){ :|:& };:", re.IGNORECASE
              )
      ```
      
      ### Compiled Patterns
      
      Pre-compile expensive operations:
      
      ```python
      import re
      from claude_agent_sdk import AgentHooks
      
      
      class CompiledPatternHooks(AgentHooks):
          """Use compiled patterns for speed."""
      
          def __init__(self):
              #  SLOW: Compile every time
              # self.pattern_str = r'rm\s+-rf'
      
              #  FAST: Compile once
              self.dangerous_cmd = re.compile(r"rm\s+-rf\s+/", re.IGNORECASE)
              self.secret_api_key = re.compile(
                  r'(api[_-]?key["\s:=]+)([^\s,}]+)', re.IGNORECASE
              )
              self.secret_token = re.compile(r'(token["\s:=]+)([^\s,}]+)', re.IGNORECASE)
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Fast pattern matching with compiled regex."""
              if tool_name == "Bash":
                  command = tool_input.get("command", "")
      
                  # Fast: pattern already compiled
                  if self.dangerous_cmd.search(command):
                      raise ValueError("Dangerous command")
      
              return None
      ```
      
      ## Caching
      
      ### Expensive Computations
      
      Cache expensive operations:
      
      ```python
      from functools import lru_cache
      from claude_agent_sdk import AgentHooks
      
      
      class CachingHooks(AgentHooks):
          """Cache expensive computations."""
      
          @lru_cache(maxsize=128)
          def _is_safe_path(self, path: str) -> bool:
              """Cache path safety checks."""
              from pathlib import Path
      
              try:
                  resolved = Path(path).resolve()
                  # Expensive: file system operations
                  return resolved.is_relative_to(Path.home())
      
              except (OSError, ValueError):
                  return False
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Use cached path validation."""
              if tool_name == "Read":
                  file_path = tool_input.get("file_path", "")
      
                  # Fast: cache hit on repeated paths
                  if not self._is_safe_path(file_path):
                      raise ValueError("Unsafe path")
      
              return None
      ```
      
      ### TTL Cache
      
      Cache with time-to-live:
      
      ```python
      import time
      from typing import Any
      from claude_agent_sdk import AgentHooks
      
      
      class TTLCacheHooks(AgentHooks):
          """Cache with expiration."""
      
          def __init__(self, cache_ttl: float = 300.0):  # 5 minutes
              self._cache: dict[str, tuple[Any, float]] = {}
              self._cache_ttl = cache_ttl
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Use TTL cache for config."""
              config = await self._get_config()  # Cached
              # Use config for validation...
              return None
      
          async def _get_config(self) -> dict:
              """Get config with TTL caching."""
              cache_key = "validation_config"
              now = time.time()
      
              # Check cache
              if cache_key in self._cache:
                  value, expires = self._cache[cache_key]
                  if now < expires:
                      return value  # Cache hit
      
              # Cache miss: fetch and cache
              config = await self._fetch_config()  # Expensive
              self._cache[cache_key] = (config, now + self._cache_ttl)
              return config
      
          async def _fetch_config(self) -> dict:
              """Expensive config fetch."""
              # Simulate slow operation
              await asyncio.sleep(0.1)
              return {"max_command_length": 10000}
      ```
      
      ## Profiling Hooks
      
      ### Identify Bottlenecks
      
      ```python
      import cProfile
      import pstats
      from io import StringIO
      from claude_agent_sdk import AgentHooks
      
      
      class ProfilingHooks(AgentHooks):
          """Profile hook performance."""
      
          def __init__(self, enable_profiling: bool = False):
              self.enable_profiling = enable_profiling
              self._profiler = cProfile.Profile() if enable_profiling else None
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Profile validation."""
              if self.enable_profiling:
                  self._profiler.enable()
      
              try:
                  result = await self._validate(tool_input)
                  return result
      
              finally:
                  if self.enable_profiling:
                      self._profiler.disable()
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Print profiling results."""
              if self.enable_profiling:
                  s = StringIO()
                  ps = pstats.Stats(self._profiler, stream=s)
                  ps.sort_stats("cumulative")
                  ps.print_stats(20)  # Top 20 functions
                  print(s.getvalue())
      ```
      
      ## Performance Testing
      
      ### Benchmark Hooks
      
      ```python
      import pytest
      import time
      from my_hooks import ValidationHooks
      
      
      @pytest.mark.asyncio
      async def test_validation_performance():
          """validate validation meets performance budget."""
          hooks = ValidationHooks()
      
          # Test 100 validations
          start = time.perf_counter()
      
          for _ in range(100):
              await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
      
          duration = time.perf_counter() - start
          avg_duration_ms = (duration / 100) * 1000
      
          # Assert meets target (< 100ms per validation)
          assert avg_duration_ms < 100, f"Validation too slow: {avg_duration_ms:.2f}ms"
      
      
      @pytest.mark.asyncio
      async def test_logging_performance():
          """validate logging doesn't block."""
          hooks = LoggingHooks()
      
          start = time.perf_counter()
      
          await hooks.on_post_tool_use("Bash", {"command": "ls"}, "output")
      
          duration_ms = (time.perf_counter() - start) * 1000
      
          # Logging should return quickly (< 500ms)
          assert duration_ms < 500, f"Logging too slow: {duration_ms:.2f}ms"
      ```
      
      ## Optimization Checklist
      
      Before deploying hooks, verify:
      
      - [ ] **Async I/O**: All I/O operations use async
      - [ ] **Background Tasks**: Non-critical work runs in background
      - [ ] **Batch Operations**: Multiple writes batched together
      - [ ] **Bounded State**: No unbounded memory growth
      - [ ] **Early Returns**: Fast paths return immediately
      - [ ] **Compiled Patterns**: Regex patterns pre-compiled
      - [ ] **Caching**: Expensive operations cached
      - [ ] **Profiled**: Bottlenecks identified and optimized
      - [ ] **Tested**: Performance tests verify budgets
      - [ ] **Monitored**: Hook timing logged and tracked
      
      ## Common Performance Issues
      
      ### Issue 1: Blocking I/O
      
      ```python
      #  SLOW: Blocking I/O
      with open("log.txt", "a") as f:
          f.write(f"{tool_name}\n")
      
      #  FAST: Async I/O
      async with aiofiles.open("log.txt", "a") as f:
          await f.write(f"{tool_name}\n")
      ```
      
      ### Issue 2: Unbounded State
      
      ```python
      #  MEMORY LEAK: Unbounded list
      self._all_operations.append(operation)
      
      #  BOUNDED: Fixed-size deque
      self._recent_operations.append(operation)  # maxlen=1000
      ```
      
      ### Issue 3: Expensive Validation
      
      ```python
      #  SLOW: Recompile every time
      if re.search(r"dangerous", command):
          ...
      
      #  FAST: Compiled once
      if self._dangerous_pattern.search(command):
          ...
      ```
      
      ### Issue 4: Synchronous Network
      
      ```python
      #  SLOW: Blocking HTTP
      import requests
      
      config = requests.get("http://api.example.com/config").json()
      
      #  FAST: Async HTTP
      import aiohttp
      
      async with aiohttp.ClientSession() as session:
          async with session.get("http://api.example.com/config") as resp:
              config = await resp.json()
      ```
      
      ## Related Modules
      
      See the Module References section in `SKILL.md` for the full module list and
      what each one covers. The hub owns that index so the modules do not have to
      keep parallel copies of it in sync.
      
    • scope-selection.md 13.2 KB
      # Hook Scope Selection Guide
      
      Detailed decision framework for choosing where to place hooks: plugin, project, or global scope.
      
      ## Important: Auto-Loading Behavior
      
      > **`hooks/hooks.json` is automatically loaded** by Claude Code when the plugin is enabled.
      > Do NOT add `"hooks": "./hooks/hooks.json"` to your `plugin.json` - this causes duplicate load errors.
      > The `hooks` field in `plugin.json` is only needed for **additional** hook files beyond the standard `hooks/hooks.json`.
      
      **Correct** (hooks/hooks.json auto-loads):
      ```json
      {
        "name": "my-plugin",
        "version": "1.0.0",
        "license": "MIT"
      }
      ```
      
      **Incorrect** (causes duplicate hook error):
      ```json
      {
        "name": "my-plugin",
        "version": "1.0.0",
        "license": "MIT",
        "hooks": "./hooks/hooks.json"
      }
      ```
      
      ## The Three Scopes
      
      | Scope | Location | Audience | Version Controlled | Persistence |
      |-------|----------|----------|-------------------|-------------|
      | **Plugin** | `<plugin-root>/hooks/hooks.json` | Plugin users | Yes (with plugin) | When plugin enabled |
      | **Project** | `.claude/settings.json` | Team members | Yes (in repo) | Per project |
      | **Global** | `~/.claude/settings.json` | Only you | Never | All sessions |
      
      ## Decision Framework
      
      ### Three Key Questions
      
      ```
      Question 1: Who needs this hook?
      ├─ Only plugin users → Plugin hooks
      ├─ All team members on this project → Project hooks
      └─ Only me, everywhere → Global hooks
      
      Question 2: Should this be version controlled?
      ├─ Yes, as part of distributable plugin → Plugin hooks
      ├─ Yes, shared with team in repo → Project hooks
      └─ No, keep private → Global hooks
      
      Question 3: What's the persistence requirement?
      ├─ Only when my plugin is active → Plugin hooks
      ├─ Always in this specific project → Project hooks
      └─ Always, in every project → Global hooks
      ```
      
      ### Decision Flowchart
      
      ```
      Is this hook part of a plugin's core functionality?
      ├─ YES → Plugin hooks (hooks/hooks.json in plugin)
      └─ NO ↓
      
      Should all team members on this project have this hook?
      ├─ YES → Project hooks (.claude/settings.json)
      └─ NO ↓
      
      Should this hook apply to all my Claude sessions?
      ├─ YES → Global hooks (~/.claude/settings.json)
      └─ NO → Reconsider if you need a hook at all
      ```
      
      ## Plugin Hooks
      
      ### When to Use
      
      The hook is **intrinsic to your plugin's functionality** and should automatically activate when users enable your plugin.
      
      **Perfect for:**
      - Validation specific to your plugin's domain (e.g., YAML syntax for YAML plugin)
      - Auto-formatting that's part of your plugin's features
      - Logging operations specific to plugin functionality
      - Integration with plugin-specific tools or services
      
      ### Location
      
      ```
      my-plugin/
      ├── .claude-plugin/
      │   └── plugin.json
      ├── hooks/
      │   └── hooks.json          ← Plugin hooks here
      └── skills/
          └── my-skill/
              └── SKILL.md
      ```
      
      ### Configuration
      
      **JSON Format** (`hooks/hooks.json`):
      
      ```json
      {
        "PreToolUse": [
          {
            "matcher": "Read",
            "hooks": [{
              "type": "command",
              "command": "echo \"Plugin reading: $(jq -r '.tool_input.file_path')\" >> ${CLAUDE_PLUGIN_ROOT}/log.txt"
            }]
          }
        ]
      }
      ```
      
      **Key Features:**
      - Use `${CLAUDE_PLUGIN_ROOT}` for plugin-relative paths
      - Automatically merges when plugin is enabled
      - Deactivates when plugin is disabled
      - Distributed with plugin code
      
      ### Examples
      
      **YAML Validation Plugin**:
      ```json
      {
        "PreToolUse": [
          {
            "matcher": "Edit",
            "hooks": [{
              "type": "command",
              "command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate-yaml.sh \"$(jq -r '.tool_input.file_path')\""
            }]
          }
        ]
      }
      ```
      
      > **Note**: Use string matchers (`"Edit"`) per Claude Code SDK. Filter by file pattern in hook script.
      
      **Code Formatter Plugin**:
      ```json
      {
        "PostToolUse": [
          {
            "matcher": "Edit",
            "hooks": [{
              "type": "command",
              "command": "${CLAUDE_PLUGIN_ROOT}/scripts/format-code.py"
            }]
          }
        ]
      }
      ```
      
      ## Project Hooks
      
      ### When to Use
      
      The hook should apply to **all team members** working on this specific project.
      
      **Perfect for:**
      - Enforcing team-wide coding conventions
      - Protecting project-specific resources (e.g., production configs)
      - Requiring tests before commits
      - Project-specific security policies
      - Team workflow requirements
      
      ### Location
      
      ```
      my-project/
      ├── .claude/
      │   └── settings.json       ← Project hooks here
      ├── .git/
      ├── src/
      └── README.md
      ```
      
      ### Configuration
      
      **JSON Format** (`.claude/settings.json`):
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [{
                "type": "command",
                "command": "cmd=$(jq -r '.tool_input.command // empty'); if [[ \"$cmd\" == *\"production\"* ]]; then echo 'BLOCKED: Production access requires approval'; exit 1; fi"
              }]
            }
          ]
        }
      }
      ```
      
      **Key Features:**
      - Committed to version control
      - Shared across all team members
      - Changes visible in PRs (governance trail)
      - Project-specific, not personal
      
      ### Examples
      
      **Block Production Edits**:
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Edit",
              "hooks": [{
                "type": "command",
                "command": "fp=$(jq -r '.tool_input.file_path // empty'); if [[ \"$fp\" == */production/* ]]; then echo 'ERROR: Cannot edit production configs without approval'; exit 1; fi"
              }]
            }
          ]
        }
      }
      ```
      
      **Require Tests Before Completion**:
      ```json
      {
        "hooks": {
          "Stop": [
            {
              "hooks": [{
                "type": "command",
                "command": ".claude/hooks/check-tests-run.sh"
              }]
            }
          ]
        }
      }
      ```
      
      **Project Convention Enforcement**:
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Write",
              "hooks": [{
                "type": "command",
                "command": ".claude/hooks/validate-naming-convention.py"
              }]
            }
          ]
        }
      }
      ```
      
      ## Global Hooks
      
      ### When to Use
      
      The hook should apply to **all your Claude sessions** across all projects.
      
      **Perfect for:**
      - Personal workflow preferences
      - Cross-project audit logging
      - Organization-wide compliance you want everywhere
      - Development environment preferences
      - Private rules that shouldn't be shared
      
      ### Location
      
      ```
      ~/.claude/
      ├── settings.json           ← Global hooks here
      ├── audit.log
      └── projects/
      ```
      
      ### Configuration
      
      **JSON Format** (`~/.claude/settings.json`):
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "hooks": [{
                "type": "command",
                "command": "echo \"$(date): $(jq -r '.tool_name')\" >> ~/.claude/audit.log"
              }]
            }
          ]
        }
      }
      ```
      
      **Key Features:**
      - Never committed to any repo
      - Applies to ALL Claude Code sessions
      - Personal to your user account
      - Survives across projects
      
      ### Examples
      
      **Personal Audit Logging**:
      ```json
      {
        "hooks": {
          "PostToolUse": [
            {
              "hooks": [{
                "type": "command",
                "command": "echo \"$(date '+%Y-%m-%d %H:%M:%S') - $(jq -r '.tool_name')\" >> ~/.claude/audit.log"
              }]
            }
          ]
        }
      }
      ```
      
      **Cross-Project Safety**:
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [{
                "type": "command",
                "command": "~/.claude/hooks/global-safety-check.sh"
              }]
            }
          ]
        }
      }
      ```
      
      **Development Environment Setup**:
      ```json
      {
        "hooks": {
          "UserPromptSubmit": [
            {
              "hooks": [{
                "type": "command",
                "command": "~/.claude/hooks/inject-dev-context.sh"
              }]
            }
          ]
        }
      }
      ```
      
      ## Loading Order & Precedence
      
      ### Hook Execution Priority
      
      Claude Code loads settings in this priority (highest first):
      
      1. **Enterprise policies** (organization-managed)
      2. **Command-line arguments** (`claude --flag`)
      3. **Local project settings** (`.claude/settings.local.json` - not committed)
      4. **Shared project settings** (`.claude/settings.json` - committed)
      5. **User settings** (`~/.claude/settings.json`)
      
      ### Multiple Matching Hooks
      
      When multiple hooks from different scopes match the same event, **all matching hooks execute in parallel**.
      
      **Example:**
      ```
      Event: PreToolUse (Bash)
      
      Global hook:  Logs to ~/.claude/audit.log
      Project hook: Checks production access
      Plugin hook:  Validates command syntax
      
      All three execute in parallel
      ```
      
      ## Scope Comparison Matrix
      
      | Criterion | Plugin | Project | Global |
      |-----------|--------|---------|--------|
      | **Distribution** | With plugin | In repo | Personal only |
      | **Activation** | When plugin enabled | Always in project | Always everywhere |
      | **Audience** | Plugin users | Project team | Individual user |
      | **Version Control** | Plugin repo | Project repo | Never |
      | **Governance** | Plugin maintainer | Team consensus | Personal choice |
      | **Security Review** | Plugin installation | PR review | Self-review |
      | **Scope** | Plugin operations | Project files | All sessions |
      
      ## Common Patterns by Scope
      
      ### Plugin Hook Patterns
      
      **Validation**: Check files match plugin's expected format
      ```json
      {
        "PreToolUse": [{
          "matcher": "Edit",
          "hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/validate.sh"}]
        }]
      }
      ```
      
      > **Note**: Use string matchers. Filter by file pattern inside the hook script.
      
      **Auto-completion**: Suggest plugin-specific completions
      ```json
      {
        "PostToolUse": [{
          "matcher": "Read",
          "hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/suggest-completions.py"}]
        }]
      }
      ```
      
      ### Project Hook Patterns
      
      **Protection**: Block dangerous operations on sensitive paths
      ```json
      {
        "PreToolUse": [{
          "matcher": "Edit",
          "hooks": [{
            "type": "command",
            "command": "fp=$(jq -r '.tool_input.file_path // empty'); if [[ \"$fp\" == */production/* ]]; then exit 1; fi"
          }]
        }]
      }
      ```
      
      **Enforcement**: Require tests, linting, or builds
      ```json
      {
        "Stop": [{
          "hooks": [{
            "type": "command",
            "command": ".claude/hooks/check-all-tests-passed.sh"
          }]
        }]
      }
      ```
      
      ### Global Hook Patterns
      
      **Auditing**: Log all operations for personal review
      ```json
      {
        "PostToolUse": [{
          "hooks": [{
            "type": "command",
            "command": "payload=$(cat); name=$(echo \"$payload\" | jq -r '.tool_name'); input=$(echo \"$payload\" | jq -c '.tool_input'); echo \"$name: $input\" >> ~/.claude/audit.log"
          }]
        }]
      }
      ```
      
      **Safety**: Universal dangerous command detection
      ```json
      {
        "PreToolUse": [{
          "matcher": "Bash",
          "hooks": [{
            "type": "command",
            "command": "~/.claude/hooks/safety-check.sh"
          }]
        }]
      }
      ```
      
      ## Security Considerations
      
      ### Plugin Hooks
      - **Security Review**: Audited during plugin installation
      - **User Consent**: Users enable plugin knowing hooks will run
      - **Scope Limitation**: Limited to plugin's stated purpose
      - **Transparency**: Hooks visible in plugin source
      
      ### Project Hooks
      - **Team Visibility**: All team members see hook definitions
      - **PR Review**: Changes reviewed in pull requests
      - **Consensus**: Should reflect team agreement
      - **Documentation**: Document purpose in repo
      
      ### Global Hooks
      - **Personal Risk**: Execute with your credentials everywhere
      - **Unexpected Effects**: Can affect all projects
      - **Review Carefully**: Test thoroughly before adding
      - **Keep Private**: Never commit to any repo
      
      ## Migration Patterns
      
      ### Plugin → Project
      
      When a plugin hook should become project-specific:
      
      ```bash
      # Copy from plugin
      cp plugins/my-plugin/hooks/hooks.json .claude/hooks/
      
      # Customize for project
      # Edit .claude/hooks/hooks.json to remove plugin-specific logic
      ```
      
      ### Project → Global
      
      When a project hook should apply to all your projects:
      
      ```bash
      # Extract to global hooks
      cat .claude/settings.json | jq '.hooks' >> ~/.claude/settings.json
      
      # Remove from project
      # Remove hook from .claude/settings.json
      ```
      
      ### Global → Plugin
      
      When your personal hook would benefit all plugin users:
      
      ```bash
      # Create plugin hooks directory
      mkdir -p plugins/my-plugin/hooks/
      
      # Move hook to plugin
      # Copy hook logic to plugins/my-plugin/hooks/hooks.json
      
      # Update paths to use ${CLAUDE_PLUGIN_ROOT}
      ```
      
      ## Troubleshooting Scope Issues
      
      ### Hook Not Executing
      
      **Check scope activation:**
      ```bash
      # Verify plugin is enabled
      claude plugins list
      
      # Check project hooks exist
      cat .claude/settings.json
      
      # Verify global hooks exist
      cat ~/.claude/settings.json
      ```
      
      ### Hook Executing Unexpectedly
      
      **Check all scopes:**
      ```bash
      # Check which hooks are active
      claude hooks list
      
      # Disable plugin temporarily
      claude plugins disable my-plugin
      
      # Remove project hook
      # Edit .claude/settings.json
      ```
      
      ### Conflicting Hooks
      
      **Identify source:**
      ```bash
      # Show all active hooks
      claude hooks list --verbose
      
      # Check precedence order
      # Global → Project → Plugin
      ```
      
      ## Best Practices
      
      ### Plugin Hooks
      1. Use plugin-relative paths (`${CLAUDE_PLUGIN_ROOT}`)
      2. Document hooks in plugin README
      3. Make hooks optional when possible
      4. Test with plugin enabled/disabled
      
      ### Project Hooks
      1. Document purpose in README or HOOKS.md
      2. Review in PRs like other code changes
      3. Keep focused on project-specific needs
      4. Avoid personal preferences
      
      ### Global Hooks
      1. Test thoroughly before deploying
      2. Document in personal notes
      3. Review periodically for relevance
      4. Consider security implications
      
      ## Related Modules
      
      See the Module References section in `SKILL.md` for the full module list and
      what each one covers. The hub owns that index so the modules do not have to
      keep parallel copies of it in sync.
      
    • sdk-callbacks.md 19.6 KB
      # SDK Callbacks and Implementation Patterns
      
      Complete guide to implementing Claude Agent SDK hooks with Python, including patterns, best practices, and production examples.
      
      ## AgentHooks Base Class
      
      The `AgentHooks` class from `claude_agent_sdk` provides the foundation for all SDK hooks:
      
      ```python
      from claude_agent_sdk import AgentHooks
      
      
      class MyHooks(AgentHooks):
          """Custom hooks for agent lifecycle events."""
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Hook before tool execution."""
              pass
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Hook after tool execution."""
              pass
      
          async def on_post_tool_use_failure(
              self, tool_name: str, tool_input: dict, error: str
          ) -> str | None:
              """Hook when tool execution fails (2.1.20+)."""
              pass
      
          async def on_user_prompt_submit(self, message: str) -> str | None:
              """Hook when user submits a message."""
              pass
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Hook when agent stops."""
              pass
      
          async def on_subagent_start(self, subagent_id: str, task: Any) -> None:
              """Hook when subagent spawns (2.1.20+)."""
              pass
      
          async def on_subagent_stop(self, subagent_id: str, result: Any) -> None:
              """Hook when subagent completes."""
              pass
      
          async def on_permission_request(
              self, tool_name: str, tool_input: dict
          ) -> dict | None:
              """Hook when permission dialog would appear."""
              pass
      
          async def on_teammate_idle(self, teammate_id: str) -> None:
              """Hook when teammate agent becomes idle (2.1.33+)."""
              pass
      
          async def on_task_completed(self, task_id: str, result: Any) -> None:
              """Hook when task finishes execution (2.1.33+)."""
              pass
      
          async def on_pre_compact(self, context_size: int) -> dict | None:
              """Hook before context compaction."""
              pass
      ```
      
      All callbacks are **optional** - implement only the hooks you need.
      
      ## Implementation Patterns
      
      ### Pattern 1: Validation Hook
      
      Block operations that violate security policies:
      
      ```python
      from typing import Any
      from claude_agent_sdk import AgentHooks
      
      
      class ValidationHooks(AgentHooks):
          """Validate tool inputs against security policies."""
      
          def __init__(self, config: dict[str, Any] | None = None):
              self.config = config or {}
              self.blocked_patterns = self.config.get(
                  "blocked_patterns",
                  [
                      r"rm\s+-rf\s+/",
                      r":(){ :|:& };:",  # Fork bomb
                      r"dd\s+if=/dev/zero",
                  ],
              )
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Validate tool inputs before execution."""
              if tool_name == "Bash":
                  command = tool_input.get("command", "")
      
                  # Check for dangerous patterns
                  import re
      
                  for pattern in self.blocked_patterns:
                      if re.search(pattern, command):
                          raise ValueError(
                              f"Command blocked by security policy: pattern '{pattern}' matched"
                          )
      
                  # Check for production access
                  if "production" in command.lower() and not self._has_production_approval():
                      raise ValueError("Production access requires approval")
      
              elif tool_name == "Edit":
                  file_path = tool_input.get("file_path", "")
      
                  # Block edits to sensitive files
                  sensitive_paths = ["/etc/", "/sys/", "/production/"]
                  if any(sensitive in file_path for sensitive in sensitive_paths):
                      raise ValueError(f"Cannot edit protected path: {file_path}")
      
              return None  # Allow operation
      
          def _has_production_approval(self) -> bool:
              """Check if production access is approved."""
              # Implementation: check environment, file, or API
              import os
      
              return os.getenv("PRODUCTION_APPROVED") == "true"
      ```
      
      ### Pattern 2: Logging Hook
      
      detailed audit logging with sanitization:
      
      ```python
      import asyncio
      import json
      import re
      from datetime import datetime
      from pathlib import Path
      from typing import Any
      from claude_agent_sdk import AgentHooks
      
      
      class LoggingHooks(AgentHooks):
          """Audit logging for all tool operations."""
      
          # Patterns that might contain secrets
          SECRET_PATTERNS = [
              r'(api[_-]?key["\s:=]+)([^\s,}]+)',
              r'(password["\s:=]+)([^\s,}]+)',
              r'(token["\s:=]+)([^\s,}]+)',
              r'(secret["\s:=]+)([^\s,}]+)',
              r'(auth["\s:=]+)([^\s,}]+)',
          ]
      
          def __init__(self, log_file: Path | None = None):
              self.log_file = log_file or Path.home() / ".claude" / "audit.log"
              self._log_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
              self._log_task: asyncio.Task[None] | None = None
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Log tool use initiation."""
              await self._queue_log(
                  {
                      "event": "pre_tool_use",
                      "tool": tool_name,
                      "input_size": len(str(tool_input)),
                      "timestamp": datetime.now().isoformat(),
                  }
              )
              return None
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Log tool completion with sanitized output."""
              safe_output = self._sanitize_secrets(tool_output)
      
              await self._queue_log(
                  {
                      "event": "post_tool_use",
                      "tool": tool_name,
                      "input_size": len(str(tool_input)),
                      "output_size": len(tool_output),
                      "output_preview": safe_output[:200],
                      "timestamp": datetime.now().isoformat(),
                  }
              )
              return None
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Log session completion."""
              await self._queue_log(
                  {"event": "stop", "reason": reason, "timestamp": datetime.now().isoformat()}
              )
      
              # validate all logs are written before exit
              await self._flush_logs()
      
          def _sanitize_secrets(self, text: str) -> str:
              """Remove potential secrets from text."""
              for pattern in self.SECRET_PATTERNS:
                  text = re.sub(pattern, r"\1***REDACTED***", text, flags=re.IGNORECASE)
              return text
      
          async def _queue_log(self, entry: dict[str, Any]) -> None:
              """Add log entry to queue for async writing."""
              await self._log_queue.put(entry)
      
              # Start background writer if not running
              if self._log_task is None or self._log_task.done():
                  self._log_task = asyncio.create_task(self._write_logs())
      
          async def _write_logs(self) -> None:
              """Background task to write logs asynchronously."""
              while not self._log_queue.empty():
                  try:
                      entry = await asyncio.wait_for(self._log_queue.get(), timeout=1.0)
      
                      # Append to log file
                      async with asyncio.Lock():
                          with open(self.log_file, "a") as f:
                              f.write(json.dumps(entry) + "\n")
      
                  except asyncio.TimeoutError:
                      break
      
          async def _flush_logs(self) -> None:
              """Wait for all queued logs to be written."""
              if self._log_task and not self._log_task.done():
                  await self._log_task
      ```
      
      ### Pattern 3: Transformation Hook
      
      Modify tool inputs or outputs:
      
      ```python
      from claude_agent_sdk import AgentHooks
      
      
      class TransformationHooks(AgentHooks):
          """Transform tool inputs and outputs."""
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Transform tool inputs before execution."""
              if tool_name == "Read":
                  # Normalize file paths
                  file_path = tool_input.get("file_path", "")
                  normalized = self._normalize_path(file_path)
      
                  if normalized != file_path:
                      # Return modified input
                      return {**tool_input, "file_path": normalized}
      
              return None  # No transformation needed
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Transform tool outputs after execution."""
              if tool_name == "Bash" and "ls" in tool_input.get("command", ""):
                  # Add metadata to ls output
                  enhanced = f"Directory listing:\n{tool_output}\n\nTotal items: {len(tool_output.splitlines())}"
                  return enhanced
      
              return None  # No transformation
      
          def _normalize_path(self, path: str) -> str:
              """Normalize file path to absolute path."""
              from pathlib import Path
      
              return str(Path(path).resolve())
      ```
      
      ### Pattern 4: Metrics Collection Hook
      
      Track performance and usage metrics:
      
      ```python
      import time
      from collections import defaultdict
      from typing import Any
      from claude_agent_sdk import AgentHooks
      
      
      class MetricsHooks(AgentHooks):
          """Collect performance and usage metrics."""
      
          def __init__(self):
              self._tool_counts: defaultdict[str, int] = defaultdict(int)
              self._tool_durations: dict[str, list[float]] = defaultdict(list)
              self._start_times: dict[str, float] = {}
              self._session_start = time.time()
              self._tool_instance_counter = 0
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Record tool invocation start time."""
              self._tool_instance_counter += 1
              instance_id = f"{tool_name}_{self._tool_instance_counter}"
              self._start_times[instance_id] = time.time()
              self._tool_counts[tool_name] += 1
              return None
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Record tool execution duration."""
              # Find most recent instance of this tool
              instance_id = f"{tool_name}_{self._tool_counts[tool_name]}"
      
              if instance_id in self._start_times:
                  duration = time.time() - self._start_times[instance_id]
                  self._tool_durations[tool_name].append(duration)
                  del self._start_times[instance_id]
      
              return None
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Generate and display metrics summary."""
              session_duration = time.time() - self._session_start
      
              print("\n=== Session Metrics ===")
              print(f"Total Duration: {session_duration:.2f}s")
              print(f"Tools Used: {sum(self._tool_counts.values())}")
              print(f"\nTool Breakdown:")
      
              for tool, count in sorted(self._tool_counts.items()):
                  durations = self._tool_durations[tool]
                  avg_duration = sum(durations) / len(durations) if durations else 0
                  print(f"  {tool}: {count} calls, avg {avg_duration:.3f}s")
      
              # Save metrics to file
              await self._save_metrics(
                  {
                      "session_duration": session_duration,
                      "tool_counts": dict(self._tool_counts),
                      "tool_durations": {k: sum(v) for k, v in self._tool_durations.items()},
                      "stop_reason": reason,
                  }
              )
      
          async def _save_metrics(self, metrics: dict[str, Any]) -> None:
              """Save metrics to JSON file."""
              import json
              from pathlib import Path
              from datetime import datetime
      
              metrics_file = (
                  Path.home() / ".claude" / "metrics" / f"{datetime.now().isoformat()}.json"
              )
              metrics_file.parent.mkdir(exist_ok=True)
      
              with open(metrics_file, "w") as f:
                  json.dump(metrics, f, indent=2)
      ```
      
      ### Pattern 5: Context Injection Hook
      
      Enhance user prompts with additional context:
      
      ```python
      from pathlib import Path
      from claude_agent_sdk import AgentHooks
      
      
      class ContextInjectionHooks(AgentHooks):
          """Inject project context into user prompts."""
      
          def __init__(self, project_root: Path):
              self.project_root = project_root
      
          async def on_user_prompt_submit(self, message: str) -> str | None:
              """Inject relevant project context."""
              # Detect if user is asking about code/files
              code_keywords = [
                  "file",
                  "function",
                  "class",
                  "code",
                  "implement",
                  "edit",
                  "change",
              ]
      
              if any(kw in message.lower() for kw in code_keywords):
                  context = await self._load_project_context()
                  enhanced = f"{context}\n\nUser Request: {message}"
                  return enhanced
      
              return None
      
          async def _load_project_context(self) -> str:
              """Load relevant project context."""
              context_parts = []
      
              # Add README if exists
              readme = self.project_root / "README.md"
              if readme.exists():
                  content = readme.read_text()
                  context_parts.append(f"Project Overview:\n{content[:500]}...")
      
              # Add coding conventions if exists
              conventions = self.project_root / "CONVENTIONS.md"
              if conventions.exists():
                  content = conventions.read_text()
                  context_parts.append(f"Coding Conventions:\n{content}")
      
              # Add architecture notes if exists
              architecture = self.project_root / "ARCHITECTURE.md"
              if architecture.exists():
                  content = architecture.read_text()
                  context_parts.append(f"Architecture:\n{content[:300]}...")
      
              return "\n\n".join(context_parts) if context_parts else ""
      ```
      
      ## State Management
      
      ### Session State
      
      Maintain state across hook invocations within a session:
      
      ```python
      from claude_agent_sdk import AgentHooks
      
      
      class StatefulHooks(AgentHooks):
          """Maintain state across hook invocations."""
      
          def __init__(self):
              self._session_state = {"tools_used": [], "errors": [], "warnings": []}
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Track tool usage."""
              self._session_state["tools_used"].append(tool_name)
              return None
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Detect errors in output."""
              if "error" in tool_output.lower():
                  self._session_state["errors"].append(
                      {"tool": tool_name, "output": tool_output[:200]}
                  )
              return None
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Report session state."""
              print(f"\nSession Summary:")
              print(f"Tools used: {', '.join(set(self._session_state['tools_used']))}")
              print(f"Errors encountered: {len(self._session_state['errors'])}")
      ```
      
      ### Persistent State
      
      Save state across sessions:
      
      ```python
      import json
      from pathlib import Path
      from claude_agent_sdk import AgentHooks
      
      
      class PersistentHooks(AgentHooks):
          """Maintain state across sessions."""
      
          def __init__(self, state_file: Path | None = None):
              self.state_file = state_file or Path.home() / ".claude" / "hook_state.json"
              self._state = self._load_state()
      
          def _load_state(self) -> dict:
              """Load state from file."""
              if self.state_file.exists():
                  return json.loads(self.state_file.read_text())
              return {"session_count": 0, "total_tools": 0}
      
          def _save_state(self) -> None:
              """Save state to file."""
              self.state_file.parent.mkdir(exist_ok=True)
              self.state_file.write_text(json.dumps(self._state, indent=2))
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Increment tool counter."""
              self._state["total_tools"] += 1
              self._save_state()
              return None
      
          async def on_stop(self, reason: str, result: Any) -> None:
              """Increment session counter."""
              self._state["session_count"] += 1
              self._save_state()
              print(
                  f"Session #{self._state['session_count']}, Total tools: {self._state['total_tools']}"
              )
      ```
      
      ## Error Handling
      
      ### Graceful Degradation
      
      ```python
      import logging
      from claude_agent_sdk import AgentHooks
      
      logger = logging.getLogger(__name__)
      
      
      class ResilientHooks(AgentHooks):
          """Handle errors gracefully without blocking agent."""
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Validate with graceful error handling."""
              try:
                  if not self._is_valid_input(tool_input):
                      raise ValueError("Invalid input")
      
              except Exception as e:
                  logger.error(f"Validation error (non-blocking): {e}")
                  # Don't block operation on validation errors
                  return None
      
              return None
      
          async def on_post_tool_use(
              self, tool_name: str, tool_input: dict, tool_output: str
          ) -> str | None:
              """Log with error handling."""
              try:
                  await self._log_operation(tool_name, tool_output)
      
              except Exception as e:
                  logger.error(f"Logging failed: {e}")
                  # Don't block on logging failures
      
              return None
      
          def _is_valid_input(self, tool_input: dict) -> bool:
              """Validate tool input."""
              # Validation logic
              return True
      ```
      
      ### Timeout Handling
      
      ```python
      import asyncio
      from claude_agent_sdk import AgentHooks
      
      
      class TimeoutHooks(AgentHooks):
          """Apply timeouts to hook operations."""
      
          HOOK_TIMEOUT = 5.0  # seconds
      
          async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
              """Validation with timeout."""
              try:
                  result = await asyncio.wait_for(
                      self._validate_input(tool_input), timeout=self.HOOK_TIMEOUT
                  )
                  return result
      
              except asyncio.TimeoutError:
                  logger.warning(f"Validation timeout for {tool_name}")
                  return None  # Allow on timeout
      
          async def _validate_input(self, tool_input: dict) -> dict | None:
              """Async validation logic."""
              # Potentially slow validation
              await asyncio.sleep(0.1)
              return None
      ```
      
      ## Testing SDK Hooks
      
      ### Unit Tests
      
      ```python
      import pytest
      from my_hooks import ValidationHooks, LoggingHooks
      
      
      @pytest.mark.asyncio
      async def test_validation_blocks_dangerous_command():
          hooks = ValidationHooks()
      
          with pytest.raises(ValueError, match="blocked by security policy"):
              await hooks.on_pre_tool_use("Bash", {"command": "rm -rf /"})
      
      
      @pytest.mark.asyncio
      async def test_validation_allows_safe_command():
          hooks = ValidationHooks()
          result = await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
          assert result is None
      
      
      @pytest.mark.asyncio
      async def test_logging_sanitizes_secrets():
          import tempfile
          from pathlib import Path
      
          with tempfile.NamedTemporaryFile(delete=False) as f:
              log_file = Path(f.name)
      
          hooks = LoggingHooks(log_file)
      
          await hooks.on_post_tool_use("Bash", {"command": "echo"}, "api_key=secret123")
      
          await hooks._flush_logs()
      
          log_content = log_file.read_text()
          assert "secret123" not in log_content
          assert "REDACTED" in log_content
      
          log_file.unlink()
      ```
      
      ### Integration Tests
      
      ```python
      import pytest
      from claude_agent_sdk import Agent
      from my_hooks import MyHooks
      
      
      @pytest.mark.asyncio
      async def test_hooks_integration():
          hooks = MyHooks()
          agent = Agent(hooks=hooks)
      
          # Execute agent with hooks
          result = await agent.run("List files in current directory")
      
          # Verify hooks were called
          assert len(hooks._log_entries) > 0
          assert any(entry["tool"] == "Bash" for entry in hooks._log_entries)
      ```
      
      ## Related Modules
      
      See the Module References section in `SKILL.md` for the full module list and
      what each one covers. The hub owns that index so the modules do not have to
      keep parallel copies of it in sync.
      
    • testing-hooks.md 3.2 KB
      # Testing Hooks
      
      Testing strategies for hook development. Covers unit testing hook logic, mocking external dependencies, and CI/CD integration.
      
      ## Testing Philosophy
      
      ### Core Testing Principles
      
      1. **Test Hook Logic, Not Tools**: Test your hook's behavior, not Claude's tools
      2. **Mock External Dependencies**: Isolate hook logic from I/O and network
      3. **Test All Paths**: Happy path, error cases, edge cases
      4. **Verify Performance**: Test timing budgets and resource usage
      5. **Security Testing**: Test security controls and sanitization
      
      ## Test Categories
      
      | Category | Purpose | Budget |
      |----------|---------|--------|
      | Unit | Hook method logic | < 100ms |
      | Integration | Hook chains, mock agent | < 500ms |
      | Security | Secret sanitization, injection prevention | < 200ms |
      | Performance | Timing budgets, memory bounds | Varies |
      
      ## Quick Reference
      
      ### Unit Test Pattern
      
      ```python
      @pytest.mark.asyncio
      async def test_hook_behavior():
          hooks = MyHooks()
          result = await hooks.on_pre_tool_use("Bash", {"command": "ls"})
          assert result is None  # or expected modification
      ```
      
      ### Return Value Testing
      
      | Hook | Return None | Return Modified |
      |------|-------------|-----------------|
      | PreToolUse | Allow unchanged | Modified input dict |
      | PostToolUse | Allow unchanged | Modified output string |
      | Stop | Block with error | N/A |
      
      ### Error Handling Pattern
      
      ```python
      @pytest.mark.asyncio
      async def test_graceful_failure():
          hooks = ResilientHooks()
          # Even with invalid input, should return None (allow)
          result = await hooks.on_pre_tool_use("Bash", {"invalid": "input"})
          assert result is None
      ```
      
      ## Security Test Checklist
      
      - [ ] API keys redacted from logs
      - [ ] Passwords sanitized in output
      - [ ] Path traversal blocked
      - [ ] Command injection prevented
      - [ ] Allowed operations permitted
      
      ## Performance Targets
      
      | Operation | P50 | P95 | Max |
      |-----------|-----|-----|-----|
      | PreToolUse validation | < 50ms | < 100ms | < 200ms |
      | PostToolUse logging | < 100ms | < 500ms | < 1s |
      | Memory per 10K ops | < 10MB | < 20MB | < 50MB |
      
      ## Test Fixtures
      
      ### Essential Fixtures
      
      ```python
      @pytest.fixture
      def temp_log_file():
          with tempfile.NamedTemporaryFile(suffix=".log") as f:
              yield Path(f.name)
      
      
      @pytest.fixture
      def mock_file_system():
          mock_fs = MagicMock()
          mock_fs.exists.return_value = True
          return mock_fs
      ```
      
      ## Testing Checklist
      
      Before deploying hooks, verify:
      
      - [ ] **Unit Tests**: All hook methods tested
      - [ ] **Happy Path**: Normal operations work
      - [ ] **Error Cases**: Errors handled gracefully
      - [ ] **Edge Cases**: Boundary conditions tested
      - [ ] **Security**: Secret sanitization verified
      - [ ] **Performance**: Timing budgets met
      - [ ] **Memory**: No memory leaks
      - [ ] **Integration**: Works with mock agent
      - [ ] **Coverage**: > 90% line coverage
      
      ## Detailed Examples
      
      For more test examples including:
      - Full unit test suites
      - Integration test patterns
      - Security test cases
      - Performance benchmarks
      - CI/CD configuration
      
      See `Skill(abstract:hook-authoring)` for full hook development patterns including test examples.
      
      ## Related Modules
      
      See the Module References section in `SKILL.md` for the full module list and
      what each one covers. The hub owns that index so the modules do not have to
      keep parallel copies of it in sync.
      
  • scripts
    • README.md 5.3 KB
      # Hook Authoring Scripts
      
      Utilities for validating and testing Claude Code and SDK hooks.
      
      ## hook_validator.py
      
      Validates hook files for syntax, structure, and compliance with hook specifications.
      
      ### Features
      
      - **JSON Hook Validation**: Validates `hooks.json` files for Claude Code
        - JSON syntax validation
        - Required field checking
        - Known event type verification
        - Hook action validation
      
      - **Python SDK Hook Validation**: Validates Python files containing `AgentHooks` subclasses
        - Python syntax validation
        - `AgentHooks` inheritance checking
        - Callback method signature verification
        - Async definition validation
      
      ### Usage
      
      ```bash
      # Make executable (first time only)
      # hook_validator.py ships executable in git
      
      # Validate JSON hook file
      python3 plugins/abstract/scripts/hook_validator.py hooks/hooks.json
      
      # Validate Python SDK hook file
      python3 plugins/abstract/scripts/hook_validator.py my_hooks.py
      
      # Specify type explicitly
      python3 plugins/abstract/scripts/hook_validator.py hooks.json --type json
      python3 plugins/abstract/scripts/hook_validator.py my_hooks.py --type python
      
      # Verbose output (show info messages)
      python3 plugins/abstract/scripts/hook_validator.py hooks.json --verbose
      ```
      
      ### Exit Codes
      
      - `0`: Success, no issues found
      - `1`: Warnings found (valid but with recommendations)
      - `2`: Errors found (invalid)
      
      ### Example Output
      
      **Valid JSON hook:**
      ```
      OK Valid
      
      Info:
        [INFO]  Loaded JSON from hooks/hooks.json
        [INFO]  Validated 2 event type(s)
      ```
      
      **Invalid Python hook:**
      ```
      FAIL Invalid
      
      Errors:
        FAIL MyHooks.on_pre_tool_use: should be async (async def)
        FAIL MyHooks.on_post_tool_use: incorrect arguments. Expected ['self', 'tool_name', 'tool_input', 'tool_output'], got ['self', 'tool', 'output']
      ```
      
      ### JSON Hook Validation
      
      Checks for:
      - Valid JSON syntax
      - Known event types (`PreToolUse`, `PostToolUse`, etc.)
      - Required fields (`hooks` array)
      - Hook action structure (`type`, `command`)
      - **Matcher format**: String regex patterns (e.g., `"Bash"`, `"Read|Write"`)
        - Object format `{"toolName": "Bash"}` is deprecated and will generate warnings
      
      ### Python SDK Hook Validation
      
      Checks for:
      - Valid Python syntax
      - `AgentHooks` base class inheritance
      - Async callback methods (`async def`)
      - Correct callback signatures:
        - `on_pre_tool_use(self, tool_name, tool_input) -> dict | None`
        - `on_post_tool_use(self, tool_name, tool_input, tool_output) -> str | None`
        - `on_user_prompt_submit(self, message) -> str | None`
        - `on_stop(self, reason, result) -> None`
        - `on_subagent_stop(self, subagent_id, result) -> None`
        - `on_pre_compact(self, context_size) -> dict | None`
      
      ## Integration with CI/CD
      
      ### Pre-commit Hook
      
      Add to `.git/hooks/pre-commit`:
      
      ```bash
      #!/bin/bash
      echo "Validating hooks..."
      
      # Find all hook files
      json_hooks=$(find . -name "hooks.json" -not -path "*/node_modules/*" -not -path "*/.git/*")
      python_hooks=$(find . -name "*_hooks.py" -not -path "*/tests/*" -not -path "*/.git/*")
      
      # Validate JSON hooks
      for hook in $json_hooks; do
          if ! python3 plugins/abstract/scripts/hook_validator.py "$hook"; then
              echo "Hook validation failed: $hook"
              exit 1
          fi
      done
      
      # Validate Python hooks
      for hook in $python_hooks; do
          if ! python3 plugins/abstract/scripts/hook_validator.py "$hook"; then
              echo "Hook validation failed: $hook"
              exit 1
          fi
      done
      
      echo "OK All hooks validated"
      ```
      
      ### GitHub Actions
      
      Add to `.github/workflows/validate-hooks.yml`:
      
      ```yaml
      name: Validate Hooks
      
      on: [push, pull_request]
      
      jobs:
        validate:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v3
      
            - name: Set up Python
              uses: actions/setup-python@v4
              with:
                python-version: '3.11'
      
            - name: Validate JSON hooks
              run: |
                find . -name "hooks.json" \
                  -not -path "*/node_modules/*" -not -path "*/.venv/*" \
                  -not -path "*/__pycache__/*" -not -path "*/.git/*" | while read hook; do
                  python3 plugins/abstract/scripts/hook_validator.py "$hook" --verbose
                done
      
            - name: Validate Python hooks
              run: |
                find . -name "*_hooks.py" -not -path "*/tests/*" \
                  -not -path "*/.venv/*" -not -path "*/__pycache__/*" \
                  -not -path "*/node_modules/*" -not -path "*/.git/*" | while read hook; do
                  python3 plugins/abstract/scripts/hook_validator.py "$hook" --verbose
                done
      ```
      
      ## Testing
      
      Test the validator itself:
      
      ```bash
      # Test with valid JSON hook (string matcher format)
      echo '{
        "PreToolUse": [{
          "matcher": "Bash",
          "hooks": [{"type": "command", "command": "echo test"}]
        }]
      }' > test_hooks.json
      
      python3 plugins/abstract/scripts/hook_validator.py test_hooks.json
      # Should exit with 0
      
      # Test with invalid JSON hook
      echo '{"invalid": "structure"}' > test_invalid.json
      
      python3 plugins/abstract/scripts/hook_validator.py test_invalid.json
      # Should exit with 1 or 2
      
      # Clean up
      rm test_hooks.json test_invalid.json
      ```
      
      ## Dependencies
      
      - Python 3.11+
      - Standard library only (no external dependencies)
      
      ## Related Files
      
      - **SKILL.md**: Main hook authoring guide
      - **modules/hook-types.md**: Hook event specifications
      - **modules/sdk-callbacks.md**: Python SDK patterns
      - **modules/security-patterns.md**: Security validation guidelines
      - **modules/testing-hooks.md**: detailed testing strategies
      
  • SKILL.md 23.7 KB
    ---
    name: hook-authoring
    description: 'Guide creating Claude Code hooks with security-first design. Use for validation and enforcement.'
    alwaysApply: false
    category: hook-development
    tags:
    - hooks
    - sdk
    - security
    - performance
    - automation
    - validation
    dependencies: []
    estimated_tokens: 1200
    complexity: intermediate
    model_hint: standard
    provides:
      patterns:
      - hook-authoring
      - security-patterns
      - performance-optimization
      infrastructure:
      - hook-validation
      - testing-framework
    usage_patterns:
    - writing-hooks
    - hook-validation
    - security-patterns
    - performance-optimization
    - sdk-integration
    ---
    
    ## When NOT To Use
    
    - Auditing a hook that already exists (use `abstract:hooks-eval`)
    - Choosing where a hook should live (use `abstract:hook-scope-guide`)
    - Authoring a skill rather than a hook (use `abstract:skill-authoring`)
    
    # Hook Authoring Guide
    
    ## Overview
    
    Hooks are event interceptors that allow you to extend Claude Code and Claude Agent SDK behavior by executing custom logic at specific points in the agent lifecycle. They enable validation before tool use, logging after actions, context injection, workflow automation, and security enforcement.
    
    This skill teaches you how to write effective, secure, and performant hooks for both declarative JSON (Claude Code) and programmatic Python (Claude Agent SDK) use cases.
    
    ### Key Capabilities
    
    - **PreToolUse**: Validate, filter, or transform tool inputs before execution; inject context (2.1.9+)
    - **PostToolUse**: Log, analyze, or modify tool outputs after execution
    - **UserPromptSubmit**: Inject context or filter user messages before processing
    - **Stop/SubagentStop**: Cleanup, final reporting, or result aggregation
    - **TeammateIdle/TaskCompleted**: Multi-agent coordination and orchestration (2.1.33+)
    - **PreCompact**: State preservation before context window compaction
    
    > **New in 2.1.9**: PreToolUse hooks can now return `additionalContext` to inject information before a tool executes. This enables patterns like cache hints, security warnings, or relevant context injection.
    
    ## Quick Start
    
    ### Your First Hook (JSON - Claude Code)
    
    Create a simple logging hook in `.claude/settings.json`:
    
    ```json
    {
      "PostToolUse": [
        {
          "matcher": "Bash",
          "hooks": [{
            "type": "command",
            "command": "echo \"$(date): Executed $(jq -r '.tool_name')\" >> ~/.claude/audit.log"
          }]
        }
      ]
    }
    ```
    
    **Note**: Use string matchers (`"Bash"`) not object matchers (`{"toolName": "Bash"}`).
    
    **Verification:** Run the command with `--help` flag to verify availability.
    
    This logs every Bash command execution with a timestamp.
    
    ### Your First Hook (Python - Claude Agent SDK)
    
    Create a validation hook using the SDK:
    
    ```python
    from claude_agent_sdk import AgentHooks
    
    
    class ValidationHooks(AgentHooks):
        async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
            """Validate tool inputs before execution."""
            if tool_name == "Bash":
                command = tool_input.get("command", "")
                if "rm -rf /" in command:
                    raise ValueError("Dangerous command blocked by hook")
    
            # Return None to proceed unchanged, or modified dict to transform
            return None
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    ## Hook Event Types
    
    Quick reference for all supported hook events:
    
    | Event | Trigger Point | Parameters | Common Use Cases |
    |-------|--------------|------------|------------------|
    | **PreToolUse** | Before tool execution | `tool_name`, `tool_input` | Validation, filtering, input transformation |
    | **PostToolUse** | After tool execution | `tool_name`, `tool_input`, `tool_output` | Logging, metrics, output transformation |
    | **UserPromptSubmit** | User sends message | `message` | Context injection, content filtering |
    | **PermissionRequest** | Permission dialog shown | `tool_name`, `tool_input` | Auto-approve/deny with custom logic |
    | **Notification** | Claude Code sends notification | `message` | Custom notification handling |
    | **Stop** | Agent completes | `reason`, `result` | Final cleanup, summary reports |
    | **SubagentStop** | Subagent completes | `subagent_id`, `result` | Result processing, aggregation |
    | **TeammateIdle** | Teammate agent becomes idle | `agent_id`, `session_id` | Work assignment, load balancing (2.1.33+) |
    | **TaskCompleted** | Task finishes execution | `task_id`, `result` | Coordination, chaining, reporting (2.1.33+) |
    | **PreCompact** | Before context compact | `context_size` | State preservation, checkpointing |
    | **SessionStart** | Session starts/resumes | `session_id`, `source`, `agent_type` | Initialization, context loading |
    | **SessionEnd** | Session terminates | `session_id` | Cleanup, final logging |
    | **WorktreeCreate** | Agent worktree created | `worktree_path`, `session_id` | Custom VCS setup, symlink .venv, pre-populate caches (2.1.50+) |
    | **WorktreeRemove** | Agent worktree removed | `worktree_path`, `session_id` | Cleanup temp files, teardown worktree-scoped resources (2.1.50+) |
    
    ### SessionStart Input Schema (Claude Code 2.1.2+)
    
    The SessionStart hook receives JSON input via stdin with these fields:
    
    ```json
    {
      "session_id": "abc123",
      "source": "startup",
      "agent_type": "my-agent"
    }
    ```
    
    Fields: `source` is one of `"startup"`, `"resume"`, `"clear"`, or `"compact"`. `agent_type` is populated when the `--agent` flag is used.
    
    **`agent_type` field**: When Claude Code is launched with `--agent my-agent`, this field contains the agent name, enabling agent-specific initialization:
    
    ```python
    # Python example: Agent-aware SessionStart hook
    input_data = json.loads(sys.stdin.read())
    agent_type = input_data.get("agent_type", "")
    
    if agent_type in ["code-reviewer", "quick-query"]:
        # Skip heavy context injection for lightweight agents
        print(json.dumps({"hookSpecificOutput": {"additionalContext": "Minimal context"}}))
    else:
        # Full initialization for implementation agents
        print(json.dumps({"hookSpecificOutput": {"additionalContext": full_context}}))
    ```
    
    ```bash
    # Bash example: Agent-aware SessionStart hook
    HOOK_INPUT=$(cat)
    AGENT_TYPE=$(echo "$HOOK_INPUT" | jq -r '.agent_type // empty')
    
    case "$AGENT_TYPE" in
        code-reviewer|quick-query)
            echo '{"hookSpecificOutput": {"additionalContext": "Minimal context"}}'
            ;;
        *)
            echo '{"hookSpecificOutput": {"additionalContext": "Full context"}}'
            ;;
    esac
    ```
    
    ## Hooks in Frontmatter (Claude Code 2.1.0+)
    
    **New in 2.1.0:** Define hooks directly in skill, command, or agent frontmatter. These hooks are scoped to the component's lifecycle.
    
    ### Skill/Command/Agent Frontmatter Hooks
    
    ```yaml
    ---
    name: validated-skill
    description: Skill with lifecycle hooks
    hooks:
      PreToolUse:
        - matcher: "Bash"
          command: "./validate-command.sh"
          once: true  # NEW: Run only once per session
        - matcher: "Write|Edit"
          command: "./pre-edit-check.sh"
      PostToolUse:
        - matcher: "Write|Edit"
          command: "./format-on-save.sh"
      Stop:
        - command: "./cleanup-and-report.sh"
    ---
    ```
    
    ### The `once: true` Configuration
    
    **New in 2.1.0:** Use `once: true` to execute a hook only once per session, ideal for:
    - One-time setup/initialization
    - Resource allocation that shouldn't repeat
    - Session-level configuration
    
    ```yaml
    hooks:
      PreToolUse:
        - matcher: "Bash"
          command: "./setup-environment.sh"
          once: true  # Runs only on first Bash call
      SessionStart:
        - command: "./initialize-session.sh"
          once: true  # Runs only once at session start
    ```
    
    ### Frontmatter vs Settings Hooks
    
    | Aspect | Frontmatter Hooks | Settings Hooks |
    |--------|-------------------|----------------|
    | Scope | Component lifecycle | Global/project |
    | Location | In skill/agent/command | settings.json |
    | Persistence | Active only when component runs | Always active |
    | Use case | Component-specific validation | Cross-cutting concerns |
    
    ### PreToolUse updatedInput (2.1.0 Fix)
    
    PreToolUse hooks can now return `updatedInput` when returning `ask` permission decision, enabling hooks to act as middleware while still requesting user consent:
    
    ```json
    {
      "decision": "ask",
      "updatedInput": {
        "command": "modified-command --safe-flag"
      }
    }
    ```
    
    ## Claude Code vs SDK
    
    ### JSON Hooks (Claude Code)
    
    **Declarative configuration** in `.claude/settings.json`, project `.claude/settings.json`, or plugin `hooks/hooks.json`:
    
    ```json
    {
      "PreToolUse": [
        {
          "matcher": "Edit",
          "hooks": [{
            "type": "command",
            "command": "echo 'WARNING: Editing production file' >&2"
          }]
        }
      ]
    }
    ```
    
    **Important**: Use string matchers (regex patterns), not object matchers. The object format `{"toolName": "Edit"}` is deprecated.
    
    **Matcher patterns**:
    - `"Edit"` - Match single tool
    - `"Read|Write|Edit"` - Match multiple tools (regex OR)
    - `".*"` - Match all tools
    
    **Verification:** Run the command with `--help` flag to verify availability.
    
    **Pros:** Simple, no code required, easy to version control
    **Cons:** Limited logic capabilities, shell command only
    
    ### HTTP Hooks (Claude Code 2.1.63+)
    
    **New in 2.1.63:** Hooks can POST JSON to a URL and receive JSON responses instead of running shell commands. Use `"type": "http"` with a `"url"` field:
    
    ```json
    {
      "PreToolUse": [
        {
          "matcher": "Bash",
          "hooks": [{
            "type": "http",
            "url": "https://my-service.example.com/hooks/validate-bash"
          }]
        }
      ]
    }
    ```
    
    The hook POSTs the standard hook input as JSON and expects a standard hook response JSON body.
    
    **When to use HTTP hooks over command hooks:**
    - Enterprise environments where shell execution is restricted
    - Centralized hook logic shared across teams via a web service
    - Sandboxed or containerized setups without local script access
    - Integration with external validation/logging services
    
    **Pros:** No local scripts needed, centralized logic, works in sandboxed environments
    **Cons:** Network latency, requires running HTTP service, external dependency
    
    ### Python SDK Hooks
    
    **Programmatic callbacks** using `AgentHooks` base class:
    
    ```python
    from claude_agent_sdk import AgentHooks
    
    
    class MyHooks(AgentHooks):
        async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
            # Complex validation logic
            if self._is_dangerous(tool_input):
                raise ValueError("Operation blocked")
            return None  # or return modified input
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    **Pros:** Full Python capabilities, complex logic, state management
    **Cons:** Requires Python, more complex setup
    
    ## Bash Permission Matching Notes
    
    ### Environment Variable Wrappers (2.1.38+)
    
    Permission rules now correctly match commands prefixed with environment variable assignments. Before 2.1.38, `NODE_ENV=production npm test` would not match a rule for `Bash(npm *)`.
    
    ```
    # These now all match `Bash(npm *)`:
    npm test
    NODE_ENV=production npm test
    FORCE_COLOR=1 CI=true npm test
    ```
    
    When writing PreToolUse hooks that inspect bash commands, be aware that the permission system strips env var prefixes for matching, but your hook receives the full command string including prefixes.
    
    ### Heredoc Delimiter Security (2.1.38+)
    
    Claude Code now validates heredoc delimiters to prevent command smuggling. The recommended pattern `<<'EOF'` (single-quoted) remains the safest approach. Always use single-quoted delimiters in heredoc patterns to prevent variable expansion.
    
    ## Security Essentials
    
    ### Critical Security Rules
    
    1. **Input Validation**: Always validate tool inputs before processing
    2. **No Secret Logging**: Never log API keys, tokens, passwords, or credentials
    3. **Sandbox Awareness**: Respect sandbox boundaries, don't escape. Note: `.claude/skills/` is read-only in sandbox mode (2.1.38+)
    4. **Fail-Safe Defaults**: Return None on error instead of blocking the agent
    5. **Rate Limiting**: Prevent hook abuse from malicious or buggy code
    6. **Injection Prevention**: Sanitize all logged content to prevent log injection
    
    ### Example: Secure Logging Hook
    
    ```python
    import re
    from claude_agent_sdk import AgentHooks
    
    
    class SecureLoggingHooks(AgentHooks):
        # Patterns that might contain secrets
        SECRET_PATTERNS = [
            r"api[_-]?key",
            r"password",
            r"token",
            r"secret",
            r"credential",
            r"auth",
        ]
    
        def _sanitize_output(self, text: str) -> str:
            """Remove potential secrets from log output."""
            for pattern in self.SECRET_PATTERNS:
                text = re.sub(
                    rf'({pattern}["\s:=]+)([^\s,}}]+)',
                    r"\1***REDACTED***",
                    text,
                    flags=re.IGNORECASE,
                )
            return text
    
        async def on_post_tool_use(
            self, tool_name: str, tool_input: dict, tool_output: str
        ) -> str | None:
            """Log tool use with sanitization."""
            safe_output = self._sanitize_output(tool_output)
            # Log safe_output...
            return None  # Don't modify output
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    See `modules/testing-hooks.md` for detailed security guidance.
    
    ## Performance Guidelines
    
    ### Performance Best Practices
    
    1. **Non-Blocking**: Use `async`/`await` properly, don't block the event loop
    2. **Timeout Handling**: Hook timeout is 10 minutes (increased from 60s in 2.1.3). For most hooks, aim for < 30s; use extended time only for CI/CD integration, complex validation, or external API calls
    3. **Efficient Logging**: Batch writes, use async I/O
    4. **Memory Management**: Don't accumulate unbounded state
    5. **Fail Fast**: Quick validation, early returns, avoid expensive operations
    
    ### Example: Efficient Hook
    
    ```python
    import asyncio
    from claude_agent_sdk import AgentHooks
    
    
    class EfficientHooks(AgentHooks):
        def __init__(self):
            self._log_queue = asyncio.Queue()
            self._log_task = None
    
        async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
            # Quick validation only
            if not self._is_valid_input(tool_input):
                raise ValueError("Invalid input")
            return None
    
        async def on_post_tool_use(
            self, tool_name: str, tool_input: dict, tool_output: str
        ) -> str | None:
            # Queue log entry without blocking
            await self._log_queue.put({"tool": tool_name, "timestamp": time.time()})
            return None
    
        def _is_valid_input(self, tool_input: dict) -> bool:
            """Fast validation check."""
            # Simple checks only, < 10ms
            return len(str(tool_input)) < 1_000_000
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    See `modules/performance-guidelines.md` for detailed optimization techniques.
    
    ## Scope Selection
    
    Choose the right location for your hooks based on audience and purpose.
    
    ### Important: Auto-Loading Behavior
    
    > **`hooks/hooks.json` is automatically loaded** when a plugin is enabled.
    > Do NOT add `"hooks": "./hooks/hooks.json"` to `plugin.json` - this causes duplicate load errors.
    > Only use the `hooks` field for additional hook files beyond the standard location.
    
    ### Decision Framework
    
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    Is this hook part of a plugin's core functionality?
    ├─ YES → Plugin hooks (hooks/hooks.json in plugin)
    └─ NO ↓
    
    Should all team members on this project have this hook?
    ├─ YES → Project hooks (.claude/settings.json)
    └─ NO ↓
    
    Should this hook apply to all my Claude sessions?
    ├─ YES → Global hooks (~/.claude/settings.json)
    └─ NO → Reconsider if you need a hook at all
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    ### Scope Comparison
    
    | Scope | Location | Audience | Committed? | Example Use Case |
    |-------|----------|----------|------------|------------------|
    | **Plugin** | `hooks/hooks.json` | Plugin users | Yes (with plugin) | YAML validation in YAML plugin |
    | **Project** | `.claude/settings.json` | Team members | Yes (in repo) | Block production config edits |
    | **Global** | `~/.claude/settings.json` | Only you | Never | Personal audit logging |
    
    See `modules/scope-selection.md` for detailed scope decision guidance.
    
    ## Common Patterns
    
    ### Validation Hook
    
    Block dangerous operations before execution:
    
    ```python
    async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
        if tool_name == "Bash":
            command = tool_input.get("command", "")
    
            # Block dangerous patterns
            if any(pattern in command for pattern in ["rm -rf /", ":(){ :|:& };:"]):
                raise ValueError(f"Dangerous command blocked: {command}")
    
            # Block production access
            if "production" in command and not self._has_approval():
                raise ValueError("Production access requires approval")
    
        return None
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    ### Logging Hook
    
    Audit all tool operations:
    
    ```python
    async def on_post_tool_use(
        self, tool_name: str, tool_input: dict, tool_output: str
    ) -> str | None:
        await self._log_entry(
            {
                "timestamp": datetime.now().isoformat(),
                "tool": tool_name,
                "input_size": len(str(tool_input)),
                "output_size": len(tool_output),
                "success": True,
            }
        )
        return None
    ```
    **Verification:** Run the command with `--help` flag to verify availability.
    
    ### Context Injection Hook
    
    Add relevant context before user prompts:
    
    ```python
    async def on_user_prompt_submit(self, message: str) -> str | None:
        # Inject project-specific context
        context = await self._load_project_context()
        enhanced_message = f"{context}\n\n{message}"
        return enhanced_message
    ```
    
    ### PreToolUse Context Injection (Claude Code 2.1.9+)
    
    Inject context before a tool executes using `additionalContext`:
    
    ```python
    #!/usr/bin/env python3
    """PreToolUse hook that injects context before WebFetch."""
    
    import json
    import sys
    
    
    def main():
        payload = json.load(sys.stdin)
        tool_name = payload.get("tool_name", "")
    
        if tool_name == "WebFetch":
            url = payload.get("tool_input", {}).get("url", "")
            # Check cache or knowledge base
            cached = lookup_knowledge_base(url)
            if cached:
                print(
                    json.dumps(
                        {
                            "hookSpecificOutput": {
                                "hookEventName": "PreToolUse",
                                "additionalContext": f"Relevant cached info: {cached}",
                            }
                        }
                    )
                )
        sys.exit(0)
    
    
    if __name__ == "__main__":
        main()
    ```
    
    This pattern is useful for: cache hints before web requests, security warnings before risky operations, and injecting relevant project context before file operations.
    
    ## Testing Hooks
    
    ### Unit Testing
    
    ```python
    import pytest
    from my_hooks import ValidationHooks
    
    
    @pytest.mark.asyncio
    async def test_dangerous_command_blocked():
        hooks = ValidationHooks()
    
        with pytest.raises(ValueError, match="Dangerous command"):
            await hooks.on_pre_tool_use("Bash", {"command": "rm -rf /"})
    
    
    @pytest.mark.asyncio
    async def test_safe_command_allowed():
        hooks = ValidationHooks()
        result = await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
        assert result is None  # Allows execution
    ```
    **Verification:** Run `pytest -v from` to verify.
    
    See `modules/testing-hooks.md` for detailed testing strategies.
    
    ## Module References
    
    For detailed guidance on specific topics:
    
    - **Hook Types**: `modules/hook-types.md` - Detailed event signatures and parameters
    - **SDK Callbacks**: `modules/sdk-callbacks.md` - Python SDK implementation patterns
    - **Security Patterns**: `modules/testing-hooks.md` - detailed security guidance
    - **Performance Guidelines**: `modules/performance-guidelines.md` - Optimization techniques
    - **Scope Selection**: `modules/scope-selection.md` - Choosing plugin/project/global
    - **Testing Hooks**: `modules/testing-hooks.md` - Testing strategies and fixtures
    - **Observability Warnings**: `modules/observability-warnings.md` - Copy-pasteable resolution pattern for binary-actionable drift hooks
    
    ## Tools
    
    - **hook_validator.py**: Validate hook structure and syntax (at
      `plugins/abstract/scripts/hook_validator.py`)
    
    ## Related Skills
    
    - **hook-scope-guide**: Decision framework for hook placement (existing)
    - **modular-skills**: Design patterns for skill architecture
    - **skills-eval**: Quality assessment and improvement framework
    
    ## Next Steps
    
    1. Choose your hook type (JSON vs SDK) based on complexity needs
    2. Select the appropriate scope (plugin/project/global)
    3. Implement following security and performance best practices
    4. Test thoroughly with unit and integration tests
    5. Validate using `hook_validator.py` before deployment
    
    ## Environment Variables (Claude Code 2.1.2+)
    
    ### `FORCE_AUTOUPDATE_PLUGINS`
    
    Forces plugin auto-update even when the main Claude Code auto-updater is disabled.
    
    **Use cases**:
    - CI/CD pipelines that need latest plugin versions
    - Development environments testing plugin updates
    - Controlled update rollouts in enterprise settings
    
    ```bash
    # Enable forced plugin updates
    export FORCE_AUTOUPDATE_PLUGINS=1
    claude
    
    # Or inline
    FORCE_AUTOUPDATE_PLUGINS=1 claude --agent my-agent
    ```
    
    **Note**: This only affects plugin updates, not Claude Code core updates.
    
    ## References
    
    - [Claude Code Hooks Documentation](https://docs.anthropic.com/en/docs/claude-code/hooks)
    - [Claude Agent SDK Documentation](https://docs.anthropic.com/en/docs/claude-agent-sdk)
    - [Settings Configuration](https://docs.anthropic.com/en/docs/claude-code/settings)
    ## Hook Exit Codes
    
    Hooks communicate decisions to Claude Code via exit codes:
    
    | Exit Code | Meaning | stdout | stderr |
    |-----------|---------|--------|--------|
    | **0** | Success/allow | Shown to Claude as system context | Ignored |
    | **2** | Block/deny | Ignored | Shown to user as explanation (2.1.39+ fix) |
    | **Other** | Error | Ignored | Shown to user as error message |
    
    ### Blocking with Exit Code 2 (2.1.39+)
    
    Use exit code 2 to block an action and display a message to the user:
    
    ```bash
    #!/bin/bash
    # Example: Block force pushes with user-facing message
    command=$(echo "$1" | jq -r '.tool_input.command // empty')
    if echo "$command" | grep -q 'push.*--force'; then
      echo "Force push blocked: use --force-with-lease instead" >&2
      exit 2
    fi
    exit 0
    ```
    
    **Important**: Before Claude Code 2.1.39, stderr from exit code 2 was silently swallowed ([#10964](https://github.com/anthropics/claude-code/issues/10964)). Users would see a generic "hook error" instead of the custom message. This is now fixed: stderr is properly displayed to the user.
    
    **Plugin hooks**: Before 2.1.39, plugin-installed hooks had a separate code path that also failed to show stderr for exit code 2 ([#10412](https://github.com/anthropics/claude-code/issues/10412)). Both plugin and project hooks now work correctly.
    
    ## Troubleshooting
    
    ### Common Issues
    
    **Hook not firing**
    Verify hook pattern matches the event. Check hook logs for errors
    
    **Syntax errors**
    Validate JSON/Python syntax before deployment
    
    **Permission denied**
    Check hook file permissions and ownership
    
    **Hook blocking message not shown (pre-2.1.39)**
    If using exit code 2 to block with a user-facing message and the message isn't appearing, upgrade to Claude Code 2.1.39+. In older versions, use exit 0 with stdout as a workaround.
    
    ## Exit Criteria
    
    - [ ] The authored hook file exists at a valid scope location (`hooks/hooks.json`,
      `.claude/settings.json`, or `~/.claude/settings.json`) with correct JSON or Python syntax.
    - [ ] The hook fires on the target event: a test invocation of the matching tool call triggers
      the hook command or callback without error.
    - [ ] The hook contains no secret logging: no field names matching `api[_-]?key`, `password`,
      `token`, `secret`, or `credential` appear in log output paths.
    - [ ] Blocking hooks exit with code 2 and write the user-facing explanation to stderr (not stdout).
    - [ ] If `abstract:validate-hook` is available, it exits 0 on the authored hook file.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related