#!/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_EXIT).\\n\\nPlease check git status and try again." } EOF exit 0 fi if [[ "$PLAN_IS_TRACKED" != "true" ]]; then cat << EOF { "decision": "block", "reason": "Plan file is no longer tracked in git.\\n\\nFile: $PLAN_FILE\\n\\nThis RLCR loop was started with --track-plan-file, but the plan file has been removed from git tracking." } EOF exit 0 fi if [[ -n "$PLAN_GIT_STATUS" ]]; then cat << EOF { "decision": "block", "reason": "Plan file has uncommitted modifications.\\n\\nFile: $PLAN_FILE\\nStatus: $PLAN_GIT_STATUS\\n\\nThis RLCR loop was started with --track-plan-file. Plan file modifications are not allowed during the loop." } EOF exit 0 fi else # Must be gitignored (not tracked) # Check if git command succeeds - fail closed on timeout/error # 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") if [[ "$PLAN_IS_TRACKED" == "true" ]]; then cat << EOF { "decision": "block", "reason": "Plan file is now tracked in git but loop was started without --track-plan-file.\\n\\nFile: $PLAN_FILE\\n\\nThe plan file must remain gitignored during this RLCR loop." } EOF exit 0 fi fi exit 0