Skip to content

Instantly share code, notes, and snippets.

@th3hunt
Last active May 21, 2026 15:21
Show Gist options
  • Select an option

  • Save th3hunt/7b60970bd275817085b48fae34103d52 to your computer and use it in GitHub Desktop.

Select an option

Save th3hunt/7b60970bd275817085b48fae34103d52 to your computer and use it in GitHub Desktop.
ralph-loop
#!/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

th3hunt commented May 21, 2026

Copy link
Copy Markdown
Author
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment