Files
OpenHarness BotandClaude Opus 4.7 6e5abe268a feat: US-004 — add DeepAgents to Ralph harness selection
Accept --harness=deepagents and RALPH_HARNESS=deepagents in argument
validation, add a run_deepagents function with a constrained default
shell allow-list, and always cap the per-call turn count via
RALPH_DEEPAGENTS_MAX_TURNS so a single DeepAgents call cannot hang an
iteration. Existing Claude/Pi/Codex execution and fallback behavior are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:26:37 -06:00

405 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/ralph.sh — Ralph loop runner per SPEC §scripts.
#
# Usage: scripts/ralph.sh [--harness=claude|pi|codex|opencode|deepagents] <taskdesc>
# Example: scripts/ralph.sh openharness-v07-convergence
# Example: scripts/ralph.sh --harness=pi openharness-v07-convergence
# Example: scripts/ralph.sh --harness=codex openharness-v07-convergence
# Example: scripts/ralph.sh --harness=opencode openharness-v07-convergence
# Example: scripts/ralph.sh --harness=deepagents openharness-v07-convergence
#
# Validates the four-file contract (prd.md, prd.json, prompt.md, progress.txt),
# launches a named tmux session running the loop. Each iteration sends prompt.md
# to the selected harness (default: Claude). The agent is responsible for editing
# files, committing, updating prd.json (passes: true), and appending to
# progress.txt. Loop terminates when progress.txt contains a line:
# STATUS: COMPLETE.
#
# Idempotent: re-invoking on an existing session attaches instead of duplicating.
#
# Harnesses:
# --harness=claude run Claude first (default); fall back to Pi after a
# recognized Claude usage-limit message; Codex is fallback
# after Pi if Pi is unavailable
# --harness=pi run Pi for every iteration; fall back to Codex if Pi is
# unavailable
# --harness=codex run Codex for every iteration
# --harness=opencode run OpenCode for every iteration
# --harness=deepagents run DeepAgents for every iteration; explicit opt-in only,
# no automatic fallback. Default flags use
# --shell-allow-list recommended (constrained shell);
# unrestricted shell requires an explicit operator
# override via RALPH_DEEPAGENTS_FLAGS — combined with the
# mounted Docker socket, --shell-allow-list all can affect
# sibling containers or host Docker, so use only for
# trusted tasks.
#
# Configuration (env vars):
# RALPH_HARNESS default harness if --harness is omitted
# (claude|pi|codex|opencode|deepagents)
# RALPH_CLAUDE_FLAGS Claude flags (default: --dangerously-skip-permissions --print)
# RALPH_AGENT_FLAGS backwards-compatible alias for RALPH_CLAUDE_FLAGS
# RALPH_PI_FLAGS Pi flags (default: --print)
# RALPH_OPENCODE_FLAGS OpenCode run flags (default: empty)
# RALPH_DEEPAGENTS_FLAGS DeepAgents flags excluding the task and turn cap.
# Default: "-y --shell-allow-list recommended -q --no-stream".
# To allow unrestricted shell, set this to
# "-y --shell-allow-list all -q --no-stream"; combined with
# the mounted Docker socket, that can affect sibling
# containers or host Docker — only use for trusted tasks.
# RALPH_DEEPAGENTS_MAX_TURNS per-call turn cap passed to DeepAgents as --max-turns
# (default: 25). Prevents a single DeepAgents call from
# hanging an iteration; always appended after the
# flag string above.
# RALPH_MAX_ITERATIONS safety cap (default: 50)
# RALPH_SLEEP seconds between iterations (default: 2)
set -euo pipefail
usage() {
echo "Usage: $0 [--harness=claude|pi|codex|opencode|deepagents] <taskdesc>" >&2
}
normalize_harness() {
local harness="$1"
case "$harness" in
claude|pi|codex|opencode|deepagents)
printf '%s\n' "$harness"
;;
*)
echo "Error: unknown harness '$harness' (expected: claude, pi, codex, opencode, or deepagents)." >&2
exit 2
;;
esac
}
parse_args() {
HARNESS="${RALPH_HARNESS:-claude}"
POSITIONAL=()
while [ "$#" -gt 0 ]; do
case "$1" in
--harness=*)
HARNESS="${1#--harness=}"
;;
--harness)
shift
if [ "${1-}" = "" ]; then
echo "Error: --harness requires a value (claude, pi, codex, opencode, or deepagents)." >&2
exit 2
fi
HARNESS="$1"
;;
--)
shift
while [ "$#" -gt 0 ]; do
POSITIONAL+=("$1")
shift
done
break
;;
-*)
echo "Error: unknown option '$1'." >&2
usage
exit 2
;;
*)
POSITIONAL+=("$1")
;;
esac
shift
done
HARNESS="$(normalize_harness "$HARNESS")"
if [ "${#POSITIONAL[@]}" -ne 1 ]; then
usage
exit 2
fi
TASKDESC="${POSITIONAL[0]}"
}
require_harness_command() {
local harness="$1"
local command_name="$harness"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Error: active harness '$harness' requires '$command_name' on PATH." >&2
exit 1
fi
}
fallback_after_harness() {
local harness="$1"
case "$harness" in
claude)
if command -v pi >/dev/null 2>&1; then
printf 'pi\n'
return 0
fi
if command -v codex >/dev/null 2>&1; then
printf 'codex\n'
return 0
fi
echo "Error: Claude usage limit detected, but fallback harnesses 'pi' and 'codex' are not on PATH." >&2
return 1
;;
pi)
if command -v codex >/dev/null 2>&1; then
printf 'codex\n'
return 0
fi
echo "Error: fallback harness 'codex' is not on PATH after Pi." >&2
return 1
;;
*)
echo "Error: no fallback configured after harness '$harness'." >&2
return 1
;;
esac
}
resolve_initial_harness() {
local harness="$1"
local fallback
if command -v "$harness" >/dev/null 2>&1; then
printf '%s\n' "$harness"
return 0
fi
if [ "$harness" = "pi" ]; then
fallback="$(fallback_after_harness pi)" || exit 1
echo "Warning: harness 'pi' is not on PATH; starting with fallback harness '$fallback'." >&2
printf '%s\n' "$fallback"
return 0
fi
echo "Error: active harness '$harness' requires '$harness' on PATH." >&2
exit 1
}
claude_limit_detected() {
local output_file="$1"
grep -Eiq 'hit (your |the )?limit' "$output_file" && grep -Eiq 'resets?' "$output_file"
}
run_claude() {
local task="$1"
local claude_flags="${RALPH_CLAUDE_FLAGS:-${RALPH_AGENT_FLAGS:---dangerously-skip-permissions --print}}"
# Intentionally leave flags unquoted so callers can provide multiple flags.
# shellcheck disable=SC2086
printf '%s' "$task" | claude $claude_flags
}
run_pi() {
local task="$1"
local pi_flags="${RALPH_PI_FLAGS:---print}"
# Intentionally leave flags unquoted so callers can provide multiple flags.
# shellcheck disable=SC2086
pi $pi_flags "$task"
}
run_codex() {
local task="$1"
codex exec --sandbox danger-full-access "$task"
}
run_opencode() {
local task="$1"
local opencode_flags="${RALPH_OPENCODE_FLAGS:-}"
# Intentionally leave flags unquoted so callers can provide multiple flags.
# shellcheck disable=SC2086
opencode run $opencode_flags "$task"
}
run_deepagents() {
local task="$1"
local max_turns="${RALPH_DEEPAGENTS_MAX_TURNS:-25}"
# Default flags: non-interactive (-n is added below with the task), clean buffered
# output (-q --no-stream), confirm-on-edits skipped (-y), and shell calls limited to
# the recommended allow list. --shell-allow-list all opens unrestricted shell
# execution; combined with the mounted Docker socket it can affect sibling
# containers or the host Docker daemon, so it must be an explicit operator opt-in
# via RALPH_DEEPAGENTS_FLAGS, never the default.
local default_flags="-y --shell-allow-list recommended -q --no-stream"
local deepagents_flags="${RALPH_DEEPAGENTS_FLAGS:-$default_flags}"
# --max-turns is always appended (even if the operator overrides
# RALPH_DEEPAGENTS_FLAGS) so a single DeepAgents call cannot hang the iteration.
# Operators who set RALPH_DEEPAGENTS_FLAGS should not include --max-turns there;
# adjust the cap via RALPH_DEEPAGENTS_MAX_TURNS instead.
# Intentionally leave flags unquoted so callers can provide multiple flags.
# shellcheck disable=SC2086
deepagents $deepagents_flags --max-turns "$max_turns" -n "$task"
}
run_iteration() {
local harness="$1"
local task="$2"
local output_file="$3"
local status
set +e
case "$harness" in
claude)
run_claude "$task" 2>&1 | tee "$output_file"
status=${PIPESTATUS[0]}
;;
pi)
run_pi "$task" 2>&1 | tee "$output_file"
status=${PIPESTATUS[0]}
;;
codex)
run_codex "$task" 2>&1 | tee "$output_file"
status=${PIPESTATUS[0]}
;;
opencode)
run_opencode "$task" 2>&1 | tee "$output_file"
status=${PIPESTATUS[0]}
;;
deepagents)
run_deepagents "$task" 2>&1 | tee "$output_file"
status=${PIPESTATUS[0]}
;;
*)
echo "Error: internal unknown harness '$harness'." >&2
status=2
;;
esac
set -e
return "$status"
}
# ─── Loop mode (re-entry from inside tmux) ───────────────────────────
if [ "${1-}" = "--loop" ]; then
shift
parse_args "$@"
REPO_ROOT="${REPO_ROOT:-$(pwd)}"
TASK_DIR="$REPO_ROOT/tasks/$TASKDESC"
PROMPT="$TASK_DIR/prompt.md"
PROGRESS="$TASK_DIR/progress.txt"
ACTIVE_HARNESS="$(resolve_initial_harness "$HARNESS")"
CLAUDE_FLAGS="${RALPH_CLAUDE_FLAGS:-${RALPH_AGENT_FLAGS:---dangerously-skip-permissions --print}}"
DEEPAGENTS_FLAGS="${RALPH_DEEPAGENTS_FLAGS:--y --shell-allow-list recommended -q --no-stream}"
DEEPAGENTS_MAX_TURNS="${RALPH_DEEPAGENTS_MAX_TURNS:-25}"
MAX_ITER="${RALPH_MAX_ITERATIONS:-50}"
SLEEP_SECONDS="${RALPH_SLEEP:-2}"
cd "$REPO_ROOT"
require_harness_command "$ACTIVE_HARNESS"
printf '\n╭─ Ralph: %s\n' "$TASKDESC"
printf '│ harness: %s\n' "$ACTIVE_HARNESS"
printf '│ claude: claude %s\n' "$CLAUDE_FLAGS"
printf '│ pi: pi %s <task>\n' "${RALPH_PI_FLAGS:---print}"
printf '│ codex: codex exec --sandbox danger-full-access <task>\n'
printf '│ opencode: opencode run %s <task>\n' "${RALPH_OPENCODE_FLAGS:-}"
printf '│ deepagents: deepagents %s --max-turns %s -n <task>\n' "$DEEPAGENTS_FLAGS" "$DEEPAGENTS_MAX_TURNS"
printf '│ max iterations: %s\n' "$MAX_ITER"
printf '│ progress: %s\n╰─\n\n' "$PROGRESS"
i=0
while [ "$i" -lt "$MAX_ITER" ]; do
i=$((i + 1))
if grep -q '^STATUS: COMPLETE$' "$PROGRESS"; then
printf '\n✓ STATUS: COMPLETE found in progress.txt — exiting at iteration %d.\n' "$i"
break
fi
printf '\n── iteration %d / %s (%s) ──────────────────────────────\n' "$i" "$MAX_ITER" "$ACTIVE_HARNESS"
# All harnesses receive the same Ralph task text from prompt.md. Claude gets
# it on stdin (matching historical behavior); Pi, Codex, and OpenCode get it as the
# prompt/task argument required by their CLIs.
TASK_TEXT="$(cat "$PROMPT")"
ITERATION_OUTPUT="$(mktemp -t "ralph-$TASKDESC-$i.XXXXXX")"
run_iteration "$ACTIVE_HARNESS" "$TASK_TEXT" "$ITERATION_OUTPUT" || true
if [ "$ACTIVE_HARNESS" = "claude" ] && claude_limit_detected "$ITERATION_OUTPUT"; then
FALLBACK_HARNESS="$(fallback_after_harness claude)" || {
rm -f "$ITERATION_OUTPUT"
exit 1
}
printf '\n⚠ Claude usage limit detected; switching Ralph harness to %s for subsequent iterations.\n' "$FALLBACK_HARNESS"
ACTIVE_HARNESS="$FALLBACK_HARNESS"
fi
rm -f "$ITERATION_OUTPUT"
sleep "$SLEEP_SECONDS"
done
if [ "$i" -ge "$MAX_ITER" ] && ! grep -q '^STATUS: COMPLETE$' "$PROGRESS"; then
printf '\n✗ Reached max iterations (%s) without STATUS: COMPLETE.\n' "$MAX_ITER"
printf ' Inspect: tail -n 50 %s\n' "$PROGRESS"
fi
printf '\nSession persists for inspection.\n'
printf ' Detach: Ctrl-b d\n'
printf ' Kill: tmux kill-session -t %s\n\n' "$TASKDESC"
exec bash
fi
# ─── Normal mode: validate args + launch tmux ───────────────────────
parse_args "$@"
# Per SPEC: kebab-case, shell-safe (used as tmux session name).
if ! [[ "$TASKDESC" =~ ^[a-z0-9-]+$ ]]; then
echo "Error: <taskdesc> must match ^[a-z0-9-]+\$ (got: '$TASKDESC')" >&2
exit 2
fi
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
TASK_DIR="$REPO_ROOT/tasks/$TASKDESC"
if [ ! -d "$TASK_DIR" ]; then
echo "Error: $TASK_DIR does not exist." >&2
echo "Hint: scaffold a task with /prd then /ralph first." >&2
exit 1
fi
# Four-file contract per SPEC §tasks/.
for f in prd.md prd.json prompt.md progress.txt; do
if [ ! -f "$TASK_DIR/$f" ]; then
echo "Error: $TASK_DIR/$f is missing (SPEC §tasks/ four-file contract)." >&2
exit 1
fi
done
HARNESS="$(resolve_initial_harness "$HARNESS")"
require_harness_command "$HARNESS"
# ─── Idempotent: if session exists, attach ──────────────────────────
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "$TASKDESC" 2>/dev/null; then
echo "Session '$TASKDESC' exists — attaching."
exec tmux attach -t "$TASKDESC"
fi
# ─── Launch new tmux session ────────────────────────────────────────
SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
if ! command -v tmux >/dev/null 2>&1; then
echo "Warning: tmux not found — running loop in foreground." >&2
REPO_ROOT="$REPO_ROOT" exec bash "$SCRIPT_PATH" --loop --harness="$HARNESS" "$TASKDESC"
fi
LAUNCH_CMD="REPO_ROOT=$(printf %q "$REPO_ROOT") bash $(printf %q "$SCRIPT_PATH") --loop --harness=$(printf %q "$HARNESS") $(printf %q "$TASKDESC") 2>&1 | tee $(printf %q "/tmp/ralph-$TASKDESC.log")"
tmux new-session -d -s "$TASKDESC" "$LAUNCH_CMD"
echo "✓ Launched tmux session: $TASKDESC"
echo " Harness: $HARNESS"
echo " Attach: tmux attach -t $TASKDESC"
echo " Tail: tail -f $TASK_DIR/progress.txt"
echo " Log: tail -f /tmp/ralph-$TASKDESC.log"
echo " Kill: tmux kill-session -t $TASKDESC"