Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ Copy the ralph files into your project:

```bash
# From your project root
mkdir -p scripts/ralph
mkdir -p scripts/ralph/lib
cp /path/to/ralph/ralph.sh scripts/ralph/
cp /path/to/ralph/lib/rate-limit.sh scripts/ralph/lib/

# Copy the prompt template for your AI tool of choice:
cp /path/to/ralph/prompt.md scripts/ralph/prompt.md # For Amp
Expand Down Expand Up @@ -119,6 +120,8 @@ This creates `prd.json` with user stories structured for autonomous execution.

Default is 10 iterations. Use `--tool amp` or `--tool claude` to select your AI coding tool.

When running with `--tool claude`, Ralph automatically detects Claude Code quota/rate-limit messages, waits until the reported reset time when possible, and retries the same iteration instead of burning through the remaining loop count.

Ralph will:
1. Create a feature branch (from PRD `branchName`)
2. Pick the highest priority story where `passes: false`
Expand All @@ -129,6 +132,19 @@ Ralph will:
7. Append learnings to `progress.txt`
8. Repeat until all stories pass or max iterations reached

## Smoke Test

You can run a local smoke test for the loop and rate-limit handling without installing Amp or Claude Code:

```bash
./test-rate-limit.sh
```

This script mocks the CLI tools and verifies:
- Claude quota messages with parseable reset times retry the same iteration
- Claude quota messages without reset details fall back to a 5-hour wait
- The default Amp path still completes normally

## Key Files

| File | Purpose |
Expand Down
134 changes: 134 additions & 0 deletions lib/rate-limit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/bin/bash

RATE_LIMIT_FALLBACK_WAIT_SECONDS=$((5 * 60 * 60))

is_rate_limit_output() {
local output_lower
output_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')

[[ "$output_lower" == *"usage limit reached"* ]] \
|| [[ "$output_lower" == *"rate limit"* ]] \
|| [[ "$output_lower" == *"quota exceeded"* ]] \
|| [[ "$output_lower" == *"hit your"* ]]
}

extract_rate_limit_reset_details() {
local output="$1"
local reset_time=""
local reset_timezone=""
local reset_regex='[Rr]esets?([[:space:]]+at)?[[:space:]]+([^()[:space:]][^()]*)[[:space:]]\(([^)]+)\)'

if [[ "$output" =~ $reset_regex ]]; then
reset_time="${BASH_REMATCH[2]}"
reset_timezone="${BASH_REMATCH[3]}"
fi

if [[ -n "$reset_time" && -n "$reset_timezone" ]]; then
printf '%s|%s\n' "$reset_time" "$reset_timezone"
return 0
fi

return 1
}

calculate_rate_limit_wait_seconds() {
local reset_time="$1"
local reset_timezone="$2"
local seconds_until_reset=""

if ! command -v python3 >/dev/null 2>&1; then
return 1
fi

seconds_until_reset=$(python3 - "$reset_time" "$reset_timezone" <<'PY'
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import sys

reset_time = sys.argv[1].strip().lower().replace(".", "")
reset_timezone = sys.argv[2].strip()

formats = ("%I%p", "%I:%M%p", "%I %p", "%I:%M %p")
now = datetime.now(ZoneInfo(reset_timezone))

parsed_time = None
for fmt in formats:
try:
parsed_time = datetime.strptime(reset_time.upper(), fmt)
break
except ValueError:
continue

if parsed_time is None:
raise SystemExit(1)

reset_at = now.replace(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=0,
microsecond=0,
)

if reset_at <= now:
reset_at += timedelta(days=1)

print(max(1, int((reset_at - now).total_seconds())))
PY
) || return 1

if [[ "$seconds_until_reset" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$seconds_until_reset"
return 0
fi

return 1
}

format_wait_duration() {
local total_seconds="$1"
local hours=$((total_seconds / 3600))
local minutes=$(((total_seconds % 3600) / 60))
local seconds=$((total_seconds % 60))

if (( hours > 0 )); then
printf '%dh %dm %ds' "$hours" "$minutes" "$seconds"
elif (( minutes > 0 )); then
printf '%dm %ds' "$minutes" "$seconds"
else
printf '%ds' "$seconds"
fi
}

handle_rate_limit() {
local output="$1"
local wait_seconds="$RATE_LIMIT_FALLBACK_WAIT_SECONDS"
local reset_details=""
local reset_time=""
local reset_timezone=""
local wait_duration=""

if ! is_rate_limit_output "$output"; then
return 1
fi

echo "Claude hit a rate limit. Waiting for quota reset before retrying this iteration..."

if reset_details=$(extract_rate_limit_reset_details "$output"); then
reset_time="${reset_details%%|*}"
reset_timezone="${reset_details#*|}"

if wait_seconds=$(calculate_rate_limit_wait_seconds "$reset_time" "$reset_timezone"); then
wait_duration=$(format_wait_duration "$wait_seconds")
echo "Detected reset time: $reset_time ($reset_timezone). Sleeping for $wait_duration."
else
wait_duration=$(format_wait_duration "$wait_seconds")
echo "Couldn't calculate reset time from Claude output. Falling back to $wait_duration."
fi
else
wait_duration=$(format_wait_duration "$wait_seconds")
echo "Couldn't parse reset details from Claude output. Falling back to $wait_duration."
fi

sleep "$wait_seconds"
return 0
}
23 changes: 16 additions & 7 deletions ralph.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/rate-limit.sh"
PRD_FILE="$SCRIPT_DIR/prd.json"
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
ARCHIVE_DIR="$SCRIPT_DIR/archive"
Expand Down Expand Up @@ -87,13 +88,21 @@ for i in $(seq 1 $MAX_ITERATIONS); do
echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)"
echo "==============================================================="

# Run the selected tool with the ralph prompt
if [[ "$TOOL" == "amp" ]]; then
OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
else
# Claude Code: use --dangerously-skip-permissions for autonomous operation, --print for output
OUTPUT=$(claude --dangerously-skip-permissions --print < "$SCRIPT_DIR/CLAUDE.md" 2>&1 | tee /dev/stderr) || true
fi
while true; do
# Run the selected tool with the ralph prompt
if [[ "$TOOL" == "amp" ]]; then
OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
else
# Claude Code: use --dangerously-skip-permissions for autonomous operation, --print for output
OUTPUT=$(claude --dangerously-skip-permissions --print < "$SCRIPT_DIR/CLAUDE.md" 2>&1 | tee /dev/stderr) || true
fi

if [[ "$TOOL" == "claude" ]] && handle_rate_limit "$OUTPUT"; then
continue
fi

break
done

# Check for completion signal
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
Expand Down
Loading