| name | ralph |
| description | Scaffold and run a RALPH loop โ an autonomous multi-group implementation plan executed by Claude via CLI with state tracking, retries, and per-group learning notes |
| context | main |
RALPH Loop Skill
RALPH = Research, Analyze, Learn, Plan, Hack โ an autonomous multi-group implementation pattern. Each group is a focused direction; Claude researches, plans, and implements it, then signals completion. The bash runner orchestrates retries, validation, and state between groups.
When to Use
Large migrations, rewrites, or feature rollouts that are:
- Too big for a single Claude session (>3โ4 hours of work)
- Naturally sequenced (each group builds on the previous)
- Risky enough to need per-group validation and rollback safety
- Complex enough to benefit from Claude planning each group independently
Examples: language rewrites, database migrations, API redesigns, CI/CD overhauls.
Invocation
/ralph setup # Scaffold a new RALPH loop for the current project
/ralph run # Start/continue running pending groups
/ralph status # Print group status from state file
/ralph reset N # Reset group N to pending
/ralph babysitter # Dynamic /loop observer โ status checks + Slack updates every 30 min
/ralph cleanup # Distill notes into docs/migrations/<name>.md, then delete all ralph artifacts (one-way)
How to Run Setup (/ralph setup)
When the user invokes /ralph setup, follow this workflow:
Step 1 โ Understand the task
Ask the user:
- What is the overall goal? (e.g. "rewrite TypeScript server in Go")
- What tech stack / toolchain is involved?
- What are the validation commands? (build, test, lint, typecheck, E2E)
- How many groups (rough estimate)? Groups should fit in 45 min of autonomous Claude time each (the
CLAUDE_TIMEOUT), which usually maps to 1โ2h of human work.
- Any hard sequencing constraints (e.g. "Group 5 must pass E2E before Group 6")?
- Does the migration have a deploy-pause window โ groups whose output is correct but ships brokenness if deployed before the cutover? If yes,
RALPH_BRANCH defaults to migration/<name> and the runner stays on it.
- Which 1Password account does this workspace use? Do NOT guess. Read the user's global CLAUDE.md (
~/.claude/CLAUDE.md), find the "1Password routing" section, and resolve based on the repo path: SourceRoot repos โ tkrumm, IuRoot repos โ careerpartner. The op_account_for_cwd helper encodes this logic. The runner's require_op_session and prefetch_secrets must use the correct account โ a typo like tkrmm for tkrumm silently breaks the session check.
Step 2 โ Define groups
Decompose the goal into focused groups. Apply the split-trigger heuristics below โ typical migrations end up in the 10โ16 range, not the 5โ12 range, once strictness baseline + cross-cutting concerns are extracted.
Rules:
- Group 1 is always the skeleton/foundation (no validation failures possible yet)
- Groups build on previous โ never require skipping a group
- Strictness baseline (TS strict, lint plugins, lefthook, React Compiler) lands as Group 3 or 4 โ not at the end. See the dedicated section below.
- E2E green checkpoints: at least one group explicitly validates full E2E before risky changes
- Dangerous/breaking groups (delete old system, cut over production) go last
- Apply the split-trigger heuristics below before finalizing the list
Output a numbered list for user review before creating files.
Step 3 โ Create directory structure
<project>/
scripts/
ralph.sh # runner (generated from template below)
ralph-reset.sh # reset helper
docs/ralph/
shared-context.md # injected into every group prompt
RALPH_NOTES.md # Claude appends after each group
RALPH_REPORT.md # auto-generated status
prompts/
group-1.md
group-2.md
...
State, logs, lock, and secrets are gitignored:
.ralph-tasks.json
.ralph-logs/
.ralph-lock
.ralph-secrets.env
Add to .gitignore:
.ralph-tasks.json
.ralph-logs/
.ralph-lock
.ralph-secrets.env
Step 4 โ Write shared-context.md
The shared context is prepended to every group prompt. Include:
# <Project> โ RALPH Shared Context
You are implementing: **<goal>**. Read this fully before starting your group.
---
## What <Project> Is
[2โ3 paragraph description: what it does, why it exists, key design decisions]
---
## Repository Layout
[tree or table of relevant files/dirs]
---
## Tech Stack
| Concern | Choice |
|-|-|
| ... | ... |
---
## Validation Commands
**Primary (run after every group):**
```bash
<build command> # must compile/bundle clean
<test command> # all unit tests pass
<lint command> # must be clean
E2E (only when instructed โ may require Docker/infra):
<e2e command>
Research Before Implementing
Always start by:
- Explore the codebase with Glob/Grep/Read โ understand existing patterns
- Research unfamiliar libraries with the
/research skill (research-gateway) or Context7
- Read relevant existing code before writing new code
- The group prompt is direction, not prescription โ use a better approach if you find one
Learning Notes
After completing each group, always append to docs/ralph/RALPH_NOTES.md:
## Group N: <title>
### What was implemented
<1โ3 sentences>
### Deviations from prompt
<what you changed and why>
### Gotchas & surprises
<anything unexpected โ library APIs, language quirks, tooling surprises>
### Security notes
<security-relevant decisions, if any>
### Tests added
<list of test files/functions added>
### Future improvements
<deferred work, tech debt, better approaches possible>
Commit Format
Conventional commits, no AI attribution:
feat(<scope>): <description>
refactor(<scope>): <description>
fix(<scope>): <description>
Stage only modified files. Commit before signaling completion.
Use raw git only โ never invoke interactive skills. Use git add <files> + git commit -m "..." directly. Do not invoke /commit, /commit --split, /pr, /check, /review, /ship, or any other slash-command skill from inside a group. These skills are interactive workflows that present proposals and wait for user confirmation; in claude -p headless mode the confirmation never comes, the skill prints a strategy, the model returns "success" with no side effect, and the group exits with no commit and no RALPH_TASK_COMPLETE signal. The runner then resets the group to pending, the working tree is left dirty, and the babysitter has to wake the human. If a group genuinely needs to split into multiple commits, do git add <subset> && git commit -m "..." once per logical commit.
Completion Signal
Output exactly one of these at the end, as the very last line:
RALPH_TASK_COMPLETE: Group N
If you cannot proceed due to an unresolvable blocker:
RALPH_TASK_BLOCKED: Group N - <reason in one sentence>
### Step 5 โ Write group prompt files
Each `group-N.md` follows this template:
```markdown
# Group N: <Title>
## What You're Doing
[2โ4 sentences. What is the goal of this group? What state does it leave the codebase in?]
---
## Research & Exploration First
1. [Specific file to read โ always read before writing]
2. [Library to research via the `/research` skill or Context7]
3. [Existing pattern to understand]
4. [Edge case to investigate]
---
## What to Implement
### 1. <Component/file name>
[What to create or change. Be specific about interfaces, types, function signatures.]
```<lang>
// Key signatures or skeleton
2.
[...]
Validation
<build>
<test>
<lint>
[List what to test specifically โ table-driven tests, edge cases, happy paths.]
Commit
feat(<scope>): <description of this group's work>
Done
Append learning notes to docs/ralph/RALPH_NOTES.md, then:
RALPH_TASK_COMPLETE: Group N
**Group prompt discipline:**
- Group 1: foundation only, no validation gate (nothing to validate yet)
- E2E checkpoint groups: explicitly state "Run full E2E: `<cmd>`"
- Cutover/breaking groups: add a "DANGER" note at the top, explicit rollback instructions
- Keep prompts tight: direction + key signatures + validation. Not a full spec.
### Step 6 โ Generate the runner script
Write `scripts/ralph.sh` using the proven template:
```bash
#!/usr/bin/env bash
# <Project> โ RALPH Loop Runner
#
# Usage:
# ./scripts/ralph.sh # Run all pending groups
# ./scripts/ralph.sh 3 # Run only group 3
# ./scripts/ralph.sh --reset 3 # Reset group 3 to pending, then run
# ./scripts/ralph.sh --status # Print status and exit
#
# Logs: .ralph-logs/group-N.log
# Watch live: tail -f .ralph-logs/group-N.log
#
# Prerequisites:
# brew install coreutils # for gtimeout
# claude CLI must be in PATH
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCS_DIR="$REPO_ROOT/docs/ralph"
PROMPTS_DIR="$DOCS_DIR/prompts"
STATE_FILE="$REPO_ROOT/.ralph-tasks.json"
LOGS_DIR="$REPO_ROOT/.ralph-logs"
REPORT_FILE="$DOCS_DIR/RALPH_REPORT.md"
MAX_RETRIES=3
CLAUDE_TIMEOUT=2700 # 45 minutes per group
# Model + transport (override via env at launch, e.g. RALPH_TRANSPORT=bridge ./scripts/ralph.sh run).
# max โ sonnet on the Max subscription. Best quality/ceiling for autonomous
# multi-hour groups; burns Max quota heavily.
# bridge โ every group routed through the local LiteLLM bridge to DeepSeek-V4-Pro
# (EU/GDPR), IU per-token billing, ZERO Max quota โ the same lane the
# `ca` launcher (config/zsh/claude.zsh, dotfiles) uses interactively.
# Caveats: no WebSearch/WebFetch (research-phase groups lose web), the
# worker model can throttle under load across a long loop (leans on
# retries), and a lower implementation ceiling than sonnet. Use for
# cost-sensitive or EU-bound loops; keep `max` for quality-critical
# migrations.
RALPH_EFFORT="${RALPH_EFFORT:-high}"
RALPH_TRANSPORT="${RALPH_TRANSPORT:-max}" # max | bridge
LITELLM_BRIDGE_URL="${LITELLM_BRIDGE_URL:-http://127.0.0.1:4000}"
LITELLM_BRIDGE_TOKEN="${LITELLM_BRIDGE_TOKEN:-sk-litellm-master-key}"
# RALPH_MODEL defaults per transport: a bare tier alias like "sonnet" is only
# guaranteed to resolve against api.anthropic.com. Against the bridge, pass the
# literal LiteLLM model id (config/litellm/config.yaml, dotfiles) the same way
# `ca`/`claude_bridge` default their --model โ an unmapped name 404s.
if [[ -z "${RALPH_MODEL:-}" ]]; then
if [[ "$RALPH_TRANSPORT" == "bridge" ]]; then
RALPH_MODEL="DeepSeek-V4-Pro"
else
RALPH_MODEL="sonnet"
fi
fi
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
TOTAL_GROUPS=<N>
GROUP_TITLES=(
"" # 1-indexed
"<title 1>"
"<title 2>"
# ...
)
log_info() { echo -e "${BLUE}[ralph]${NC} $*"; }
log_success() { echo -e "${GREEN}[ralph]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[ralph]${NC} $*"; }
log_error() { echo -e "${RED}[ralph]${NC} $*"; }
require_commands() {
local missing=0
for cmd in claude gtimeout python3; do
if ! command -v "$cmd" &>/dev/null; then
log_error "$cmd not found."
missing=1
fi
done
[[ $missing -eq 0 ]] || { echo "Install: brew install coreutils"; exit 1; }
}
# โโ State management โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# All python helpers use a QUOTED heredoc (`<<'PYEOF'`) and pass dynamic values
# through argv โ never string-interpolate shell into the Python source. An unquoted
# heredoc lets the shell expand `$(...)`, backticks, and `$N` inside the script body
# AND inside any interpolated data (titles, notes, dates). That is the "generate_report
# backtick bug": a group title or report value containing a backtick or `$(...)` was
# executed by the shell at report time. Quoted heredoc + argv eliminates the whole class.
init_state() {
[[ -f "$STATE_FILE" ]] && { log_info "Resuming from existing state."; return; }
log_info "Initializing task state..."
python3 - "$STATE_FILE" "${GROUP_TITLES[@]:1}" <<'PYEOF'
import json, sys, datetime
state_file = sys.argv[1]
titles = sys.argv[2:]
groups = [{"id": i+1, "title": t, "status": "pending", "attempts": 0,
"started_at": None, "completed_at": None}
for i, t in enumerate(titles)]
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
state = {"groups": groups, "created_at": now}
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
print("State initialized.")
PYEOF
}
get_field() {
python3 - "$STATE_FILE" "$1" "$2" <<'PYEOF'
import json, sys
state_file, gid, field = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(state_file) as f:
state = json.load(f)
for g in state['groups']:
if g['id'] == gid:
print(g.get(field, ''))
break
PYEOF
}
set_field() {
python3 - "$STATE_FILE" "$1" "$2" "$3" <<'PYEOF'
import json, sys
state_file, gid, field, raw = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
val = {'True': True, 'False': False, 'None': None}.get(raw, raw)
with open(state_file) as f:
state = json.load(f)
for g in state['groups']:
if g['id'] == gid:
g[field] = val
break
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
PYEOF
}
inc_attempts() {
python3 - "$STATE_FILE" "$1" <<'PYEOF'
import json, sys
state_file, gid = sys.argv[1], int(sys.argv[2])
with open(state_file) as f:
state = json.load(f)
for g in state['groups']:
if g['id'] == gid:
g['attempts'] = g.get('attempts', 0) + 1
break
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
PYEOF
}
# Roll an attempt back โ used when a run fails for a reason that isn't the group's
# fault (e.g. the Max usage limit), so the group keeps its full retry budget.
dec_attempts() {
python3 - "$STATE_FILE" "$1" <<'PYEOF'
import json, sys
state_file, gid = sys.argv[1], int(sys.argv[2])
with open(state_file) as f:
state = json.load(f)
for g in state['groups']:
if g['id'] == gid:
g['attempts'] = max(0, g.get('attempts', 0) - 1)
break
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
PYEOF
}
print_status() {
python3 - "$STATE_FILE" <<'PYEOF'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
icons = {'complete': 'โ
', 'blocked': '๐ซ', 'pending': 'โฌ', 'in_progress': '๐'}
total = len(state['groups'])
done = sum(1 for g in state['groups'] if g['status'] == 'complete')
blocked = sum(1 for g in state['groups'] if g['status'] == 'blocked')
pending = total - done - blocked
print(f" {total} groups | {done} complete | {pending} pending | {blocked} blocked")
print()
for g in state['groups']:
icon = icons.get(g['status'], 'โฌ')
attempts = f" (attempt {g['attempts']})" if g['attempts'] > 0 else ""
print(f" {icon} Group {g['id']}: {g['title']}{attempts}")
PYEOF
}
# โโ Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
validate() {
local label=${1:-""}
log_info "Validation${label:+ ($label)}..."
cd "$REPO_ROOT"
mkdir -p "$LOGS_DIR"
local vlog="$LOGS_DIR/validate${label:+-${label// /-}}.log"
# CUSTOMIZE: replace with your project's validation commands.
# All command output (build, bundler, test chatter) is captured to $vlog and
# surfaced ONLY on failure โ the runner's grouped [ralph] logs stay readable
# instead of drowning in successful-build noise. On failure the tail is printed
# so the actual errors still propagate.
if ! {
<build command> &&
<test command>
} > "$vlog" 2>&1; then
log_error "Validation failed โ last 40 lines of $vlog:"
tail -n 40 "$vlog" >&2
return 1
fi
log_success "Validation passed (full output: $vlog)"
return 0
}
# โโ Claude invocation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
run_group() {
local group_id=$1
local prompt_file="$PROMPTS_DIR/group-$group_id.md"
local context_file="$DOCS_DIR/shared-context.md"
local log_file="$LOGS_DIR/group-$group_id.log"
mkdir -p "$LOGS_DIR"
if [[ ! -f "$prompt_file" ]]; then
log_error "Prompt not found: $prompt_file"
return 1
fi
local full_prompt
full_prompt="$(cat "$context_file")"$'\n\n---\n\n'"$(cat "$prompt_file")"
log_info "Claude running (model: $RALPH_MODEL, transport: $RALPH_TRANSPORT, timeout: ${CLAUDE_TIMEOUT}s) โ log: .ralph-logs/group-$group_id.log"
log_info "Watch live: tail -f .ralph-logs/group-$group_id.log"
echo ""
# Env prefix for the spawn. Always at least the two interactive-noise suppressors,
# so the array is never empty (avoids the bash "unbound variable" trap under set -u).
# bridge mode appends the LiteLLM routing vars; ANTHROPIC_API_KEY is stripped in
# both modes (max uses the OAuth login; the key would shadow the bridge token).
local -a group_env=(CLAUDE_CODE_ENABLE_TASKS=true CLAUDECODE=)
local -a claude_flags=()
if [[ "$RALPH_TRANSPORT" == "bridge" ]]; then
if ! curl -fsS -m 3 "${LITELLM_BRIDGE_URL}/health/liveliness" >/dev/null 2>&1; then
log_error "RALPH_TRANSPORT=bridge but LiteLLM bridge unreachable at $LITELLM_BRIDGE_URL โ run 'make litellm-restart' in dotfiles."
return 1
fi
group_env+=(
ANTHROPIC_BASE_URL="$LITELLM_BRIDGE_URL"
ANTHROPIC_AUTH_TOKEN="$LITELLM_BRIDGE_TOKEN"
# Subagents/background tasks (Explore, @implementer โ CLAUDE_CODE_ENABLE_TASKS=true
# above means groups CAN spawn them) resolve by TIER, not by the top-level --model.
# Without these pins each tier falls back to its hardcoded claude-* default, which
# the bridge doesn't map โ "400 Invalid model name" the instant a subagent spawns
# mid-group. Mirrors `ca`/`claude_bridge` in config/zsh/claude.zsh (dotfiles).
ANTHROPIC_DEFAULT_OPUS_MODEL=DeepSeek-V4-Pro
ANTHROPIC_DEFAULT_SONNET_MODEL=DeepSeek-V4-Pro
ANTHROPIC_DEFAULT_HAIKU_MODEL=DeepSeek-V4-Flash
ANTHROPIC_DEFAULT_FABLE_MODEL=DeepSeek-V4-Pro
# Claude Code hardcodes a 200k context window for any model over a custom
# ANTHROPIC_BASE_URL. This restores DeepSeek's real 1M so long research- and
# edit-heavy groups don't auto-compact early (same fix as `ca`).
CLAUDE_CODE_MAX_CONTEXT_TOKENS=1000000
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1
)
# DeepSeek over the bridge tends to self-invoke EnterPlanMode. In headless -p
# mode there is no user to exit plan mode, so the group would present a plan
# and never implement it โ silently burning a retry with no completion signal.
claude_flags+=(--disallowedTools EnterPlanMode)
fi
local exit_code=0
if env -u ANTHROPIC_API_KEY "${group_env[@]}" gtimeout "$CLAUDE_TIMEOUT" claude \
-p "$full_prompt" \
--model "$RALPH_MODEL" \
--effort "$RALPH_EFFORT" \
"${claude_flags[@]}" \
--dangerously-skip-permissions \
--output-format stream-json \
--verbose \
--no-session-persistence \
< /dev/null > "$log_file" 2>&1; then
exit_code=0
else
exit_code=$?
fi
# Check completion signal BEFORE the timeout guard โ Claude may have finished its
# work and emitted the signal, but the post-signal summary/notes/commit pushed the
# process past the timeout limit. In that case the group is done; don't treat it as failed.
grep -q "RALPH_TASK_COMPLETE: Group $group_id" "$log_file" && return 0
grep -q "RALPH_TASK_BLOCKED: Group $group_id" "$log_file" && return 2
# Session / usage-limit detection (return 3). When the Max 5-hour limit is hit,
# claude -p exits WITHOUT the completion signal and without doing the work.
# Retrying just slams the same wall and silently eats the group's retry budget,
# so surface it as a distinct outcome โ main() pauses the loop and rolls the
# attempt back rather than burning attempts 1โ3 against a locked account.
# Only treat this as a limit hit when there's no completion signal (checked above),
# so a group that genuinely finished before the limit landed still counts as done.
if grep -qiE "Claude AI usage limit reached|usage limit reached|5-hour limit|limit will reset" "$log_file"; then
log_error "Claude usage/session limit reached โ pausing loop (this attempt does not count)."
grep -iE "usage limit|limit will reset|reset at" "$log_file" | tail -n 2 >&2 || true
return 3
fi
[[ $exit_code -eq 124 ]] && { log_error "Timed out after ${CLAUDE_TIMEOUT}s"; return 1; }
log_warn "Claude finished but no completion signal in log."
return 1
}
# โโ Report โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
generate_report() {
python3 - "$STATE_FILE" "$REPORT_FILE" <<'PYEOF'
import json, sys, datetime
state_file, report_file = sys.argv[1], sys.argv[2]
with open(state_file) as f:
state = json.load(f)
icons = {'complete': 'โ
', 'blocked': '๐ซ', 'pending': 'โฌ', 'in_progress': '๐'}
total = len(state['groups'])
done = sum(1 for g in state['groups'] if g['status'] == 'complete')
blocked = sum(1 for g in state['groups'] if g['status'] == 'blocked')
pending = total - done - blocked
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = [
"# RALPH Report",
"",
f"Generated: {now}",
f"Groups: {total} total | {done} complete | {pending} pending | {blocked} blocked",
"", "## Status", "",
]
for g in state['groups']:
icon = icons.get(g['status'], 'โฌ')
attempts = f" (attempts: {g['attempts']})" if g['attempts'] > 0 else ""
lines.append(f"- {icon} **Group {g['id']}**: {g['title']}{attempts}")
lines += ["", "## Next Steps", ""]
if done == total:
lines += ["All groups complete.", "", "1. Review: `git log --oneline -20`", "2. Run full E2E", "3. Create PR: `/pr`"]
elif pending > 0:
lines.append("Run `./scripts/ralph.sh` to continue.")
with open(report_file, 'w') as f:
f.write('\n'.join(lines) + '\n')
print(f"Report: {report_file}")
PYEOF
}
# โโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
main() {
local target_group=""
local do_reset=false
local status_only=false
while [[ $# -gt 0 ]]; do
case $1 in
--status) status_only=true; shift ;;
--reset) do_reset=true; target_group="${2:?'--reset requires a group number'}"; shift 2 ;;
[0-9]*) target_group="$1"; shift ;;
*) echo "Unknown: $1"; echo "Usage: $0 [group] [--reset group] [--status]"; exit 1 ;;
esac
done
echo ""
echo -e "${BOLD} RALPH Loop${NC}"
echo ""
require_commands
cd "$REPO_ROOT"
init_state
if $status_only; then print_status; exit 0; fi
if $do_reset; then
log_info "Resetting Group $target_group to pending..."
set_field "$target_group" "status" "pending"
python3 - "$STATE_FILE" "$target_group" <<'PYEOF'
import json, sys
state_file, gid = sys.argv[1], int(sys.argv[2])
with open(state_file) as f:
state = json.load(f)
for g in state['groups']:
if g['id'] == gid:
g['attempts'] = 0; break
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
PYEOF
fi
print_status; echo ""
# Singleton lock โ acquire only on the actual run path (after the --status
# early-exit above). This is the real fix for the post-completion fork bomb:
# a re-entrant or concurrent `./scripts/ralph.sh` is refused instead of stacking
# a second runner on top of the first (which corrupts state and can spawn a
# cascade of nested processes). Side-channel --status / --reset never reach here.
acquire_lock
local groups_to_run=()
if [[ -n "$target_group" ]]; then
groups_to_run=("$target_group")
else
for i in $(seq 1 $TOTAL_GROUPS); do groups_to_run+=("$i"); done
fi
for group_id in "${groups_to_run[@]}"; do
local status
status=$(get_field "$group_id" "status")
if [[ "$status" == "complete" ]]; then
echo -e " โ
Group $group_id: ${GROUP_TITLES[$group_id]} โ skipped (complete)"
continue
fi
if [[ "$status" == "blocked" ]]; then
echo -e " ๐ซ Group $group_id: ${GROUP_TITLES[$group_id]} โ skipped (blocked)"
continue
fi
local attempts
attempts=$(get_field "$group_id" "attempts")
if [[ "$attempts" -ge "$MAX_RETRIES" ]]; then
log_warn "Group $group_id reached max retries. Marking blocked."
set_field "$group_id" "status" "blocked"
continue
fi
echo ""
echo " โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"
echo -e " ${BOLD}Group $group_id: ${GROUP_TITLES[$group_id]}${NC}"
echo " Attempt: $((attempts + 1)) / $MAX_RETRIES"
echo " โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"
echo ""
# Pre-group validation (skip group 1 โ nothing to validate yet)
if [[ "$group_id" -gt 1 ]]; then
if ! validate "pre-group $group_id"; then
log_error "Pre-group validation failed. Fix before continuing."
exit 1
fi
echo ""
fi
set_field "$group_id" "status" "in_progress"
inc_attempts "$group_id"
run_result=0
run_group "$group_id" || run_result=$?
echo ""
if [[ $run_result -eq 0 ]]; then
log_success "Group $group_id complete."
set_field "$group_id" "status" "complete"
set_field "$group_id" "completed_at" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
if validate "post-group $group_id"; then
log_success "Post-group validation passed โ"
else
log_warn "Post-group validation FAILED. Review log and fix."
log_warn "Retry: ./scripts/ralph.sh --reset $group_id"
fi
elif [[ $run_result -eq 2 ]]; then
log_warn "Group $group_id blocked. See: .ralph-logs/group-$group_id.log"
set_field "$group_id" "status" "blocked"
elif [[ $run_result -eq 3 ]]; then
# Usage / session limit โ not the group's fault. Roll the attempt back, leave
# the group pending, and stop the loop. Re-running after the limit resets
# resumes cleanly at this group with its full retry budget intact.
set_field "$group_id" "status" "pending"
dec_attempts "$group_id"
log_error "Group $group_id paused: Claude usage/session limit reached."
log_error "Re-run ./scripts/ralph.sh once the limit resets โ it resumes here."
break
else
log_error "Group $group_id failed (attempt $((attempts + 1)) / $MAX_RETRIES)"
set_field "$group_id" "status" "pending"
log_info "Log: .ralph-logs/group-$group_id.log"
new_attempts=$(get_field "$group_id" "attempts")
if [[ "$new_attempts" -ge "$MAX_RETRIES" ]]; then
set_field "$group_id" "status" "blocked"
elif [[ -z "$target_group" ]]; then
log_warn "Stopping. Fix Group $group_id before proceeding."
break
fi
fi
echo ""
done
echo ""
generate_report
echo ""
echo -e "${BOLD} RALPH loop done.${NC}"
echo ""
print_status
echo ""
}
main "$@"
Also create scripts/ralph-reset.sh:
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/ralph.sh" --reset "${1:?'Usage: ralph-reset.sh <group-id>'}"
Make both executable: chmod +x scripts/ralph.sh scripts/ralph-reset.sh
Pre-flight Setup (REQUIRED โ bake into runner)
Five issues silently break autonomous loops on macOS + 1Password + multi-day migrations. All are mechanical fixes that belong in ralph.sh, not in user instructions.
1. Commit-signing biometric block
If the user's git config has commit.gpgsign = true with gpg.format = ssh and gpg.ssh.program = /Applications/1Password.app/Contents/MacOS/op-ssh-sign, every commit hangs on Touch ID. The autonomous loop will time out at the first commit.
Detect this at runner startup. Disable signing per-repo for the loop's duration. Restore via trap on exit. Never --no-gpg-sign (that violates the user's commit rules); use the local config override instead.
ORIG_GPGSIGN=""
GPGSIGN_TOUCHED=false
disable_commit_signing() {
cd "$REPO_ROOT"
ORIG_GPGSIGN="$(git config --local --get commit.gpgsign || echo '__unset__')"
local effective
effective="$(git config --get commit.gpgsign || echo 'false')"
if [[ "$effective" == "true" ]]; then
log_warn "commit.gpgsign=true detected โ disabling for the loop (would block on Touch ID)."
git config --local commit.gpgsign false
GPGSIGN_TOUCHED=true
fi
}
restore_commit_signing() {
$GPGSIGN_TOUCHED || return 0
cd "$REPO_ROOT" 2>/dev/null || return 0
if [[ "$ORIG_GPGSIGN" == "__unset__" ]]; then
git config --local --unset commit.gpgsign 2>/dev/null || true
else
git config --local commit.gpgsign "$ORIG_GPGSIGN"
fi
log_info "Restored commit.gpgsign (was: $ORIG_GPGSIGN)."
}
trap restore_commit_signing EXIT
Call disable_commit_signing from main() after require_commands. The trap handles every exit path (success, failure, SIGINT).
2. Default-branch guard
Autonomous commits to master / main are unsafe โ deploy.yml typically fires on push, and the user's workflow assumes humans review before that happens. The runner must refuse to run when HEAD is on the default branch.
This is a guard, not a branch-creator. Users normally invoke /ralph setup from a feature branch they already chose (e.g. feat/v2, wip/migration). Silently switching them to a hard-coded branch name fights that workflow. The right move is to fail loudly and let the user git checkout -b <name> themselves.
refuse_default_branch() {
cd "$REPO_ROOT"
local current
current="$(git rev-parse --abbrev-ref HEAD)"
case "$current" in
master|main)
log_error "Refusing to run on '$current' โ autonomous commits to the default branch are unsafe."
log_error "Switch to a feature/migration branch first: git checkout -b <name>"
exit 1
;;
esac
log_info "Running on branch: $current"
}
Call from main() together with the signing fix. For migrations with a "deploy pause" window (groups that produce code that's correct in isolation but breaks production if shipped before the cutover lands), the same guard suffices โ the user runs the loop on their existing branch and pushes manually only when ready. No long-lived migration/<name> branch needed unless the user explicitly wants one.
3. 1Password CLI session pre-flight
Groups that read secrets via op run --account <acct> (database URLs, API keys, OTLP credentials) hang on Touch ID the same way op-ssh-sign does, just on a different surface. The runner must verify the session is alive before launching the first group โ otherwise group N is mid-flight when biometric prompts the user at 3am.
Account resolution: Replace <acct> with the actual 1Password account short name for this workspace. Resolve it from the user's global CLAUDE.md "1Password routing" section โ SourceRoot repos use tkrumm, IuRoot repos use careerpartner. Never guess the account name; a typo produces a silent op whoami failure that looks like an expired session.
require_op_session() {
log_info "Verifying 1Password CLI session (op --account <acct>)..."
if ! gtimeout 5 op whoami --account <acct> >/dev/null 2>&1; then
log_error "1Password CLI session is not active."
log_error "Sign in once before launching: eval \$(op signin --account <acct>)"
exit 1
fi
log_success "op session active."
}
Note the gtimeout 5 โ without it, a missing session can itself hang on Touch ID prompt. With it, the runner exits fast and tells the user what to do.
4. Secrets pre-fetch (eliminate op run from the loop body)
The op whoami check (step 3) only verifies a session exists. It doesn't guarantee that op run mid-loop won't prompt for Touch ID โ that depends on the user's 1Password settings (auto-lock timer, "Require Touch ID for each command", etc.). At 3am when the user is asleep, any mid-loop biometric prompt halts everything.
The robust pattern: fetch every secret the loop needs during pre-flight, write to a mode-600 env file, source it into the runner's environment, and delete on exit. Mid-loop, no op interaction. Groups that need a secret read the env var directly; groups that need an op://-backed config file generate it from the env var (e.g. docker-compose env interpolation).
Account resolution: Same rule as ยง3 โ replace <acct> with the resolved account from CLAUDE.md's 1Password routing section (tkrumm for SourceRoot, careerpartner for IuRoot). Every op read call in this block uses the same account.
SECRETS_FILE="$REPO_ROOT/.ralph-secrets.env"
SECRETS_INSTALLED=false
prefetch_secrets() {
log_info "Pre-fetching secrets via op (Touch ID may prompt)..."
local db_password
db_password="$(gtimeout 30 op read 'op://<vault>/<item>/<field>' --account <acct> 2>/dev/null || true)"
if [[ -z "$db_password" ]]; then
log_error "Failed to read secret. Make sure the 1Password app is unlocked."
exit 1
fi
local slack_webhook
slack_webhook="$(gtimeout 15 op read 'op://<vault>/<slack-webhook-item>/url' --account <acct> 2>/dev/null || true)"
umask 077
cat > "$SECRETS_FILE" <<EOF
# Auto-generated by scripts/ralph.sh โ DO NOT COMMIT. Deleted on runner exit.
PROJECT_DB_PASSWORD=$db_password
PROJECT_LOCAL_DATABASE_URL=postgres://user:$db_password@localhost:5433/db
RALPH_SLACK_WEBHOOK_URL=$slack_webhook
EOF
chmod 600 "$SECRETS_FILE"
set -a
source "$SECRETS_FILE"
set +a
SECRETS_INSTALLED=true
log_success "Secrets cached to .ralph-secrets.env (mode 600) and exported."
}
remove_secrets() {
$SECRETS_INSTALLED || return 0
[[ -f "$SECRETS_FILE" ]] || return 0
rm -f "$SECRETS_FILE"
log_info "Removed .ralph-secrets.env."
}
Why the sentinel matters: the cleanup trap fires on every exit, including --status and --reset. Without the $SECRETS_INSTALLED guard, a babysitter or human running ./scripts/ralph.sh --status to peek at progress will silently delete the secrets file the active runner installed. The running process is unaffected (its env was sourced before the trap was registered) โ but the babysitter loses RALPH_SLACK_WEBHOOK_URL, and re-fetching from 1Password forces a Touch ID prompt every 30 min, which defeats the whole point of pre-fetching. Apply the same <name>_INSTALLED sentinel pattern to install_push_guard / remove_push_guard (and any future pre-flight installer).
Add .ralph-secrets.env to .gitignore. Mode-600 + auto-delete are belt-and-suspenders; the gitignore is the real safety net against accidental commit.
Patterns:
- Single source of truth for shared values. If local dev and production use the same DB password (e.g. you provisioned the local container with the production password rather than inventing a throwaway), pre-fetch once and reuse for both
PROJECT_LOCAL_DATABASE_URL and PROJECT_PROD_DATABASE_URL. Don't make the agent invent local-only throwaway credentials โ that's a footgun if the agent's hardcoded "throwaway" later collides with a real value.
- docker-compose env interpolation.
POSTGRES_PASSWORD: ${PROJECT_DB_PASSWORD} in docker-compose.dev.yml reads from the runner's exported env. Local devs running make db-up outside the loop should have the Makefile target source .ralph-secrets.env first โ same mechanism.
- Subprocess inheritance is the whole point. The
set -a; source; set +a pattern auto-exports every variable defined in the file. Without set -a, claude -p child processes don't see the vars and you're back to needing op run per command.
- Group prompts reference env vars, not
op run. Rewrite group prompts so any DB URL / API key is "$PROJECT_LOCAL_DATABASE_URL" (proper double-quote interpolation), not op run --env-file=....
- The babysitter's Slack webhook is part of pre-fetch, not a separate concern.
RALPH_SLACK_WEBHOOK_URL must be written into .ralph-secrets.env here (best-effort, non-fatal if empty), because the /ralph babysitter reads only that file โ it never calls op itself (that would Touch-ID-prompt every tick). If you scaffold a runner without this line, the babysitter has no webhook and silently can't post; it must then fall back to in-session reporting. Decide at setup time: babysat loop โ wire the webhook path; unbabysat loop โ omit it deliberately. Don't leave the babysitter to discover the gap mid-run.
What pre-fetch does NOT cover: truly interactive groups like the production cutover, which the user runs hands-on-keyboard during the day. Those can op run directly โ Touch ID will resolve in seconds. Pre-fetch is for the autonomous overnight window.
5. Pre-push hook guard
Shared-context tells Claude "commit only, don't push" but that's a soft constraint. An autonomous agent in retry could git push and fire deploy.yml mid-migration. Install a real pre-push hook at runner startup that exits 1; remove on trap exit.
PUSH_GUARD_INSTALLED=false
install_push_guard() {
cd "$REPO_ROOT"
PRE_PUSH_HOOK="$(git rev-parse --git-path hooks)/pre-push"
if [[ -f "$PRE_PUSH_HOOK" ]]; then
PRE_PUSH_BACKUP="${PRE_PUSH_HOOK}.ralph-backup"
mv "$PRE_PUSH_HOOK" "$PRE_PUSH_BACKUP"
fi
cat > "$PRE_PUSH_HOOK" <<'HOOK'
echo "[ralph] pre-push hook: autonomous push blocked." >&2
exit 1
HOOK
chmod +x "$PRE_PUSH_HOOK"
PUSH_GUARD_INSTALLED=true
}
remove_push_guard() {
$PUSH_GUARD_INSTALLED || return 0
cd "$REPO_ROOT" 2>/dev/null || return 0
PRE_PUSH_HOOK="$(git rev-parse --git-path hooks)/pre-push"
rm -f "$PRE_PUSH_HOOK"
if [[ -f "${PRE_PUSH_HOOK}.ralph-backup" ]]; then
mv "${PRE_PUSH_HOOK}.ralph-backup" "$PRE_PUSH_HOOK"
fi
}
Cheap belt-and-suspenders against agent free will.
6. Singleton lock (fork-bomb guard)
The runner has no concept of "am I already running." A re-entrant or concurrent
./scripts/ralph.sh โ a stray second terminal, a babysitter that shells the script,
a finished loop accidentally re-launched โ stacks a second runner on the same state
file. Two runners racing the same .ralph-tasks.json corrupt it, double-attempt
groups, and (observed in the wild) cascade into hundreds of nested ralph.sh
processes โ a post-completion fork bomb. The narrow signal-detection and trap
fixes don't prevent this; a PID lockfile does, by refusing the second invocation.
LOCK_FILE="$REPO_ROOT/.ralph-lock"
LOCK_ACQUIRED=false
acquire_lock() {
cd "$REPO_ROOT"
if [[ -f "$LOCK_FILE" ]]; then
local other_pid
other_pid="$(cat "$LOCK_FILE" 2>/dev/null || echo '')"
if [[ -n "$other_pid" ]] && kill -0 "$other_pid" 2>/dev/null; then
log_error "Another ralph.sh is already running (PID $other_pid)."
log_error "Refusing a second runner โ concurrent loops corrupt state and can fork-bomb."
log_error "If you are certain that PID is dead: rm $LOCK_FILE"
exit 1
fi
log_warn "Stale lock from dead PID '$other_pid' โ reclaiming."
fi
echo "$$" > "$LOCK_FILE"
LOCK_ACQUIRED=true
log_info "Acquired runner lock (PID $$)."
}
release_lock() {
$LOCK_ACQUIRED || return 0
[[ -f "$LOCK_FILE" && "$(cat "$LOCK_FILE" 2>/dev/null)" == "$$" ]] || return 0
rm -f "$LOCK_FILE"
}
acquire_lock is called on the run path only โ after the --status early-exit
in main(), so a read-only status peek never locks (and never refuses while a real
runner holds the lock). Add .ralph-lock to .gitignore. The stale-lock reclaim
(kill -0 on a dead PID) means a crashed runner doesn't wedge the next legitimate
start.
Combined cleanup trap
All pre-flight installers register a single cleanup hook:
cleanup_on_exit() {
restore_commit_signing
remove_push_guard
remove_secrets
release_lock
}
trap cleanup_on_exit EXIT
Pre-flight order in main():
require_commands (claude, gtimeout, python3, bun, op)
refuse_default_branch
require_op_session
prefetch_secrets โ fetch + export into runner env
disable_commit_signing
install_push_guard
init_state
acquire_lock โ run path only, after the --status early-exit (last, so a
refused run leaves the other guards' cleanup untouched)
Pull tooling forward โ don't trail with strictness
A common pattern in PRDs: "we'll add max-strict TS, extended lint plugins, React Compiler, and pre-commit hooks at the end as Group N." This always backfires:
- Groups 3 โ (N-1) produce code without those rules.
- Group N flips on
noUncheckedIndexedAccess + exactOptionalPropertyTypes + extended lint patterns.
- A cascade of typecheck/lint errors appears across every file written in the prior groups.
- Group N now has two jobs: add the tooling AND fix every cascading error. It silently triples in size.
Rule: add the rules themselves as early as possible โ right after the scaffold group, before any meaningful code lands. Tests and CI workflow can still live at the end (they have content-dependencies on the implementation). But TS strictness, lint plugins, lefthook, and React Compiler all belong in a small standalone group that runs early.
This isn't a "nice to have" โ it's load-bearing. Errors caught while writing the code that produced them are 10ร cheaper than errors caught in a final sweep.
In group-decomposition step (Step 2 of /ralph setup), explicitly extract a "strictness baseline" group and place it as Group 4 or earlier.
Key Design Decisions (battle-tested)
Claude invocation flags
env -u ANTHROPIC_API_KEY "${group_env[@]}" gtimeout "$CLAUDE_TIMEOUT" claude \
-p "$full_prompt" \
--model "$RALPH_MODEL" \
--effort "$RALPH_EFFORT" \
"${claude_flags[@]}" \
--dangerously-skip-permissions \
--output-format stream-json \
--verbose \
--no-session-persistence \
< /dev/null
group_env always carries CLAUDE_CODE_ENABLE_TASKS=true + CLAUDECODE= (suppress interactive UI noise) and, in bridge mode, the LiteLLM routing vars.
Model choice: --model and --effort must be set explicitly. The /model and /effort commands in an interactive Claude Code session are session-level only โ they are not inherited by spawned claude -p subprocesses. Without explicit flags, each group silently uses whatever the global default is. Sonnet + high effort is the right default for RALPH on max; DeepSeek-V4-Pro is the right default on bridge (see below). Override per-group if needed (e.g. bump to opus for a particularly complex migration group).
Transport choice (RALPH_TRANSPORT): the default max runs $RALPH_MODEL (default sonnet) on the Max subscription โ best quality, but a full autonomous loop (45 min ร N groups) is the single heaviest Max-quota consumer in this setup. Set RALPH_TRANSPORT=bridge to route every group through the local LiteLLM bridge to DeepSeek-V4-Pro (EU/GDPR, Azure Spain) at IU per-token billing โ zero Max quota โ the same lane the ca launcher (config/zsh/claude.zsh, dotfiles) uses interactively. RALPH_MODEL then defaults to the literal bridge model id DeepSeek-V4-Pro instead of the tier alias sonnet (an unmapped name 404s against the bridge โ see config/litellm/config.yaml).
Three fixes make bridge mode safe for RALPH's fully-autonomous, subagent-capable groups โ all mirrored from ca/claude_bridge (dotfiles config/zsh/claude.zsh), which hit and fixed the same issues for interactive/one-shot bridge use:
- Tier pins (
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL): groups run with CLAUDE_CODE_ENABLE_TASKS=true, so they can spawn Explore/@implementer subagents. Subagents resolve by tier, not by the top-level --model โ without these pins, the first subagent spawn 400s on an unmapped claude-* default the instant it fires, mid-group.
- 1M context window (
CLAUDE_CODE_MAX_CONTEXT_TOKENS=1000000): Claude Code hardcodes 200k for any model behind a custom ANTHROPIC_BASE_URL. Without this, long research/edit-heavy groups hit DeepSeek's true 1M ceiling far too early and auto-compact mid-group.
--disallowedTools EnterPlanMode: DeepSeek over the bridge tends to self-invoke plan mode. In headless -p mode there's no user to call ExitPlanMode, so the group would present a plan and never implement it โ a silent retry burn with no completion signal, same failure shape as the interactive-skill anti-pattern above.
The bridge's remaining trade-offs are real and not fixed by the above: no WebSearch/WebFetch (research-phase groups that lean on web must be restructured to use Bash + curl/Context7), the worker model can still throttle under load so a long loop leans hard on the runner's retry logic, and its implementation ceiling is below sonnet. The runner health-checks the bridge before each group and aborts with a make litellm-restart hint if it's down. Rule of thumb: max for quality-critical or web-heavy migrations; bridge for cost-sensitive, EU-bound, or overnight loops where retries absorb the throttling.
Completion signal detection
The runner greps the raw log file for RALPH_TASK_COMPLETE: Group N. Claude must emit this as literal text in its response (not inside a code block). If Claude finishes without the signal, it's treated as a failure and retried.
Validation gate
Pre-group validation (group > 1): ensures previous group left repo clean before Claude starts.
Post-group validation: catches regressions introduced in the current group.
If post-group fails: print warning but don't mark as blocked โ Claude completed its task; the human needs to fix validation errors before retrying.
Retry semantics
attempts increments before run (not after)
- On failure: status โ pending; runner stops sequential execution so human can inspect log
- On blocked signal: status โ blocked; skipped in all future runs until manual
--reset
- Max retries reached: auto-mark blocked
Shared context injection
full_prompt = shared_context + "\n\n---\n\n" + group_prompt
Shared context is read fresh each group run โ it can be updated between runs.
Group Sizing Guidelines
| Group duration | Size indicator |
|---|
| < 30 min | Too small โ merge with adjacent group |
| 1โ2h | Ideal |
| 2โ3h | Acceptable for focused work |
| > 3h | Split โ Claude loses focus, errors accumulate |
Each group should leave the repo in a compilable, testable state. Never have a group that deliberately breaks the build (except explicitly transient mid-group state).
Timeout risk: The 45-minute CLAUDE_TIMEOUT is generous for most groups, but groups that combine heavy research + multiple integrations + validation can stretch close to the limit. A group that looks like "2h of work" on paper can push toward timeout when Claude spends significant time researching unfamiliar APIs before writing a line of code. If a group has more than ~5 major components and requires researching 3+ libraries from scratch, consider splitting it โ not because 2h is too long conceptually, but because research time is unpredictable. The runner handles the timeout-after-completion edge case (emitting the signal before the clock runs out but not before cleanup finishes), so this is a soft concern, not a hard rule.
Split-trigger heuristics (when reviewing a draft group list)
A group is a split candidate if any of these apply:
- More than one architectural decision. Pagination shape, error envelope, schema lib are three decisions โ three groups, not one.
- N independent sub-resources ร M concerns. "Migrate 7 routes to a new pagination + add 7 new summary endpoints + swap the validation lib across 16 files" reads as 1 group on paper and 4โ5 hours of unfocused churn in practice. Each concern is its own group; routes within a concern can be batched.
- Touches the same file as another planned group. Coordination overhead is high; consolidate or sequence explicitly with a "Depends on" link.
- Adds new lint or TS rules. Anything that flips strictness across the codebase is its own group, because the cascade is the work, not the rule flip.
- Library version upgrade + feature change in the same group. Separate the upgrade from the feature so a failure surface is one variable.
Conversely, mechanical fan-out of one pattern across N files is fine โ Claude can churn through 16 nearly-identical route migrations in <30 min if the pattern is established. The cost is decisions, not keystrokes.
After All Groups Complete
./scripts/ralph.sh --status โ confirm all green
git log --oneline -20 โ review commit history
- Run full E2E suite
- Review
docs/ralph/RALPH_NOTES.md โ capture gotchas in CLAUDE.md if broadly applicable
/pr โ create PR
- After PR merges and you're confident you'll never want to
--reset N and re-run a single group: /ralph cleanup โ distills notes into docs/migrations/<name>.md and deletes every ralph artifact in one commit. One-way.
Babysitter Pattern (recommended for overnight runs)
The runner is self-managing for retries but has no external observer. Stuck states (group in_progress with no log activity), repeated retries on the same group, or completion all go unnoticed until the human checks back. For multi-hour autonomous runs, pair the runner with a lightweight babysitter.
How: the user invokes /loop in a Claude Code session with a babysit prompt; that session uses ScheduleWakeup to re-fire every 20โ30 minutes. Each iteration:
- Reads
.ralph-tasks.json and prints status.
- Tails the most recent
.ralph-logs/group-N.log (last 40 lines).
- Detects "no progress in 60 min" โ the group is
in_progress but the log mtime is older than an hour. Probably stuck.
- Detects "approaching max retries" โ
attempts: 2 after a failure means one more chance before auto-blocked.
- Detects "completion" โ all 12 groups
complete. Ends the loop.
- Surfaces anomalies briefly. Schedules the next wakeup.
Why not full intervention from the babysitter? Stuck-state recovery (e.g. ./scripts/ralph.sh --reset N) usually needs human judgment about what went wrong. The babysitter's job is detection + reporting, not autonomous repair. The cost of a false-positive auto-reset is high (you lose validated work); the cost of waking the human a few minutes late is low.
The 30-minute cadence is chosen so cache stays warm across iterations (each wake under 5 min from prior cache, see the ScheduleWakeup tool's cache-window guidance โ 1200โ1800s is the sweet spot for idle observability ticks).
How to Run Babysitter (/ralph babysitter)
When the user invokes /ralph babysitter, you (the assistant) become the babysitter for that Claude Code session. The user is expected to have already launched ./scripts/ralph.sh in a separate terminal, so .ralph-secrets.env exists in the repo root.
Slack is best-effort, not assumed. Ideally the runner's prefetch_secrets wrote RALPH_SLACK_WEBHOOK_URL into that file (see Pre-flight ยง4), and you post watchdog cards each tick. But many loops are scaffolded with only DB/API secrets and no webhook โ in that case RALPH_SLACK_WEBHOOK_URL is absent or empty. Do not assume it's there and do not call op to fetch it yourself (that Touch-ID-prompts every 30 min). Detect it in Step 1 and degrade to in-session reporting (print the tick in the Claude session instead of Slack). When the webhook is missing, surface that once to the user and offer to wire it (they paste the op:// path / URL); otherwise just keep reporting in-session. Everything else in the playbook โ state read, flags, scheduling, stop conditions โ is identical regardless of Slack.
Per-iteration playbook
Each time the babysitter fires (initial invocation + every ScheduleWakeup), do this exactly:
Step 1 โ Source secrets + pick reporting mode
[ -f .ralph-secrets.env ] || { echo "no secrets file โ runner not started?"; exit 1; }
source .ralph-secrets.env
if [ -n "${RALPH_SLACK_WEBHOOK_URL:-}" ]; then echo "SLACK_ENABLED"; else echo "IN_SESSION_ONLY"; fi
If the file isn't there, the loop never started or already finished + cleaned up. Stop scheduling further wakeups.
If RALPH_SLACK_WEBHOOK_URL is set โ Slack mode (Step 4 posts cards). If empty/absent โ in-session mode: skip the curl in Step 4 and print the same status as a short message in the Claude session instead. On the first tick of in-session mode, tell the user Slack isn't wired and offer to add it (they paste the op:// path / URL into prefetch_secrets); don't re-offer every tick. Never op read the webhook from the babysitter โ that defeats pre-fetching.
Step 2 โ Read state + recent log
Never invoke ./scripts/ralph.sh (any subcommand) from the babysitter โ even --status runs the script's cleanup trap, which can interact badly with the live runner. Read .ralph-tasks.json directly:
python3 - <<'PYEOF'
import json
with open('.ralph-tasks.json') as f:
state = json.load(f)
total = len(state['groups'])
done = sum(1 for g in state['groups'] if g['status'] == 'complete')
in_prog = [g for g in state['groups'] if g['status'] == 'in_progress']
blocked = sum(1 for g in state['groups'] if g['status'] == 'blocked')
pending = total - done - blocked - len(in_prog)
print(f"TOTAL={total} DONE={done} INPROG={len(in_prog)} BLOCKED={blocked} PENDING={pending}")
for g in in_prog:
print(f"INPROG_GROUP={g['id']} TITLE={g['title']} ATTEMPTS={g['attempts']} STARTED={g.get('started_at')}")
PYEOF
Then tail the active log:
ls -t .ralph-logs/*.log 2>/dev/null | head -1 | xargs tail -n 60
Step 3 โ Compute flags
Step 4 โ Report status (Slack or in-session)
Always report a status, even if uneventful โ the user wants steady ticks, not silence. In-session mode (no webhook from Step 1): print a concise status line in the Claude session and skip the rest of this step. Slack mode: post the homelab watchdog payload shape:
post_slack() {
local title="$1" body="$2" color="${3:-#36a64f}"
curl -fsS --max-time 10 \
-H "Content-type: application/json" \
--data "$(jq -n --arg title "$title" --arg body "$body" --arg color "$color" '{
attachments: [{
color: $color,
blocks: [
{type: "header", text: {type: "plain_text", text: $title, emoji: true}},
{type: "section", text: {type: "mrkdwn", text: $body}}
]
}]
}')" \
"$RALPH_SLACK_WEBHOOK_URL" > /dev/null
}
Color codes: #36a64f green (normal tick), #ECB22E amber (AT RISK), #E01E5A red (STUCK / BLOCKED), #2EB67D teal (COMPLETE).
Message body should include:
- Current group + attempt count
- One-line status summary ("3 complete, 1 in-progress, 11 pending")
- Any flag (STUCK / AT RISK / BLOCKED / FORK BOMB)
- Runner count (
runner_count from Step 3) when it isn't exactly 1
- For STUCK/AT-RISK/BLOCKED/FORK-BOMB: the last 5 lines of the log
Step 5 โ Schedule next tick or end
If COMPLETE โ post final summary, do NOT call ScheduleWakeup, end.
If BLOCKED โ post alert, do NOT call ScheduleWakeup (human action needed), end.
If FORK BOMB โ post RED alert (runner_count >1 + offending PIDs), do NOT
call ScheduleWakeup (human must investigate), end.
If runner_count==0 and not COMPLETE โ runner exited/crashed; post alert,
do NOT call ScheduleWakeup, end.
Otherwise โ call ScheduleWakeup with delaySeconds=1800.
The next wakeup re-fires this same skill โ the prompt argument is the literal sentinel <<autonomous-loop-dynamic>> so the runtime re-injects /ralph babysitter instructions.
Stop conditions (no further wakeups)
- All groups complete.
- Any group blocked (human needed).
runner_count > 1 (re-entrant / fork-bomb โ human needed).
runner_count == 0 while groups remain (runner exited/crashed).
.ralph-secrets.env missing (runner ended).
- User explicitly cancelled.
Concrete first-iteration response template
๐ค *RALPH babysitter started.*
Status: 0/15 complete, Group 1 (Workspace move) in progress (attempt 1).
Last log activity: 2 min ago. No flags. Scheduling next tick in 30 min.
Keep main-session output concise (~100 words). Slack carries the detail.
Why a skill, not a raw /loop prompt
Encoding the babysitter as a skill (rather than a copy-pasted /loop prompt) means:
- One source of truth โ fix Slack payload here, every project benefits.
- Slack webhook discovery via the runner's secrets file is automatic.
- The user types
/ralph babysitter after launching the loop โ no copy-paste.
How to Run Cleanup (/ralph cleanup)
When the user invokes /ralph cleanup, you (the assistant) distill the migration's learning notes into a single archival summary, then delete every ralph artifact. One-way operation. After this you cannot --reset N and re-run a single group; the whole scaffold is gone.
Step 1 โ Verify completion + scope
Read .ralph-tasks.json directly via python (never invoke ./scripts/ralph.sh โ the cleanup-trap risk is the same as for the babysitter, even with the sentinel patches in place). Refuse to proceed unless every group is status: complete.
python3 - <<'PYEOF'
import json, sys
with open('.ralph-tasks.json') as f:
state = json.load(f)
incomplete = [g for g in state['groups'] if g['status'] != 'complete']
if incomplete:
print("INCOMPLETE:", [(g['id'], g['status']) for g in incomplete])
sys.exit(1)
print(f"OK_TO_CLEANUP groups={len(state['groups'])}")
PYEOF
If incomplete: surface the offending groups to the user and stop. Do not offer a --force flag implicitly; if the user wants to abandon mid-run, they should ask explicitly and you should confirm they understand they're discarding institutional memory.
Also refuse if a ralph.sh process is still running โ match the script path, not a bare name (pgrep -f '/ralph\.sh'), to avoid the self-match / child-process counting trap. The cleanup must follow runner exit, not race it.
Step 2 โ Decide the archive filename
Default: derive a slug from the user's working description (e.g. "v2 migration" โ v2-migration.md) or from the current branch (feat/v2 โ v2.md). Ask the user to confirm the filename before writing โ the archive is the migration's permanent record, the name matters.
Target path: docs/migrations/<slug>.md. Create docs/migrations/ if missing.
Step 3 โ Generate the summary
Collect inputs:
docs/ralph/shared-context.md (goal + tech stack)
docs/ralph/RALPH_NOTES.md (the per-group learning notes โ the load-bearing source)
docs/ralph/RALPH_REPORT.md (final status)
git log --oneline <first-ralph-commit>..HEAD (commit history)
Delegate the distillation to a haiku subprocess โ structured input โ structured markdown output is exactly its sweet spot, and the main thread stays cheap. Prompt template:
You are summarizing a completed multi-group code migration into one archival
markdown file. The inputs are the shared context, the per-group learning notes,
the final status report, and the git log for the migration range.
Output exactly this shape (no preamble, no AI attribution):
# <Project> โ <migration name> (<YYYY-MM-DD> โ <YYYY-MM-DD>)
## Goal
<2โ4 sentences, distilled from shared-context.md>
## Outcome
<1 paragraph โ what landed, what state the codebase is in now>
## Groups
| # | Title | Outcome |
|-|-|-|
(one line per group, "Outcome" is one short clause)
## Architectural decisions that survived
- <bullet โ pulled from "Deviations from prompt" where Claude chose differently and it stuck>
## Notable gotchas worth remembering
- <bullet โ only the cross-cutting ones; per-group quirks belong in commit messages>
## Deferred work
- <bullet โ from "Future improvements" sections>
## Tests added
<count + categories, one sentence>
Be concise. The goal is a reference document a future human can read in 60 seconds.
Do not invent details. If a section is empty, write "โ" and move on.
Invoke via:
claude_iu --model haiku "$prompt_with_inputs_inlined" > docs/migrations/<slug>.md
claude_iu (from ~/.zsh/conf.d/claude.zsh) runs the distill off Max quota on
the IU endpoint. This one-shot summary runs in the orchestrator's zsh context, so
the helper is available; the autonomous group runner above is a standalone bash
script and instead inlines the routing vars via RALPH_TRANSPORT.
Show the user the generated file and ask them to confirm before proceeding to deletion. This is the only point where they can still bail.
Step 4 โ Delete artifacts
After user confirmation:
rm -rf scripts/ralph.sh scripts/ralph-reset.sh
rm -rf docs/ralph/
rm -rf .ralph-tasks.json .ralph-logs/ .ralph-secrets.env .ralph-lock
For .gitignore: delete the four lines (.ralph-tasks.json, .ralph-logs/, .ralph-lock, .ralph-secrets.env) only if they exist โ don't strip the user's other entries. Prefer reading the file, removing exact-match lines, and writing back via the Edit tool.
Verify nothing ralph-related survives:
git ls-files | grep -iE 'ralph' || echo "tracked: clean"
ls -la | grep -iE 'ralph' || echo "untracked: clean"
Step 5 โ Commit
Single commit:
chore(ralph): finalize <migration-name> + cleanup
Archived migration summary to docs/migrations/<slug>.md.
Removed scripts/ralph.sh, scripts/ralph-reset.sh, docs/ralph/, and
gitignored state/log/secrets paths.
Stage explicitly โ never git add -A after a bulk delete (you'll catch unrelated untracked files). Use git add docs/migrations/<slug>.md .gitignore then git add -u to capture the deletions.
What NOT to do
- Never run
./scripts/ralph.sh cleanup โ cleanup is a Claude-driven playbook, not a bash subcommand. The script's EXIT trap is unsafe for any control-plane operation.
- Never delete before writing the summary. If summary generation fails or the user disagrees with the output, you need the source notes to retry.
- Never delete commits or rewrite history. Cleanup removes artifacts going forward; the per-group commits in git history are the audit trail.
- Never run cleanup while
pgrep -f '/ralph\.sh' returns a PID. The trap-vs-runner race is the same as the babysitter case. (Match the script path, not a bare ralph.sh, to dodge the self-match / child-process counting trap.)
Anti-Patterns to Avoid
- God groups: one group does everything โ split it
- Underspecified validation: "it should work" โ name the exact commands
- No research step in prompt: Claude invents APIs it doesn't know โ always include "Research First"
- Skipping the notes template: the notes file is the institutional memory โ don't skip it
- Overly prescriptive prompts: include key interfaces and constraints, not a full implementation spec โ leave Claude room to find better approaches
- E2E-only validation: E2E is slow and fragile for early groups; use unit tests until the system is wired together
- Strictness baseline at the end: adding
noUncheckedIndexedAccess, extended lint plugins, or React Compiler as the final group creates a cascade-error pit. Land the rules early โ Group 3 or 4 โ even if tests/CI stay at the end.
- Operating on
master / main: autonomous commits to the default branch are unsafe โ deploy.yml typically fires on push. The runner must refuse to start there. Use a simple guard, not a long-lived branch creator (users normally checkout their own feature branch first).
- Committing without disabling 1Password signing: on macOS with
op-ssh-sign and commit.gpgsign=true, every commit hangs on Touch ID. Always disable per-repo signing at runner startup; restore via trap on exit. Never use --no-gpg-sign (violates user commit rules).
- No
op whoami pre-flight: if any group invokes op run, an inactive 1Password session blocks mid-loop at 3am. Verify at startup with a gtimeout 5 op whoami check; exit fast and tell the user how to sign in.
op run in group prompts: even with a warm session, mid-loop op invocations risk Touch ID prompts (depends on per-command biometric settings). Pre-fetch every loop-needed secret at startup, write to .ralph-secrets.env (mode 600, gitignored, trap-cleaned), source it into runner env so claude -p children inherit it, and rewrite group prompts to reference env vars instead of op run. Reserve op run for hands-on-keyboard groups (cutover, manual).
- Inventing throwaway local creds: don't make the agent hardcode local dev passwords (e.g.
argo:argo). If the production secret is in 1Password, pre-fetch it once and reuse for both local container (POSTGRES_PASSWORD: ${PROJECT_DB_PASSWORD}) and prod. One source of truth eliminates a class of "the agent invented something that collides" footguns.
- No push-block hook: shared context says "commit don't push" but an autonomous agent can violate it. Drop a
pre-push hook that exits 1 at runner startup; restore on exit trap.
- Cutover-only data migration testing: when a migration involves data (DB ports, schema changes), don't wait until cutover to first exercise the migration script. Pull production data snapshot to local and run the migration script during the early group that introduces the new system. Every later group develops against realistic data and the cutover becomes "run the same script again" โ high confidence, low risk surface.
- No babysitter on overnight runs: a stuck group can burn 3 ร 45min before the runner marks it blocked, then sit idle until you check back. A 30-min
/loop babysitter detects stuck states in time to act. See the Babysitter Pattern section above.
- Babysitter shelling into
ralph.sh: invoking ./scripts/ralph.sh --status (or --reset) from the babysitter is unsafe โ the script's EXIT trap runs remove_secrets / remove_push_guard regardless of subcommand. The running runner is unaffected (env was sourced before the trap), but the babysitter loses .ralph-secrets.env (and with it RALPH_SLACK_WEBHOOK_URL), forcing a Touch ID prompt on every subsequent tick. Fix in two places: gate cleanup with a <name>_INSTALLED sentinel that only prefetch_secrets / install_push_guard flip true, AND make the babysitter read .ralph-tasks.json directly via python rather than calling the script at all.
- Babysitter assumes a Slack webhook the runner never pre-fetched: the babysitter section hard-states
.ralph-secrets.env has RALPH_SLACK_WEBHOOK_URL "set by the runner's pre-fetch," but the prefetch_secrets template only fetched DB/API secrets โ it never wrote the webhook. Result: the babysitter sources the file, RALPH_SLACK_WEBHOOK_URL is empty, and the curl -fsS โฆ "$RALPH_SLACK_WEBHOOK_URL" posts to an empty URL (fails, swallowed by > /dev/null) โ silent no-op ticks. Two-sided fix: (1) setup side โ prefetch_secrets writes RALPH_SLACK_WEBHOOK_URL (best-effort, non-fatal if empty), since the babysitter reads only that file and must never op read the webhook itself (Touch ID every 30 min); (2) babysitter side โ Step 1 detects an empty/absent webhook and degrades to in-session reporting instead of assuming Slack, offering to wire it once. Decide babysat-or-not at /ralph setup, don't discover the gap mid-run.
- Invoking interactive slash-skills (
/commit, /commit --split, /pr, /check, /review, /ship) from inside a group: these are interactive Claude Code workflows that propose a plan and wait for user confirmation. In claude -p headless mode there is no user โ the model prints the proposal, the tool call returns "success" without producing a commit/PR/etc., and the group exits with no RALPH_TASK_COMPLETE signal. The runner then resets the group to pending, the working tree is left dirty with all of the group's actual work uncommitted, and the human gets paged. Symptom in the log: RESULT_OBJ shows subtype: "success", but the last few text blocks discuss a "split commit strategy" instead of acting; final tool calls are git status / Skill: ... rather than git commit. Fix: group prompts must use raw shell (git add <files> && git commit -m "...") and shared-context.md must explicitly forbid /commit and friends. Build/check tasks should run the underlying tool directly (bun test, bun run typecheck, bun run lint), never /check.
- No singleton lock (fork-bomb risk): the runner has no "am I already running" guard. A re-entrant or concurrent
./scripts/ralph.sh โ second terminal, accidental re-launch after completion, a babysitter that shells the script โ stacks a second runner on the same state file. Observed in the wild: a post-completion re-invocation cascaded into hundreds of nested ralph.sh processes. The fix is a PID lockfile (acquire_lock/release_lock, .ralph-lock) that refuses the second invocation; signal-detection and traps don't prevent this. Acquire on the run path only (after the --status early-exit), reclaim stale locks via kill -0. See Pre-flight Setup ยง6.
- Unquoted python heredocs (the "generate_report backtick bug"): writing state/report helpers as
python3 - <<PYEOF โฆ '$STATE_FILE' โฆ $(date โฆ) โฆ lets the shell expand $(...), backticks, and $N inside the heredoc โ including inside interpolated data like group titles or report values. A title or note containing a backtick or $(...) is then executed by the shell at report time (a code-injection + breakage vector). Always use a quoted delimiter (<<'PYEOF') and pass dynamic values through argv (python3 - "$STATE_FILE" "$1" <<'PYEOF' โ sys.argv); compute timestamps in Python (datetime.now(timezone.utc)), not via $(date) in the body.
- Retrying through a usage/session limit: when the Max 5-hour limit hits,
claude -p exits without the completion signal and without doing the work. Treated as a generic failure, the runner burns attempts 1โ3 slamming the same wall, then auto-blocks the group for nothing. Detect the limit in the log (grep -qiE "usage limit reached|limit will reset"), return a distinct code, roll the attempt back (dec_attempts), leave the group pending, and stop the loop with a "re-run after reset" message. See run_group (return 3) + the main() handler.
- Validation output flooding the runner log: piping build/bundler/test output straight to the terminal (
<build> 2>&1) buries the grouped [ralph] info logs under successful-build chatter, making the run unreadable and the actual errors hard to find. Capture all validation output to a per-label file under .ralph-logs/ and surface it only on failure (tail -n 40 "$vlog" >&2); on success print just the grouped one-liner. Errors still propagate; noise doesn't.
- The
pgrep counting trap (babysitter / cleanup): pgrep -fl ralph.sh | wc -l (or ps | grep ralph.sh) to decide "is the runner alive" is wrong three ways โ it self-matches the grep, it matches child claude/python3 processes whose argv mentions a ralph path, and an unbounded count reads as "running" when it actually means "fork-bombed." Match the script path (pgrep -f '/ralph\.sh'), count distinct PIDs, and bound the result: 0 = exited, 1 = healthy, >1 = re-entrant/fork-bomb (RED alert, stop scheduling, page human).