loop-post-bash-hook.sh 5.67 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#!/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