Initial commit
This commit is contained in:
15
.claude-plugin/plugin.json
Normal file
15
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "claude-bumper-lanes",
|
||||
"description": "Enforces git diff thresholds to promote disciplined code review during AI agent sessions",
|
||||
"version": "1.2.0",
|
||||
"author": {
|
||||
"name": "Kyle Snow Schwartz",
|
||||
"email": "kyle.snowschwartz@gmail.com"
|
||||
},
|
||||
"commands": [
|
||||
"./commands"
|
||||
],
|
||||
"hooks": [
|
||||
"./hooks"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# claude-bumper-lanes
|
||||
|
||||
Enforces git diff thresholds to promote disciplined code review during AI agent sessions
|
||||
9
commands/bumper-pause.md
Normal file
9
commands/bumper-pause.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: Temporarily suspend threshold enforcement while continuing to track changes
|
||||
---
|
||||
|
||||
/claude-bumper-lanes:bumper-pause
|
||||
|
||||
Pausing enforcement... (This command is handled by the UserPromptSubmit hook)
|
||||
|
||||
Additional user arguments: $ARGUMENTS
|
||||
9
commands/bumper-reset.md
Normal file
9
commands/bumper-reset.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: Reset the diff baseline and restore threshold budget
|
||||
---
|
||||
|
||||
/claude-bumper-lanes:bumper-reset
|
||||
|
||||
Resetting baseline... (This command is handled by the UserPromptSubmit hook)
|
||||
|
||||
Additional user arguments: $ARGUMENTS
|
||||
9
commands/bumper-resume.md
Normal file
9
commands/bumper-resume.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: Re-enable threshold enforcement after a pause
|
||||
---
|
||||
|
||||
/claude-bumper-lanes:bumper-resume
|
||||
|
||||
Resuming enforcement... (This command is handled by the UserPromptSubmit hook)
|
||||
|
||||
Additional user arguments: $ARGUMENTS
|
||||
49
hooks/entrypoints/pause-baseline.sh
Executable file
49
hooks/entrypoints/pause-baseline.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# pause-baseline.sh - Pause threshold enforcement (NOT a hook)
|
||||
# Purpose: Temporarily suspend Write/Edit blocking while continuing to track changes
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
|
||||
# Read command-line argument (sessionId passed from command)
|
||||
session_id=${1:-}
|
||||
|
||||
if [[ -z "$session_id" ]]; then
|
||||
echo "Warning: No session ID provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load session state
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
echo "Warning: No active session found. Cannot pause."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if already paused
|
||||
paused=$(echo "$session_state" | jq -r '.paused // false')
|
||||
if [[ "$paused" == "true" ]]; then
|
||||
echo "Bumper lanes already paused. Use /bumper-resume to re-enable enforcement."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get current score for status message
|
||||
accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0')
|
||||
threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit')
|
||||
|
||||
# Set paused flag
|
||||
set_paused "$session_id" true
|
||||
|
||||
cat <<EOF
|
||||
Bumper lanes: Enforcement paused ($accumulated_score/$threshold_limit pts)
|
||||
|
||||
Edit/Write operations will proceed without threshold checks.
|
||||
Score tracking continues in the background.
|
||||
|
||||
Use /bumper-resume to re-enable enforcement.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
65
hooks/entrypoints/post-tool-use.sh
Executable file
65
hooks/entrypoints/post-tool-use.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# post-tool-use.sh - PostToolUse hook for auto-reset after git commit
|
||||
# Purpose: Reset bumper-lanes baseline after successful git commits
|
||||
# Hook: PostToolUse with Bash matcher
|
||||
# Trigger: After Bash tool completes successfully
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
session_id=$(echo "$input" | jq -r '.session_id')
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name')
|
||||
hook_event_name=$(echo "$input" | jq -r '.hook_event_name')
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
|
||||
# Validate hook event (defensive check)
|
||||
if [[ "$hook_event_name" != "PostToolUse" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only process Bash tool calls
|
||||
if [[ "$tool_name" != "Bash" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Detect git commit commands (various formats)
|
||||
# Pattern: git, optional flags (like -C /path), then commit subcommand
|
||||
# Matches: git commit, git -C /path commit, git --git-dir=/x commit
|
||||
# Rejects: prose like "use git to commit" (non-flag words between git and commit)
|
||||
if ! echo "$command" | grep -qE 'git\s+(-{1,2}[A-Za-z-]+([ =]("[^"]*"|\S+))?\s+)*commit\b'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if session state exists (session-only enforcement)
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
# No session state - not enforcing, no reset needed (fail-open)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Capture tree SHA from the commit that just happened
|
||||
# Use HEAD^{tree} to get the tree from the commit, not current index state
|
||||
# This ensures we capture exactly what was committed, not what's staged
|
||||
if ! current_tree=$(git rev-parse HEAD^{tree} 2>/dev/null); then
|
||||
# Failed to get commit tree - fail open (don't break git workflow)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reset baseline to current tree (committed state)
|
||||
if ! reset_baseline_after_commit "$session_id" "$current_tree" 2>/dev/null; then
|
||||
# Reset failed - fail open
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Output structured feedback for Claude Code
|
||||
# PostToolUse hooks return JSON with systemMessage to inform the agent
|
||||
jq -n '{
|
||||
systemMessage: "✓ Bumper lanes: Auto-reset after commit. Fresh budget: 400 pts."
|
||||
}'
|
||||
|
||||
exit 0
|
||||
168
hooks/entrypoints/pre-tool-use.sh
Executable file
168
hooks/entrypoints/pre-tool-use.sh
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# pre-tool-use.sh - PreToolUse hook for threshold enforcement
|
||||
# Purpose: Block file modification tools when diff threshold is exceeded
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
source "$SCRIPT_DIR/../lib/threshold-calculator.sh"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
session_id=$(echo "$input" | jq -r '.session_id')
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name')
|
||||
hook_event_name=$(echo "$input" | jq -r '.hook_event_name')
|
||||
|
||||
# Validate hook event (defensive check)
|
||||
if [[ "$hook_event_name" != "PreToolUse" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tool is a modification tool (early return optimization)
|
||||
case "$tool_name" in
|
||||
Write | Edit)
|
||||
# This is a file modification tool - proceed with threshold check
|
||||
;;
|
||||
*)
|
||||
# Not a file modification tool - allow it immediately
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Load session state
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
# No session state - fail open (allow tool)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract state
|
||||
baseline_tree=$(echo "$session_state" | jq -r '.baseline_tree')
|
||||
threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit')
|
||||
stop_triggered=$(echo "$session_state" | jq -r '.stop_triggered // false')
|
||||
previous_tree=$(echo "$session_state" | jq -r '.previous_tree // .baseline_tree')
|
||||
accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0')
|
||||
paused=$(echo "$session_state" | jq -r '.paused // false')
|
||||
|
||||
# Capture current working tree (need this for both paths)
|
||||
if ! current_tree=$(capture_tree 2>/dev/null); then
|
||||
# Failed to capture tree - fail open
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for branch switch BEFORE threshold calculation
|
||||
# This catches branch changes that Stop hook missed (e.g., switch and immediately edit)
|
||||
baseline_branch=$(echo "$session_state" | jq -r '.baseline_branch // ""')
|
||||
current_branch=$(get_current_branch)
|
||||
|
||||
if [[ -n "$baseline_branch" ]] && [[ -n "$current_branch" ]] && [[ "$baseline_branch" != "$current_branch" ]]; then
|
||||
# Branch switched - reset baseline and allow this tool (fresh start)
|
||||
reset_baseline_stale "$session_id" "$current_tree" "$current_branch"
|
||||
|
||||
# Output notification and allow tool
|
||||
jq -n \
|
||||
--arg baseline_branch "$baseline_branch" \
|
||||
--arg current_branch "$current_branch" \
|
||||
'{
|
||||
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
||||
systemMessage: "↪ Bumper lanes: Branch changed (\($baseline_branch) → \($current_branch)) — baseline auto-reset."
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Helper: Output fuel gauge JSON if approaching threshold
|
||||
output_fuel_gauge() {
|
||||
local score="$1"
|
||||
local limit="$2"
|
||||
|
||||
local message
|
||||
message=$(get_fuel_gauge_message "$score" "$limit")
|
||||
|
||||
if [[ -n "$message" ]]; then
|
||||
jq -n --arg msg "$message" '{
|
||||
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
||||
systemMessage: $msg
|
||||
}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if enforcement is paused
|
||||
# When paused, we still track changes but don't enforce thresholds
|
||||
if [[ "$paused" == "true" ]]; then
|
||||
threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score")
|
||||
new_accumulated_score=$(echo "$threshold_data" | jq -r '.accumulated_score')
|
||||
update_incremental_state "$session_id" "$current_tree" "$new_accumulated_score"
|
||||
|
||||
jq -n \
|
||||
--argjson score "$new_accumulated_score" \
|
||||
--argjson limit "$threshold_limit" \
|
||||
'{
|
||||
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
||||
systemMessage: "Bumper lanes paused (\($score)/\($limit) pts)"
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if Stop hook has been triggered
|
||||
# PreToolUse only blocks AFTER Stop hook has fired once
|
||||
if [[ "$stop_triggered" != "true" ]]; then
|
||||
# Stop hasn't triggered yet - allow tool to proceed
|
||||
# BUT update incremental state for next check
|
||||
threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score")
|
||||
new_accumulated_score=$(echo "$threshold_data" | jq -r '.accumulated_score')
|
||||
update_incremental_state "$session_id" "$current_tree" "$new_accumulated_score"
|
||||
|
||||
# Output fuel gauge if approaching threshold
|
||||
output_fuel_gauge "$new_accumulated_score" "$threshold_limit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stop has triggered - now enforce blocking on Write/Edit tools
|
||||
# Use incremental calculation
|
||||
threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score")
|
||||
weighted_score=$(echo "$threshold_data" | jq -r '.accumulated_score')
|
||||
|
||||
# Check threshold
|
||||
if [[ $weighted_score -le $threshold_limit ]]; then
|
||||
# Under threshold - allow tool and update state
|
||||
update_incremental_state "$session_id" "$current_tree" "$weighted_score"
|
||||
|
||||
# Output fuel gauge if approaching threshold
|
||||
output_fuel_gauge "$weighted_score" "$threshold_limit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Over threshold - deny tool call
|
||||
# Format breakdown for user message
|
||||
# Note: threshold_data contains both weighted_score (delta) and accumulated_score (total)
|
||||
# For user display, we need to show the accumulated total, not just this turn's delta
|
||||
threshold_data_for_display=$(echo "$threshold_data" | jq '.weighted_score = .accumulated_score')
|
||||
breakdown=$(format_threshold_breakdown "$threshold_data_for_display" "$threshold_limit")
|
||||
|
||||
# Build denial reason
|
||||
reason="
|
||||
|
||||
🚫 Bumper lanes: Diff threshold exceeded
|
||||
|
||||
$breakdown
|
||||
|
||||
Cannot modify files while over threshold.
|
||||
|
||||
Review your changes and run /bumper-reset to continue.
|
||||
|
||||
"
|
||||
|
||||
# Output denial decision using modern JSON format
|
||||
jq -n \
|
||||
--arg reason "$reason" \
|
||||
'{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: $reason
|
||||
}
|
||||
}'
|
||||
|
||||
exit 0
|
||||
86
hooks/entrypoints/reset-baseline.sh
Executable file
86
hooks/entrypoints/reset-baseline.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# reset-baseline.sh - Reset baseline script (NOT a hook)
|
||||
# Purpose: Reset baseline tree to current working tree state, update session state
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
|
||||
# Read command-line argument (sessionId passed from command)
|
||||
session_id=${1:-}
|
||||
|
||||
if [[ -z "$session_id" ]]; then
|
||||
echo "⚠ Bumper Lanes: Error - No session ID provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load session state
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
# No active session - print error message
|
||||
echo "⚠ Bumper Lanes: No active session found. Baseline reset skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
old_baseline=$(echo "$session_state" | jq -r '.baseline_tree')
|
||||
threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit')
|
||||
created_at=$(echo "$session_state" | jq -r '.created_at')
|
||||
|
||||
# Compute final diff stats (for reporting accepted changes)
|
||||
current_tree=$(capture_tree)
|
||||
if [[ -z "$current_tree" ]]; then
|
||||
echo "⚠ Bumper Lanes: Failed to reset baseline. Please try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
diff_output=$(compute_diff "$old_baseline" "$current_tree")
|
||||
|
||||
# Parse diff stats inline (git diff-tree --shortstat format)
|
||||
# Format: "N files changed, X insertions(+), Y deletions(-)"
|
||||
files_changed=0
|
||||
lines_added=0
|
||||
lines_deleted=0
|
||||
|
||||
if [[ "$diff_output" =~ ([0-9]+)\ file ]]; then
|
||||
files_changed=${BASH_REMATCH[1]}
|
||||
fi
|
||||
if [[ "$diff_output" =~ ([0-9]+)\ insertion ]]; then
|
||||
lines_added=${BASH_REMATCH[1]}
|
||||
fi
|
||||
if [[ "$diff_output" =~ ([0-9]+)\ deletion ]]; then
|
||||
lines_deleted=${BASH_REMATCH[1]}
|
||||
fi
|
||||
|
||||
total_lines=$((lines_added + lines_deleted))
|
||||
|
||||
# Update session state with new baseline and clear incremental tracking
|
||||
new_baseline="$current_tree"
|
||||
write_session_state "$session_id" "$new_baseline"
|
||||
set_stop_triggered "$session_id" false
|
||||
# Reset incremental tracking: previous_tree = baseline, accumulated_score = 0
|
||||
update_incremental_state "$session_id" "$new_baseline" 0
|
||||
|
||||
# Build confirmation message
|
||||
# Format timestamps for display
|
||||
old_timestamp=$(date -r "$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created_at" +%s)" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$created_at")
|
||||
new_timestamp=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Truncate SHAs for display
|
||||
old_baseline_short="${old_baseline:0:7}"
|
||||
new_baseline_short="${new_baseline:0:7}"
|
||||
|
||||
# Build multi-line confirmation message
|
||||
cat <<EOF
|
||||
✓ Baseline reset complete.
|
||||
|
||||
Previous baseline: $old_baseline_short (captured $old_timestamp)
|
||||
New baseline: $new_baseline_short (captured $new_timestamp)
|
||||
|
||||
Changes accepted: $files_changed files, $lines_added insertions(+), $lines_deleted deletions(-) [$total_lines lines total]
|
||||
|
||||
You now have a fresh diff budget of $threshold_limit points. Pick up where we left off?
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
66
hooks/entrypoints/resume-baseline.sh
Executable file
66
hooks/entrypoints/resume-baseline.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# resume-baseline.sh - Resume threshold enforcement (NOT a hook)
|
||||
# Purpose: Re-enable Write/Edit blocking after a pause
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
|
||||
# Read command-line argument (sessionId passed from command)
|
||||
session_id=${1:-}
|
||||
|
||||
if [[ -z "$session_id" ]]; then
|
||||
echo "Warning: No session ID provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load session state
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
echo "Warning: No active session found. Cannot resume."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if already unpaused
|
||||
paused=$(echo "$session_state" | jq -r '.paused // false')
|
||||
if [[ "$paused" != "true" ]]; then
|
||||
echo "Bumper lanes not paused. Enforcement is already active."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get current score for status message
|
||||
accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0')
|
||||
threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit')
|
||||
stop_triggered=$(echo "$session_state" | jq -r '.stop_triggered // false')
|
||||
|
||||
# Clear paused flag
|
||||
set_paused "$session_id" false
|
||||
|
||||
# Calculate percentage for status
|
||||
pct=$((accumulated_score * 100 / threshold_limit))
|
||||
|
||||
# Check if over threshold and warn
|
||||
if [[ $accumulated_score -gt $threshold_limit ]]; then
|
||||
cat <<EOF
|
||||
Warning: Bumper lanes resumed — OVER THRESHOLD ($accumulated_score/$threshold_limit pts, ${pct}%)
|
||||
|
||||
Write/Edit operations will be blocked until /bumper-reset.
|
||||
Review your changes before resetting.
|
||||
EOF
|
||||
elif [[ $pct -ge 75 ]]; then
|
||||
cat <<EOF
|
||||
Bumper lanes: Enforcement resumed ($accumulated_score/$threshold_limit pts, ${pct}%)
|
||||
|
||||
Approaching threshold. Consider committing working state soon.
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
Bumper lanes: Enforcement resumed ($accumulated_score/$threshold_limit pts)
|
||||
|
||||
Threshold checks active.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
34
hooks/entrypoints/session-end.sh
Executable file
34
hooks/entrypoints/session-end.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# session-end.sh - SessionEnd hook for checkpoint cleanup
|
||||
# Purpose: Remove session-specific checkpoint file when Claude session terminates
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
session_id=$(echo "$input" | jq -r '.session_id // ""')
|
||||
|
||||
# Validate session_id
|
||||
if [[ -z "$session_id" ]]; then
|
||||
echo "ERROR: No session_id provided to SessionEnd hook" >&2
|
||||
exit 0 # Fail open - SessionEnd can't block anyway
|
||||
fi
|
||||
|
||||
# Check if git repo (checkpoint dir won't exist otherwise)
|
||||
if ! git rev-parse --git-dir &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
checkpoint_dir=".git/bumper-checkpoints"
|
||||
state_file="$checkpoint_dir/session-$session_id"
|
||||
|
||||
# Remove this session's checkpoint file
|
||||
if [[ -f "$state_file" ]]; then
|
||||
rm -f "$state_file" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Optional: Clean stale checkpoints (files older than 30 days)
|
||||
# Uncomment the line below to enable automatic stale file cleanup:
|
||||
# find "$checkpoint_dir" -type f -name "session-*" -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
38
hooks/entrypoints/session-start.sh
Executable file
38
hooks/entrypoints/session-start.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# session-start.sh - SessionStart hook for baseline capture
|
||||
# Purpose: Capture working tree state as baseline when Claude session starts
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
session_id=$(echo "$input" | jq -r '.session_id')
|
||||
|
||||
# Hook is already executed in project directory (cwd field in JSON)
|
||||
|
||||
# Check if this is a git repository
|
||||
if ! git rev-parse --git-dir &>/dev/null; then
|
||||
# Not a git repo - disable plugin gracefully
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Capture baseline tree
|
||||
baseline_tree=$(capture_tree)
|
||||
if [[ -z "$baseline_tree" ]]; then
|
||||
echo "ERROR: Failed to capture baseline tree" >&2
|
||||
exit 0 # Fail open
|
||||
fi
|
||||
|
||||
# Capture current branch name for staleness detection
|
||||
baseline_branch=$(get_current_branch)
|
||||
|
||||
# Write session state (with branch tracking)
|
||||
write_session_state "$session_id" "$baseline_tree" "$baseline_branch"
|
||||
|
||||
# Allow session start
|
||||
exit 0
|
||||
141
hooks/entrypoints/stop.sh
Executable file
141
hooks/entrypoints/stop.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# stop.sh - Stop hook for threshold enforcement
|
||||
# Purpose: Check diff threshold when agent stops, block if exceeded
|
||||
|
||||
# Source library functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/git-state.sh"
|
||||
source "$SCRIPT_DIR/../lib/state-manager.sh"
|
||||
source "$SCRIPT_DIR/../lib/threshold-calculator.sh"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
session_id=$(echo "$input" | jq -r '.session_id')
|
||||
stop_hook_active=$(echo "$input" | jq -r '.stop_hook_active // false')
|
||||
|
||||
# Hook is already executed in project directory (cwd field in JSON)
|
||||
|
||||
# If already blocked once, allow stop this time to prevent infinite loop
|
||||
if [[ "$stop_hook_active" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Load session state
|
||||
if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then
|
||||
# No baseline - allow stop (fail open)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exit early if bumper lanes already tripped - allow review collaboration
|
||||
stop_triggered=$(echo "$session_state" | jq -r '.stop_triggered // false')
|
||||
if [[ "$stop_triggered" == "true" ]]; then
|
||||
# PreToolUse is actively blocking Write/Edit - no need to block stop
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exit early if enforcement is paused - allow work to continue
|
||||
paused=$(echo "$session_state" | jq -r '.paused // false')
|
||||
if [[ "$paused" == "true" ]]; then
|
||||
# Still need to track changes while paused
|
||||
current_tree=$(capture_tree)
|
||||
if [[ -n "$current_tree" ]]; then
|
||||
previous_tree=$(echo "$session_state" | jq -r '.previous_tree // .baseline_tree')
|
||||
accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0')
|
||||
threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score")
|
||||
weighted_score=$(echo "$threshold_data" | jq -r '.accumulated_score')
|
||||
update_incremental_state "$session_id" "$current_tree" "$weighted_score"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
baseline_tree=$(echo "$session_state" | jq -r '.baseline_tree')
|
||||
baseline_branch=$(echo "$session_state" | jq -r '.baseline_branch // ""')
|
||||
threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit')
|
||||
previous_tree=$(echo "$session_state" | jq -r '.previous_tree // .baseline_tree')
|
||||
accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0')
|
||||
|
||||
# Capture current working tree
|
||||
current_tree=$(capture_tree)
|
||||
if [[ -z "$current_tree" ]]; then
|
||||
echo "ERROR: Failed to capture current tree" >&2
|
||||
exit 0 # Fail open
|
||||
fi
|
||||
|
||||
# Detect branch switch - auto-reset baseline to keep diffs meaningful
|
||||
current_branch=$(get_current_branch)
|
||||
|
||||
if [[ -n "$baseline_branch" ]] && [[ -n "$current_branch" ]] && [[ "$baseline_branch" != "$current_branch" ]]; then
|
||||
# Branch switched - reset baseline (score to 0, stop_triggered to false, update branch)
|
||||
reset_baseline_stale "$session_id" "$current_tree" "$current_branch"
|
||||
|
||||
# Allow stop via JSON API (continue: true)
|
||||
jq -n \
|
||||
--arg baseline_branch "$baseline_branch" \
|
||||
--arg current_branch "$current_branch" \
|
||||
'{
|
||||
continue: true,
|
||||
systemMessage: "↪ Bumper lanes: Branch changed (\($baseline_branch) → \($current_branch)) — baseline auto-reset.",
|
||||
suppressOutput: false
|
||||
}'
|
||||
|
||||
# Exit after JSON response (avoid threshold check)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compute incremental threshold (previous → current + accumulated)
|
||||
threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score")
|
||||
weighted_score=$(echo "$threshold_data" | jq -r '.accumulated_score')
|
||||
|
||||
if [[ $weighted_score -le $threshold_limit ]]; then
|
||||
# Under threshold - allow stop and update incremental state
|
||||
update_incremental_state "$session_id" "$current_tree" "$weighted_score"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Over threshold - set stop_triggered flag to activate PreToolUse blocking
|
||||
set_stop_triggered "$session_id" true
|
||||
# Also update incremental state
|
||||
update_incremental_state "$session_id" "$current_tree" "$weighted_score"
|
||||
|
||||
# Format breakdown for user message
|
||||
# Note: threshold_data contains both weighted_score (delta) and accumulated_score (total)
|
||||
# For user display, we need to show the accumulated total, not just this turn's delta
|
||||
threshold_data_for_display=$(echo "$threshold_data" | jq '.weighted_score = .accumulated_score')
|
||||
breakdown=$(format_threshold_breakdown "$threshold_data_for_display" "$threshold_limit")
|
||||
|
||||
# Build reason message
|
||||
reason="
|
||||
|
||||
⚠️ Bumper lanes: Diff threshold exceeded
|
||||
|
||||
$breakdown
|
||||
|
||||
Ask the User: Would you like to conduct a structured, manual review?
|
||||
|
||||
This workflow ensures incremental code review at predictable checkpoints.
|
||||
|
||||
"
|
||||
|
||||
threshold_pct=$(awk "BEGIN {printf \"%.0f\", ($weighted_score / $threshold_limit) * 100}")
|
||||
|
||||
# Output block decision to STDOUT (JSON API pattern with exit code 0)
|
||||
jq -n \
|
||||
--arg decision "block" \
|
||||
--arg reason "$reason" \
|
||||
--argjson continue true \
|
||||
--arg systemMessage "/bumper-reset after code review." \
|
||||
--argjson threshold_data "$threshold_data" \
|
||||
--argjson threshold_limit "$threshold_limit" \
|
||||
--argjson threshold_percentage "$threshold_pct" \
|
||||
'{
|
||||
continue: $continue,
|
||||
systemMessage: $systemMessage,
|
||||
suppressOutput: true,
|
||||
decision: $decision,
|
||||
reason: $reason,
|
||||
threshold_data: ($threshold_data + {threshold_limit: $threshold_limit, threshold_percentage: $threshold_percentage})
|
||||
}'
|
||||
|
||||
exit 0
|
||||
49
hooks/entrypoints/user-prompt-submit.sh
Executable file
49
hooks/entrypoints/user-prompt-submit.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# user-prompt-submit.sh - UserPromptSubmit hook for /bumper-reset command
|
||||
# Purpose: Watch for /bumper-reset in user prompt and execute reset-baseline.sh
|
||||
|
||||
# Source library functions (for potential future use)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
prompt=$(echo "$input" | jq -r '.prompt // ""')
|
||||
session_id=$(echo "$input" | jq -r '.session_id')
|
||||
|
||||
# Helper function to output command result as JSON
|
||||
output_command_result() {
|
||||
local output="$1"
|
||||
jq -n \
|
||||
--arg output "$output" \
|
||||
'{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: $output
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
# Check if user typed /claude-bumper-lanes:bumper-reset
|
||||
if [[ "$prompt" == *"/claude-bumper-lanes:bumper-reset"* ]]; then
|
||||
reset_output=$("$SCRIPT_DIR/reset-baseline.sh" "$session_id" 2>&1)
|
||||
output_command_result "$reset_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if user typed /claude-bumper-lanes:bumper-pause
|
||||
if [[ "$prompt" == *"/claude-bumper-lanes:bumper-pause"* ]]; then
|
||||
pause_output=$("$SCRIPT_DIR/pause-baseline.sh" "$session_id" 2>&1)
|
||||
output_command_result "$pause_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if user typed /claude-bumper-lanes:bumper-resume
|
||||
if [[ "$prompt" == *"/claude-bumper-lanes:bumper-resume"* ]]; then
|
||||
resume_output=$("$SCRIPT_DIR/resume-baseline.sh" "$session_id" 2>&1)
|
||||
output_command_result "$resume_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
67
hooks/hooks.json
Normal file
67
hooks/hooks.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"description": "Bumper Lanes hook configuration for threshold enforcement",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/stop.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/user-prompt-submit.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/pre-tool-use.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/post-tool-use.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/session-end.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
105
plugin.lock.json
Normal file
105
plugin.lock.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:kylesnowschwartz/claude-bumper-lanes:bumper-lanes-plugin",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "c1966d425ec5aac617ac0e3e2f5c2ece72e19835",
|
||||
"treeHash": "6463824b80fa53ae189e55e5a8ebfee42d4242b94a593cb543596f63f06ab1de",
|
||||
"generatedAt": "2025-11-28T10:20:02.022187Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "claude-bumper-lanes",
|
||||
"description": "Enforces git diff thresholds to promote disciplined code review during AI agent sessions",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "8ecf5ff6d08f9a18306f97eb7fbab4824b548e6379fe6a6192b336f6cda32552"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks.json",
|
||||
"sha256": "73687af1261fd167c5f694e2cb364e4d5e8747aa1902eb3111adcbb8474648f8"
|
||||
},
|
||||
{
|
||||
"path": "hooks/lib/git-state.sh",
|
||||
"sha256": "faedd43659d24a6cb3df1c1039bef011ee5b9272c8acd2a9d45c319aa9a41c14"
|
||||
},
|
||||
{
|
||||
"path": "hooks/lib/state-manager.sh",
|
||||
"sha256": "a8b286251b84080fced34b54c6febd846a652b4beae20eebe55e84217704f7d5"
|
||||
},
|
||||
{
|
||||
"path": "hooks/lib/threshold-calculator.sh",
|
||||
"sha256": "6d486b76a15e5c836bda9ecc730da7e209c059605d0c94f431dac5a5734e96e2"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/user-prompt-submit.sh",
|
||||
"sha256": "0b406abc15b70b9ff251c0cef998909b8361ffb18930019f18aad0ef7f460951"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/pause-baseline.sh",
|
||||
"sha256": "cd6a160ddf4fee8654be0d3cdf169551a4742a384b1411065b85a92cec1b36c4"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/reset-baseline.sh",
|
||||
"sha256": "3f43dfa791e54952d180b9d2a10a19b8dc43f409af978d695f198f2398952131"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/pre-tool-use.sh",
|
||||
"sha256": "58bca0058f4f1012fd22ddede7f7de45ce04431f296a9ab6c0b7b9d83912fd1c"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/session-start.sh",
|
||||
"sha256": "44622c20335effe57b8d79ffbd4a0c497a0151983992be428d2293411d91a2c3"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/session-end.sh",
|
||||
"sha256": "29d8ee18e4d45d66f9b97940dc9fc677bebd9ed47d0e61193953621aee016493"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/post-tool-use.sh",
|
||||
"sha256": "801d417179369d3e18741c5721f6fdfa28e52d9b3aad28fea491ced294772c76"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/stop.sh",
|
||||
"sha256": "8f875bfb76a88d6386a1860055598881858b4bce21a951adc10321718d234550"
|
||||
},
|
||||
{
|
||||
"path": "hooks/entrypoints/resume-baseline.sh",
|
||||
"sha256": "0536ec1561672b716f9bef0d1e68ff3eb067775b7af32f3b76d31e4cf862cbaa"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "9216794e8daea5816d25aab492a9c82c8b7318d840096d32a1b74587cf185724"
|
||||
},
|
||||
{
|
||||
"path": "commands/bumper-reset.md",
|
||||
"sha256": "cbaf768a331cb313ce600d77e166185b855b9d2070f447c16bbc0615250b37bc"
|
||||
},
|
||||
{
|
||||
"path": "commands/bumper-resume.md",
|
||||
"sha256": "e55729fa98e9f1342840ef500269163465fdecce95433102c9ee1fab3508a70d"
|
||||
},
|
||||
{
|
||||
"path": "commands/bumper-pause.md",
|
||||
"sha256": "a0c810c68d5ea2e743f333fcfd5de59b03125b8ecf8140e5d3f008a72f89ff32"
|
||||
}
|
||||
],
|
||||
"dirSha256": "6463824b80fa53ae189e55e5a8ebfee42d4242b94a593cb543596f63f06ab1de"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user