Skip to content
Development
Hook

Hooks

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

From plugin
humanize
1.4k6 skills4 agents5 commands4 hooks
Install
> /plugin marketplace add PolyArch/humanize
> /plugin install humanize@PolyArch

Ships with humanize. Installing the plugin gets these hooks.

What fires, and when

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/loop-plan-file-validator.sh

PreToolUse

  • MatchesWrite${CLAUDE_PLUGIN_ROOT}/hooks/loop-write-validator.sh
  • MatchesEdit${CLAUDE_PLUGIN_ROOT}/hooks/loop-edit-validator.sh
  • MatchesRead${CLAUDE_PLUGIN_ROOT}/hooks/loop-read-validator.sh
  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/loop-bash-validator.sh

PostToolUse

  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/loop-post-bash-hook.sh

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/loop-codex-stop-hook.sh
Read hooks/hooks.json

In the plugin's words

How humanize describes its own hook set.

Humanize Plugin Hooks - Validation hooks and Stop hooks for /start-rlcr-loop

Where it lives

  • hooks/check-todos-from-transcript.pyGitHub
  • hooks/loop-bash-validator.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse Hook: Validate Bash commands for RLCR loop
    #
    # Blocks attempts to bypass Write/Edit hooks using shell commands:
    # - cat/echo/printf > file.md (redirection)
    # - tee file.md
    # - sed -i file.md (in-place edit)
    # - goal-tracker.md modifications via Bash
    #
    
    set -euo pipefail
    
    # Load shared functions
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    source "$SCRIPT_DIR/lib/loop-common.sh"
    
    # ========================================
    # Parse Hook Input
    # ========================================
    
    HOOK_INPUT=$(cat)
    
    # Validate JSON input structure
    if ! validate_hook_input "$HOOK_INPUT"; then
        exit 1
    fi
    
    # Check for deeply nested JSON (potential DoS)
    if is_deeply_nested "$HOOK_INPUT" 30; then
        exit 1
    fi
    
    TOOL_NAME="$VALIDATED_TOOL_NAME"
    
    if [[ "$TOOL_NAME" != "Bash" ]]; then
        exit 0
    fi
    
    # Require command for Bash tool
    if ! require_tool_input_field "$HOOK_INPUT" "command"; then
        exit 1
    fi
    
    COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // ""')
    COMMAND_LOWER=$(to_lower "$COMMAND")
    
    # ========================================
    # Find Active Loops (needed for multiple checks)
    # ========================================
    
    PROJECT_ROOT="$(resolve_project_root)" || exit 0
    
    # Extract session_id from hook input for session-aware loop filtering
    HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT")
    
    # Check for active RLCR loop (filtered by session_id)
    LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
    ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")
    
    # ========================================
    # Methodology Analysis Phase Bash Restriction
    # ========================================
    # During methodology analysis, block file-modifying bash commands.
    # Only read-only operations and cancel-rlcr-loop.sh are allowed.
    # This prevents source code modifications after Codex has signed off.
    #
    # Accepted limitations:
    # - Read-only bash commands (cat, grep, find, etc.) are NOT blocked. Blocking
    #   them would break basic Claude operations. The analysis prompt directs Claude
    #   to derive user-facing content only from methodology-analysis-report.md.
    # - Spawned agents (different session_id) are not restricted by hooks; their
    #   sanitization is enforced by the analysis prompt. This is an inherent
    #   limitation of the hook architecture which cannot distinguish spawned agents
    #   from unrelated sessions.
    #
    # Use only the session-matched loop. Do NOT fall back to an unfiltered search,
    # as that would incorrectly restrict unrelated sessions opened in the same repo.
    _MA_BASH_DIR="$ACTIVE_LOOP_DIR"
    
    if [[ -n "$_MA_BASH_DIR" ]] && [[ -f "$_MA_BASH_DIR/methodology-analysis-state.md" ]]; then
        # Allow cancel-rlcr-loop.sh only as the leading command (not as an argument
        # to another command like cp/mv). The optional path prefix must be a single
        # token with no embedded whitespace, otherwise commands like
        # `bash cancel-rlcr-loop.sh` or `tee cancel-rlcr-loop.sh` would match.
        # The script name must be followed by whitespace or end-of-line so trailing
        # tokens cannot hide additional arguments.
        #
        # Also reject any shell metacharacter that can inject or redirect work
        # after the cancel invocation: pipes/sequence/background operators,
        # command substitution ($(...) or backticks), redirection (<, >), and
        # multi-line payloads. The earlier narrower check only rejected ; | &,
        # letting payloads like `cancel-rlcr-loop.sh $(touch /tmp/pwn)` or a
        # newline-delimited second command slip past this early exit and reach
        # arbitrary file modifications before the downstream blockers run.
        _ma_has_shell_meta=false
        case "$COMMAND_LOWER" in
            *';'*|*'|'*|*'&'*|*'`'*|*'>'*|*'<'*|*'$('*|*$'\n'*)
                _ma_has_shell_meta=true
                ;;
        esac
        if [[ "$_ma_has_shell_meta" != "true" ]] && \
           echo "$COMMAND_LOWER" | grep -qE '^[[:space:]]*"?([^[:space:]"]+/)?cancel-rlcr-loop\.sh"?([[:space:]]|$)'; then
            exit 0
        fi
        # Block git commands that modify the working tree
        if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])git[[:space:]]+(commit|add|reset|checkout|merge|rebase|cherry-pick|am|apply|stash|push|restore|clean|rm|mv|switch|pull|clone|submodule|worktree)'; then
            echo "# Bash Blocked During Methodology Analysis
    
    Git write commands are not allowed during the methodology analysis phase." >&2
            exit 2
        fi
        # Block file manipulation commands (touch, mv, cp, rm, mkdir, ln, patch, etc.)
        if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(tee|install|touch|mv|cp|rm|dd|truncate|chmod|chown|mkdir|rmdir|ln|mktemp|patch)[[:space:]]'; then
            echo "# Bash Blocked During Methodology Analysis
    
    File modification commands are not allowed during the methodology analysis phase." >&2
            exit 2
        fi
        # Block in-place file editing tools
        if echo "$COMMAND_LOWER" | grep -qE 'sed[[:space:]]+-i|awk[[:space:]]+-i[[:space:]]+inplace|perl[[:space:]]+-[^[:space:]]*i'; then
            echo "# Bash Blocked During Methodology Analysis
    
    In-place file editing is not allowed during the methodology analysis phase." >&2
            exit 2
        fi
        # Block common interpreters that could write files (defense-in-depth)
        if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(python[23]?|ruby|node|perl|php)[[:space:]]'; then
            echo "# Bash Blocked During Methodology Analysis
    
    Running interpreters is not allowed during the methodology analysis phase." >&2
            exit 2
        fi
        # Block shell script entry points (bash script.sh, sh script.sh, source, .)
        if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(/usr/bin/env[[:space:]]+)?(bash|sh|zsh|/bin/bash|/bin/sh|/bin/zsh)[[:space:]]'; then
            echo "# Bash Blocked During Methodology Analysis
    
    Running shell scripts is not allowed during the methodology analysis phase." >&2
            exit 2
        fi
        # Block build tools that execute arbitrary commands
        if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space
  • hooks/loop-codex-stop-hook.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # Stop Hook for RLCR loop
    #
    # Intercepts Claude's exit attempts and uses Codex to review work.
    # If Codex doesn't confirm completion, blocks exit and feeds review back.
    #
    # State directory: .humanize/rlcr/<timestamp>/
    # State file: state.md (current_round, max_iterations, codex config)
    # Summary file: round-N-summary.md (Claude's work summary)
    # Review prompt: round-N-review-prompt.md (prompt sent to Codex)
    # Review result: round-N-review-result.md (Codex's review)
    #
    
    set -euo pipefail
    
    # ========================================
    # Default Configuration
    # ========================================
    
    # DEFAULT_CODEX_MODEL and DEFAULT_CODEX_EFFORT are provided by loop-common.sh (sourced below)
    DEFAULT_CODEX_TIMEOUT=5400
    
    # ========================================
    # Read Hook Input
    # ========================================
    
    HOOK_INPUT=$(cat)
    
    # NOTE: We intentionally do NOT check stop_hook_active here.
    # For iterative loops, stop_hook_active will be true when Claude is continuing
    # from a previous blocked stop. We WANT to run Codex review each iteration.
    # Loop termination is controlled by:
    # - No active loop directory (no state.md) -> exit early below
    # - Codex outputs MARKER_COMPLETE -> allow exit
    # - current_round >= max_iterations -> allow exit
    
    # ========================================
    # Find Active Loop
    # ========================================
    
    # Source shared loop functions and template loader
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    source "$SCRIPT_DIR/lib/loop-common.sh"
    
    PROJECT_ROOT="$(resolve_project_root)" || exit 0
    LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
    
    # Source portable timeout wrapper for git operations
    PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
    source "$PLUGIN_ROOT/scripts/portable-timeout.sh"
    
    # Source methodology analysis library
    source "$SCRIPT_DIR/lib/methodology-analysis.sh"
    
    # Default timeout for git operations (30 seconds)
    GIT_TIMEOUT=30
    
    # Template directory is set by loop-common.sh via template-loader.sh
    
    # Extract session_id from hook input for session-aware loop filtering
    HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT")
    
    LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID" true)
    
    # If no active loop (or session_id mismatch), allow exit
    if [[ -z "$LOOP_DIR" ]]; then
        exit 0
    fi
    
    # ========================================
    # Background-Task Guards
    # ========================================
    # Delegates to handle_bg_task_short_circuit (hooks/lib/loop-bg-tasks.sh),
    # which runs four cohesive guards in order:
    #   1. Ambiguous-caller marker guard (no session_id + marker present)
    #   2. Cross-session parked-loop guard (foreign session walking in)
    #   3. Pending-bg short-circuit (this session has async work in flight)
    #   4. Same-session stale-marker cleanup (bg work just finished)
    # When any guard short-circuits, it emits the appropriate JSON on stdout
    # and `exit 0`s directly; we never return from that call. When no guard
    # fires we continue into the normal gate logic below.
    handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
    
    # ========================================
    # Detect Loop Phase: Normal or Finalize
    # ========================================
    # Normal loop: state.md exists
    # Finalize Phase: finalize-state.md exists (after Codex COMPLETE, before final completion)
    
    STATE_FILE=$(resolve_active_state_file "$LOOP_DIR")
    if [[ -z "$STATE_FILE" ]]; then
        # No state file found, allow exit
        exit 0
    fi
    
    IS_FINALIZE_PHASE=false
    [[ "$STATE_FILE" == *"/finalize-state.md" ]] && IS_FINALIZE_PHASE=true
    
    IS_METHODOLOGY_ANALYSIS_PHASE=false
    [[ "$STATE_FILE" == *"/methodology-analysis-state.md" ]] && IS_METHODOLOGY_ANALYSIS_PHASE=true
    
    # ========================================
    # Parse State File (using shared function)
    # ========================================
    
    # First extract raw frontmatter to check which fields are actually present
    # This prevents silently using defaults for missing critical fields
    RAW_FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "")
    
    # Check if critical fields are present before parsing (which applies defaults)
    RAW_CURRENT_ROUND=$(echo "$RAW_FRONTMATTER" | grep "^current_round:" || true)
    RAW_MAX_ITERATIONS=$(echo "$RAW_FRONTMATTER" | grep "^max_iterations:" || true)
    RAW_FULL_REVIEW_ROUND=$(echo "$RAW_FRONTMATTER" | grep "^full_review_round:" || true)
    RAW_BITLESSON_REQUIRED=$(echo "$RAW_FRONTMATTER" | grep "^bitlesson_required:" || true)
    RAW_BITLESSON_FILE=$(echo "$RAW_FRONTMATTER" | grep "^bitlesson_file:" || true)
    RAW_BITLESSON_ALLOW_EMPTY_NONE=$(echo "$RAW_FRONTMATTER" | grep "^bitlesson_allow_empty_none:" || true)
    
    # Use tolerant parsing to extract values
    # Note: parse_state_file applies defaults for missing current_round/max_iterations
    if ! parse_state_file "$STATE_FILE" 2>/dev/null; then
        echo "Warning: parse_state_file returned non-zero, proceeding to schema validation" >&2
    fi
    
    # Map STATE_* variables to local names for backward compatibility
    PLAN_TRACKED="$STATE_PLAN_TRACKED"
    START_BRANCH="$STATE_START_BRANCH"
    BASE_BRANCH="${STATE_BASE_BRANCH:-}"
    BASE_COMMIT="${STATE_BASE_COMMIT:-}"
    PLAN_FILE="$STATE_PLAN_FILE"
    CURRENT_ROUND="$STATE_CURRENT_ROUND"
    MAX_ITERATIONS="$STATE_MAX_ITERATIONS"
    PUSH_EVERY_ROUND="$STATE_PUSH_EVERY_ROUND"
    FULL_REVIEW_ROUND="${STATE_FULL_REVIEW_ROUND:-5}"
    REVIEW_STARTED="$STATE_REVIEW_STARTED"
    CODEX_EXEC_MODEL="${STATE_CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
    CODEX_EXEC_EFFORT="${STATE_CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
    CODEX_REVIEW_MODEL="$CODEX_EXEC_MODEL"
    CODEX_REVIEW_EFFORT="high"
    CODEX_TIMEOUT="${STATE_CODEX_TIMEOUT:-${CODEX_TIMEOUT:-$DEFAULT_CODEX_TIMEOUT}}"
    ASK_CODEX_QUESTION="${STATE_ASK_CODEX_QUESTION:-false}"
    AGENT_TEAMS="${STATE_AGENT_TEAMS:-false}"
    PRIVACY_MODE="${STATE_PRIVACY_MODE:-true}"
    BITLESSON_REQUIRED="false"
    if [[ -n "$RAW_BITLESSON_REQUIRED" ]]; then
        BITLESSON_REQUIRED=$(echo "$RAW_BITLESSON_REQUIRED" | sed 's/^bitlesson_required:[[:space:]]*//' | tr -d ' "')
    fi
    BITLES
  • hooks/loop-edit-validator.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse Hook: Validate Edit paths for RLCR loop
    #
    # Blocks Claude from editing:
    # - Todos files (should use native Task tools instead)
    # - Prompt files (read-only, generated by Codex)
    # - State files (managed by hooks, not Claude)
    # - Wrong round number contract files
    # - Goal tracker edits outside the active loop or that alter the immutable section
    #
    
    set -euo pipefail
    
    # Load shared functions
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    source "$SCRIPT_DIR/lib/loop-common.sh"
    
    # ========================================
    # Parse Hook Input
    # ========================================
    
    HOOK_INPUT=$(cat)
    TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // ""')
    
    if [[ "$TOOL_NAME" != "Edit" ]]; then
        exit 0
    fi
    
    FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // ""')
    FILE_PATH_LOWER=$(to_lower "$FILE_PATH")
    
    # Extract session_id from hook input for session-aware loop filtering
    HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT")
    
    # ========================================
    # Block Todos and Prompt Files
    # ========================================
    
    if is_round_file_type "$FILE_PATH_LOWER" "todos"; then
        PROJECT_ROOT="$(resolve_project_root)" || exit 0
        LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
        LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")
        if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then
            todos_blocked_message "Edit" >&2
            exit 2
        fi
    fi
    
    if is_round_file_type "$FILE_PATH_LOWER" "prompt"; then
        prompt_write_blocked_message >&2
        exit 2
    fi
    
    # ========================================
    # Methodology Analysis Phase Edit Restriction
    # ========================================
    # During methodology analysis, only methodology artifacts can be edited.
    # This prevents source code modifications after Codex has signed off.
    # This check MUST come before the humanize loop dir early exit below.
    
    PROJECT_ROOT="${PROJECT_ROOT:-$(resolve_project_root 2>/dev/null || true)}"
    [[ -z "$PROJECT_ROOT" ]] && exit 0
    LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}"
    # Use only the session-matched loop. Do NOT fall back to an unfiltered search,
    # as that would incorrectly restrict unrelated sessions opened in the same repo.
    # Limitation: Spawned agents (different session_id) are not restricted by hooks;
    # their sanitization is enforced by the analysis prompt.
    _MA_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")}"
    
    if [[ -n "$_MA_LOOP_DIR" ]] && [[ -f "$_MA_LOOP_DIR/methodology-analysis-state.md" ]]; then
        # If realpath fails (file doesn't exist yet on BSD/macOS), resolve parent dir
        _ma_real_path=$(realpath "$FILE_PATH" 2>/dev/null || echo "")
        if [[ -z "$_ma_real_path" ]]; then
            _ma_parent=$(realpath "$(dirname "$FILE_PATH")" 2>/dev/null || echo "")
            [[ -n "$_ma_parent" ]] && _ma_real_path="$_ma_parent/$(basename "$FILE_PATH")"
        fi
        _ma_real_loop=$(realpath "$_MA_LOOP_DIR" 2>/dev/null || echo "")
        # Fallback to raw paths when realpath is unavailable (older macOS/BSD)
        # Ensure paths are absolute and reject ".." to prevent traversal bypasses.
        if [[ -z "$_ma_real_path" ]]; then
            if [[ "$FILE_PATH" == *".."* ]]; then
                echo "# Edit Blocked During Methodology Analysis
    
    Path contains traversal segments that cannot be resolved without realpath." >&2
                exit 2
            fi
            # Fail closed if the leaf is a symlink we cannot resolve; the raw
            # path would satisfy the loop-dir prefix check while pointing at a
            # target outside the loop, letting the basename allowlist approve
            # edits to arbitrary files during methodology-analysis mode.
            if [[ -L "$FILE_PATH" ]]; then
                echo "# Edit Blocked During Methodology Analysis
    
    Path is a symlink that cannot be resolved without realpath." >&2
                exit 2
            fi
            if [[ "$FILE_PATH" == /* ]]; then
                _ma_real_path="$FILE_PATH"
            else
                _ma_real_path="$PROJECT_ROOT/$FILE_PATH"
            fi
        fi
        if [[ -z "$_ma_real_loop" ]]; then
            if [[ "$_MA_LOOP_DIR" == /* ]]; then
                _ma_real_loop="$_MA_LOOP_DIR"
            else
                _ma_real_loop="$PROJECT_ROOT/$_MA_LOOP_DIR"
            fi
        fi
        if [[ "$_ma_real_path" == "$_ma_real_loop/"* ]]; then
            _ma_basename=$(basename "$_ma_real_path")
            case "$_ma_basename" in
                methodology-analysis-report.md|methodology-analysis-done.md)
                    exit 0
                    ;;
            esac
        fi
        echo "# Edit Blocked During Methodology Analysis
    
    During the methodology analysis phase, only methodology artifacts can be edited.
    Allowed: methodology-analysis-report.md, methodology-analysis-done.md" >&2
        exit 2
    fi
    
    # ========================================
    # Check if File is in .humanize/rlcr
    # ========================================
    
    if ! is_in_humanize_loop_dir "$FILE_PATH"; then
        exit 0
    fi
    
    # ========================================
    # Find Active Loop and Current Round
    # ========================================
    
    PROJECT_ROOT="${PROJECT_ROOT:-$(resolve_project_root 2>/dev/null || true)}"
    [[ -z "$PROJECT_ROOT" ]] && exit 0
    LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}"
    ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")}"
    
    if [[ -z "$ACTIVE_LOOP_DIR" ]]; then
        exit 0
    fi
    
    # Detect if we're in Finalize Phase (finalize-state.md exists)
    STATE_FILE_TO_PARSE=$(resolve_active_state_file "$ACTIVE_LOOP_DIR")
    IS_FINALIZE_PHASE=false
    if [[ "$STATE_FILE_TO_PARSE" == *"/finalize-state.md" ]]; then
        IS_FINALIZE_PHASE=true
    fi
    
    # Parse state file using strict validation (fail closed on malformed state)
    if ! parse_state_file_strict "$STATE_FILE_TO_PARSE" 2>/dev/null; then
        echo "Error: Malformed state file, blocking operation for safety" >&2
        exit 1
    fi
    CURRENT_ROUND="$STATE_CURRENT_ROUND"
    
    # ========================================
    # Blo
  • hooks/loop-plan-file-validator.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # UserPromptSubmit hook for plan file validation during RLCR loop
    #
    # Validates:
    # - State schema version (plan_tracked, start_branch fields required)
    # - Branch consistency (no switching during loop)
    # - Plan file tracking status consistency
    #
    
    set -euo pipefail
    
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    
    # Source shared loop functions and template loader
    source "$SCRIPT_DIR/lib/loop-common.sh"
    
    PROJECT_ROOT="$(resolve_project_root)" || exit 0
    
    # Source portable timeout wrapper for git operations
    PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
    source "$PLUGIN_ROOT/scripts/portable-timeout.sh"
    
    # Default timeout for git operations (30 seconds)
    GIT_TIMEOUT=30
    
    # Read hook input (required for UserPromptSubmit hooks)
    INPUT=$(cat)
    
    # Extract session_id from hook input for session-aware loop filtering
    HOOK_SESSION_ID=$(extract_session_id "$INPUT")
    
    # Find active loop using shared function (filtered by session_id)
    LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
    LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")
    
    # If no active loop, allow exit
    if [[ -z "$LOOP_DIR" ]]; then
        exit 0
    fi
    
    # Detect if we're in Finalize Phase (finalize-state.md exists)
    STATE_FILE=$(resolve_active_state_file "$LOOP_DIR")
    
    # Parse state file using strict validation (fail closed on malformed state)
    if ! parse_state_file_strict "$STATE_FILE" 2>/dev/null; then
        echo "Error: Malformed state file, blocking operation for safety" >&2
        exit 1
    fi
    
    # Map STATE_* variables to local names for backward compatibility
    PLAN_TRACKED="$STATE_PLAN_TRACKED"
    PLAN_FILE="$STATE_PLAN_FILE"
    START_BRANCH="$STATE_START_BRANCH"
    
    # ========================================
    # Schema Validation (v1.1.2+ required fields)
    # ========================================
    
    # Helper function to output schema validation error
    schema_validation_error() {
        local field_name="$1"
        local fallback="RLCR loop state file is missing required field: \`${field_name}\`\n\nThis indicates the loop was started with an older version of humanize.\n\n**Options:**\n1. Cancel the loop: \`/humanize:cancel-rlcr-loop\`\n2. Update humanize plugin to version 1.1.2+\n3. Restart the RLCR loop with the updated plugin"
    
        local reason
        reason=$(load_and_render_safe "$TEMPLATE_DIR" "block/schema-outdated.md" "$fallback" "FIELD_NAME=$field_name")
    
        # Escape newlines for JSON
        local escaped_reason
        escaped_reason=$(echo "$reason" | jq -Rs '.')
    
        cat << EOF
    {
      "decision": "block",
      "reason": $escaped_reason
    }
    EOF
    }
    
    # Check required fields (using FIELD_* constants from loop-common.sh)
    REQUIRED_FIELDS=("${FIELD_PLAN_TRACKED}:$PLAN_TRACKED" "${FIELD_START_BRANCH}:$START_BRANCH")
    for field_entry in "${REQUIRED_FIELDS[@]}"; do
        field_name="${field_entry%%:*}"
        field_value="${field_entry#*:}"
    
        if [[ -z "$field_value" ]]; then
            schema_validation_error "$field_name"
            exit 0
        fi
    done
    
    # ========================================
    # Branch Consistency Check
    # ========================================
    
    # Use || GIT_EXIT_CODE=$? to prevent set -e from aborting on non-zero exit
    CURRENT_BRANCH=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null) || GIT_EXIT_CODE=$?
    GIT_EXIT_CODE=${GIT_EXIT_CODE:-0}
    if [[ $GIT_EXIT_CODE -ne 0 || -z "$CURRENT_BRANCH" ]]; then
        cat << EOF
    {
      "decision": "block",
      "reason": "Git operation failed or timed out.\\n\\nCannot verify branch consistency. Please check git status and try again."
    }
    EOF
        exit 0
    fi
    if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then
        cat << EOF
    {
      "decision": "block",
      "reason": "Git branch has changed during RLCR loop.\\n\\nStarted on: $START_BRANCH\\nCurrent: $CURRENT_BRANCH\\n\\nBranch switching is not allowed during an active RLCR loop. Please switch back to the original branch or cancel the loop with /humanize:cancel-rlcr-loop"
    }
    EOF
        exit 0
    fi
    
    # ========================================
    # Plan File Tracking Status Check
    # ========================================
    
    FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE"
    
    if [[ "$PLAN_TRACKED" == "true" ]]; then
        # Must be tracked and clean
        # Use || LS_FILES_EXIT=$? to prevent set -e from aborting on non-zero exit
        # ls-files --error-unmatch returns: 0 (tracked), 1 (not tracked), 124 (timeout), other (error)
        run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$?
        LS_FILES_EXIT=${LS_FILES_EXIT:-0}
        if [[ $LS_FILES_EXIT -eq 124 ]]; then
            # Timeout - fail closed
            cat << EOF
    {
      "decision": "block",
      "reason": "Git operation timed out while checking plan file tracking status.\\n\\nPlease check git status and try again."
    }
    EOF
            exit 0
        elif [[ $LS_FILES_EXIT -ne 0 && $LS_FILES_EXIT -ne 1 ]]; then
            # Unexpected git error - fail closed
            cat << EOF
    {
      "decision": "block",
      "reason": "Git operation failed while checking plan file tracking status (exit code: $LS_FILES_EXIT).\\n\\nPlease check git status and try again."
    }
    EOF
            exit 0
        fi
        PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false")
    
        # Use || STATUS_EXIT=$? to prevent set -e from aborting on non-zero exit
        # git status --porcelain returns: 0 (success), 124 (timeout), other (error)
        PLAN_GIT_STATUS=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null) || STATUS_EXIT=$?
        STATUS_EXIT=${STATUS_EXIT:-0}
        if [[ $STATUS_EXIT -eq 124 ]]; then
            # Timeout - fail closed
            cat << EOF
    {
      "decision": "block",
      "reason": "Git operation timed out while checking plan file status.\\n\\nPlease check git status and try again."
    }
    EOF
            exit 0
        elif [[ $STATUS_EXIT -ne 0 ]]; then
            # Unexpected git error - fail closed
            cat << EOF
    {
      "decision": "block",
      "reason": "Git operation failed while checking plan file status (exit code: $STATUS_E
  • hooks/loop-post-bash-hook.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PostToolUse Bash Hook for RLCR loop
    #
    # Records the Claude Code session_id into state.md immediately after setup.
    # This hook fires right after the setup script's Bash command completes.
    #
    # Mechanism:
    # 1. Setup script creates .humanize/.pending-session-id with:
    #    Line 1: path to state.md
    #    Line 2: full resolved path of setup script (command signature)
    # 2. This hook checks for the signal file on every Bash PostToolUse event
    # 3. Boundary-aware match: verifies the Bash command is a valid invocation
    #    of the setup script path (path followed by end-of-string or whitespace),
    #    preventing false positives from substrings and concatenated forms
    # 4. Extracts session_id from hook JSON input
    # 5. Patches state.md with the session_id value using safe awk replacement
    # 6. Removes the signal file (one-shot mechanism)
    #
    # This ensures session_id is recorded BEFORE any team members can be created,
    # so only the team leader (main session) is affected by RLCR loop hooks.
    #
    
    set -euo pipefail
    
    # Read hook JSON input from stdin
    HOOK_INPUT=$(cat)
    
    # Determine project root using the shared deterministic resolver.
    # If neither CLAUDE_PROJECT_DIR nor a git toplevel is available, there
    # is no active loop to patch - exit cleanly (pwd is NOT used as a
    # fallback because it drifts with `cd` during a session).
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    source "$SCRIPT_DIR/lib/project-root.sh"
    PROJECT_ROOT="$(resolve_project_root)" || exit 0
    
    # Check for pending session_id signal file
    SIGNAL_FILE="$PROJECT_ROOT/.humanize/.pending-session-id"
    
    if [[ ! -f "$SIGNAL_FILE" ]]; then
        # No pending session_id to record - this is the normal case
        exit 0
    fi
    
    # Read the signal file contents
    # Line 1: state file path
    # Line 2: full resolved path of setup script (command signature)
    STATE_FILE_PATH=""
    COMMAND_SIGNATURE=""
    {
        read -r STATE_FILE_PATH || true
        read -r COMMAND_SIGNATURE || true
    } < "$SIGNAL_FILE"
    
    if [[ -z "$STATE_FILE_PATH" ]] || [[ ! -f "$STATE_FILE_PATH" ]]; then
        # Signal file is empty or points to non-existent state file - clean up
        rm -f "$SIGNAL_FILE"
        exit 0
    fi
    
    # Verify the Bash command is a real setup script invocation (not arbitrary text)
    # The command signature is the full resolved path of setup-rlcr-loop.sh.
    # We require the command to START with this path (quoted or unquoted),
    # preventing false positives like 'echo setup-rlcr-loop.sh' from consuming the signal.
    if [[ -n "$COMMAND_SIGNATURE" ]]; then
        HOOK_COMMAND=""
        if command -v jq >/dev/null 2>&1; then
            HOOK_COMMAND=$(printf '%s' "$HOOK_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")
        fi
    
        if [[ -z "$HOOK_COMMAND" ]]; then
            exit 0
        fi
    
        # Normalize consecutive slashes (e.g. "PolyArch//scripts" -> "PolyArch/scripts").
        # CLAUDE_PLUGIN_ROOT may have a trailing slash, producing double slashes when
        # concatenated with "/scripts/..." in the command template. The setup script
        # normalizes its own path via cd+pwd (removing double slashes), but the
        # tool_input.command preserves the original string. Without normalization,
        # the string comparison below always fails and session_id is never written.
        # See: https://github.com/PolyArch/humanize/issues/67
        HOOK_COMMAND=$(printf '%s' "$HOOK_COMMAND" | tr -s '/')
        COMMAND_SIGNATURE=$(printf '%s' "$COMMAND_SIGNATURE" | tr -s '/')
    
        # Boundary-aware match: command must be a valid setup invocation form.
        # Requires the script path to be followed by end-of-string or any POSIX
        # whitespace ([[:space:]]), preventing concatenated forms.
        # Accepts: "/full/path/setup-rlcr-loop.sh" args  (quoted, space-delimited)
        #          "/full/path/setup-rlcr-loop.sh"\targs  (quoted, tab-delimited)
        #          "/full/path/setup-rlcr-loop.sh"        (quoted, no args)
        #          /full/path/setup-rlcr-loop.sh args     (unquoted, space-delimited)
        #          /full/path/setup-rlcr-loop.sh\targs    (unquoted, tab-delimited)
        #          /full/path/setup-rlcr-loop.sh           (unquoted, no args)
        # Rejects: "/full/path/setup-rlcr-loop.sh"foo     (no boundary after quote)
        #          echo /full/path/setup-rlcr-loop.sh      (does not start with path)
        IS_SETUP="false"
        if [[ "$HOOK_COMMAND" == "\"${COMMAND_SIGNATURE}\"" ]] || [[ "$HOOK_COMMAND" == "\"${COMMAND_SIGNATURE}\""[[:space:]]* ]]; then
            IS_SETUP="true"
        elif [[ "$HOOK_COMMAND" == "${COMMAND_SIGNATURE}" ]] || [[ "$HOOK_COMMAND" == "${COMMAND_SIGNATURE}"[[:space:]]* ]]; then
            IS_SETUP="true"
        fi
    
        if [[ "$IS_SETUP" != "true" ]]; then
            # This Bash event is not from the setup script - do not consume signal
            exit 0
        fi
    fi
    
    # Extract session_id from the hook JSON input
    SESSION_ID=""
    if command -v jq >/dev/null 2>&1; then
        SESSION_ID=$(printf '%s' "$HOOK_INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "")
    fi
    
    if [[ -z "$SESSION_ID" ]]; then
        # No session_id available in hook input - leave signal file for next attempt
        exit 0
    fi
    
    # Patch state.md: replace empty session_id with actual value
    # Only patch if session_id is currently empty (safety check)
    CURRENT_SESSION_ID=$(grep "^session_id:" "$STATE_FILE_PATH" 2>/dev/null | sed 's/session_id: *//' || echo "")
    
    if [[ -z "$CURRENT_SESSION_ID" ]]; then
        # Use awk for safe replacement (handles special chars in SESSION_ID: /, &, etc.)
        TEMP_FILE="${STATE_FILE_PATH}.tmp.$$"
        awk -v new_id="$SESSION_ID" '{
            if ($0 ~ /^session_id:$/) {
                print "session_id: " new_id
            } else {
                print
            }
        }' "$STATE_FILE_PATH" > "$TEMP_FILE"
        mv "$TEMP_FILE" "$STATE_FILE_PATH"
    fi
    
    # Remove signal file (one-shot: session_id is now recorded)
    rm -f "$SIGNAL_FILE"
    
    exit 0
    
  • hooks/loop-read-validator.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse Hook: Validate Read access for RLCR loop files
    #
    # Blocks Claude from reading:
    # - Wrong round's prompt/summary/contract files (outdated information)
    # - Round files from wrong locations (not in .humanize/rlcr/)
    # - Round files from old session directories
    # - Todos files (should use native Task tools instead)
    # - goal-tracker.md from old RLCR sessions
    #
    
    set -euo pipefail
    
    # Load shared functions
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    source "$SCRIPT_DIR/lib/loop-common.sh"
    
    # ========================================
    # Parse Hook Input
    # ========================================
    
    HOOK_INPUT=$(cat)
    
    # Validate JSON input structure
    if ! validate_hook_input "$HOOK_INPUT"; then
        exit 1
    fi
    
    # Check for deeply nested JSON (potential DoS)
    if is_deeply_nested "$HOOK_INPUT" 30; then
        exit 1
    fi
    
    TOOL_NAME="$VALIDATED_TOOL_NAME"
    
    if [[ "$TOOL_NAME" != "Read" ]]; then
        exit 0
    fi
    
    # Require file_path for Read tool
    if ! require_tool_input_field "$HOOK_INPUT" "file_path"; then
        exit 1
    fi
    
    FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // ""')
    FILE_PATH_LOWER=$(to_lower "$FILE_PATH")
    
    # Extract session_id from hook input for session-aware loop filtering
    HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT")
    
    # ========================================
    # Block Todos Files
    # ========================================
    
    if is_round_file_type "$FILE_PATH_LOWER" "todos"; then
        PROJECT_ROOT="$(resolve_project_root)" || exit 0
        LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
        LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")
        if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then
            todos_blocked_message "Read" >&2
            exit 2
        fi
    fi
    
    # ========================================
    # Methodology Analysis Phase Read Restriction
    # ========================================
    # During methodology analysis, restrict reads of files within the loop
    # directory to only the artifacts the analysis agent needs. This prevents
    # project-specific information from leaking into the analysis report.
    # Files outside the loop directory are allowed (Claude needs system files).
    # This check MUST come before the summary/prompt early exit below,
    # otherwise non-summary/prompt files in the loop dir escape restriction.
    
    PROJECT_ROOT="${PROJECT_ROOT:-$(resolve_project_root 2>/dev/null || true)}"
    [[ -z "$PROJECT_ROOT" ]] && exit 0
    LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}"
    # Use only the session-matched loop. Do NOT fall back to an unfiltered search,
    # as that would incorrectly restrict unrelated sessions opened in the same repo.
    # Limitation: Spawned agents (different session_id) are not restricted by hooks;
    # their sanitization is enforced by the analysis prompt.
    ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")}"
    _MA_CHECK_DIR="$ACTIVE_LOOP_DIR"
    
    if [[ -n "$_MA_CHECK_DIR" ]]; then
        _MA_STATE=$(resolve_active_state_file "$_MA_CHECK_DIR")
        if [[ "$_MA_STATE" == *"/methodology-analysis-state.md" ]]; then
            # Canonicalize to prevent path traversal
            # If realpath fails (file doesn't exist yet on BSD/macOS), resolve parent dir
            _ma_real_path=$(realpath "$FILE_PATH" 2>/dev/null || echo "")
            if [[ -z "$_ma_real_path" ]]; then
                _ma_parent=$(realpath "$(dirname "$FILE_PATH")" 2>/dev/null || echo "")
                [[ -n "$_ma_parent" ]] && _ma_real_path="$_ma_parent/$(basename "$FILE_PATH")"
            fi
            _ma_real_loop=$(realpath "$_MA_CHECK_DIR" 2>/dev/null || echo "")
            # Fallback to raw paths when realpath is unavailable (older macOS/BSD)
            # Ensure paths are absolute so prefix guards cannot be bypassed.
            # Reject paths with ".." segments to prevent traversal bypasses
            # when we cannot canonicalize (fail closed).
            if [[ -z "$_ma_real_path" ]]; then
                if [[ "$FILE_PATH" == *".."* ]]; then
                    echo "# Read Blocked During Methodology Analysis
    
    Path contains traversal segments that cannot be resolved without realpath." >&2
                    exit 2
                fi
                # Fail closed if the file is a symlink we cannot resolve; the raw
                # path would skip the project-root prefix guard, allowing a symlink
                # outside the project to point back at restricted project content.
                if [[ -L "$FILE_PATH" ]]; then
                    echo "# Read Blocked During Methodology Analysis
    
    Path is a symlink that cannot be resolved without realpath." >&2
                    exit 2
                fi
                if [[ "$FILE_PATH" == /* ]]; then
                    _ma_real_path="$FILE_PATH"
                else
                    _ma_real_path="$PROJECT_ROOT/$FILE_PATH"
                fi
            fi
            if [[ -z "$_ma_real_loop" ]]; then
                if [[ "$_MA_CHECK_DIR" == /* ]]; then
                    _ma_real_loop="$_MA_CHECK_DIR"
                else
                    _ma_real_loop="$PROJECT_ROOT/$_MA_CHECK_DIR"
                fi
            fi
            if [[ "$_ma_real_path" == "$_ma_real_loop/"* ]]; then
                _ma_basename=$(basename "$_ma_real_path")
                # Allowlist: only methodology artifacts (not raw development records).
                # Raw records (round-*-summary.md, round-*-review-result.md) are
                # intentionally excluded so the originating session cannot read
                # project-specific content and must rely solely on the sanitized
                # methodology-analysis-report.md for all user-facing output.
                # The spawned Opus agent reads raw records directly (not restricted
                # by hooks due to different session_id -- see limitation comment above).
                case "$_ma_basename" in
                    methodology-analysis-report.md|methodology-analysis-done.md|methodology-analysis-state.md)
                        exit 0
                        ;;
                    *)
                        echo "# Read Blocked During Methodology Analysis
    
    Only me
  • hooks/loop-write-validator.shRunsGitHub

All 8 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withhumanize

Derived from the GAAC (GitHub-as-a-Context) project. A Claude Code plugin that provides iterative development with independent AI review. Build with confidence through continuous feedback loops.

Get the whole plugin