142 lines
5.1 KiB
Bash
Executable File
142 lines
5.1 KiB
Bash
Executable File
#!/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
|