# Trace: Add Brief Summary to Notification Messages

Agent: claude-code | Model: Claude Opus 4.6 | Project: ralph

---

## User

Can we add a short summary to the notification message?

## Assistant

## Assistant

Let me look at how notifications are currently sent.

## Assistant

### Tool: Grep

**Args:**
```json
{
  "glob": "*.sh",
  "path": "/mnt/dest-btrfs/home/jc/ralph",
  "pattern": "notif|discord|send_"
}
```

## Assistant

### Tool: Glob

**Args:**
```json
{
  "path": "/mnt/dest-btrfs/home/jc/ralph",
  "pattern": "**/*.sh"
}
```

## Assistant

**Result** (success):
```
Found 22 files
ralph.sh
lib/constants.sh
notify.sh
setup-notifications.sh
update.sh
tests/test-ralph.sh
tests/test-security-fixes.sh
tests/test-security.sh
tests/test-setup.sh
tests/test-validation-lib.sh
tests/docker-test.sh
tests/run-all-tests.sh
tests/test-constants-lib.sh
tests/test-integration.sh
tests/test-monitor.sh
tests/test-notify.sh
monitor-progress.sh
install.sh
launcher.sh
configure.sh
decrypt-env.sh
detect-platform.sh
```

## Assistant

**Result** (success):
```
/mnt/dest-btrfs/home/jc/ralph/configure.sh
/mnt/dest-btrfs/home/jc/ralph/decrypt-env.sh
/mnt/dest-btrfs/home/jc/ralph/detect-platform.sh
/mnt/dest-btrfs/home/jc/ralph/install.sh
/mnt/dest-btrfs/home/jc/ralph/launcher.sh
/mnt/dest-btrfs/home/jc/ralph/lib/example-usage.sh
/mnt/dest-btrfs/home/jc/ralph/lib/platform-utils.sh
/mnt/dest-btrfs/home/jc/ralph/lib/process-mgmt.sh
/mnt/dest-btrfs/home/jc/ralph/lib/test-compat.sh
/mnt/dest-btrfs/home/jc/ralph/lib/validation.sh
/mnt/dest-btrfs/home/jc/ralph/monitor-progress.sh
/mnt/dest-btrfs/home/jc/ralph/start-monitor.sh
/mnt/dest-btrfs/home/jc/ralph/tests/docker-test.sh
/mnt/dest-btrfs/home/jc/ralph/tests/run-all-tests.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-constants-lib.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-integration.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-monitor.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-notify.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-ralph.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-security-fixes.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-security.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-setup.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-validation-lib.sh
/mnt/dest-btrfs/home/jc/ralph/tests/test-windows-compat.sh
/mnt/dest-btrfs/home/jc/ralph/uninstall.sh
/mnt/dest-btrfs/home/jc/ralph/update.sh
/mnt/dest-btrfs/home/jc/ralph/setup-notifications.sh
/mnt/dest-btrfs/home/jc/ralph/notify.sh
/mnt/dest-btrfs/home/jc/ralph/lib/constants.sh
/mnt/dest-btrfs/home/jc/ralph/ralph.sh
```

## Assistant

### Tool: Read

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/notify.sh"
}
```

## Assistant

**Result** (success):
```
     1→#!/bin/bash
     2→# notify.sh - Multi-platform notifications for Ralph
     3→# Supports: Slack, Discord, Telegram, Email, and Custom scripts
     4→#
     5→# Configuration (via environment variables):
     6→#
     7→#   SLACK:
     8→#     RALPH_SLACK_WEBHOOK_URL  - Slack incoming webhook URL
     9→#     RALPH_SLACK_CHANNEL      - Override default channel (optional)
    10→#     RALPH_SLACK_USERNAME     - Bot username (default: "Ralph")
    11→#     RALPH_SLACK_ICON_EMOJI   - Bot icon (default: ":robot_face:")
    12→#
    13→#   DISCORD:
    14→#     RALPH_DISCORD_WEBHOOK_URL - Discord webhook URL
    15→#     RALPH_DISCORD_USERNAME    - Bot username (default: "Ralph")
    16→#     RALPH_DISCORD_AVATAR_URL  - Bot avatar URL (optional)
    17→#
    18→#   TELEGRAM:
    19→#     RALPH_TELEGRAM_BOT_TOKEN - Telegram bot token (from @BotFather)
    20→#     RALPH_TELEGRAM_CHAT_ID   - Chat/group/channel ID to send to
    21→#
    22→#   EMAIL:
    23→#     RALPH_EMAIL_TO           - Recipient email address(es) (comma-separated)
    24→#     RALPH_EMAIL_FROM         - Sender email address
    25→#     RALPH_EMAIL_SUBJECT      - Email subject prefix (default: "Ralph Notification")
    26→#
    27→#     SMTP Configuration (Traditional):
    28→#     RALPH_SMTP_HOST          - SMTP server hostname
    29→#     RALPH_SMTP_PORT          - SMTP server port (default: 587)
    30→#     RALPH_SMTP_USER          - SMTP username
    31→#     RALPH_SMTP_PASSWORD      - SMTP password
    32→#     RALPH_SMTP_TLS           - Use TLS (true/false, default: true)
    33→#
    34→#     SendGrid API (Alternative to SMTP):
    35→#     RALPH_SENDGRID_API_KEY   - SendGrid API key
    36→#
    37→#     AWS SES (Alternative to SMTP):
    38→#     RALPH_AWS_SES_REGION     - AWS region (e.g., us-east-1)
    39→#     RALPH_AWS_ACCESS_KEY_ID  - AWS access key
    40→#     RALPH_AWS_SECRET_KEY     - AWS secret key
    41→#
    42→#     Email Options:
    43→#     RALPH_EMAIL_HTML         - Send HTML emails (true/false, default: true)
    44→#     RALPH_EMAIL_BATCH_DELAY  - Seconds to wait before sending batched emails (default: 300)
    45→#     RALPH_EMAIL_BATCH_MAX    - Maximum notifications per batch (default: 10)
    46→#
    47→#   CUSTOM:
    48→#     RALPH_CUSTOM_NOTIFY_SCRIPT - Path to custom notification script
    49→#                                  Script receives message as $1
    50→#                                  Use this for proprietary integrations
    51→#                                  (database bridges, internal APIs, etc.)
    52→#
    53→#   BEHAVIOR:
    54→#     RALPH_NOTIFY_FREQUENCY     - How often to send progress notifications
    55→#                                  5 = every 5th iteration (default)
    56→#                                  1 = every iteration
    57→#
    58→# Usage:
    59→#   ./notify.sh "Your message here"
    60→#   ./notify.sh --test              # Send test notification to all configured platforms
    61→#
    62→# Messages are sent to ALL configured platforms. If none are configured, exits silently.
    63→
    64→set -euo pipefail
    65→
    66→RALPH_DIR="$(cd "$(dirname "$0")" && pwd)"
    67→
    68→# Load constants
    69→if [ -f "$RALPH_DIR/lib/constants.sh" ]; then
    70→    source "$RALPH_DIR/lib/constants.sh"
    71→fi
    72→
    73→# Load platform utilities for cross-platform support
    74→if [ -f "$RALPH_DIR/lib/platform-utils.sh" ]; then
    75→    source "$RALPH_DIR/lib/platform-utils.sh"
    76→fi
    77→
    78→# Load and decrypt environment variables
    79→if [ -f "$RALPH_DIR/decrypt-env.sh" ]; then
    80→    source "$RALPH_DIR/decrypt-env.sh"
    81→    # Call decrypt_ralph_env directly to allow variable updates
    82→    # Don't pipe the output which would run in a subshell
    83→    if ! decrypt_ralph_env; then
    84→        # Only show warning in test mode, otherwise notifications might spam
    85→        if [ "${1:-}" = "--test" ]; then
    86→            echo "Warning: Failed to decrypt some environment variables" >&2
    87→            echo "Some notification platforms may not work" >&2
    88→        fi
    89→    fi
    90→fi
    91→
    92→# ============================================
    93→# SECURITY FUNCTIONS
    94→# ============================================
    95→
    96→# Source shared validation library
    97→source "${RALPH_DIR}/lib/validation.sh"
    98→
    99→# All validation functions are now loaded from lib/validation.sh:
   100→# - json_escape()
   101→# - mask_token()
   102→# - validate_webhook_url() / validate_url()
   103→# - validate_email()
   104→# - validate_numeric()
   105→# - validate_path()
   106→
   107→# Rate limiting: track notification timestamps (use platform-appropriate temp dir)
   108→if command -v get_temp_dir &>/dev/null; then
   109→    TEMP_DIR=$(get_temp_dir)
   110→else
   111→    TEMP_DIR="${TMPDIR:-/tmp}"
   112→fi
   113→RATE_LIMIT_FILE="${TEMP_DIR}/ralph_notify_rate_limit_$$"
   114→# Use RATE_LIMIT_MAX from constants.sh if available, otherwise default to 60
   115→# Note: Don't reassign if already readonly from constants.sh
   116→if [ -z "${RATE_LIMIT_MAX:-}" ]; then
   117→    RATE_LIMIT_MAX=60  # Max notifications per minute
   118→fi
   119→
   120→check_rate_limit() {
   121→    local now
   122→    now=$(date +%s)
   123→    local rate_window="${RATE_LIMIT_WINDOW:-60}"
   124→
   125→    # Clean up old entries (older than rate limit window)
   126→    if [ -f "$RATE_LIMIT_FILE" ]; then
   127→        if awk -v cutoff="$((now - rate_window))" '$1 > cutoff' "$RATE_LIMIT_FILE" > "${RATE_LIMIT_FILE}.tmp" 2>/dev/null; then
   128→            if ! mv "${RATE_LIMIT_FILE}.tmp" "$RATE_LIMIT_FILE" 2>/dev/null; then
   129→                # If mv fails, cleanup temp file
   130→                rm -f "${RATE_LIMIT_FILE}.tmp" 2>/dev/null
   131→            fi
   132→        else
   133→            # If awk fails, remove temp file if it exists
   134→            rm -f "${RATE_LIMIT_FILE}.tmp" 2>/dev/null
   135→        fi
   136→    fi
   137→
   138→    # Count recent notifications
   139→    local count=0
   140→    if [ -f "$RATE_LIMIT_FILE" ]; then
   141→        count=$(wc -l < "$RATE_LIMIT_FILE" 2>/dev/null || echo 0)
   142→    fi
   143→
   144→    if [ "$count" -ge "$RATE_LIMIT_MAX" ]; then
   145→        return 1  # Rate limit exceeded
   146→    fi
   147→
   148→    # Record this notification (if we can't write, rate limiting is disabled)
   149→    if ! echo "$now" >> "$RATE_LIMIT_FILE" 2>/dev/null; then
   150→        # If we can't write to rate limit file, continue anyway
   151→        # This prevents notification failures due to file system issues
   152→        return 0
   153→    fi
   154→    return 0
   155→}
   156→
   157→# Check for test mode
   158→if [ "${1:-}" = "--test" ]; then
   159→    TEST_MODE=true
   160→    MESSAGE="Test notification from Ralph"
   161→else
   162→    TEST_MODE=false
   163→    MESSAGE="${1:-}"
   164→fi
   165→
   166→# Exit if no message provided
   167→if [ -z "$MESSAGE" ]; then
   168→    exit 0
   169→fi
   170→
   171→# Convert literal \n to actual newlines
   172→MESSAGE=$(printf '%b' "$MESSAGE")
   173→
   174→# Track if any notification was sent
   175→SENT_ANY=false
   176→
   177→# ============================================
   178→# SLACK
   179→# ============================================
   180→send_slack() {
   181→    local msg="$1"
   182→
   183→    if [ -z "${RALPH_SLACK_WEBHOOK_URL:-}" ]; then
   184→        return 0
   185→    fi
   186→
   187→    # Validate webhook URL
   188→    if ! validate_webhook_url "$RALPH_SLACK_WEBHOOK_URL"; then
   189→        $TEST_MODE && echo "  Slack: FAILED (invalid webhook URL)"
   190→        return 1
   191→    fi
   192→
   193→    local username="${RALPH_SLACK_USERNAME:-Ralph}"
   194→    local icon_emoji="${RALPH_SLACK_ICON_EMOJI:-:robot_face:}"
   195→    local channel="${RALPH_SLACK_CHANNEL:-}"
   196→
   197→    local payload
   198→    if command -v jq &> /dev/null; then
   199→        payload=$(jq -n \
   200→            --arg text "$msg" \
   201→            --arg username "$username" \
   202→            --arg icon_emoji "$icon_emoji" \
   203→            --arg channel "$channel" \
   204→            '{
   205→                text: $text,
   206→                username: $username,
   207→                icon_emoji: $icon_emoji
   208→            } + (if $channel != "" then {channel: $channel} else {} end)')
   209→    else
   210→        # Use safe JSON escaping function (no sed)
   211→        local escaped_msg
   212→        local escaped_username
   213→        local escaped_icon
   214→        local escaped_channel
   215→        escaped_msg=$(json_escape "$msg")
   216→        escaped_username=$(json_escape "$username")
   217→        escaped_icon=$(json_escape "$icon_emoji")
   218→        escaped_channel=$(json_escape "$channel")
   219→
   220→        if [ -n "$channel" ]; then
   221→            payload="{\"text\":\"$escaped_msg\",\"username\":\"$escaped_username\",\"icon_emoji\":\"$escaped_icon\",\"channel\":\"$escaped_channel\"}"
   222→        else
   223→            payload="{\"text\":\"$escaped_msg\",\"username\":\"$escaped_username\",\"icon_emoji\":\"$escaped_icon\"}"
   224→        fi
   225→    fi
   226→
   227→    # Send with timeout and error handling
   228→    local http_code
   229→    local max_time="${HTTP_MAX_TIME:-10}"
   230→    local connect_timeout="${HTTP_CONNECT_TIMEOUT:-5}"
   231→    http_code=$(curl -s -w "%{http_code}" -o /dev/null \
   232→        -X POST \
   233→        -H 'Content-type: application/json' \
   234→        --max-time "$max_time" \
   235→        --connect-timeout "$connect_timeout" \
   236→        --data "$payload" \
   237→        "$RALPH_SLACK_WEBHOOK_URL" 2>&1)
   238→    local curl_exit=$?
   239→
   240→    local success_min="${HTTP_STATUS_SUCCESS_MIN:-200}"
   241→    local success_max="${HTTP_STATUS_SUCCESS_MAX:-300}"
   242→    if [ $curl_exit -eq 0 ] && [ "$http_code" -ge "$success_min" ] && [ "$http_code" -lt "$success_max" ]; then
   243→        SENT_ANY=true
   244→        $TEST_MODE && echo "  Slack: sent"
   245→        return 0
   246→    else
   247→        $TEST_MODE && echo "  Slack: FAILED (HTTP $http_code, exit $curl_exit)"
   248→        return 1
   249→    fi
   250→}
   251→
   252→# ============================================
   253→# DISCORD
   254→# ============================================
   255→send_discord() {
   256→    local msg="$1"
   257→
   258→    if [ -z "${RALPH_DISCORD_WEBHOOK_URL:-}" ]; then
   259→        return 0
   260→    fi
   261→
   262→    # Validate webhook URL
   263→    if ! validate_webhook_url "$RALPH_DISCORD_WEBHOOK_URL"; then
   264→        $TEST_MODE && echo "  Discord: FAILED (invalid webhook URL)"
   265→        return 1
   266→    fi
   267→
   268→    local username="${RALPH_DISCORD_USERNAME:-Ralph}"
   269→    local avatar_url="${RALPH_DISCORD_AVATAR_URL:-}"
   270→
   271→    # Convert Slack-style *bold* to Discord **bold** in a single left-to-right pass
   272→    # (A while loop replacing *x* with **x** loops forever since **x** still matches *x*)
   273→    local discord_msg=""
   274→    local remaining="$msg"
   275→    while [[ "$remaining" =~ ^([^*]*)\*([^*]+)\*(.*) ]]; do
   276→        discord_msg+="${BASH_REMATCH[1]}**${BASH_REMATCH[2]}**"
   277→        remaining="${BASH_REMATCH[3]}"
   278→    done
   279→    discord_msg+="$remaining"
   280→
   281→    local payload
   282→    if command -v jq &> /dev/null; then
   283→        payload=$(jq -n \
   284→            --arg content "$discord_msg" \
   285→            --arg username "$username" \
   286→            --arg avatar_url "$avatar_url" \
   287→            '{
   288→                content: $content,
   289→                username: $username
   290→            } + (if $avatar_url != "" then {avatar_url: $avatar_url} else {} end)')
   291→    else
   292→        # Use safe JSON escaping function
   293→        local escaped_msg
   294→        local escaped_username
   295→        local escaped_avatar
   296→        escaped_msg=$(json_escape "$discord_msg")
   297→        escaped_username=$(json_escape "$username")
   298→        escaped_avatar=$(json_escape "$avatar_url")
   299→
   300→        if [ -n "$avatar_url" ]; then
   301→            payload="{\"content\":\"$escaped_msg\",\"username\":\"$escaped_username\",\"avatar_url\":\"$escaped_avatar\"}"
   302→        else
   303→            payload="{\"content\":\"$escaped_msg\",\"username\":\"$escaped_username\"}"
   304→        fi
   305→    fi
   306→
   307→    # Send with timeout and error handling
   308→    local http_code
   309→    local max_time="${HTTP_MAX_TIME:-10}"
   310→    local connect_timeout="${HTTP_CONNECT_TIMEOUT:-5}"
   311→    http_code=$(curl -s -w "%{http_code}" -o /dev/null \
   312→        -X POST \
   313→        -H 'Content-type: application/json' \
   314→        --max-time "$max_time" \
   315→        --connect-timeout "$connect_timeout" \
   316→        --data "$payload" \
   317→        "$RALPH_DISCORD_WEBHOOK_URL" 2>&1)
   318→    local curl_exit=$?
   319→
   320→    local success_min="${HTTP_STATUS_SUCCESS_MIN:-200}"
   321→    local success_max="${HTTP_STATUS_SUCCESS_MAX:-300}"
   322→    if [ $curl_exit -eq 0 ] && [ "$http_code" -ge "$success_min" ] && [ "$http_code" -lt "$success_max" ]; then
   323→        SENT_ANY=true
   324→        $TEST_MODE && echo "  Discord: sent"
   325→        return 0
   326→    else
   327→        $TEST_MODE && echo "  Discord: FAILED (HTTP $http_code, exit $curl_exit)"
   328→        return 1
   329→    fi
   330→}
   331→
   332→# ============================================
   333→# TELEGRAM
   334→# ============================================
   335→send_telegram() {
   336→    local msg="$1"
   337→
   338→    if [ -z "${RALPH_TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${RALPH_TELEGRAM_CHAT_ID:-}" ]; then
   339→        return 0
   340→    fi
   341→
   342→    # Convert Slack-style emoji codes to Unicode (avoid sed on user input)
   343→    local telegram_msg="$msg"
   344→    telegram_msg="${telegram_msg//:rocket:/🚀}"
   345→    telegram_msg="${telegram_msg//:white_check_mark:/✅}"
   346→    telegram_msg="${telegram_msg//:warning:/⚠️}"
   347→    telegram_msg="${telegram_msg//:gear:/⚙️}"
   348→    telegram_msg="${telegram_msg//:robot_face:/🤖}"
   349→    telegram_msg="${telegram_msg//:x:/❌}"
   350→    telegram_msg="${telegram_msg//:clipboard:/📋}"
   351→
   352→    # Escape special characters for Telegram MarkdownV2 using bash
   353→    # Characters that need escaping: _ * [ ] ( ) ~ ` > # + - = | { } . !
   354→    # Keep * for bold, escape others
   355→    local escaped_msg="$telegram_msg"
   356→    escaped_msg="${escaped_msg//\\/\\\\}"
   357→    escaped_msg="${escaped_msg//./\\.}"
   358→    escaped_msg="${escaped_msg//!/\\!}"
   359→    escaped_msg="${escaped_msg//-/\\-}"
   360→    escaped_msg="${escaped_msg//=/\\=}"
   361→    escaped_msg="${escaped_msg//|/\\|}"
   362→    escaped_msg="${escaped_msg//\{/\\{}"
   363→    escaped_msg="${escaped_msg//\}/\\}}"
   364→    escaped_msg="${escaped_msg//(/\\(}"
   365→    escaped_msg="${escaped_msg//)/\\)}"
   366→    escaped_msg="${escaped_msg//\[/\\[}"
   367→    escaped_msg="${escaped_msg//\]/\\]}"
   368→
   369→    # Construct API URL with masked token for logs
   370→    local api_url="https://api.telegram.org/bot${RALPH_TELEGRAM_BOT_TOKEN}/sendMessage"
   371→    local masked_url="https://api.telegram.org/bot$(mask_token "$RALPH_TELEGRAM_BOT_TOKEN")/sendMessage"
   372→
   373→    local payload
   374→    if command -v jq &> /dev/null; then
   375→        payload=$(jq -n \
   376→            --arg chat_id "$RALPH_TELEGRAM_CHAT_ID" \
   377→            --arg text "$escaped_msg" \
   378→            '{
   379→                chat_id: $chat_id,
   380→                text: $text,
   381→                parse_mode: "MarkdownV2"
   382→            }')
   383→    else
   384→        # Use safe JSON escaping function
   385→        local json_escaped_msg
   386→        local json_escaped_chat
   387→        json_escaped_msg=$(json_escape "$escaped_msg")
   388→        json_escaped_chat=$(json_escape "$RALPH_TELEGRAM_CHAT_ID")
   389→        payload="{\"chat_id\":\"$json_escaped_chat\",\"text\":\"$json_escaped_msg\",\"parse_mode\":\"MarkdownV2\"}"
   390→    fi
   391→
   392→    # Send with timeout and error handling (never log actual token)
   393→    local http_code
   394→    local error_output
   395→    local max_time="${HTTP_MAX_TIME:-10}"
   396→    local connect_timeout="${HTTP_CONNECT_TIMEOUT:-5}"
   397→    error_output=$(mktemp) || {
   398→        $TEST_MODE && echo "  Telegram: FAILED (temp file creation failed)"
   399→        return 1
   400→    }
   401→    trap 'rm -f "$error_output" 2>/dev/null' RETURN
   402→
   403→    http_code=$(curl -s -w "%{http_code}" -o /dev/null \
   404→        -X POST \
   405→        -H 'Content-type: application/json' \
   406→        --max-time "$max_time" \
   407→        --connect-timeout "$connect_timeout" \
   408→        --data "$payload" \
   409→        "$api_url" 2>"$error_output")
   410→    local curl_exit=$?
   411→
   412→    # Clean up temp file
   413→    rm -f "$error_output"
   414→
   415→    local success_min="${HTTP_STATUS_SUCCESS_MIN:-200}"
   416→    local success_max="${HTTP_STATUS_SUCCESS_MAX:-300}"
   417→    if [ $curl_exit -eq 0 ] && [ "$http_code" -ge "$success_min" ] && [ "$http_code" -lt "$success_max" ]; then
   418→        SENT_ANY=true
   419→        $TEST_MODE && echo "  Telegram: sent"
   420→        return 0
   421→    else
   422→        # Never expose the actual token in error messages
   423→        $TEST_MODE && echo "  Telegram: FAILED (HTTP $http_code, exit $curl_exit, URL: $masked_url)"
   424→        return 1
   425→    fi
   426→}
   427→
   428→# ============================================
   429→# EMAIL
   430→# ============================================
   431→# Supports three delivery methods:
   432→#   1. SMTP (traditional mail servers)
   433→#   2. SendGrid API (cloud service)
   434→#   3. AWS SES (Amazon Simple Email Service)
   435→#
   436→# Features:
   437→#   - HTML and plain text emails
   438→#   - Event batching to reduce spam
   439→#   - Multiple recipients
   440→#   - Template-based formatting
   441→
   442→# Email batching state (use platform-appropriate temp dir)
   443→EMAIL_BATCH_DIR="${TEMP_DIR}/ralph_email_batch"
   444→EMAIL_BATCH_FILE="$EMAIL_BATCH_DIR/pending.txt"
   445→EMAIL_BATCH_LOCK="$EMAIL_BATCH_DIR/batch.lock"
   446→
   447→# Initialize email batch directory
   448→init_email_batch() {
   449→    mkdir -p "$EMAIL_BATCH_DIR"
   450→    chmod 700 "$EMAIL_BATCH_DIR"
   451→}
   452→
   453→# Check if we should batch this notification
   454→should_batch_email() {
   455→    local batch_delay="${RALPH_EMAIL_BATCH_DELAY:-${EMAIL_BATCH_DELAY_DEFAULT:-300}}"
   456→
   457→    # Batching disabled if delay is 0
   458→    if [ "$batch_delay" -eq 0 ]; then
   459→        return 1  # Don't batch
   460→    fi
   461→
   462→    # Check if this is a high-priority message (errors, warnings)
   463→    if echo "$1" | grep -qiE "(error|failed|critical|warning)"; then
   464→        return 1  # Don't batch critical messages
   465→    fi
   466→
   467→    return 0  # Batch this message
   468→}
   469→
   470→# Add message to batch queue
   471→add_to_batch() {
   472→    local msg="$1"
   473→    local timestamp
   474→    timestamp=$(date +%s)
   475→
   476→    init_email_batch
   477→
   478→    # Acquire lock
   479→    local lock_acquired=false
   480→    local lock_retries="${EMAIL_BATCH_LOCK_RETRIES:-10}"
   481→    local lock_delay="${EMAIL_BATCH_LOCK_DELAY:-0.1}"
   482→    for i in $(seq 1 "$lock_retries"); do
   483→        if mkdir "$EMAIL_BATCH_LOCK" 2>/dev/null; then
   484→            lock_acquired=true
   485→            break
   486→        fi
   487→        sleep "$lock_delay"
   488→    done
   489→
   490→    if [ "$lock_acquired" = false ]; then
   491→        return 1
   492→    fi
   493→
   494→    # Add to batch file
   495→    if ! echo "${timestamp}|${msg}" >> "$EMAIL_BATCH_FILE" 2>/dev/null; then
   496→        # Release lock before returning
   497→        rmdir "$EMAIL_BATCH_LOCK" 2>/dev/null
   498→        return 1
   499→    fi
   500→
   501→    # Release lock
   502→    if ! rmdir "$EMAIL_BATCH_LOCK" 2>/dev/null; then
   503→        # Lock directory removal failed, but message was added
   504→        # This is not critical, the lock will be handled on next attempt
   505→        :
   506→    fi
   507→
   508→    # Check if we should send now
   509→    check_and_send_batch
   510→}
   511→
   512→# Check if batch should be sent
   513→check_and_send_batch() {
   514→    local batch_delay="${RALPH_EMAIL_BATCH_DELAY:-${EMAIL_BATCH_DELAY_DEFAULT:-300}}"
   515→    local batch_max="${RALPH_EMAIL_BATCH_MAX:-${EMAIL_BATCH_MAX_DEFAULT:-10}}"
   516→    local now
   517→    now=$(date +%s)
   518→
   519→    if [ ! -f "$EMAIL_BATCH_FILE" ]; then
   520→        return 0
   521→    fi
   522→
   523→    local count
   524→    count=$(wc -l < "$EMAIL_BATCH_FILE" 2>/dev/null || echo 0)
   525→
   526→    if [ "$count" -eq 0 ]; then
   527→        return 0
   528→    fi
   529→
   530→    # Get oldest message timestamp
   531→    local oldest
   532→    oldest=$(head -n1 "$EMAIL_BATCH_FILE" | cut -d'|' -f1)
   533→
   534→    local age=$((now - oldest))
   535→
   536→    # Send if batch is old enough OR full enough
   537→    if [ "$age" -ge "$batch_delay" ] || [ "$count" -ge "$batch_max" ]; then
   538→        send_batched_email
   539→    fi
   540→}
   541→
   542→# Send batched emails
   543→send_batched_email() {
   544→    if [ ! -f "$EMAIL_BATCH_FILE" ]; then
   545→        return 0
   546→    fi
   547→
   548→    # Acquire lock
   549→    if ! mkdir "$EMAIL_BATCH_LOCK" 2>/dev/null; then
   550→        return 1
   551→    fi
   552→
   553→    # Read and clear batch file
   554→    local batch_content
   555→    if ! batch_content=$(cat "$EMAIL_BATCH_FILE" 2>/dev/null); then
   556→        # Failed to read batch file
   557→        rmdir "$EMAIL_BATCH_LOCK" 2>/dev/null
   558→        return 1
   559→    fi
   560→
   561→    if ! : > "$EMAIL_BATCH_FILE" 2>/dev/null; then
   562→        # Failed to clear batch file, but we have the content
   563→        # Continue processing, file will be cleared next time
   564→        :
   565→    fi
   566→
   567→    # Release lock
   568→    if ! rmdir "$EMAIL_BATCH_LOCK" 2>/dev/null; then
   569→        # Lock removal failed, but we have the batch content
   570→        # This is not critical, continue processing
   571→        :
   572→    fi
   573→
   574→    if [ -z "$batch_content" ]; then
   575→        return 0
   576→    fi
   577→
   578→    # Format batched messages
   579→    local batch_count
   580→    batch_count=$(echo "$batch_content" | wc -l)
   581→
   582→    local batched_msgs=""
   583→    while IFS='|' read -r timestamp msg; do
   584→        local msg_time
   585→        msg_time=$(date -d "@$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$timestamp")
   586→        batched_msgs="${batched_msgs}[${msg_time}] ${msg}\n\n"
   587→    done <<< "$batch_content"
   588→
   589→    # Send as a single email with batched content
   590→    local combined_msg="Batched notifications (${batch_count} messages):\n\n${batched_msgs}"
   591→    send_email_direct "$combined_msg" "info" "$batch_count"
   592→}
   593→
   594→# Escape HTML special characters to prevent XSS
   595→html_escape() {
   596→    local str="$1"
   597→    str="${str//&/&amp;}"
   598→    str="${str//</&lt;}"
   599→    str="${str//>/&gt;}"
   600→    str="${str//\"/&quot;}"
   601→    str="${str//\'/&#x27;}"
   602→    printf '%s' "$str"
   603→}
   604→
   605→# Remove template sections without using sed
   606→remove_template_section() {
   607→    local content="$1"
   608→    local start_tag="$2"
   609→    local end_tag="$3"
   610→
   611→    # Use bash pattern matching to remove sections
   612→    while [[ "$content" =~ (.*)"$start_tag"[^}]*"$end_tag"(.*) ]]; do
   613→        content="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
   614→    done
   615→
   616→    printf '%s' "$content"
   617→}
   618→
   619→# Render email template
   620→render_email_template() {
   621→    local template_file="$1"
   622→    local message="$2"
   623→    local type="${3:-info}"
   624→    local batch_count="${4:-0}"
   625→
   626→    local timestamp
   627→    timestamp=$(date "+%Y-%m-%d %H:%M:%S %Z")
   628→
   629→    local hostname
   630→    hostname=$(hostname)
   631→
   632→    local project="${RALPH_PROJECT_NAME:-}"
   633→
   634→    # Determine type label
   635→    local type_label="Information"
   636→    case "$type" in
   637→        success) type_label="Success" ;;
   638→        warning) type_label="Warning" ;;
   639→        error) type_label="Error" ;;
   640→        progress) type_label="Progress Update" ;;
   641→    esac
   642→
   643→    # Read template
   644→    local content
   645→    content=$(cat "$template_file")
   646→
   647→    # Escape message for HTML if template is HTML
   648→    local escaped_message="$message"
   649→    if [[ "$template_file" == *.html ]]; then
   650→        escaped_message=$(html_escape "$message")
   651→    fi
   652→
   653→    # Replace variables using bash substitution (no sed)
   654→    content="${content//\{\{MESSAGE\}\}/$escaped_message}"
   655→    content="${content//\{\{TYPE\}\}/$type}"
   656→    content="${content//\{\{TYPE_LABEL\}\}/$type_label}"
   657→    content="${content//\{\{TIMESTAMP\}\}/$timestamp}"
   658→    content="${content//\{\{HOSTNAME\}\}/$hostname}"
   659→    content="${content//\{\{PROJECT\}\}/$project}"
   660→    content="${content//\{\{BATCH_COUNT\}\}/$batch_count}"
   661→
   662→    # Handle conditional sections without sed
   663→    if [ -n "$project" ]; then
   664→        content="${content//\{\{#HAS_PROJECT\}\}/}"
   665→        content="${content//\{\{\/HAS_PROJECT\}\}/}"
   666→    else
   667→        # Remove project sections using bash pattern matching
   668→        content=$(remove_template_section "$content" "{{#HAS_PROJECT}}" "{{/HAS_PROJECT}}")
   669→    fi
   670→
   671→    if [ "$batch_count" -gt 0 ]; then
   672→        content="${content//\{\{#HAS_BATCHED\}\}/}"
   673→        content="${content//\{\{\/HAS_BATCHED\}\}/}"
   674→    else
   675→        # Remove batched sections using bash pattern matching
   676→        content=$(remove_template_section "$content" "{{#HAS_BATCHED}}" "{{/HAS_BATCHED}}")
   677→    fi
   678→
   679→    echo "$content"
   680→}
   681→
   682→# Send email via SMTP
   683→send_email_smtp() {
   684→    local to="$1"
   685→    local subject="$2"
   686→    local body="$3"
   687→    local is_html="${4:-false}"
   688→
   689→    local smtp_host="${RALPH_SMTP_HOST}"
   690→    local smtp_port="${RALPH_SMTP_PORT:-587}"
   691→    local smtp_user="${RALPH_SMTP_USER}"
   692→    local smtp_pass="${RALPH_SMTP_PASSWORD}"
   693→    local smtp_tls="${RALPH_SMTP_TLS:-true}"
   694→    local from="${RALPH_EMAIL_FROM}"
   695→
   696→    # Check if we have required SMTP credentials
   697→    if [ -z "$smtp_host" ] || [ -z "$smtp_user" ] || [ -z "$smtp_pass" ]; then
   698→        return 1
   699→    fi
   700→
   701→    # Create email message
   702→    local content_type="text/plain; charset=UTF-8"
   703→    if [ "$is_html" = true ]; then
   704→        content_type="text/html; charset=UTF-8"
   705→    fi
   706→
   707→    local email_file
   708→    email_file=$(mktemp) || {
   709→        return 1
   710→    }
   711→    chmod 600 "$email_file"
   712→    trap 'rm -f "$email_file" 2>/dev/null' RETURN
   713→
   714→    cat > "$email_file" << EOF
   715→From: $from
   716→To: $to
   717→Subject: $subject
   718→MIME-Version: 1.0
   719→Content-Type: $content_type
   720→
   721→$body
   722→EOF
   723→
   724→    # Send using curl with SMTP
   725→    local smtp_url="smtp://${smtp_host}:${smtp_port}"
   726→    local smtp_opts=""
   727→
   728→    if [ "$smtp_tls" = true ]; then
   729→        smtp_opts="--ssl-reqd"
   730→    fi
   731→
   732→    local result=0
   733→    local smtp_timeout="${HTTP_SMTP_TIMEOUT:-30}"
   734→    curl -s --url "$smtp_url" \
   735→        $smtp_opts \
   736→        --mail-from "$from" \
   737→        --mail-rcpt "$to" \
   738→        --user "${smtp_user}:${smtp_pass}" \
   739→        --upload-file "$email_file" \
   740→        --max-time "$smtp_timeout" 2>&1 > /dev/null || result=$?
   741→
   742→    rm -f "$email_file"
   743→    return $result
   744→}
   745→
   746→# Send email via SendGrid API
   747→send_email_sendgrid() {
   748→    local to="$1"
   749→    local subject="$2"
   750→    local body="$3"
   751→    local is_html="${4:-false}"
   752→
   753→    local api_key="${RALPH_SENDGRID_API_KEY}"
   754→    local from="${RALPH_EMAIL_FROM}"
   755→
   756→    if [ -z "$api_key" ]; then
   757→        return 1
   758→    fi
   759→
   760→    # Build JSON payload
   761→    local content_type="text/plain"
   762→    if [ "$is_html" = true ]; then
   763→        content_type="text/html"
   764→    fi
   765→
   766→    local payload
   767→    if command -v jq &> /dev/null; then
   768→        payload=$(jq -n \
   769→            --arg from "$from" \
   770→            --arg to "$to" \
   771→            --arg subject "$subject" \
   772→            --arg body "$body" \
   773→            --arg content_type "$content_type" \
   774→            '{
   775→                personalizations: [{to: [{email: $to}]}],
   776→                from: {email: $from},
   777→                subject: $subject,
   778→                content: [{type: $content_type, value: $body}]
   779→            }')
   780→    else
   781→        local escaped_body
   782→        escaped_body=$(json_escape "$body")
   783→        local escaped_subject
   784→        escaped_subject=$(json_escape "$subject")
   785→        payload="{\"personalizations\":[{\"to\":[{\"email\":\"$to\"}]}],\"from\":{\"email\":\"$from\"},\"subject\":\"$escaped_subject\",\"content\":[{\"type\":\"$content_type\",\"value\":\"$escaped_body\"}]}"
   786→    fi
   787→
   788→    # Send via SendGrid API
   789→    local http_code
   790→    local smtp_timeout="${HTTP_SMTP_TIMEOUT:-30}"
   791→    http_code=$(curl -s -w "%{http_code}" -o /dev/null \
   792→        -X POST \
   793→        "https://api.sendgrid.com/v3/mail/send" \
   794→        -H "Authorization: Bearer $api_key" \
   795→        -H "Content-Type: application/json" \
   796→        --data "$payload" \
   797→        --max-time "$smtp_timeout" 2>&1)
   798→
   799→    local success_min="${HTTP_STATUS_SUCCESS_MIN:-200}"
   800→    local success_max="${HTTP_STATUS_SUCCESS_MAX:-300}"
   801→    if [ "$http_code" -ge "$success_min" ] && [ "$http_code" -lt "$success_max" ]; then
   802→        return 0
   803→    else
   804→        return 1
   805→    fi
   806→}
   807→
   808→# Send email via AWS SES
   809→send_email_ses() {
   810→    local to="$1"
   811→    local subject="$2"
   812→    local body="$3"
   813→    local is_html="${4:-false}"
   814→
   815→    local region="${RALPH_AWS_SES_REGION}"
   816→    local access_key="${RALPH_AWS_ACCESS_KEY_ID}"
   817→    local secret_key="${RALPH_AWS_SECRET_KEY}"
   818→    local from="${RALPH_EMAIL_FROM}"
   819→
   820→    if [ -z "$region" ] || [ -z "$access_key" ] || [ -z "$secret_key" ]; then
   821→        return 1
   822→    fi
   823→
   824→    # Check if AWS CLI is available
   825→    if ! command -v aws &> /dev/null; then
   826→        return 1
   827→    fi
   828→
   829→    # Escape quotes in body and subject
   830→    local escaped_body
   831→    local escaped_subject
   832→    escaped_body="${body//\"/\\\"}"
   833→    escaped_subject="${subject//\"/\\\"}"
   834→
   835→    # Determine message format
   836→    local message_arg
   837→    if [ "$is_html" = true ]; then
   838→        message_arg="Body={Html={Data=\"$escaped_body\",Charset=utf-8}}"
   839→    else
   840→        message_arg="Body={Text={Data=\"$escaped_body\",Charset=utf-8}}"
   841→    fi
   842→
   843→    # Send via AWS CLI
   844→    AWS_ACCESS_KEY_ID="$access_key" \
   845→    AWS_SECRET_ACCESS_KEY="$secret_key" \
   846→    AWS_DEFAULT_REGION="$region" \
   847→    aws ses send-email \
   848→        --from "$from" \
   849→        --destination "ToAddresses=$to" \
   850→        --message "Subject={Data=\"$escaped_subject\",Charset=utf-8},$message_arg" \
   851→        --region "$region" \
   852→        --output json > /dev/null 2>&1
   853→
   854→    return $?
   855→}
   856→
   857→# Main email sending function
   858→send_email_direct() {
   859→    local msg="$1"
   860→    local type="${2:-info}"
   861→    local batch_count="${3:-0}"
   862→
   863→    local to="${RALPH_EMAIL_TO}"
   864→    local from="${RALPH_EMAIL_FROM}"
   865→    local subject_prefix="${RALPH_EMAIL_SUBJECT:-Ralph Notification}"
   866→    local use_html="${RALPH_EMAIL_HTML:-true}"
   867→
   868→    # Check required config
   869→    if [ -z "$to" ] || [ -z "$from" ]; then
   870→        return 0  # Not configured, skip silently
   871→    fi
   872→
   873→    # Validate email addresses
   874→    if ! validate_email "$to"; then
   875→        $TEST_MODE && echo "  Email: FAILED (invalid recipient email address)"
   876→        return 1
   877→    fi
   878→
   879→    if ! validate_email "$from"; then
   880→        $TEST_MODE && echo "  Email: FAILED (invalid sender email address)"
   881→        return 1
   882→    fi
   883→
   884→    # Determine subject based on message type
   885→    local subject="$subject_prefix"
   886→    case "$type" in
   887→        success) subject="$subject_prefix - Success" ;;
   888→        warning) subject="$subject_prefix - Warning" ;;
   889→        error) subject="$subject_prefix - Error" ;;
   890→        progress) subject="$subject_prefix - Progress Update" ;;
   891→    esac
   892→
   893→    if [ "$batch_count" -gt 0 ]; then
   894→        subject="$subject_prefix - Batch Update ($batch_count notifications)"
   895→    fi
   896→
   897→    # Prepare email body
   898→    local html_body=""
   899→    local text_body=""
   900→
   901→    if [ "$use_html" = true ] && [ -f "$RALPH_DIR/templates/email-notification.html" ]; then
   902→        html_body=$(render_email_template "$RALPH_DIR/templates/email-notification.html" "$msg" "$type" "$batch_count")
   903→    fi
   904→
   905→    if [ -f "$RALPH_DIR/templates/email-notification.txt" ]; then
   906→        text_body=$(render_email_template "$RALPH_DIR/templates/email-notification.txt" "$msg" "$type" "$batch_count")
   907→    else
   908→        # Fallback to plain message
   909→        text_body="$msg"
   910→    fi
   911→
   912→    # Try sending methods in order of preference
   913→    local sent=false
   914→
   915→    # 1. Try SendGrid (fastest, most reliable cloud option)
   916→    if [ -n "${RALPH_SENDGRID_API_KEY:-}" ]; then
   917→        if [ "$use_html" = true ] && [ -n "$html_body" ]; then
   918→            if send_email_sendgrid "$to" "$subject" "$html_body" true; then
   919→                sent=true
   920→            fi
   921→        else
   922→            if send_email_sendgrid "$to" "$subject" "$text_body" false; then
   923→                sent=true
   924→            fi
   925→        fi
   926→    fi
   927→
   928→    # 2. Try AWS SES
   929→    if [ "$sent" = false ] && [ -n "${RALPH_AWS_SES_REGION:-}" ]; then
   930→        if [ "$use_html" = true ] && [ -n "$html_body" ]; then
   931→            if send_email_ses "$to" "$subject" "$html_body" true; then
   932→                sent=true
   933→            fi
   934→        else
   935→            if send_email_ses "$to" "$subject" "$text_body" false; then
   936→                sent=true
   937→            fi
   938→        fi
   939→    fi
   940→
   941→    # 3. Try SMTP
   942→    if [ "$sent" = false ] && [ -n "${RALPH_SMTP_HOST:-}" ]; then
   943→        if [ "$use_html" = true ] && [ -n "$html_body" ]; then
   944→            if send_email_smtp "$to" "$subject" "$html_body" true; then
   945→                sent=true
   946→            fi
   947→        else
   948→            if send_email_smtp "$to" "$subject" "$text_body" false; then
   949→                sent=true
   950→            fi
   951→        fi
   952→    fi
   953→
   954→    if [ "$sent" = true ]; then
   955→        return 0
   956→    else
   957→        return 1
   958→    fi
   959→}
   960→
   961→# Public send_email function (with batching logic)
   962→send_email() {
   963→    local msg="$1"
   964→
   965→    if [ -z "${RALPH_EMAIL_TO:-}" ]; then
   966→        return 0
   967→    fi
   968→
   969→    # Determine message type from content
   970→    local msg_type="info"
   971→    if echo "$msg" | grep -qiE "(error|failed|critical)"; then
   972→        msg_type="error"
   973→    elif echo "$msg" | grep -qiE "warning"; then
   974→        msg_type="warning"
   975→    elif echo "$msg" | grep -qiE "(success|completed|done)"; then
   976→        msg_type="success"
   977→    elif echo "$msg" | grep -qiE "(progress|running|processing)"; then
   978→        msg_type="progress"
   979→    fi
   980→
   981→    # Check if we should batch this message
   982→    if should_batch_email "$msg"; then
   983→        add_to_batch "$msg"
   984→        $TEST_MODE && echo "  Email: queued for batch"
   985→        SENT_ANY=true
   986→        return 0
   987→    fi
   988→
   989→    # Send immediately
   990→    if send_email_direct "$msg" "$msg_type"; then
   991→        SENT_ANY=true
   992→        $TEST_MODE && echo "  Email: sent"
   993→        return 0
   994→    else
   995→        $TEST_MODE && echo "  Email: FAILED"
   996→        return 1
   997→    fi
   998→}
   999→
  1000→# ============================================
  1001→# CUSTOM SCRIPT
  1002→# ============================================
  1003→# For proprietary integrations (database bridges, internal APIs, etc.)
  1004→# Your script receives the message as $1 and handles delivery however you need.
  1005→#
  1006→# Example use cases:
  1007→#   - Database-to-Slack bridge (insert into DB, separate service posts to Slack)
  1008→#   - Internal company notification API
  1009→#   - SMS gateway
  1010→#   - Email relay
  1011→#
  1012→# Example script (my-notify.sh):
  1013→#   #!/bin/bash
  1014→#   MESSAGE="$1"
  1015→#   curl -X POST -d "message=$MESSAGE" https://internal.api/notify
  1016→#
  1017→send_custom() {
  1018→    local msg="$1"
  1019→
  1020→    if [ -z "${RALPH_CUSTOM_NOTIFY_SCRIPT:-}" ]; then
  1021→        return 0
  1022→    fi
  1023→
  1024→    # Security: Validate script path
  1025→    # Must be absolute path, no traversal, and owned by user/root
  1026→    if [[ ! "$RALPH_CUSTOM_NOTIFY_SCRIPT" =~ ^/ ]]; then
  1027→        $TEST_MODE && echo "  Custom: FAILED (script must be absolute path)"
  1028→        return 1
  1029→    fi
  1030→
  1031→    if [[ "$RALPH_CUSTOM_NOTIFY_SCRIPT" =~ \.\. ]]; then
  1032→        $TEST_MODE && echo "  Custom: FAILED (path traversal detected)"
  1033→        return 1
  1034→    fi
  1035→
  1036→    # Verify script exists and is executable
  1037→    if [ ! -f "$RALPH_CUSTOM_NOTIFY_SCRIPT" ]; then
  1038→        $TEST_MODE && echo "  Custom: FAILED (script not found)"
  1039→        return 1
  1040→    fi
  1041→
  1042→    if [ ! -x "$RALPH_CUSTOM_NOTIFY_SCRIPT" ]; then
  1043→        $TEST_MODE && echo "  Custom: FAILED (script not executable)"
  1044→        return 1
  1045→    fi
  1046→
  1047→    # Check script ownership (must be owned by current user or root)
  1048→    local script_owner
  1049→    script_owner=$(stat -c '%U' "$RALPH_CUSTOM_NOTIFY_SCRIPT" 2>/dev/null || echo "unknown")
  1050→    if [ "$script_owner" != "$USER" ] && [ "$script_owner" != "root" ]; then
  1051→        $TEST_MODE && echo "  Custom: FAILED (script not owned by user or root)"
  1052→        return 1
  1053→    fi
  1054→
  1055→    # Security: Check file permissions - should not be world-writable
  1056→    local perms
  1057→    perms=$(stat -c '%a' "$RALPH_CUSTOM_NOTIFY_SCRIPT" 2>/dev/null)
  1058→    if [[ "${perms: -1}" =~ [2367] ]]; then
  1059→        $TEST_MODE && echo "  Custom: FAILED (script is world-writable)"
  1060→        return 1
  1061→    fi
  1062→
  1063→    # Security: Scan script for suspicious content
  1064→    local suspicious_patterns=(
  1065→        'rm -rf /'
  1066→        'dd if='
  1067→        'mkfs\.'
  1068→        '>/dev/sd'
  1069→        'curl.*\|.*sh'
  1070→        'wget.*\|.*sh'
  1071→        'eval.*\$'
  1072→        'base64 -d.*\|.*sh'
  1073→        'nc -e'
  1074→        '/dev/tcp/'
  1075→    )
  1076→
  1077→    for pattern in "${suspicious_patterns[@]}"; do
  1078→        if grep -qE "$pattern" "$RALPH_CUSTOM_NOTIFY_SCRIPT" 2>/dev/null; then
  1079→            $TEST_MODE && echo "  Custom: FAILED (suspicious content detected: $pattern)"
  1080→            return 1
  1081→        fi
  1082→    done
  1083→
  1084→    # Security: For root-owned scripts, require user confirmation
  1085→    if [ "$script_owner" = "root" ]; then
  1086→        # Check if we've already confirmed this script (cache confirmation)
  1087→        local script_hash
  1088→        script_hash=$(sha256sum "$RALPH_CUSTOM_NOTIFY_SCRIPT" 2>/dev/null | awk '{print $1}')
  1089→        # Use platform-appropriate home directory
  1090→        if command -v get_home_dir &>/dev/null; then
  1091→            local user_home=$(get_home_dir)
  1092→        else
  1093→            local user_home="${HOME}"
  1094→        fi
  1095→        local confirm_file="${user_home}/.ralph_custom_script_confirmed"
  1096→
  1097→        if [ ! -f "$confirm_file" ] || ! grep -q "^${script_hash}$" "$confirm_file" 2>/dev/null; then
  1098→            # Interactive confirmation required
  1099→            if [ -t 0 ]; then
  1100→                echo ""
  1101→                echo "WARNING: Custom notification script is owned by root:"
  1102→                echo "  Path: $RALPH_CUSTOM_NOTIFY_SCRIPT"
  1103→                echo "  Owner: $script_owner"
  1104→                echo ""
  1105→                read -p "Do you want to execute this script? (yes/no): " -r confirm
  1106→                echo ""
  1107→
  1108→                if [[ ! "$confirm" =~ ^[Yy][Ee][Ss]$ ]]; then
  1109→                    $TEST_MODE && echo "  Custom: SKIPPED (user declined root-owned script)"
  1110→                    return 1
  1111→                fi
  1112→
  1113→                # Cache confirmation for this script version
  1114→                mkdir -p "$(dirname "$confirm_file")"
  1115→                echo "$script_hash" >> "$confirm_file"
  1116→            else
  1117→                # Non-interactive mode: refuse to run root-owned scripts without prior confirmation
  1118→                $TEST_MODE && echo "  Custom: FAILED (root-owned script requires interactive confirmation)"
  1119→                return 1
  1120→            fi
  1121→        fi
  1122→    fi
  1123→
  1124→    # Strip Slack-style emoji codes for cleaner output (avoid sed on user input)
  1125→    local clean_msg="$msg"
  1126→    clean_msg="${clean_msg//:rocket:/🚀}"
  1127→    clean_msg="${clean_msg//:white_check_mark:/✅}"
  1128→    clean_msg="${clean_msg//:warning:/⚠️}"
  1129→    clean_msg="${clean_msg//:gear:/⚙️}"
  1130→    clean_msg="${clean_msg//:robot_face:/🤖}"
  1131→    clean_msg="${clean_msg//:x:/❌}"
  1132→    clean_msg="${clean_msg//:clipboard:/📋}"
  1133→
  1134→    # Execute script with timeout and capture exit code
  1135→    local exit_code=0
  1136→    local script_timeout="${CUSTOM_SCRIPT_TIMEOUT:-30}"
  1137→    timeout "$script_timeout" "$RALPH_CUSTOM_NOTIFY_SCRIPT" "$clean_msg" > /dev/null 2>&1 || exit_code=$?
  1138→
  1139→    if [ $exit_code -eq 0 ]; then
  1140→        SENT_ANY=true
  1141→        $TEST_MODE && echo "  Custom: sent"
  1142→        return 0
  1143→    elif [ $exit_code -eq 124 ]; then
  1144→        $TEST_MODE && echo "  Custom: FAILED (timeout after ${script_timeout}s)"
  1145→        return 1
  1146→    else
  1147→        $TEST_MODE && echo "  Custom: FAILED (exit code $exit_code)"
  1148→        return 1
  1149→    fi
  1150→}
  1151→
  1152→# ============================================
  1153→# MAIN
  1154→# ============================================
  1155→
  1156→if $TEST_MODE; then
  1157→    echo "Testing Ralph notifications..."
  1158→    echo ""
  1159→    echo "Configured platforms:"
  1160→    if [ -n "${RALPH_SLACK_WEBHOOK_URL:-}" ]; then
  1161→        if validate_webhook_url "$RALPH_SLACK_WEBHOOK_URL"; then
  1162→            echo "  - Slack: configured (valid)"
  1163→        else
  1164→            echo "  - Slack: configured (INVALID URL - must use HTTP or HTTPS)"
  1165→        fi
  1166→    else
  1167→        echo "  - Slack: not configured"
  1168→    fi
  1169→
  1170→    if [ -n "${RALPH_DISCORD_WEBHOOK_URL:-}" ]; then
  1171→        if validate_webhook_url "$RALPH_DISCORD_WEBHOOK_URL"; then
  1172→            echo "  - Discord: configured (valid)"
  1173→        else
  1174→            echo "  - Discord: configured (INVALID URL - must use HTTP or HTTPS)"
  1175→        fi
  1176→    else
  1177→        echo "  - Discord: not configured"
  1178→    fi
  1179→
  1180→    if [ -n "${RALPH_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${RALPH_TELEGRAM_CHAT_ID:-}" ]; then
  1181→        echo "  - Telegram: configured (token: $(mask_token "${RALPH_TELEGRAM_BOT_TOKEN}"))"
  1182→    else
  1183→        echo "  - Telegram: not configured"
  1184→    fi
  1185→
  1186→    # Email configuration check
  1187→    if [ -n "${RALPH_EMAIL_TO:-}" ] && [ -n "${RALPH_EMAIL_FROM:-}" ]; then
  1188→        local email_method="unknown"
  1189→        if [ -n "${RALPH_SENDGRID_API_KEY:-}" ]; then
  1190→            email_method="SendGrid API"
  1191→        elif [ -n "${RALPH_AWS_SES_REGION:-}" ]; then
  1192→            email_method="AWS SES"
  1193→        elif [ -n "${RALPH_SMTP_HOST:-}" ]; then
  1194→            email_method="SMTP ($RALPH_SMTP_HOST)"
  1195→        fi
  1196→        echo "  - Email: configured (to: $RALPH_EMAIL_TO, method: $email_method)"
  1197→    else
  1198→        echo "  - Email: not configured"
  1199→    fi
  1200→
  1201→    if [ -n "${RALPH_CUSTOM_NOTIFY_SCRIPT:-}" ]; then
  1202→        echo "  - Custom: configured ($RALPH_CUSTOM_NOTIFY_SCRIPT)"
  1203→    else
  1204→        echo "  - Custom: not configured"
  1205→    fi
  1206→    echo ""
  1207→    echo "Sending test message..."
  1208→fi
  1209→
  1210→# Check rate limit (skip in test mode)
  1211→if ! $TEST_MODE; then
  1212→    if ! check_rate_limit; then
  1213→        echo "Rate limit exceeded (max $RATE_LIMIT_MAX notifications per minute)" >&2
  1214→        exit 1
  1215→    fi
  1216→fi
  1217→
  1218→# Track errors for better reporting
  1219→declare -A SEND_ERRORS=()
  1220→
  1221→# Send to all configured platforms with individual error tracking
  1222→if ! send_slack "$MESSAGE"; then
  1223→    SEND_ERRORS[slack]="failed"
  1224→fi
  1225→
  1226→if ! send_discord "$MESSAGE"; then
  1227→    SEND_ERRORS[discord]="failed"
  1228→fi
  1229→
  1230→if ! send_telegram "$MESSAGE"; then
  1231→    SEND_ERRORS[telegram]="failed"
  1232→fi
  1233→
  1234→if ! send_email "$MESSAGE"; then
  1235→    SEND_ERRORS[email]="failed"
  1236→fi
  1237→
  1238→if ! send_custom "$MESSAGE"; then
  1239→    SEND_ERRORS[custom]="failed"
  1240→fi
  1241→
  1242→# Cleanup rate limit file on exit
  1243→trap "rm -f '$RATE_LIMIT_FILE' 2>/dev/null || true" EXIT
  1244→
  1245→if $TEST_MODE; then
  1246→    echo ""
  1247→    if $SENT_ANY; then
  1248→        echo "Test complete! Check your notification channels."
  1249→        # Check if SEND_ERRORS has any elements
  1250→        if [ ${#SEND_ERRORS[@]} -gt 0 ] 2>/dev/null; then
  1251→            echo ""
  1252→            echo "Note: Some platforms failed to send. Check configuration."
  1253→        fi
  1254→    else
  1255→        echo "No notifications sent. Configure at least one platform."
  1256→        echo "Run: ralph notify setup"
  1257→    fi
  1258→fi
  1259→
  1260→# Note: We exit 0 even if notifications fail to avoid blocking the main script.
  1261→# The main ralph.sh script should continue even if notifications are down.
  1262→# Errors are logged but don't cause a fatal exit.
  1263→
  1264→exit 0
  1265→
```

## Assistant

### Tool: Read

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh"
}
```

## Assistant

**Result** (success):
```
     1→#!/bin/bash
     2→# Ralph - Autonomous AI Development Loop
     3→# Usage: ralph <plan-file> [plan|build] [max-iterations]
     4→#
     5→# Examples:
     6→#   ralph ./my-feature-plan.md           # Build mode (default), runs until RALPH_DONE
     7→#   ralph ./my-feature-plan.md plan      # Plan mode, generates implementation tasks
     8→#   ralph ./my-feature-plan.md build 20  # Build mode, max 20 iterations
     9→#
    10→# Exit conditions:
    11→#   - Plan mode: Exits after 1 iteration (planning complete)
    12→#   - Build mode: "RALPH_DONE" appears in progress file
    13→#   - Max iterations reached (if specified)
    14→#   - Ctrl+C
    15→#
    16→# Progress is tracked in: <plan-name>_PROGRESS.md (in current directory)
    17→
    18→set -euo pipefail
    19→
    20→RALPH_DIR="$(cd "$(dirname "$0")" && pwd)"
    21→
    22→# Load constants
    23→if [ -f "$RALPH_DIR/lib/constants.sh" ]; then
    24→    source "$RALPH_DIR/lib/constants.sh"
    25→fi
    26→
    27→# Load platform utilities for cross-platform support
    28→if [ -f "$RALPH_DIR/lib/platform-utils.sh" ]; then
    29→    source "$RALPH_DIR/lib/platform-utils.sh"
    30→fi
    31→
    32→# Colors (defined early for use in validation messages)
    33→RED='\033[0;31m'
    34→GREEN='\033[0;32m'
    35→YELLOW='\033[1;33m'
    36→BLUE='\033[0;34m'
    37→NC='\033[0m' # No Color
    38→
    39→# Log directory for errors (use platform-appropriate paths)
    40→if command -v get_home_dir &>/dev/null && command -v get_temp_dir &>/dev/null; then
    41→    USER_HOME=$(get_home_dir)
    42→    TEMP_DIR=$(get_temp_dir)
    43→else
    44→    USER_HOME="${HOME}"
    45→    TEMP_DIR="${TMPDIR:-/tmp}"
    46→fi
    47→
    48→LOG_DIR="${USER_HOME}/.portableralph/logs"
    49→if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
    50→    echo "Warning: Could not create log directory: $LOG_DIR" >&2
    51→    LOG_DIR="${TEMP_DIR}/ralph_logs"
    52→    mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=""
    53→fi
    54→
    55→# Error logging function
    56→log_error() {
    57→    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    58→    local message="$1"
    59→    local logfile="${LOG_DIR}/ralph_$(date '+%Y%m%d').log"
    60→
    61→    # Log to file if LOG_DIR is available
    62→    if [ -n "$LOG_DIR" ] && [ -d "$LOG_DIR" ]; then
    63→        if ! echo "[$timestamp] ERROR: $message" >> "$logfile" 2>/dev/null; then
    64→            # If logging to file fails, at least note it on stderr
    65→            echo "[$timestamp] WARNING: Failed to write to log file: $logfile" >&2
    66→        fi
    67→    fi
    68→
    69→    # Always log to stderr
    70→    echo -e "${RED}Error: $message${NC}" >&2
    71→}
    72→
    73→# Source shared validation library
    74→source "${RALPH_DIR}/lib/validation.sh"
    75→
    76→# The following validation functions are now loaded from lib/validation.sh:
    77→# - validate_webhook_url() / validate_url()
    78→# - validate_numeric()
    79→# - validate_email()
    80→# - validate_file_path() / validate_path()
    81→# - json_escape()
    82→# - mask_token()
    83→
    84→# Just use the library's validate_path function
    85→# Users should be able to use whatever plan files they want
    86→validate_file_path() {
    87→    validate_path "$@"
    88→}
    89→
    90→# Validate config file syntax before sourcing
    91→validate_config() {
    92→    local config_file="$1"
    93→
    94→    # Check if file exists
    95→    if [ ! -f "$config_file" ]; then
    96→        return 0  # File doesn't exist, nothing to validate
    97→    fi
    98→
    99→    # Just check basic bash syntax
   100→    if ! bash -n "$config_file" 2>/dev/null; then
   101→        echo -e "${YELLOW}Warning: Syntax error in $config_file${NC}" >&2
   102→        echo -e "${YELLOW}Run: bash -n $config_file to see details${NC}" >&2
   103→        return 1
   104→    fi
   105→
   106→    return 0
   107→}
   108→
   109→# Load configuration (use platform-appropriate config location)
   110→RALPH_CONFIG_FILE="${USER_HOME}/.ralph.env"
   111→if [ -f "$RALPH_CONFIG_FILE" ] && validate_config "$RALPH_CONFIG_FILE"; then
   112→    source "$RALPH_CONFIG_FILE"
   113→
   114→    # Decrypt encrypted environment variables BEFORE validation
   115→    if [ -f "$RALPH_DIR/decrypt-env.sh" ]; then
   116→        source "$RALPH_DIR/decrypt-env.sh"
   117→        # Call decrypt_ralph_env directly to allow variable updates
   118→        # Capture any errors but don't pipe the output (which would run in a subshell)
   119→        if ! decrypt_ralph_env; then
   120→            echo -e "${YELLOW}Warning: Failed to decrypt some environment variables${NC}" >&2
   121→            echo "Some notification platforms may not work correctly" >&2
   122→        fi
   123→    fi
   124→
   125→    # Validate loaded configuration values (now with decrypted values)
   126→    if [ -n "${RALPH_SLACK_WEBHOOK_URL:-}" ]; then
   127→        if ! validate_webhook_url "$RALPH_SLACK_WEBHOOK_URL" "RALPH_SLACK_WEBHOOK_URL"; then
   128→            echo -e "${YELLOW}Warning: Invalid RALPH_SLACK_WEBHOOK_URL, disabling Slack notifications${NC}" >&2
   129→            unset RALPH_SLACK_WEBHOOK_URL
   130→        fi
   131→    fi
   132→
   133→    if [ -n "${RALPH_DISCORD_WEBHOOK_URL:-}" ]; then
   134→        if ! validate_webhook_url "$RALPH_DISCORD_WEBHOOK_URL" "RALPH_DISCORD_WEBHOOK_URL"; then
   135→            echo -e "${YELLOW}Warning: Invalid RALPH_DISCORD_WEBHOOK_URL, disabling Discord notifications${NC}" >&2
   136→            unset RALPH_DISCORD_WEBHOOK_URL
   137→        fi
   138→    fi
   139→
   140→    if [ -n "${RALPH_EMAIL_TO:-}" ]; then
   141→        if ! validate_email "$RALPH_EMAIL_TO" "RALPH_EMAIL_TO"; then
   142→            echo -e "${YELLOW}Warning: Invalid RALPH_EMAIL_TO, disabling email notifications${NC}" >&2
   143→            unset RALPH_EMAIL_TO
   144→        fi
   145→    fi
   146→
   147→    if [ -n "${RALPH_EMAIL_FROM:-}" ]; then
   148→        if ! validate_email "$RALPH_EMAIL_FROM" "RALPH_EMAIL_FROM"; then
   149→            echo -e "${YELLOW}Warning: Invalid RALPH_EMAIL_FROM, disabling email notifications${NC}" >&2
   150→            unset RALPH_EMAIL_FROM
   151→        fi
   152→    fi
   153→
   154→    if [ -n "${RALPH_NOTIFY_FREQUENCY:-}" ]; then
   155→        # Note: Not using 'local' here since we're at top level, not in a function
   156→        _notify_min="${NOTIFY_FREQUENCY_MIN:-1}"
   157→        _notify_max="${NOTIFY_FREQUENCY_MAX:-100}"
   158→        _notify_default="${NOTIFY_FREQUENCY_DEFAULT:-5}"
   159→        if ! validate_numeric "$RALPH_NOTIFY_FREQUENCY" "RALPH_NOTIFY_FREQUENCY" "$_notify_min" "$_notify_max"; then
   160→            echo -e "${YELLOW}Warning: Invalid RALPH_NOTIFY_FREQUENCY, using default: ${_notify_default}${NC}" >&2
   161→            export RALPH_NOTIFY_FREQUENCY="$_notify_default"
   162→        fi
   163→        unset _notify_min _notify_max _notify_default
   164→    fi
   165→fi
   166→
   167→VERSION="1.7.0"
   168→
   169→# Auto-commit setting (default: true)
   170→# Can be disabled via: ralph config commit off
   171→# Or by adding DO_NOT_COMMIT on its own line in the plan file
   172→RALPH_AUTO_COMMIT="${RALPH_AUTO_COMMIT:-true}"
   173→
   174→# Model setting (default: sonnet)
   175→# Can be set via: export RALPH_MODEL="claude-opus-4-6" in ~/.ralph.env
   176→RALPH_MODEL="${RALPH_MODEL:-sonnet}"
   177→
   178→# Check if plan file contains DO_NOT_COMMIT directive
   179→# Skips content inside ``` code blocks to avoid false positives
   180→should_skip_commit_from_plan() {
   181→    local plan_file="$1"
   182→    [ ! -f "$plan_file" ] && return 1
   183→
   184→    # Use awk to skip code blocks and find DO_NOT_COMMIT on its own line
   185→    # Handles whitespace before/after the directive
   186→    awk '
   187→        /^```/ { in_code = !in_code; next }
   188→        !in_code && /^[[:space:]]*DO_NOT_COMMIT[[:space:]]*$/ { found=1; exit }
   189→        END { exit !found }
   190→    ' "$plan_file"
   191→}
   192→
   193→# Notification helper with retry logic and error logging
   194→notify() {
   195→    local message="$1"
   196→    local emoji="${2:-}"
   197→    local max_retries="${NOTIFY_MAX_RETRIES:-3}"
   198→    local retry_delay="${NOTIFY_RETRY_DELAY:-2}"
   199→    local attempt=1
   200→    local notification_log="${LOG_DIR}/ralph_notifications_$(date '+%Y%m%d').log"
   201→
   202→    # Skip if LOG_DIR is not available (use platform-appropriate null device)
   203→    if [ -z "$LOG_DIR" ] || [ ! -d "$LOG_DIR" ]; then
   204→        if command -v get_null_device &>/dev/null; then
   205→            notification_log="$(get_null_device)"
   206→        else
   207→            notification_log="/dev/null"
   208→        fi
   209→    fi
   210→
   211→    while [ $attempt -le $max_retries ]; do
   212→        local notify_output
   213→        local notify_exit=0
   214→
   215→        # Capture output and exit code
   216→        notify_output=$("$RALPH_DIR/notify.sh" "$message" "$emoji" 2>&1) || notify_exit=$?
   217→
   218→        # Log the output with proper error handling (check for null device)
   219→        local null_dev="/dev/null"
   220→        if command -v get_null_device &>/dev/null; then
   221→            null_dev="$(get_null_device)"
   222→        fi
   223→        if [ "$notification_log" != "$null_dev" ] && [ "$notification_log" != "NUL" ]; then
   224→            if ! echo "[$(date '+%Y-%m-%d %H:%M:%S')] Attempt $attempt: $notify_output" >> "$notification_log" 2>/dev/null; then
   225→                # If logging fails, report to stderr but don't fail the notification
   226→                echo "Warning: Failed to write to notification log: $notification_log" >&2
   227→            fi
   228→        fi
   229→
   230→        if [ $notify_exit -eq 0 ]; then
   231→            return 0
   232→        fi
   233→
   234→        if [ $attempt -lt $max_retries ]; then
   235→            log_error "Notification attempt $attempt/$max_retries failed (exit $notify_exit), retrying in ${retry_delay}s..."
   236→            sleep $retry_delay
   237→            # Exponential backoff
   238→            retry_delay=$((retry_delay * 2))
   239→        else
   240→            local msg_truncate="${MESSAGE_TRUNCATE_LENGTH:-100}"
   241→            log_error "Notification failed after $max_retries attempts (exit $notify_exit): ${message:0:$msg_truncate}..."
   242→        fi
   243→
   244→        attempt=$((attempt + 1))
   245→    done
   246→
   247→    return 1  # Failed after all retries
   248→}
   249→
   250→# Check if any notification platform is configured
   251→notifications_enabled() {
   252→    [ -n "${RALPH_SLACK_WEBHOOK_URL:-}" ] || \
   253→    [ -n "${RALPH_DISCORD_WEBHOOK_URL:-}" ] || \
   254→    ([ -n "${RALPH_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${RALPH_TELEGRAM_CHAT_ID:-}" ]) || \
   255→    [ -n "${RALPH_CUSTOM_NOTIFY_SCRIPT:-}" ]
   256→}
   257→
   258→usage() {
   259→    echo -e "${GREEN}PortableRalph${NC} v${VERSION} - Autonomous AI Development Loop"
   260→    echo ""
   261→    echo -e "${YELLOW}Usage:${NC}"
   262→    echo "  ralph <plan-file> [mode] [max-iterations]"
   263→    echo "  ralph update [--check|--list|<version>]"
   264→    echo "  ralph rollback"
   265→    echo "  ralph config <setting>"
   266→    echo "  ralph notify <setup|test>"
   267→    echo "  ralph --help | -h"
   268→    echo "  ralph --version | -v"
   269→    echo ""
   270→    echo -e "${YELLOW}Full path:${NC} ~/ralph/ralph.sh (alias: ralph)"
   271→    echo ""
   272→    echo -e "${YELLOW}Arguments:${NC}"
   273→    echo "  plan-file       Path to your plan/spec file (required)"
   274→    echo "  mode            'plan' or 'build' (default: build)"
   275→    echo "  max-iterations  Maximum loop iterations (default: unlimited)"
   276→    echo ""
   277→    echo -e "${YELLOW}Modes:${NC}"
   278→    echo "  plan   Analyze codebase, create task list (runs once, then exits)"
   279→    echo "  build  Implement tasks one at a time until RALPH_DONE"
   280→    echo ""
   281→    echo -e "${YELLOW}Examples:${NC}"
   282→    echo "  ralph ./feature.md              # Build until done"
   283→    echo "  ralph ./feature.md plan         # Plan only (creates task list, exits)"
   284→    echo "  ralph ./feature.md build 20     # Build, max 20 iterations"
   285→    echo ""
   286→    echo -e "${YELLOW}Exit Conditions:${NC}"
   287→    echo "  - Plan mode: Exits after 1 iteration when task list is created"
   288→    echo "  - Build mode: RALPH_DONE appears in <plan-name>_PROGRESS.md"
   289→    echo "  - Max iterations reached (if specified)"
   290→    echo "  - Ctrl+C"
   291→    echo ""
   292→    echo -e "${YELLOW}Progress File:${NC}"
   293→    echo "  Created as <plan-name>_PROGRESS.md in current directory"
   294→    echo "  This is the only artifact left in your repo"
   295→    echo ""
   296→    echo -e "${YELLOW}Updates:${NC}"
   297→    echo "  ralph update              Update to latest version"
   298→    echo "  ralph update --check      Check for updates without installing"
   299→    echo "  ralph update --list       List all available versions"
   300→    echo "  ralph update <version>    Install specific version (e.g., 1.4.0)"
   301→    echo "  ralph rollback            Rollback to previous version"
   302→    echo ""
   303→    echo -e "${YELLOW}Configuration:${NC}"
   304→    echo "  ralph config commit on      Enable auto-commit (default)"
   305→    echo "  ralph config commit off     Disable auto-commit"
   306→    echo "  ralph config commit status  Show current setting"
   307→    echo ""
   308→    echo -e "${YELLOW}Plan File Directives:${NC}"
   309→    echo "  Add DO_NOT_COMMIT on its own line to disable commits for that plan"
   310→    echo ""
   311→    echo -e "${YELLOW}Notifications (optional):${NC}"
   312→    echo "  Supports Slack, Discord, Telegram, and custom scripts"
   313→    echo "  ralph notify setup    Configure notification platforms"
   314→    echo "  ralph notify test     Send a test notification"
   315→    echo ""
   316→    echo "More info: https://github.com/aaron777collins/portableralph"
   317→    exit 0
   318→}
   319→
   320→version() {
   321→    echo "PortableRalph v${VERSION}"
   322→    exit 0
   323→}
   324→
   325→# Parse arguments
   326→if [ $# -lt 1 ]; then
   327→    usage
   328→fi
   329→
   330→# Handle help and version flags
   331→if [ "$1" = "--help" ] || [ "$1" = "-h" ] || [ "$1" = "help" ]; then
   332→    usage
   333→fi
   334→
   335→if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then
   336→    version
   337→fi
   338→
   339→if [ "$1" = "--test-notify" ] || [ "$1" = "--test-notifications" ]; then
   340→    "$RALPH_DIR/notify.sh" --test
   341→    exit 0
   342→fi
   343→
   344→# Handle update subcommand
   345→if [ "$1" = "update" ]; then
   346→    exec "$RALPH_DIR/update.sh" "${@:2}"
   347→fi
   348→
   349→# Handle rollback subcommand
   350→if [ "$1" = "rollback" ]; then
   351→    exec "$RALPH_DIR/update.sh" --rollback
   352→fi
   353→
   354→# Handle notify subcommand
   355→if [ "$1" = "notify" ]; then
   356→    case "${2:-}" in
   357→        setup)
   358→            exec "$RALPH_DIR/setup-notifications.sh"
   359→            ;;
   360→        test)
   361→            exec "$RALPH_DIR/notify.sh" --test
   362→            ;;
   363→        "")
   364→            echo -e "${YELLOW}Usage:${NC} ralph notify <command>"
   365→            echo ""
   366→            echo -e "${YELLOW}Commands:${NC}"
   367→            echo "  setup    Configure Slack, Discord, Telegram, or custom notifications"
   368→            echo "  test     Send a test notification to all configured platforms"
   369→            exit 1
   370→            ;;
   371→        *)
   372→            echo -e "${RED}Unknown notify command: $2${NC}"
   373→            echo "Run 'ralph notify' for available commands."
   374→            exit 1
   375→            ;;
   376→    esac
   377→fi
   378→
   379→# Handle config subcommand
   380→if [ "$1" = "config" ]; then
   381→    CONFIG_FILE="$RALPH_CONFIG_FILE"
   382→
   383→    # Helper to set a config value (handles both export and non-export patterns)
   384→    set_config_value() {
   385→        local key="$1"
   386→        local value="$2"
   387→
   388→        # Security: Escape special characters in value for safe sed usage
   389→        # This prevents sed injection by escaping: / \ & newlines and special chars
   390→        local escaped_value
   391→        escaped_value=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g' -e 's/$/\\/' | tr -d '\n' | sed 's/\\$//')
   392→
   393→        if [ -f "$CONFIG_FILE" ]; then
   394→            # Check if key exists (with or without export)
   395→            if grep -qE "^(export )?${key}=" "$CONFIG_FILE" 2>/dev/null; then
   396→                # Update existing (handle both patterns)
   397→                # Use a temporary file for atomic operation
   398→                local temp_file
   399→                temp_file=$(mktemp) || {
   400→                    log_error "Failed to create temp file for config update"
   401→                    return 1
   402→                }
   403→                chmod 600 "$temp_file"
   404→                trap 'rm -f "$temp_file" 2>/dev/null' RETURN
   405→
   406→                # Process the file line by line to avoid sed injection
   407→                while IFS= read -r line || [ -n "$line" ]; do
   408→                    if [[ "$line" =~ ^export\ ${key}= ]] || [[ "$line" =~ ^${key}= ]]; then
   409→                        echo "export ${key}=\"${escaped_value}\""
   410→                    else
   411→                        echo "$line"
   412→                    fi
   413→                done < "$CONFIG_FILE" > "$temp_file"
   414→
   415→                mv "$temp_file" "$CONFIG_FILE"
   416→            else
   417→                # Append to existing file (preserve content)
   418→                echo "" >> "$CONFIG_FILE"
   419→                echo "# Auto-commit setting" >> "$CONFIG_FILE"
   420→                echo "export ${key}=\"${escaped_value}\"" >> "$CONFIG_FILE"
   421→            fi
   422→        else
   423→            # Create new file
   424→            echo '# PortableRalph Configuration' > "$CONFIG_FILE"
   425→            echo "# Generated on $(date)" >> "$CONFIG_FILE"
   426→            echo "" >> "$CONFIG_FILE"
   427→            echo "export ${key}=\"${escaped_value}\"" >> "$CONFIG_FILE"
   428→            chmod 600 "$CONFIG_FILE"
   429→        fi
   430→    }
   431→
   432→    case "${2:-}" in
   433→        commit)
   434→            case "${3:-}" in
   435→                on|true|yes|1)
   436→                    set_config_value "RALPH_AUTO_COMMIT" "true"
   437→                    echo -e "${GREEN}Auto-commit enabled${NC}"
   438→                    echo "Ralph will commit after each iteration."
   439→                    ;;
   440→                off|false|no|0)
   441→                    set_config_value "RALPH_AUTO_COMMIT" "false"
   442→                    echo -e "${YELLOW}Auto-commit disabled${NC}"
   443→                    echo "Ralph will NOT commit after each iteration."
   444→                    echo "You can also add DO_NOT_COMMIT on its own line in your plan file."
   445→                    ;;
   446→                status|"")
   447→                    echo -e "${YELLOW}Auto-commit setting:${NC}"
   448→                    if [ "$RALPH_AUTO_COMMIT" = "true" ]; then
   449→                        echo -e "  Current: ${GREEN}enabled${NC} (commits after each iteration)"
   450→                    else
   451→                        echo -e "  Current: ${YELLOW}disabled${NC} (no automatic commits)"
   452→                    fi
   453→                    echo ""
   454→                    echo -e "${YELLOW}Usage:${NC}"
   455→                    echo "  ralph config commit on     Enable auto-commit (default)"
   456→                    echo "  ralph config commit off    Disable auto-commit"
   457→                    echo ""
   458→                    echo -e "${YELLOW}Plan file override:${NC}"
   459→                    echo "  Add DO_NOT_COMMIT on its own line to disable commits for that plan"
   460→                    ;;
   461→                *)
   462→                    echo -e "${RED}Unknown option: $3${NC}"
   463→                    echo "Usage: ralph config commit <on|off|status>"
   464→                    exit 1
   465→                    ;;
   466→            esac
   467→            exit 0
   468→            ;;
   469→        "")
   470→            echo -e "${YELLOW}Usage:${NC} ralph config <setting>"
   471→            echo ""
   472→            echo -e "${YELLOW}Settings:${NC}"
   473→            echo "  commit <on|off|status>    Configure auto-commit behavior"
   474→            exit 1
   475→            ;;
   476→        *)
   477→            echo -e "${RED}Unknown config setting: $2${NC}"
   478→            echo "Run 'ralph config' for available settings."
   479→            exit 1
   480→            ;;
   481→    esac
   482→fi
   483→
   484→PLAN_FILE="$1"
   485→MODE="${2:-build}"
   486→MAX_ITERATIONS="${3:-${MAX_ITERATIONS_DEFAULT:-0}}"
   487→
   488→# Validate plan file path and existence
   489→if ! validate_file_path "$PLAN_FILE" "Plan file"; then
   490→    exit 1
   491→fi
   492→
   493→if [ ! -f "$PLAN_FILE" ]; then
   494→    log_error "Plan file not found: $PLAN_FILE"
   495→    exit 1
   496→fi
   497→
   498→# Validate mode
   499→if [ "$MODE" != "plan" ] && [ "$MODE" != "build" ]; then
   500→    log_error "Mode must be 'plan' or 'build', got: $MODE"
   501→    echo "Run 'ralph --help' for usage information."
   502→    exit 1
   503→fi
   504→
   505→# Validate max iterations
   506→if [ "$MAX_ITERATIONS" != "0" ]; then
   507→    max_iter_min="${MAX_ITERATIONS_MIN:-1}"
   508→    max_iter_max="${MAX_ITERATIONS_MAX:-10000}"
   509→    if ! validate_numeric "$MAX_ITERATIONS" "Max iterations" "$max_iter_min" "$max_iter_max"; then
   510→        exit 1
   511→    fi
   512→fi
   513→
   514→# Derive progress file name from plan file
   515→PLAN_BASENAME=$(basename "$PLAN_FILE" .md)
   516→PROGRESS_FILE="${PLAN_BASENAME}_PROGRESS.md"
   517→GUARDRAILS_FILE="RALPH_GUARDRAILS.md"
   518→PLAN_FILE_ABS=$(realpath "$PLAN_FILE")
   519→
   520→# Select prompt template
   521→if [ "$MODE" = "plan" ]; then
   522→    PROMPT_TEMPLATE="$RALPH_DIR/PROMPT_plan.md"
   523→else
   524→    PROMPT_TEMPLATE="$RALPH_DIR/PROMPT_build.md"
   525→fi
   526→
   527→# Verify prompt template exists
   528→if [ ! -f "$PROMPT_TEMPLATE" ]; then
   529→    echo -e "${RED}Error: Prompt template not found: $PROMPT_TEMPLATE${NC}"
   530→    echo "Run the setup script or create the template manually."
   531→    exit 1
   532→fi
   533→
   534→# Compute commit setting (check env var and plan file)
   535→# DO_NOT_COMMIT in plan file takes precedence for that specific plan
   536→SHOULD_COMMIT="true"
   537→COMMIT_DISABLED_REASON=""
   538→if [ "$RALPH_AUTO_COMMIT" != "true" ]; then
   539→    SHOULD_COMMIT="false"
   540→    COMMIT_DISABLED_REASON="(disabled via config)"
   541→elif should_skip_commit_from_plan "$PLAN_FILE"; then
   542→    SHOULD_COMMIT="false"
   543→    COMMIT_DISABLED_REASON="(DO_NOT_COMMIT in plan)"
   544→fi
   545→
   546→# Print banner
   547→echo ""
   548→echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   549→echo -e "${GREEN}  RALPH - Autonomous AI Development Loop${NC}"
   550→echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   551→echo -e "  Plan:      ${YELLOW}$PLAN_FILE${NC}"
   552→echo -e "  Mode:      ${YELLOW}$MODE${NC}"
   553→echo -e "  Progress:  ${YELLOW}$PROGRESS_FILE${NC}"
   554→[ "$MAX_ITERATIONS" -gt 0 ] && echo -e "  Max Iter:  ${YELLOW}$MAX_ITERATIONS${NC}"
   555→echo -e "  Model:     ${YELLOW}$RALPH_MODEL${NC}"
   556→if [ "$SHOULD_COMMIT" = "true" ]; then
   557→    echo -e "  Commit:    ${GREEN}enabled${NC}"
   558→else
   559→    echo -e "  Commit:    ${YELLOW}disabled${NC} ${COMMIT_DISABLED_REASON}"
   560→fi
   561→if notifications_enabled; then
   562→    PLATFORMS=""
   563→    [ -n "${RALPH_SLACK_WEBHOOK_URL:-}" ] && PLATFORMS="${PLATFORMS}Slack "
   564→    [ -n "${RALPH_DISCORD_WEBHOOK_URL:-}" ] && PLATFORMS="${PLATFORMS}Discord "
   565→    [ -n "${RALPH_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${RALPH_TELEGRAM_CHAT_ID:-}" ] && PLATFORMS="${PLATFORMS}Telegram "
   566→    [ -n "${RALPH_CUSTOM_NOTIFY_SCRIPT:-}" ] && PLATFORMS="${PLATFORMS}Custom "
   567→    echo -e "  Notify:    ${GREEN}${PLATFORMS}${NC}"
   568→else
   569→    echo -e "  Notify:    ${YELLOW}disabled${NC} (run 'ralph notify setup')"
   570→fi
   571→if [ -f "$GUARDRAILS_FILE" ]; then
   572→    GUARDRAILS_LINES=$(wc -l < "$GUARDRAILS_FILE")
   573→    guardrails_warn="${GUARDRAILS_WARN_LIMIT:-100}"
   574→    if [ "$GUARDRAILS_LINES" -gt "$guardrails_warn" ]; then
   575→        echo -e "  Guardrails:${YELLOW} $GUARDRAILS_FILE ($GUARDRAILS_LINES lines - consider consolidating)${NC}"
   576→    else
   577→        echo -e "  Guardrails:${GREEN} $GUARDRAILS_FILE ($GUARDRAILS_LINES lines)${NC}"
   578→    fi
   579→else
   580→    echo -e "  Guardrails:${YELLOW} none (created when needed)${NC}"
   581→fi
   582→echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   583→echo ""
   584→echo -e "${YELLOW}Exit conditions:${NC}"
   585→if [ "$MODE" = "plan" ]; then
   586→    echo "  - Planning completes when task list is created (Status: IN_PROGRESS)"
   587→    echo "  - Plan mode runs once then exits automatically"
   588→else
   589→    echo "  - RALPH_DONE in $PROGRESS_FILE signals all tasks complete (set by AI)"
   590→fi
   591→echo "  - Press Ctrl+C to stop manually"
   592→echo ""
   593→
   594→# Send start notification to Slack
   595→REPO_NAME=$(basename "$(pwd)")
   596→notify ":rocket: *Ralph Started*\n\`\`\`Plan: $PLAN_BASENAME\nMode: $MODE\nRepo: $REPO_NAME\`\`\`" ":rocket:"
   597→
   598→# Initialize progress file if it doesn't exist
   599→if [ ! -f "$PROGRESS_FILE" ]; then
   600→    echo "# Progress: $PLAN_BASENAME" > "$PROGRESS_FILE"
   601→    echo "" >> "$PROGRESS_FILE"
   602→    echo "Started: $(date)" >> "$PROGRESS_FILE"
   603→    echo "" >> "$PROGRESS_FILE"
   604→    echo "## Status" >> "$PROGRESS_FILE"
   605→    echo "" >> "$PROGRESS_FILE"
   606→    echo "IN_PROGRESS" >> "$PROGRESS_FILE"
   607→    echo "" >> "$PROGRESS_FILE"
   608→    echo "## Tasks Completed" >> "$PROGRESS_FILE"
   609→    echo "" >> "$PROGRESS_FILE"
   610→fi
   611→
   612→ITERATION=0
   613→
   614→# Check for completion
   615→# Uses -x to match whole lines only, preventing false positives from
   616→# instructional text like "DO NOT set RALPH_DONE" in the progress file
   617→is_done() {
   618→    if [ -f "$PROGRESS_FILE" ]; then
   619→        grep -qx "RALPH_DONE" "$PROGRESS_FILE" 2>/dev/null && return 0
   620→    fi
   621→    return 1
   622→}
   623→
   624→# ============================================================================
   625→# CONCURRENCY PROTECTION (Fix for GitHub Issue #1)
   626→# ============================================================================
   627→# Prevent multiple Ralph instances from running on the same plan file
   628→# This fixes API Error 400 due to tool use race conditions
   629→
   630→# Generate lock file path based on plan file (unique per plan)
   631→PLAN_HASH=$(echo "$PLAN_FILE_ABS" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "$PLAN_BASENAME")
   632→LOCK_FILE="${TEMP_DIR:-/tmp}/ralph_${PLAN_HASH}.lock"
   633→
   634→# Cleanup function for graceful exit
   635→cleanup_lock() {
   636→    if [ -n "${LOCK_FILE:-}" ]; then
   637→        release_lock "$LOCK_FILE" 2>/dev/null || true
   638→    fi
   639→}
   640→
   641→# Set up trap to release lock on exit (normal or error)
   642→trap cleanup_lock EXIT INT TERM
   643→
   644→# Acquire lock before starting the main loop
   645→if ! acquire_lock "$LOCK_FILE"; then
   646→    echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   647→    echo -e "${RED}  ERROR: Another Ralph instance is already running this plan${NC}"
   648→    echo -e "${RED}  Plan: $PLAN_FILE${NC}"
   649→    echo -e "${RED}  Lock: $LOCK_FILE${NC}"
   650→    echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   651→    echo ""
   652→    echo -e "${YELLOW}To force a new instance, remove the lock file:${NC}"
   653→    echo -e "  rm -f $LOCK_FILE"
   654→    exit 1
   655→fi
   656→
   657→echo -e "${GREEN}Lock acquired: $LOCK_FILE${NC}"
   658→
   659→# Main loop
   660→while true; do
   661→    # Check exit conditions
   662→    if is_done; then
   663→        echo ""
   664→        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   665→        echo -e "${GREEN}  RALPH_DONE - Work complete!${NC}"
   666→        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   667→        notify ":white_check_mark: *Ralph Complete!*\n\`\`\`Plan: $PLAN_BASENAME\nIterations: $ITERATION\nRepo: $REPO_NAME\`\`\`" ":white_check_mark:"
   668→        break
   669→    fi
   670→
   671→    if [ "$MAX_ITERATIONS" -gt 0 ] && [ "$ITERATION" -ge "$MAX_ITERATIONS" ]; then
   672→        echo ""
   673→        echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   674→        echo -e "${YELLOW}  Max iterations reached: $MAX_ITERATIONS${NC}"
   675→        echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   676→        notify ":warning: *Ralph Stopped*\n\`\`\`Plan: $PLAN_BASENAME\nReason: Max iterations reached ($MAX_ITERATIONS)\nRepo: $REPO_NAME\`\`\`" ":warning:"
   677→        break
   678→    fi
   679→
   680→    ITERATION=$((ITERATION + 1))
   681→    echo ""
   682→    echo -e "${BLUE}══════════════════ ITERATION $ITERATION ══════════════════${NC}"
   683→    echo ""
   684→
   685→    # Build the prompt with substitutions (safely escape for sed to prevent injection)
   686→    # Escape special sed characters: & \ / newlines
   687→    escape_sed() {
   688→        local str="$1"
   689→        # Escape backslashes first, then forward slashes, then ampersands
   690→        str="${str//\\/\\\\}"
   691→        str="${str//\//\\/}"
   692→        str="${str//&/\\&}"
   693→        printf '%s' "$str"
   694→    }
   695→
   696→    # Variables for sed escaping (not using local since we're in main loop, not a function)
   697→    safe_plan_file=$(escape_sed "$PLAN_FILE_ABS")
   698→    safe_progress_file=$(escape_sed "$PROGRESS_FILE")
   699→    safe_plan_name=$(escape_sed "$PLAN_BASENAME")
   700→    safe_should_commit=$(escape_sed "$SHOULD_COMMIT")
   701→    safe_guardrails_file=$(escape_sed "$GUARDRAILS_FILE")
   702→    safe_guardrails_soft_limit=$(escape_sed "${GUARDRAILS_SOFT_LIMIT:-50}")
   703→
   704→    PROMPT=$(cat "$PROMPT_TEMPLATE" | \
   705→        sed "s|\${PLAN_FILE}|$safe_plan_file|g" | \
   706→        sed "s|\${PROGRESS_FILE}|$safe_progress_file|g" | \
   707→        sed "s|\${PLAN_NAME}|$safe_plan_name|g" | \
   708→        sed "s|\${AUTO_COMMIT}|$safe_should_commit|g" | \
   709→        sed "s|\${GUARDRAILS_FILE}|$safe_guardrails_file|g" | \
   710→        sed "s|\${GUARDRAILS_SOFT_LIMIT}|$safe_guardrails_soft_limit|g")
   711→
   712→    # Run Claude with retry logic (configurable attempts with exponential backoff)
   713→    max_claude_retries="${CLAUDE_MAX_RETRIES:-3}"
   714→    claude_retry_delay="${CLAUDE_RETRY_DELAY:-5}"
   715→    claude_attempt=1
   716→    claude_success=false
   717→    claude_exit_code=0
   718→    claude_errors=""
   719→    error_detected=false
   720→    error_type="unknown"
   721→
   722→    while [ $claude_attempt -le $max_claude_retries ]; do
   723→        if [ $claude_attempt -gt 1 ]; then
   724→            echo -e "${YELLOW}Retrying Claude CLI (attempt $claude_attempt/$max_claude_retries) in ${claude_retry_delay}s...${NC}"
   725→            sleep $claude_retry_delay
   726→        fi
   727→
   728→        # Reset for this attempt
   729→        claude_exit_code=0
   730→        error_detected=false
   731→        error_type="unknown"
   732→        claude_output_file=$(mktemp) || {
   733→            log_error "Failed to create temp file for Claude output"
   734→            exit 1
   735→        }
   736→        claude_error_file=$(mktemp) || {
   737→            rm -f "$claude_output_file"
   738→            log_error "Failed to create temp file for Claude errors"
   739→            exit 1
   740→        }
   741→        chmod 600 "$claude_output_file" "$claude_error_file"
   742→
   743→        # Run Claude with streaming output
   744→        if command -v jq &>/dev/null; then
   745→            # Stream JSON events, display text content in real-time
   746→            echo "$PROMPT" | claude -p \
   747→                --dangerously-skip-permissions \
   748→                --model "$RALPH_MODEL" \
   749→                --verbose \
   750→                --output-format stream-json 2>"$claude_error_file" | \
   751→                tee "$claude_output_file" | \
   752→                jq --unbuffered -r '
   753→                    select(.type == "assistant") |
   754→                    .message.content[]? |
   755→                    if .type == "text" then .text
   756→                    elif .type == "tool_use" then
   757→                        "  \u001b[0;34m→ " + .name +
   758→                        (if .input.file_path then ": " + .input.file_path
   759→                         elif .input.pattern then " /" + .input.pattern + "/"
   760→                         elif .input.command then ": " + (.input.command | .[0:80])
   761→                         elif .input.prompt then ": " + (.input.prompt | .[0:80])
   762→                         else "" end) + "\u001b[0m"
   763→                    else empty end
   764→                ' || claude_exit_code=$?
   765→        else
   766→            # No jq available: capture output, no streaming display
   767→            echo "$PROMPT" | claude -p \
   768→                --dangerously-skip-permissions \
   769→                --model "$RALPH_MODEL" \
   770→                --verbose 2>"$claude_error_file" > "$claude_output_file" || claude_exit_code=$?
   771→        fi
   772→
   773→        # Capture any error output
   774→        claude_errors=""
   775→        if [ -f "$claude_error_file" ]; then
   776→            claude_errors=$(cat "$claude_error_file" 2>/dev/null || echo "")
   777→        fi
   778→
   779→        # Check for known error patterns even if exit code is 0
   780→        if [ $claude_exit_code -ne 0 ]; then
   781→            error_detected=true
   782→            case $claude_exit_code in
   783→                1)   error_type="general error" ;;
   784→                2)   error_type="CLI usage error" ;;
   785→                130) error_type="interrupted by user (Ctrl+C)" ;;
   786→                *)   error_type="exit code $claude_exit_code" ;;
   787→            esac
   788→        fi
   789→
   790→        # Check error output for known patterns
   791→        if [ -n "$claude_errors" ]; then
   792→            if echo "$claude_errors" | grep -qi "authentication\|unauthorized\|api.*key"; then
   793→                error_detected=true
   794→                error_type="authentication failure"
   795→            elif echo "$claude_errors" | grep -qi "rate.*limit\|too.*many.*requests"; then
   796→                error_detected=true
   797→                error_type="rate limit exceeded"
   798→            elif echo "$claude_errors" | grep -qi "network\|connection\|timeout"; then
   799→                error_detected=true
   800→                error_type="network error"
   801→            elif echo "$claude_errors" | grep -qi "not.*found\|command.*not.*found"; then
   802→                error_detected=true
   803→                error_type="Claude CLI not found or not in PATH"
   804→            # GitHub Issue #1: Detect API 400 errors from tool use concurrency
   805→            elif echo "$claude_errors" | grep -qi "400\|bad.*request\|tool.*use\|concurrency"; then
   806→                error_detected=true
   807→                error_type="API 400 error (tool use concurrency)"
   808→            fi
   809→        fi
   810→
   811→        # Clean up temp files
   812→        rm -f "$claude_output_file" "$claude_error_file"
   813→
   814→        # Check if we succeeded
   815→        if [ "$error_detected" = false ]; then
   816→            claude_success=true
   817→            break
   818→        fi
   819→
   820→        # Log the attempt error
   821→        log_error "Claude CLI error at iteration $ITERATION (attempt $claude_attempt/$max_claude_retries): $error_type"
   822→        if [ -n "$claude_errors" ]; then
   823→            err_truncate="${ERROR_DETAILS_TRUNCATE_LENGTH:-500}"
   824→            log_error "Error details: ${claude_errors:0:$err_truncate}"
   825→        fi
   826→
   827→        # For non-retryable errors, don't retry
   828→        if [[ "$error_type" =~ "authentication"|"CLI usage error"|"interrupted by user"|"not found" ]]; then
   829→            echo -e "${RED}Non-retryable error detected: $error_type${NC}"
   830→            break
   831→        fi
   832→
   833→        # Increment retry counter and increase backoff with jitter
   834→        claude_attempt=$((claude_attempt + 1))
   835→        if [ $claude_attempt -le $max_claude_retries ]; then
   836→            # Exponential backoff: 5s, 10s, 20s with random jitter (0-2s)
   837→            # Jitter prevents "thundering herd" when multiple instances retry simultaneously
   838→            # This is a key fix for GitHub Issue #1 (concurrency issues)
   839→            jitter=$((RANDOM % 3))
   840→            claude_retry_delay=$(( (claude_retry_delay * 2) + jitter ))
   841→        fi
   842→    done
   843→
   844→    # If all retries failed, stop iterations
   845→    if [ "$claude_success" = false ]; then
   846→        echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   847→        echo -e "${RED}  Claude CLI Error (after $claude_attempt attempts): $error_type${NC}"
   848→        echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   849→
   850→        # Check if this is a transient error that might benefit from manual retry
   851→        # API 400 (tool use concurrency) is now retryable with proper locking
   852→        if [[ "$error_type" =~ "rate limit"|"network"|"timeout"|"API 400" ]]; then
   853→            echo -e "${YELLOW}This appears to be a transient error.${NC}"
   854→            echo -e "${YELLOW}You may want to retry in a few minutes.${NC}"
   855→        fi
   856→
   857→        # Send error notification and log
   858→        log_error "Stopping Ralph due to Claude CLI failure at iteration $ITERATION after $claude_attempt attempts"
   859→        notify ":x: *Ralph Error*\n\`\`\`Plan: $PLAN_BASENAME\nIteration: $ITERATION\nError: $error_type (after $claude_attempt retries)\nRepo: $REPO_NAME\`\`\`" ":x:"
   860→        exit $claude_exit_code
   861→    fi
   862→
   863→    echo ""
   864→    echo -e "${GREEN}Iteration $ITERATION complete${NC}"
   865→
   866→    # Plan mode: exit after one iteration
   867→    if [ "$MODE" = "plan" ]; then
   868→        echo ""
   869→        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   870→        echo -e "${GREEN}  Planning complete! Task list created in $PROGRESS_FILE${NC}"
   871→        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
   872→        echo ""
   873→        echo -e "Next step: Run ${YELLOW}ralph $PLAN_FILE build${NC} to implement tasks"
   874→        notify ":clipboard: *Ralph Planning Complete!*\n\`\`\`Plan: $PLAN_BASENAME\nTask list created in: $PROGRESS_FILE\nRepo: $REPO_NAME\`\`\`" ":clipboard:"
   875→        break
   876→    fi
   877→
   878→    # Send iteration notification (configurable frequency via RALPH_NOTIFY_FREQUENCY)
   879→    notify_default="${NOTIFY_FREQUENCY_DEFAULT:-5}"
   880→    NOTIFY_FREQ="${RALPH_NOTIFY_FREQUENCY:-$notify_default}"
   881→    # Validate notification frequency
   882→    notify_min="${NOTIFY_FREQUENCY_MIN:-1}"
   883→    notify_max="${NOTIFY_FREQUENCY_MAX:-100}"
   884→    if ! validate_numeric "$NOTIFY_FREQ" "RALPH_NOTIFY_FREQUENCY" "$notify_min" "$notify_max"; then
   885→        NOTIFY_FREQ="$notify_default"
   886→        log_error "Invalid RALPH_NOTIFY_FREQUENCY, using default: $notify_default"
   887→    fi
   888→    if [ "$ITERATION" -eq 1 ] || [ $((ITERATION % NOTIFY_FREQ)) -eq 0 ]; then
   889→        notify ":gear: *Ralph Progress*: Iteration $ITERATION completed\n\`Plan: $PLAN_BASENAME\`" ":gear:"
   890→    fi
   891→
   892→    # Small delay between iterations
   893→    iter_delay="${ITERATION_DELAY:-2}"
   894→    sleep "$iter_delay"
   895→done
   896→
   897→echo ""
   898→echo "Total iterations: $ITERATION"
   899→echo "Progress file: $PROGRESS_FILE"
   900→
```

## Assistant

## Assistant

Now I understand the notification flow. The notifications are sent from `ralph.sh` at various points (start, progress, complete, error, etc.) but they don't include any summary of what was accomplished. I'll add a function to extract a short summary from the progress file and include it in the relevant notifications.

Let me check what the progress file typically looks like.

## Assistant

### Tool: Grep

**Args:**
```json
{
  "-C": 3,
  "output_mode": "content",
  "path": "/mnt/dest-btrfs/home/jc/ralph/PROMPT_build.md",
  "pattern": "PROGRESS|progress.*file"
}
```

## Assistant

**Result** (success):
```
3-## Your Inputs
4-
5-1. **Plan File**: ${PLAN_FILE}
6:2. **Progress File**: ${PROGRESS_FILE}
7-3. **Guardrails File**: ${GUARDRAILS_FILE}
8-
9-## Guardrails
--
37-## Instructions
38-
39-0a. Read the plan file to understand the overall goal.
40:0b. Read the progress file to see the task list and current state.
41:0c. If there's no task list yet, create one based on the plan (keep Status as IN_PROGRESS).
42-
43-1. **Select ONE task**: Pick the highest-priority uncompleted task (marked with [ ]).
44-   - Before implementing, search the codebase to confirm it's not already done
--
54-   - Run build/typecheck/lint as appropriate
55-   - Fix any issues before considering the task complete
56-
57:4. **Update progress file**:
58-   - Mark the completed task with [x]
59-   - Add any discoveries or notes
60-   - If you found bugs or new tasks, add them to the list
--
66-
67-6. **Check completion** (IMPORTANT - read carefully):
68-   - Count the tasks: How many total? How many marked [x]? How many still [ ]?
69:   - If there are ANY tasks still marked [ ], keep Status as IN_PROGRESS
70-   - ONLY write the completion marker when ALL tasks are marked [x] complete AND verified working
71:   - When in doubt, leave Status as IN_PROGRESS - another iteration will check again
72-
73-## Rules
74-
75-- **ONE task per iteration** - do not try to do multiple tasks
76-- **Search before implementing** - use subagents to explore and verify before coding
77-- **Run validation** - tests, build, lint as appropriate
78:- **Update progress file** - this is how the loop tracks state
79-- **Commit your work** - each iteration should produce a commit (unless auto-commit is disabled)
80-- **Only signal completion when truly done** - all tasks must be [x] complete and verified
81-
82-## Progress File Updates
83-
84:After completing a task, update ${PROGRESS_FILE}:
85-
86-```
87-# Progress: ${PLAN_NAME}
88-
89-## Status
90:IN_PROGRESS
91-
92-## Task List
93-- [x] Task 1: completed
--
108-1. Count ALL tasks in the task list
109-2. Confirm EVERY SINGLE task is marked [x] - not just most, ALL of them
110-3. Use subagents to verify the implementation actually works (run tests, check build)
111:4. If ANY task is still [ ] or unverified, keep Status as IN_PROGRESS
112-
113-**Only when ALL of the above are true:**
114-1. Verify everything works (tests pass, builds clean)
--
123-**If you cannot complete a task** (blocked, needs clarification):
124-- Add a note explaining why
125-- Move to the next task
126:- Keep Status as IN_PROGRESS
127-
128:**When in doubt, keep Status as IN_PROGRESS.** It's better to run an extra iteration than to exit prematurely.
```

## Assistant

### Tool: Glob

**Args:**
```json
{
  "path": "/mnt/dest-btrfs/home/jc/ralph",
  "pattern": "*PROGRESS*"
}
```

## Assistant

**Result** (success):
```
/mnt/dest-btrfs/home/jc/ralph/shadow_PROGRESS.md
/mnt/dest-btrfs/home/jc/ralph/test_PROGRESS.md
```

## Assistant

### Tool: Read

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/shadow_PROGRESS.md"
}
```

## Assistant

**Result** (success):
```
     1→# Progress: shadow
     2→
     3→Started: Sat Feb 14 05:30:50 PM EST 2026
     4→
     5→## Status
     6→
     7→IN_PROGRESS
     8→
     9→## Tasks Completed
    10→
    11→
```

## Assistant

Good, now I can see the progress file format. The progress file has a task list with `[x]` for completed and `[ ]` for pending tasks. I'll add a function to extract a brief summary (last completed task or task counts) and include it in the notification messages.

## Assistant

### Tool: Read

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "limit": 30,
  "offset": 610
}
```

## Assistant

**Result** (success):
```
   610→fi
   611→
   612→ITERATION=0
   613→
   614→# Check for completion
   615→# Uses -x to match whole lines only, preventing false positives from
   616→# instructional text like "DO NOT set RALPH_DONE" in the progress file
   617→is_done() {
   618→    if [ -f "$PROGRESS_FILE" ]; then
   619→        grep -qx "RALPH_DONE" "$PROGRESS_FILE" 2>/dev/null && return 0
   620→    fi
   621→    return 1
   622→}
   623→
   624→# ============================================================================
   625→# CONCURRENCY PROTECTION (Fix for GitHub Issue #1)
   626→# ============================================================================
   627→# Prevent multiple Ralph instances from running on the same plan file
   628→# This fixes API Error 400 due to tool use race conditions
   629→
   630→# Generate lock file path based on plan file (unique per plan)
   631→PLAN_HASH=$(echo "$PLAN_FILE_ABS" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "$PLAN_BASENAME")
   632→LOCK_FILE="${TEMP_DIR:-/tmp}/ralph_${PLAN_HASH}.lock"
   633→
   634→# Cleanup function for graceful exit
   635→cleanup_lock() {
   636→    if [ -n "${LOCK_FILE:-}" ]; then
   637→        release_lock "$LOCK_FILE" 2>/dev/null || true
   638→    fi
   639→}
```

## Assistant

Now I'll add the summary function and update the notification messages.

## Assistant

### Tool: Edit

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "new_string": "ITERATION=0\n\n# Extract a short summary from the progress file (task counts + last completed task)\nget_progress_summary() {\n    if [ ! -f \"$PROGRESS_FILE\" ]; then\n        echo \"\"\n        return\n    fi\n\n    local done_count=0\n    local total_count=0\n    local last_done=\"\"\n\n    while IFS= read -r line; do\n        if [[ \"$line\" =~ ^-\\ \\[x\\]\\ (.+) ]]; then\n            total_count=$((total_count + 1))\n            done_count=$((done_count + 1))\n            last_done=\"${BASH_REMATCH[1]}\"\n        elif [[ \"$line\" =~ ^-\\ \\[\\ \\]\\ (.+) ]]; then\n            total_count=$((total_count + 1))\n        fi\n    done < \"$PROGRESS_FILE\"\n\n    if [ \"$total_count\" -eq 0 ]; then\n        echo \"\"\n        return\n    fi\n\n    local summary=\"Tasks: ${done_count}/${total_count}\"\n    if [ -n \"$last_done\" ]; then\n        # Truncate long task names\n        if [ \"${#last_done}\" -gt 60 ]; then\n            last_done=\"${last_done:0:57}...\"\n        fi\n        summary=\"${summary}\\nLast: ${last_done}\"\n    fi\n    echo \"$summary\"\n}\n\n# Check for completion",
  "old_string": "ITERATION=0\n\n# Check for completion",
  "replace_all": false
}
```

## Assistant

**Result** (success):
```
The file /mnt/dest-btrfs/home/jc/ralph/ralph.sh has been updated successfully.
```

## Assistant

Now update the notification messages to include the summary. Let me update the completion, max-iterations, progress, and error notifications.

## Assistant

### Tool: Edit

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "new_string": "        SUMMARY=$(get_progress_summary)\n        SUMMARY_BLOCK=\"\"\n        [ -n \"$SUMMARY\" ] && SUMMARY_BLOCK=\"\\n$SUMMARY\"\n        notify \":white_check_mark: *Ralph Complete!*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nIterations: $ITERATION\\nRepo: $REPO_NAME${SUMMARY_BLOCK}\\`\\`\\`\" \":white_check_mark:\"",
  "old_string": "        notify \":white_check_mark: *Ralph Complete!*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nIterations: $ITERATION\\nRepo: $REPO_NAME\\`\\`\\`\" \":white_check_mark:\"",
  "replace_all": false
}
```

## Assistant

**Result** (success):
```
The file /mnt/dest-btrfs/home/jc/ralph/ralph.sh has been updated successfully.
```

## Assistant

### Tool: Edit

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "new_string": "        SUMMARY=$(get_progress_summary)\n        SUMMARY_BLOCK=\"\"\n        [ -n \"$SUMMARY\" ] && SUMMARY_BLOCK=\"\\n$SUMMARY\"\n        notify \":warning: *Ralph Stopped*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nReason: Max iterations reached ($MAX_ITERATIONS)\\nRepo: $REPO_NAME${SUMMARY_BLOCK}\\`\\`\\`\" \":warning:\"",
  "old_string": "        notify \":warning: *Ralph Stopped*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nReason: Max iterations reached ($MAX_ITERATIONS)\\nRepo: $REPO_NAME\\`\\`\\`\" \":warning:\"",
  "replace_all": false
}
```

## Assistant

**Result** (success):
```
The file /mnt/dest-btrfs/home/jc/ralph/ralph.sh has been updated successfully.
```

## Assistant

### Tool: Edit

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "new_string": "        SUMMARY=$(get_progress_summary)\n        SUMMARY_BLOCK=\"\"\n        [ -n \"$SUMMARY\" ] && SUMMARY_BLOCK=\"\\n$SUMMARY\"\n        notify \":x: *Ralph Error*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nIteration: $ITERATION\\nError: $error_type (after $claude_attempt retries)\\nRepo: $REPO_NAME${SUMMARY_BLOCK}\\`\\`\\`\" \":x:\"",
  "old_string": "        notify \":x: *Ralph Error*\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nIteration: $ITERATION\\nError: $error_type (after $claude_attempt retries)\\nRepo: $REPO_NAME\\`\\`\\`\" \":x:\"",
  "replace_all": false
}
```

## Assistant

**Result** (success):
```
The file /mnt/dest-btrfs/home/jc/ralph/ralph.sh has been updated successfully.
```

## Assistant

### Tool: Edit

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "new_string": "        SUMMARY=$(get_progress_summary)\n        SUMMARY_BLOCK=\"\"\n        [ -n \"$SUMMARY\" ] && SUMMARY_BLOCK=\"\\n$SUMMARY\"\n        notify \":gear: *Ralph Progress*: Iteration $ITERATION completed\\n\\`\\`\\`Plan: $PLAN_BASENAME\\nRepo: $REPO_NAME${SUMMARY_BLOCK}\\`\\`\\`\" \":gear:\"",
  "old_string": "        notify \":gear: *Ralph Progress*: Iteration $ITERATION completed\\n\\`Plan: $PLAN_BASENAME\\`\" \":gear:\"",
  "replace_all": false
}
```

## Assistant

**Result** (success):
```
The file /mnt/dest-btrfs/home/jc/ralph/ralph.sh has been updated successfully.
```

## Assistant

Now let me verify the changes look correct.

## Assistant

### Tool: Read

**Args:**
```json
{
  "file_path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "limit": 50,
  "offset": 612
}
```

## Assistant

**Result** (success):
```
   612→ITERATION=0
   613→
   614→# Extract a short summary from the progress file (task counts + last completed task)
   615→get_progress_summary() {
   616→    if [ ! -f "$PROGRESS_FILE" ]; then
   617→        echo ""
   618→        return
   619→    fi
   620→
   621→    local done_count=0
   622→    local total_count=0
   623→    local last_done=""
   624→
   625→    while IFS= read -r line; do
   626→        if [[ "$line" =~ ^-\ \[x\]\ (.+) ]]; then
   627→            total_count=$((total_count + 1))
   628→            done_count=$((done_count + 1))
   629→            last_done="${BASH_REMATCH[1]}"
   630→        elif [[ "$line" =~ ^-\ \[\ \]\ (.+) ]]; then
   631→            total_count=$((total_count + 1))
   632→        fi
   633→    done < "$PROGRESS_FILE"
   634→
   635→    if [ "$total_count" -eq 0 ]; then
   636→        echo ""
   637→        return
   638→    fi
   639→
   640→    local summary="Tasks: ${done_count}/${total_count}"
   641→    if [ -n "$last_done" ]; then
   642→        # Truncate long task names
   643→        if [ "${#last_done}" -gt 60 ]; then
   644→            last_done="${last_done:0:57}..."
   645→        fi
   646→        summary="${summary}\nLast: ${last_done}"
   647→    fi
   648→    echo "$summary"
   649→}
   650→
   651→# Check for completion
   652→# Uses -x to match whole lines only, preventing false positives from
   653→# instructional text like "DO NOT set RALPH_DONE" in the progress file
   654→is_done() {
   655→    if [ -f "$PROGRESS_FILE" ]; then
   656→        grep -qx "RALPH_DONE" "$PROGRESS_FILE" 2>/dev/null && return 0
   657→    fi
   658→    return 1
   659→}
   660→
   661→# ============================================================================
```

## Assistant

Let me also quickly verify the notification call sites look right.

## Assistant

### Tool: Grep

**Args:**
```json
{
  "output_mode": "content",
  "path": "/mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "pattern": "SUMMARY_BLOCK|get_progress_summary"
}
```

## Assistant

**Result** (success):
```
615:get_progress_summary() {
704:        SUMMARY=$(get_progress_summary)
705:        SUMMARY_BLOCK=""
706:        [ -n "$SUMMARY" ] && SUMMARY_BLOCK="\n$SUMMARY"
707:        notify ":white_check_mark: *Ralph Complete!*\n\`\`\`Plan: $PLAN_BASENAME\nIterations: $ITERATION\nRepo: $REPO_NAME${SUMMARY_BLOCK}\`\`\`" ":white_check_mark:"
716:        SUMMARY=$(get_progress_summary)
717:        SUMMARY_BLOCK=""
718:        [ -n "$SUMMARY" ] && SUMMARY_BLOCK="\n$SUMMARY"
719:        notify ":warning: *Ralph Stopped*\n\`\`\`Plan: $PLAN_BASENAME\nReason: Max iterations reached ($MAX_ITERATIONS)\nRepo: $REPO_NAME${SUMMARY_BLOCK}\`\`\`" ":warning:"
902:        SUMMARY=$(get_progress_summary)
903:        SUMMARY_BLOCK=""
904:        [ -n "$SUMMARY" ] && SUMMARY_BLOCK="\n$SUMMARY"
905:        notify ":x: *Ralph Error*\n\`\`\`Plan: $PLAN_BASENAME\nIteration: $ITERATION\nError: $error_type (after $claude_attempt retries)\nRepo: $REPO_NAME${SUMMARY_BLOCK}\`\`\`" ":x:"
935:        SUMMARY=$(get_progress_summary)
936:        SUMMARY_BLOCK=""
937:        [ -n "$SUMMARY" ] && SUMMARY_BLOCK="\n$SUMMARY"
938:        notify ":gear: *Ralph Progress*: Iteration $ITERATION completed\n\`\`\`Plan: $PLAN_BASENAME\nRepo: $REPO_NAME${SUMMARY_BLOCK}\`\`\`" ":gear:"
```

## Assistant

Let me also do a quick syntax check.

## Assistant

### Tool: Bash

**Args:**
```json
{
  "command": "bash -n /mnt/dest-btrfs/home/jc/ralph/ralph.sh",
  "description": "Check bash syntax"
}
```

## Assistant

**Result** (success):
```
(empty result)
```

## Assistant

Syntax is clean. Here's what I added:

**`get_progress_summary()`** - parses the progress file and returns a compact summary like:
```
Tasks: 3/7
Last: Add input validation to API endpoints
```

This summary is now appended to all four notification types:
- **Complete** - shows final task tally + last completed task
- **Max iterations reached** - shows how far it got
- **Error** - shows progress before the failure  
- **Progress** (periodic) - shows current task status

The summary is omitted if there's no task list yet (e.g., the start notification stays unchanged). Task names longer than 60 chars get truncated with `...`.
