Last active
May 21, 2026 15:21
-
-
Save th3hunt/7b60970bd275817085b48fae34103d52 to your computer and use it in GitHub Desktop.
ralph-loop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ralph-loop β drive Claude Code through repeated fresh-context iterations. | |
| # Methodology: https://paddo.dev/blog/ralph-wiggum-playbook | |
| set -uo pipefail | |
| # βββ constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DEFAULT_MAX_ITERATIONS=20 | |
| DEFAULT_COMPLETION_PROMISE="[PLAN COMPLETED]" | |
| DEFAULT_AGENT="claude" | |
| SUPPORTED_AGENTS=("claude" "cursor" "codex" "opencode") | |
| DEFAULT_INSTRUCTIONS=$(cat <<'EOF' | |
| * Implement the next uncompleted TODO and exit. | |
| * Treat a task as done only after all tests pass. | |
| * Mark each completed task as complete in the plan. | |
| * Commit each completed task with /conventional-commit. | |
| EOF | |
| ) | |
| COLOR_PINK=212 | |
| COLOR_PURPLE=99 | |
| COLOR_GREEN=82 | |
| COLOR_RED=196 | |
| COLOR_GOLD=220 | |
| COLOR_DIM=245 | |
| # gum encodes the user's cancel intent in its exit code: 1 = pressed Esc, | |
| # 130 = pressed Ctrl+C. (gum runs the terminal in raw mode, so Ctrl+C is read as | |
| # a keypress and reported as 130 rather than delivered as a SIGINT.) Only Ctrl+C | |
| # tears down the run; Esc is a soft cancel each prompt recovers from. | |
| GUM_ABORT_CODE=130 | |
| # βββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| usage() { | |
| cat <<'EOF' | |
| π€ ralph-loop β drive Claude Code through repeated fresh-context iterations. | |
| Usage: scripts/ralph-loop [OPTIONS] [PLAN_FILE] | |
| scripts/ralph-loop generate-config [PATH] | |
| Commands: | |
| generate-config [PATH] Write a default config file pre-filled with the | |
| built-in defaults, then exit. PATH defaults to | |
| ./.ralphrc. Refuses to overwrite an existing file. | |
| Options: | |
| -n, --iterations N Max iterations (default: 20) | |
| -c, --completion-promise STR Stop early when the agent outputs STR (default: [PLAN COMPLETED]) | |
| -a, --agent NAME Agent to drive the loop: claude | cursor | codex | opencode (default: claude) | |
| -m, --model NAME Model identifier (agent-specific). Skips the model dropdown. | |
| -y, --yolo Skip permission prompts (claude/opencode: --dangerously-skip-permissions; cursor: --force; codex: --yolo). Required for cursor & codex. | |
| -s, --instructions STR Override the instructions appended to every iteration's prompt. | |
| --instructions-file PATH Load instructions from a file (use '-' for stdin). | |
| -C, --config PATH Load bash-sourced config file overriding defaults. Auto-discovered | |
| if PATH omitted: <script-dir>/.ralphrc, then ./.ralphrc. | |
| -h, --help Show this help | |
| If PLAN_FILE is omitted, you'll be prompted with a fuzzy as-you-type picker | |
| over markdown files under the current directory. | |
| Config file (.ralphrc β bash, sourced into the script; all vars optional, CLI flags always win): | |
| RALPH_MAX_ITERATIONS=30 | |
| RALPH_COMPLETION_PROMISE="ALL DONE" | |
| RALPH_INSTRUCTIONS=$(cat <<'EOF' | |
| * Always run lint before committing. | |
| * Mark each completed task as complete in the plan. | |
| EOF | |
| ) | |
| Generate a starter config with the defaults filled in: | |
| scripts/ralph-loop generate-config | |
| Examples: | |
| scripts/ralph-loop | |
| scripts/ralph-loop docs/plans/042_pricing.md | |
| scripts/ralph-loop -y -n 50 docs/plans/042_pricing.md | |
| scripts/ralph-loop -a cursor -y docs/plans/042_pricing.md | |
| scripts/ralph-loop -a codex -y -m gpt-5-codex docs/plans/042_pricing.md | |
| scripts/ralph-loop -a opencode -y docs/plans/042_pricing.md | |
| scripts/ralph-loop -c DONE docs/plans/042_pricing.md | |
| scripts/ralph-loop -s "Always run lint before committing." | |
| scripts/ralph-loop --instructions-file ./my-instructions.md | |
| scripts/ralph-loop --config ./my-ralphrc docs/plans/042_pricing.md | |
| scripts/ralph-loop generate-config | |
| scripts/ralph-loop generate-config ./team.ralphrc | |
| EOF | |
| } | |
| die() { | |
| printf 'β %s\n' "$*" >&2 | |
| exit 1 | |
| } | |
| require_cmd() { | |
| local cmd=$1 hint=$2 | |
| if ! command -v "$cmd" >/dev/null 2>&1; then | |
| die "'$cmd' not found on PATH. $hint" | |
| fi | |
| } | |
| trim() { | |
| local s=$1 | |
| s="${s#"${s%%[![:space:]]*}"}" | |
| s="${s%"${s##*[![:space:]]}"}" | |
| printf '%s' "$s" | |
| } | |
| abspath() { | |
| local p=$1 | |
| printf '%s/%s\n' "$(cd "$(dirname "$p")" && pwd -P)" "$(basename "$p")" | |
| } | |
| # Write a default .ralphrc to PATH (default ./.ralphrc) and exit. | |
| # Refuses to clobber an existing file. Values mirror the built-in defaults so the | |
| # generated file documents exactly what the script would do with no config. | |
| generate_config() { | |
| local target=${1:-./.ralphrc} | |
| [[ -e $target ]] && die "Config file already exists: $target (remove it or pass a different path)" | |
| cat > "$target" <<EOF || die "Failed to write config file: $target" | |
| # .ralphrc β ralph-loop config (bash, sourced into the script). | |
| # All variables are optional; CLI flags always override these values. | |
| # Lookup order: --config PATH β <script-dir>/.ralphrc β ./.ralphrc | |
| # Max iterations before the loop stops (positive integer). | |
| RALPH_MAX_ITERATIONS=$DEFAULT_MAX_ITERATIONS | |
| # Stop early when the agent outputs this exact string on its own line. | |
| RALPH_COMPLETION_PROMISE="$DEFAULT_COMPLETION_PROMISE" | |
| # Instructions appended to every iteration's prompt. | |
| RALPH_INSTRUCTIONS=\$(cat <<'INSTRUCTIONS' | |
| $DEFAULT_INSTRUCTIONS | |
| INSTRUCTIONS | |
| ) | |
| EOF | |
| printf 'β Wrote default config to %s\n' "$target" | |
| } | |
| # Render claude's stream-json events into live, human-readable text. claude -p | |
| # buffers and only emits the final result at the end; --output-format stream-json | |
| # + --include-partial-messages emits incremental events, which we filter down to | |
| # assistant text deltas so the agent's output prints as it's generated. The | |
| # trailing newline keeps the next gum prompt off the model's last line. | |
| claude_stream_filter() { | |
| jq -rj --unbuffered \ | |
| 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | |
| | .event.delta.text' | |
| printf '\n' | |
| } | |
| # Other agents already stream readable text on stdout β pass it through as-is. | |
| passthrough_filter() { cat; } | |
| # βββ interactive prompt helpers ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # These translate gum's cancel exit codes (see GUM_ABORT_CODE above) into the | |
| # behaviour we want: Esc never quits, only Ctrl+C does. The selection is captured | |
| # into PROMPT_REPLY. They MUST be called from the main shell (not inside $(...) or | |
| # a pipeline) so that abort_loop's `exit` tears down the script, not a subshell. | |
| # On cancel (Esc), gum clears its own picker frame but leaves a one-line | |
| # "nothing selected" notice on stderr (printed with a trailing newline, so the | |
| # cursor ends up on the line below it). Erase that line so a re-prompt looks like | |
| # a true no-op instead of stacking notices. Guarded on a real terminal. | |
| erase_cancel_notice() { [[ -t 2 ]] && printf '\033[1A\033[2K\r' >&2; } | |
| # Required selection: re-prompt on Esc, abort on Ctrl+C, return the value on Enter. | |
| # On the first Esc we erase gum's notice and print a single dim hint; further Esc | |
| # presses only erase the notice, so the hint never stacks. | |
| prompt_required() { | |
| local rc hinted=0 | |
| while true; do | |
| PROMPT_REPLY=$("$@"); rc=$? | |
| case $rc in | |
| 0) return 0 ;; | |
| "$GUM_ABORT_CODE") abort_loop ;; | |
| 1) erase_cancel_notice | |
| if (( hinted == 0 )); then | |
| gum style --faint --foreground $COLOR_DIM --margin "0 3" \ | |
| "Make a selection (Enter), or press Ctrl+C to quit" | |
| hinted=1 | |
| fi ;; | |
| *) die "gum exited with code $rc" ;; | |
| esac | |
| done | |
| } | |
| # Optional value: Esc (or any non-Ctrl+C cancel) leaves PROMPT_REPLY empty so the | |
| # caller can fall back to a default; only Ctrl+C aborts the whole run. On cancel, | |
| # gum leaves a "not submitted" notice on stderr β erase it so the fallback is | |
| # silent rather than leaving a stray line. | |
| prompt_optional() { | |
| local rc | |
| PROMPT_REPLY=$("$@"); rc=$? | |
| (( rc == GUM_ABORT_CODE )) && abort_loop | |
| if (( rc != 0 )); then | |
| PROMPT_REPLY="" | |
| erase_cancel_notice | |
| fi | |
| return 0 | |
| } | |
| # Yes/No gate: returns 0 (yes) or 1 (no, including Esc); only Ctrl+C aborts. | |
| confirm() { | |
| local rc | |
| gum confirm "$@"; rc=$? | |
| (( rc == GUM_ABORT_CODE )) && abort_loop | |
| return $rc | |
| } | |
| # Shared teardown path for both Ctrl+C during a gum prompt (exit 130 above) and a | |
| # real SIGINT during an agent run (the INT trap). Marks the run interrupted and | |
| # exits; on_exit renders the summary. | |
| abort_loop() { | |
| INTERRUPTED=1 | |
| printf '\n' | |
| exit 130 | |
| } | |
| on_exit() { | |
| rm -f "$TMP_OUT" | |
| (( INTERRUPTED == 1 )) || return 0 | |
| if (( LOOP_STARTED == 1 )); then | |
| gum style \ | |
| --foreground $COLOR_RED \ | |
| --border-foreground $COLOR_RED \ | |
| --border rounded \ | |
| --margin "1 2" \ | |
| --padding "0 2" \ | |
| "π Interrupted after $COMPLETED / $MAX_ITERATIONS iterations" | |
| else | |
| gum style \ | |
| --foreground $COLOR_RED \ | |
| --border-foreground $COLOR_RED \ | |
| --border rounded \ | |
| --margin "1 2" \ | |
| --padding "0 2" \ | |
| "π Aborted before the loop started" | |
| fi | |
| } | |
| # βββ subcommands βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # `generate-config [PATH]` writes a default .ralphrc and exits. Handled | |
| # before preflight so it works without gum and never enters the interactive flow. | |
| if [[ ${1-} == generate-config ]]; then | |
| shift | |
| (( $# <= 1 )) || die "generate-config takes at most one PATH argument (got: $*)" | |
| generate_config "${1-}" | |
| exit 0 | |
| fi | |
| # βββ preflight βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| require_cmd gum "Install with: brew install gum" | |
| # Agent-specific binary check happens after AGENT is resolved. | |
| # βββ arg parsing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MAX_ITERATIONS="" | |
| COMPLETION_PROMISE="" | |
| AGENT="" | |
| MODEL="" | |
| YOLO=0 | |
| PLAN_FILE="" | |
| INSTRUCTIONS="" | |
| INSTRUCTIONS_SET=0 | |
| CONFIG_FILE="" | |
| while (( $# )); do | |
| case "$1" in | |
| -h|--help) | |
| usage; exit 0 ;; | |
| -y|--yolo) | |
| YOLO=1; shift ;; | |
| -n|--iterations) | |
| [[ $# -ge 2 ]] || die "$1 requires a numeric argument" | |
| [[ $2 =~ ^[1-9][0-9]*$ ]] || die "$1 must be a positive integer, got: $2" | |
| MAX_ITERATIONS=$2; shift 2 ;; | |
| -n=*|--iterations=*) | |
| val=${1#*=} | |
| [[ $val =~ ^[1-9][0-9]*$ ]] || die "iterations must be a positive integer, got: $val" | |
| MAX_ITERATIONS=$val; shift ;; | |
| -c|--completion-promise) | |
| [[ $# -ge 2 ]] || die "$1 requires a string argument" | |
| [[ -n $2 ]] || die "$1 must be a non-empty string" | |
| COMPLETION_PROMISE=$2; shift 2 ;; | |
| -c=*|--completion-promise=*) | |
| val=${1#*=} | |
| [[ -n $val ]] || die "completion promise must be a non-empty string" | |
| COMPLETION_PROMISE=$val; shift ;; | |
| -a|--agent) | |
| [[ $# -ge 2 ]] || die "$1 requires a value (claude|cursor|codex|opencode)" | |
| [[ -n $2 ]] || die "$1 must be a non-empty string" | |
| AGENT=$2; shift 2 ;; | |
| -a=*|--agent=*) | |
| val=${1#*=} | |
| [[ -n $val ]] || die "agent must be a non-empty string" | |
| AGENT=$val; shift ;; | |
| -m|--model) | |
| [[ $# -ge 2 ]] || die "$1 requires a model identifier" | |
| [[ -n $2 ]] || die "$1 must be a non-empty string" | |
| MODEL=$2; shift 2 ;; | |
| -m=*|--model=*) | |
| val=${1#*=} | |
| [[ -n $val ]] || die "model must be a non-empty string" | |
| MODEL=$val; shift ;; | |
| -s|--instructions) | |
| [[ $# -ge 2 ]] || die "$1 requires a string argument" | |
| INSTRUCTIONS=$2; INSTRUCTIONS_SET=1; shift 2 ;; | |
| -s=*|--instructions=*) | |
| INSTRUCTIONS=${1#*=}; INSTRUCTIONS_SET=1; shift ;; | |
| --instructions-file) | |
| [[ $# -ge 2 ]] || die "$1 requires a file path (use '-' for stdin)" | |
| if [[ $2 == "-" ]]; then | |
| INSTRUCTIONS=$(cat) | |
| else | |
| [[ -r $2 ]] || die "instructions file not readable: $2" | |
| INSTRUCTIONS=$(cat -- "$2") | |
| fi | |
| INSTRUCTIONS_SET=1; shift 2 ;; | |
| --instructions-file=*) | |
| val=${1#*=} | |
| if [[ $val == "-" ]]; then | |
| INSTRUCTIONS=$(cat) | |
| else | |
| [[ -r $val ]] || die "instructions file not readable: $val" | |
| INSTRUCTIONS=$(cat -- "$val") | |
| fi | |
| INSTRUCTIONS_SET=1; shift ;; | |
| -C|--config) | |
| [[ $# -ge 2 ]] || die "$1 requires a file path" | |
| [[ -r $2 ]] || die "config file not readable: $2" | |
| CONFIG_FILE=$2; shift 2 ;; | |
| -C=*|--config=*) | |
| val=${1#*=} | |
| [[ -r $val ]] || die "config file not readable: $val" | |
| CONFIG_FILE=$val; shift ;; | |
| --) | |
| shift | |
| [[ $# -ge 1 ]] || die "-- requires a plan file path" | |
| PLAN_FILE=$1; shift | |
| (( $# == 0 )) || die "Unexpected extra args after plan file: $*" | |
| ;; | |
| -*) | |
| die "Unknown option: $1 (use --help)" ;; | |
| *) | |
| [[ -z $PLAN_FILE ]] || die "Only one plan file may be passed (got: $PLAN_FILE and $1)" | |
| PLAN_FILE=$1; shift ;; | |
| esac | |
| done | |
| # βββ interrupt handling ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Wire up the traps before any interactive prompt so Ctrl+C tears the run down | |
| # cleanly from here on. State the prompt/loop code reads on abort is initialised | |
| # up front to stay safe under `set -u`. | |
| INTERRUPTED=0 | |
| LOOP_STARTED=0 | |
| TMP_OUT="" | |
| trap on_exit EXIT | |
| trap abort_loop INT | |
| # βββ banner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| gum style \ | |
| --foreground $COLOR_PINK \ | |
| --border-foreground $COLOR_PURPLE \ | |
| --border double \ | |
| --align center \ | |
| --width 60 \ | |
| --margin "1 2" \ | |
| --padding "1 4" \ | |
| 'π€ RALPH WIGGUM LOOP π' \ | |
| 'one task per iteration Β· fresh context every time' | |
| # βββ config file βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Optional bash-sourced config to override built-in defaults for instructions, | |
| # max iterations, and completion promise. CLI flags always win over config; | |
| # config wins over interactive defaults. | |
| # Lookup order: --config PATH β <script-dir>/.ralphrc β ./.ralphrc | |
| SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) | |
| if [[ -z $CONFIG_FILE ]]; then | |
| if [[ -r "$SCRIPT_DIR/.ralphrc" ]]; then | |
| CONFIG_FILE="$SCRIPT_DIR/.ralphrc" | |
| elif [[ -r "./.ralphrc" ]]; then | |
| CONFIG_FILE="./.ralphrc" | |
| fi | |
| fi | |
| if [[ -n $CONFIG_FILE ]]; then | |
| # shellcheck disable=SC1090 | |
| source "$CONFIG_FILE" || die "Failed to source config file: $CONFIG_FILE" | |
| if [[ -z $MAX_ITERATIONS && -n ${RALPH_MAX_ITERATIONS-} ]]; then | |
| [[ $RALPH_MAX_ITERATIONS =~ ^[1-9][0-9]*$ ]] \ | |
| || die "config: RALPH_MAX_ITERATIONS must be a positive integer, got: $RALPH_MAX_ITERATIONS" | |
| MAX_ITERATIONS=$RALPH_MAX_ITERATIONS | |
| fi | |
| if [[ -z $COMPLETION_PROMISE && -n ${RALPH_COMPLETION_PROMISE-} ]]; then | |
| COMPLETION_PROMISE=$RALPH_COMPLETION_PROMISE | |
| fi | |
| if (( INSTRUCTIONS_SET == 0 )) && [[ -n ${RALPH_INSTRUCTIONS-} ]]; then | |
| INSTRUCTIONS=$RALPH_INSTRUCTIONS | |
| INSTRUCTIONS_SET=1 | |
| fi | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Config: $CONFIG_FILE" | |
| fi | |
| # βββ agent selection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if [[ -z $AGENT ]]; then | |
| echo | |
| prompt_required gum choose \ | |
| --header "π€ Pick an agent to drive the loop" \ | |
| --selected "$DEFAULT_AGENT" \ | |
| "${SUPPORTED_AGENTS[@]}" | |
| AGENT=$PROMPT_REPLY | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Agent: $AGENT" | |
| fi | |
| case "$AGENT" in | |
| claude|cursor|codex|opencode) ;; | |
| *) die "Unknown agent: $AGENT (use claude, cursor, codex, or opencode)" ;; | |
| esac | |
| case "$AGENT" in | |
| claude) require_cmd claude "Install Claude Code: https://docs.claude.com/claude-code" | |
| require_cmd jq "Install with: brew install jq (used to render claude's streamed output)" ;; | |
| cursor) require_cmd agent "Install Cursor CLI: curl https://cursor.com/install -fsS | bash" ;; | |
| codex) require_cmd codex "Install Codex CLI: https://developers.openai.com/codex/cli" ;; | |
| opencode) require_cmd opencode "Install opencode: https://opencode.ai/docs" ;; | |
| esac | |
| # βββ model selection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Top-5 models per agent β hardcoded shortlists, biased toward what each CLI's | |
| # own docs/help text recommends today. Override with -m/--model at any time. | |
| case "$AGENT" in | |
| claude) MODELS=("opus" "sonnet" "haiku" "claude-opus-4-7" "claude-sonnet-4-6") ;; | |
| cursor) MODELS=("gpt-5" "sonnet-4" "opus-4" "gpt-5-codex" "gemini-2.5-pro") ;; | |
| codex) MODELS=("gpt-5-codex" "gpt-5" "o3" "o4-mini" "gpt-4.1") ;; | |
| opencode) MODELS=("anthropic/claude-sonnet-4-6" "anthropic/claude-opus-4-7" "openai/gpt-5" "openai/gpt-5-codex" "google/gemini-2.5-pro") ;; | |
| esac | |
| if [[ -z $MODEL ]]; then | |
| echo | |
| prompt_required gum choose \ | |
| --header "β¨ Pick a model for $AGENT" \ | |
| --selected "${MODELS[0]}" \ | |
| "${MODELS[@]}" | |
| MODEL=$PROMPT_REPLY | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Model: $MODEL" | |
| fi | |
| # βββ yolo selection ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Skip the prompt if -y was passed. For cursor/codex, default the confirm to | |
| # Yes since both are no-ops without it. | |
| if (( YOLO == 0 )); then | |
| YOLO_HEADER="π₯ Enable YOLO mode (skip permission prompts)?" | |
| YOLO_DEFAULT="false" | |
| case "$AGENT" in | |
| cursor) | |
| YOLO_HEADER="π₯ cursor needs --force to write files. Enable YOLO?" | |
| YOLO_DEFAULT="true" ;; | |
| codex) | |
| YOLO_HEADER="π₯ codex's default sandbox is read-only. Enable YOLO (--yolo)?" | |
| YOLO_DEFAULT="true" ;; | |
| esac | |
| echo | |
| if confirm --default="$YOLO_DEFAULT" "$YOLO_HEADER"; then | |
| YOLO=1 | |
| fi | |
| gum style --foreground $COLOR_GREEN --margin "0 2" \ | |
| "β YOLO: $( (( YOLO == 1 )) && echo enabled || echo disabled )" | |
| fi | |
| # cursor & codex default to non-writing modes (cursor proposes only; codex's | |
| # sandbox is read-only). Without YOLO the loop would spin doing nothing β fail | |
| # fast instead of burning iterations. | |
| if [[ $AGENT == cursor && $YOLO -ne 1 ]]; then | |
| die "cursor only writes files with --force; pass -y or answer Yes at the YOLO prompt." | |
| fi | |
| if [[ $AGENT == codex && $YOLO -ne 1 ]]; then | |
| die "codex's default sandbox is read-only; pass -y or answer Yes at the YOLO prompt." | |
| fi | |
| # βββ plan file selection βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if [[ -n $PLAN_FILE ]]; then | |
| [[ -e $PLAN_FILE ]] || die "Plan file does not exist: $PLAN_FILE" | |
| [[ -f $PLAN_FILE ]] || die "Plan file is not a regular file: $PLAN_FILE" | |
| [[ -r $PLAN_FILE ]] || die "Plan file not readable: $PLAN_FILE" | |
| else | |
| echo | |
| if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| CANDIDATES=$(git ls-files -- '*.md') | |
| else | |
| CANDIDATES=$(find . -type f -name '*.md' \ | |
| -not -path './node_modules/*' \ | |
| -not -path './.git/*' \ | |
| -not -path './dist/*' \ | |
| -not -path './build/*' 2>/dev/null | sed 's|^\./||') | |
| fi | |
| [[ -n $CANDIDATES ]] || die "No markdown files found under $(pwd)" | |
| # gum filter reads candidates from stdin, so it can't go through prompt_required. | |
| # Feed them via a here-string (not a `printf | gum` pipe) so gum is the only | |
| # command in the substitution and its status is plain $? β piping would bury it | |
| # in PIPESTATUS, which a command-substitution assignment doesn't expose. | |
| # Inline the same Esc-re-prompts/Ctrl+C-aborts logic here. | |
| hinted=0 | |
| while true; do | |
| PLAN_FILE=$(gum filter \ | |
| --header "π Pick a plan file (type to filter)" \ | |
| --header.foreground "$COLOR_PURPLE" \ | |
| --placeholder "e.g. my-planβ¦" \ | |
| --height 20 <<< "$CANDIDATES") | |
| rc=$? | |
| (( rc == GUM_ABORT_CODE )) && abort_loop | |
| [[ $rc -eq 0 && -n $PLAN_FILE ]] && break | |
| erase_cancel_notice | |
| if (( hinted == 0 )); then | |
| gum style --faint --foreground $COLOR_DIM --margin "0 2" \ | |
| "β©οΈ Pick a plan file (Enter), or press Ctrl+C to quit" | |
| hinted=1 | |
| fi | |
| done | |
| [[ -r $PLAN_FILE ]] || die "Plan file not readable: $PLAN_FILE" | |
| fi | |
| PLAN_FILE_ABS=$(abspath "$PLAN_FILE") | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Plan: $PLAN_FILE_ABS" | |
| # βββ completion promise ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Prompt for it interactively only if not provided via -c. Empty input β default. | |
| if [[ -z $COMPLETION_PROMISE ]]; then | |
| echo | |
| prompt_optional gum input \ | |
| --header "π Completion promise (Enter for default: $DEFAULT_COMPLETION_PROMISE)" \ | |
| --header.foreground "$COLOR_PURPLE" \ | |
| --placeholder "$DEFAULT_COMPLETION_PROMISE" \ | |
| --padding "0 2" \ | |
| --width 60 | |
| COMPLETION_PROMISE=$(trim "$PROMPT_REPLY") | |
| [[ -z $COMPLETION_PROMISE ]] && COMPLETION_PROMISE=$DEFAULT_COMPLETION_PROMISE | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Stop on: $COMPLETION_PROMISE" | |
| fi | |
| # βββ instructions βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Open the editor pre-filled with the defaults when no override was passed via | |
| # -s / --instructions-file. Submit as-is to accept; edit to override. | |
| if (( INSTRUCTIONS_SET == 0 )); then | |
| echo | |
| prompt_optional gum write \ | |
| --header "π Instructions (appended to every iteration β edit or submit as-is)" \ | |
| --header.foreground "$COLOR_PURPLE" \ | |
| --header.margin "0 0 1 0" \ | |
| --placeholder "Instructions appended to every iteration's promptβ¦" \ | |
| --value "$DEFAULT_INSTRUCTIONS" \ | |
| --width 80 \ | |
| --height 16 | |
| INSTRUCTIONS=$(trim "$PROMPT_REPLY") | |
| if [[ -z $INSTRUCTIONS ]]; then | |
| INSTRUCTIONS=$DEFAULT_INSTRUCTIONS | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Instructions: default" | |
| elif [[ $INSTRUCTIONS == "$(trim "$DEFAULT_INSTRUCTIONS")" ]]; then | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Instructions: default" | |
| else | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Instructions: overridden" | |
| fi | |
| INSTRUCTIONS_SET=1 | |
| fi | |
| # βββ max iterations ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Prompt for it interactively only if not provided via -n. Empty input β default. | |
| if [[ -z $MAX_ITERATIONS ]]; then | |
| while true; do | |
| echo | |
| prompt_optional gum input \ | |
| --header "π Max iterations (Enter for default: $DEFAULT_MAX_ITERATIONS)" \ | |
| --header.foreground "$COLOR_PURPLE" \ | |
| --placeholder "$DEFAULT_MAX_ITERATIONS" \ | |
| --padding "0 2" \ | |
| --width 60 | |
| raw=$(trim "$PROMPT_REPLY") | |
| if [[ -z $raw ]]; then | |
| MAX_ITERATIONS=$DEFAULT_MAX_ITERATIONS | |
| break | |
| fi | |
| if [[ $raw =~ ^[1-9][0-9]*$ ]]; then | |
| MAX_ITERATIONS=$raw | |
| break | |
| fi | |
| gum style --foreground $COLOR_RED --margin "0 2" \ | |
| "π’ Numbers only, please (got: $raw)" | |
| done | |
| gum style --foreground $COLOR_GREEN --margin "0 2" "β Iterations: $MAX_ITERATIONS" | |
| fi | |
| # βββ summary card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| BRANCH=$(git branch --show-current 2>/dev/null || echo "(not a git repo)") | |
| [[ -z $BRANCH ]] && BRANCH="(detached)" | |
| if (( YOLO == 1 )); then | |
| case "$AGENT" in | |
| claude|opencode) YOLO_LABEL="π ENABLED (--dangerously-skip-permissions)" ;; | |
| cursor) YOLO_LABEL="π ENABLED (--force)" ;; | |
| codex) YOLO_LABEL="π ENABLED (--yolo)" ;; | |
| esac | |
| else | |
| YOLO_LABEL="π disabled (permission prompts on)" | |
| fi | |
| # Build the final suffix: instructions + completion-promise directive. | |
| PROMPT_SUFFIX=$INSTRUCTIONS | |
| PROMISE_DIRECTIVE='* When ALL work is complete and no further iterations are needed, output the exact string "'"$COMPLETION_PROMISE"'" on its own line as the final line of your response. The loop watches for this and exits early.' | |
| if [[ -n $PROMPT_SUFFIX ]]; then | |
| PROMPT_SUFFIX+=$'\n'"$PROMISE_DIRECTIVE" | |
| else | |
| PROMPT_SUFFIX=$PROMISE_DIRECTIVE | |
| fi | |
| gum style \ | |
| --foreground $COLOR_GOLD \ | |
| --border-foreground $COLOR_GOLD \ | |
| --border rounded \ | |
| --margin "1 2" \ | |
| --padding "1 2" \ | |
| --width 60 \ | |
| "$(gum style --bold 'π Run plan')" \ | |
| "" \ | |
| "π€ Agent : $AGENT" \ | |
| "β¨ Model : $MODEL" \ | |
| "πΏ Branch : $BRANCH" \ | |
| "π₯ Yolo mode : $YOLO_LABEL" \ | |
| "π Stop on : $COMPLETION_PROMISE" \ | |
| "π Max Iterations : $MAX_ITERATIONS" \ | |
| "" \ | |
| "$(gum style --bold 'π Plan file')" \ | |
| "$PLAN_FILE_ABS" \ | |
| "" \ | |
| "$(gum style --bold 'π Instructions')" \ | |
| "$PROMPT_SUFFIX" | |
| confirm "π Start the loop?" || { echo "π Aborted before starting."; exit 0; } | |
| # βββ loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Traps and INTERRUPTED/TMP_OUT were wired up before the prompts; from here the | |
| # abort path renders iteration counts, so flag that the loop has started. | |
| START_TS=$(date +%s) | |
| COMPLETED=0 | |
| PROMISE_HIT=0 | |
| TMP_OUT=$(mktemp) | |
| LOOP_STARTED=1 | |
| FULL_PROMPT=$(printf 'Implement the plan defined in %s\n\nINSTRUCTIONS YOU MUST ADHERE TO: %s\n' \ | |
| "$PLAN_FILE_ABS" "$PROMPT_SUFFIX") | |
| AGENT_BIN="" | |
| AGENT_ARGS=() | |
| # 1 = prompt piped on stdin (claude/cursor/codex); 0 = prompt passed as a | |
| # positional argument after AGENT_ARGS (opencode's `run` only accepts positional). | |
| PROMPT_VIA_STDIN=1 | |
| case "$AGENT" in | |
| claude) | |
| AGENT_BIN=claude | |
| # Stream incrementally instead of buffering the whole run; claude_stream_filter | |
| # turns the JSON event stream back into readable text as it arrives. | |
| AGENT_ARGS=(-p --model "$MODEL" --output-format stream-json --verbose --include-partial-messages) | |
| if (( YOLO == 1 )); then | |
| AGENT_ARGS+=(--dangerously-skip-permissions) | |
| else | |
| AGENT_ARGS+=(--permission-mode=auto) | |
| fi | |
| ;; | |
| cursor) | |
| AGENT_BIN=agent | |
| AGENT_ARGS=(-p --force --model "$MODEL") | |
| ;; | |
| codex) | |
| AGENT_BIN=codex | |
| # `exec` is codex's non-interactive subcommand; trailing `-` reads the | |
| # prompt from stdin (codex's -p is --profile, so we can't reuse claude/ | |
| # cursor's print-mode flag). | |
| AGENT_ARGS=(exec --yolo --model "$MODEL" -) | |
| ;; | |
| opencode) | |
| AGENT_BIN=opencode | |
| AGENT_ARGS=(run --model "$MODEL") | |
| if (( YOLO == 1 )); then | |
| AGENT_ARGS+=(--dangerously-skip-permissions) | |
| fi | |
| PROMPT_VIA_STDIN=0 | |
| ;; | |
| esac | |
| # claude emits a JSON event stream we render live; other agents already print | |
| # readable text on stdout. The renderer sits between the agent and tee, so the | |
| # completion-promise grep still sees the rendered text in TMP_OUT. | |
| if [[ $AGENT == claude ]]; then | |
| RENDER_FILTER=claude_stream_filter | |
| else | |
| RENDER_FILTER=passthrough_filter | |
| fi | |
| for (( i = 1; i <= MAX_ITERATIONS; i++ )); do | |
| TS=$(date +%H:%M:%S) | |
| gum style \ | |
| --foreground $COLOR_PURPLE \ | |
| --border-foreground $COLOR_PURPLE \ | |
| --border thick \ | |
| --align center \ | |
| --width 60 \ | |
| --margin "1 2" \ | |
| --padding "0 2" \ | |
| "π Iteration $i / $MAX_ITERATIONS Β· β± $TS" | |
| if (( PROMPT_VIA_STDIN == 1 )); then | |
| printf '%s' "$FULL_PROMPT" | "$AGENT_BIN" "${AGENT_ARGS[@]}" | "$RENDER_FILTER" | tee "$TMP_OUT" | |
| rc=${PIPESTATUS[1]} | |
| else | |
| "$AGENT_BIN" "${AGENT_ARGS[@]}" "$FULL_PROMPT" | "$RENDER_FILTER" | tee "$TMP_OUT" | |
| rc=${PIPESTATUS[0]} | |
| fi | |
| if (( rc != 0 )); then | |
| gum style --foreground $COLOR_RED --margin "0 2" \ | |
| "β Iteration $i exited with code $rc" | |
| if ! confirm "Continue with the next iteration?"; then | |
| break | |
| fi | |
| else | |
| gum style --foreground $COLOR_GREEN --margin "0 2" \ | |
| "β Iteration $i complete" | |
| fi | |
| COMPLETED=$i | |
| if grep -qF -- "$COMPLETION_PROMISE" "$TMP_OUT"; then | |
| PROMISE_HIT=1 | |
| gum style \ | |
| --foreground $COLOR_GREEN \ | |
| --border-foreground $COLOR_GREEN \ | |
| --border rounded \ | |
| --align center \ | |
| --width 60 \ | |
| --margin "1 2" \ | |
| --padding "0 2" \ | |
| "π Completion promise \"$COMPLETION_PROMISE\" detected β done!" | |
| break | |
| fi | |
| done | |
| # βββ final summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| END_TS=$(date +%s) | |
| ELAPSED=$(( END_TS - START_TS )) | |
| H=$(( ELAPSED / 3600 )) | |
| M=$(( (ELAPSED % 3600) / 60 )) | |
| S=$(( ELAPSED % 60 )) | |
| ELAPSED_FMT=$(printf '%02dh %02dm %02ds' $H $M $S) | |
| gum style \ | |
| --foreground $COLOR_GREEN \ | |
| --border-foreground $COLOR_GREEN \ | |
| --border double \ | |
| --align center \ | |
| --width 60 \ | |
| --margin "1 2" \ | |
| --padding "1 4" \ | |
| 'π RALPH LOOP COMPLETE' \ | |
| "" \ | |
| "π Iterations: $COMPLETED / $MAX_ITERATIONS" \ | |
| "β± Elapsed: $ELAPSED_FMT" \ | |
| "πΏ Branch: $BRANCH" \ | |
| "π― Outcome: $( (( PROMISE_HIT == 1 )) && echo "promise \"$COMPLETION_PROMISE\" hit" || echo "cap reached" )" |
th3hunt
commented
May 21, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment