#!/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