ralph
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
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
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
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Read and write the second brain — a git-backed Obsidian vault at ~/SourceRoot/brain (PARA structure + a top-level wiki/ knowledge tree). Use whenever the user mentions "brain", "second brain", "knowledge base", "note", "remember", "look up", "find note", "capture", "wiki", or "okf". Also use for any request to save, retrieve, or organize personal knowledge that belongs in the brain rather than in a repo-specific doc.
Write, draft, and polish prose through a human-owned 7-step pipeline. Use whenever the user says "distill", "write article", "draft", "summarize into", "create content", "synthesize", or "publish". Also use for any task where the output is a piece of long-form writing — the pipeline keeps prose quality out of coding sessions.
RETIRED — manage the (now decommissioned) per-machine mlx-audio TTS/STT stack; TTS/STT moved to the cloud audio-gateway (VPS)
Operate the Mac mini remote dev host — connecting from the MacBook over mosh or herdr --remote, running and reattaching many Claude Code agents, herdr workspaces/panes and its socket API, claude --bg durable daemons, the Uptime Kuma readiness heartbeat, and the failure modes specific to a headless always-on Mac. Use when the user mentions herdr, mosh, the mini, "remote dev", "dev host", detaching or reattaching agents, "did my agent survive", sessions dying on lid-close, or asks how to reach a dev server running on the mini.
Manage the personal image stack — public CDN uploads/transform URLs and the private image-share layer. Use whenever an image needs a URL (public or private) for an article, blog post, vault note, README, OpenGraph tag, or a one-off share; or when the user says "upload this image", "share this image", "private image", "publish image", "host this screenshot", "get me a CDN link", "resize this", mentions "image-share", or asks where an image lives.
Bidirectionally sync ALL git repos across two machines — this MacBook and the always-on Mac mini — over SSH, using GitHub/GitLab as the transport. Commits and pushes uncommitted work on both sides, then rebases/fast-forwards each so they converge, with per-repo subagents resolving rebases and merge conflicts. This is a MULTI-repo, TWO-machine operation — distinct from /commit, /ship, or /git-cleanup, which act on a single repo. Use whenever the user wants to sync their machines/repos, "catch up" the laptop to the Mac mini or push laptop work back, is about to travel or just got back, has uncommitted work scattered across many repos, or wants to reconcile branches that diverged between the two machines. Trigger even on a bare "sync", "sync my machines", or "get my repos up to date".
| 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 = 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.
Large migrations, rewrites, or feature rollouts that are:
Examples: language rewrites, database migrations, API redesigns, CI/CD overhauls.
/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)
/ralph setup)When the user invokes /ralph setup, follow this workflow:
Ask the user:
CLAUDE_TIMEOUT), which usually maps to 1–2h of human work.RALPH_BRANCH defaults to migration/<name> and the runner stays on it.~/.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.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:
Output a numbered list for user review before creating files.
<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
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>
Always start by:
/research skill (research-gateway) or Context7After 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>
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.
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
[...]
<build> # must pass
<test> # all pass, including new tests for this group
<lint> # clean
[List what to test specifically — table-driven tests, edge cases, happy paths.]
feat(<scope>): <description of this group's work>
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
# Reset a group to pending (allows re-running after manual fix)
# Usage: ./scripts/ralph-reset.sh <group-id>
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
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.
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).
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.
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.
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 # only the invocation that wrote the file may delete it
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
# Slack webhook for the `/ralph babysitter`. The babysitter ONLY reads
# .ralph-secrets.env — it never calls op itself (that would Touch-ID-prompt
# every 30 min). So if you want Slack ticks, the webhook MUST be written here.
# Best-effort: a missing/empty webhook is NOT fatal — the babysitter degrades
# to in-session reporting. Point this at the real op:// path (the same homelab
# watchdog webhook), or delete the two lines to run an in-session-only babysitter.
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"
# Export into runner env so subprocesses (claude -p) inherit it.
set -a
# shellcheck disable=SC1090
source "$SECRETS_FILE"
set +a
SECRETS_INSTALLED=true
log_success "Secrets cached to .ralph-secrets.env (mode 600) and exported."
}
remove_secrets() {
# Only delete the file if THIS invocation created it. Side-channel invocations
# like `./scripts/ralph.sh --status` and `--reset N` skip prefetch and must not
# strip the secrets out from under a running runner (its env is already loaded,
# but the babysitter still needs the file to recover RALPH_SLACK_WEBHOOK_URL).
$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:
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.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.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.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=....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.
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'
#!/usr/bin/env bash
echo "[ralph] pre-push hook: autonomous push blocked." >&2
exit 1
HOOK
chmod +x "$PRE_PUSH_HOOK"
PUSH_GUARD_INSTALLED=true
}
remove_push_guard() {
# Same sentinel discipline as remove_secrets: --status / --reset must not
# uninstall a hook the running runner is depending on.
$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.
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() {
# Mirror the sentinel discipline: only release a lock THIS process acquired,
# and only if it still points at us — never delete a lock a concurrent runner
# now owns. Side-channel --status / --reset never call acquire_lock, so
# LOCK_ACQUIRED stays false and this is a no-op for them.
$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.
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_branchrequire_op_sessionprefetch_secrets — fetch + export into runner envdisable_commit_signinginstall_push_guardinit_stateacquire_lock — run path only, after the --status early-exit (last, so a
refused run leaves the other guards' cleanup untouched)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:
noUncheckedIndexedAccess + exactOptionalPropertyTypes + extended lint patterns.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.
env -u ANTHROPIC_API_KEY "${group_env[@]}" gtimeout "$CLAUDE_TIMEOUT" claude \
-p "$full_prompt" \
--model "$RALPH_MODEL" \ # explicit — don't inherit interactive session model
--effort "$RALPH_EFFORT" \ # explicit — don't inherit interactive session effort
"${claude_flags[@]}" \ # bridge mode adds --disallowedTools EnterPlanMode
--dangerously-skip-permissions \ # lets Claude run tools without prompting
--output-format stream-json \ # writes to log file in real-time (text format buffers)
--verbose \ # includes tool use in log output
--no-session-persistence \ # fresh context each group
< /dev/null # prevents interactive prompts from blocking
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:
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.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.
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.
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.
attempts increments before run (not after)--resetfull_prompt = shared_context + "\n\n---\n\n" + group_prompt
Shared context is read fresh each group run — it can be updated between runs.
| 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.
A group is a split candidate if any of these apply:
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.
./scripts/ralph.sh --status — confirm all greengit log --oneline -20 — review commit historydocs/ralph/RALPH_NOTES.md — capture gotchas in CLAUDE.md if broadly applicable/pr — create PR--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.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:
.ralph-tasks.json and prints status..ralph-logs/group-N.log (last 40 lines).in_progress but the log mtime is older than an hour. Probably stuck.attempts: 2 after a failure means one more chance before auto-blocked.complete. Ends the loop.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).
/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.
Each time the babysitter fires (initial invocation + every ScheduleWakeup), do this exactly:
[ -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.
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
in_progress group's log file's mtime is >60 minutes old. Use:
log_age=$(( $(date +%s) - $(stat -f %m .ralph-logs/group-N.log) ))
attempts >= 2 (one shot left before auto-block).blocked since the previous tick.complete.pgrep -fl ralph.sh | wc -l (or ps | grep ralph.sh) is wrong
three ways: it can self-match the grep, it matches child claude/python3
processes whose argv mentions a ralph path, and an unbounded count is read as
"running" when it actually means "fork-bombed." Match the script path and
count distinct PIDs:
runner_count=$(pgrep -f '/ralph\.sh' | wc -l | tr -d ' ')
Interpret: 0 → runner exited (stop scheduling wakeups — the loop is done or
crashed; cross-check .ralph-tasks.json); 1 → healthy; >1 → re-entrant /
fork-bomb — post a RED alert with the count and the offending PIDs
(pgrep -f '/ralph\.sh'), tell the user to investigate, and stop scheduling
(the singleton lock should now prevent this, but a count >1 means the lock was
bypassed and needs a human). Never act on the raw wc -l of an unanchored
pattern.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}" # green default
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:
runner_count from Step 3) when it isn't exactly 1If 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.
runner_count > 1 (re-entrant / fork-bomb — human needed).runner_count == 0 while groups remain (runner exited/crashed)..ralph-secrets.env missing (runner ended).🤖 *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.
/loop promptEncoding the babysitter as a skill (rather than a copy-pasted /loop prompt) means:
/ralph babysitter after launching the loop — no copy-paste./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.
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.
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.
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.
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
# Remove .gitignore entries that referenced ralph artifacts
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"
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.
./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.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.)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.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).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).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).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.pre-push hook that exits 1 at runner startup; restore on exit trap./loop babysitter detects stuck states in time to act. See the Babysitter Pattern section above.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..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./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../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.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.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.<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.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).