Skip to content

Instantly share code, notes, and snippets.

@mikehostetler
Created February 19, 2026 23:01
Show Gist options
  • Select an option

  • Save mikehostetler/df36f32704af611afb7b3c7574879505 to your computer and use it in GitHub Desktop.

Select an option

Save mikehostetler/df36f32704af611afb7b3c7574879505 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/ralph_wiggum_loop.sh [options]
Runs the "Ralph Wiggum" story loop:
- one Codex execution per story card
- one commit per successful story
- optional push after each commit
Options:
--start-at ST-ID Start at this story ID (inclusive)
--end-at ST-ID End at this story ID (inclusive)
--only ST-ID Run only one story ID
--max N Process at most N selected stories
--model MODEL Pass model to codex exec
--profile PROFILE Pass profile to codex exec
--codex-arg ARG Extra codex exec argument (repeatable)
--stories-dir DIR Directory containing story markdown files (default: specs/stories)
--traceability-file PATH Traceability matrix file (default: <stories-dir>/00_traceability_matrix.md)
--remote NAME Git remote for pushes (default: origin)
--branch NAME Remote branch name (default: current branch)
--no-push Commit each story but do not push
--skip-precommit Skip mix precommit gate (not recommended)
--max-fix-attempts N Max codex fix loops after precommit failure (default: 2)
--include-completed Do not skip stories already present in git log
--no-auto-include-deps Do not auto-include unmet dependencies from the backlog
--dry-run Print selected stories and exit
-h, --help Show this help
Environment variables:
LOG_FILE Log destination (default: .ralph_wiggum_loop.log)
EXPECTED_STORY_COUNT Optional warning threshold for discovered stories
Examples:
scripts/ralph_wiggum_loop.sh --dry-run
scripts/ralph_wiggum_loop.sh --start-at ST-ONB-001 --max 3
scripts/ralph_wiggum_loop.sh --only ST-ONB-001 --no-push
USAGE
}
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
fail() {
log "ERROR: $*" >&2
exit 1
}
require_next_arg() {
local opt="$1"
local argc="$2"
if [ "$argc" -lt 2 ]; then
fail "Option ${opt} requires a value"
fi
}
require_non_flag_value() {
local opt="$1"
local value="$2"
if [ -z "$value" ] || [[ "$value" == -* ]]; then
fail "Option ${opt} requires a non-flag value"
fi
}
is_non_negative_integer() {
[[ "$1" =~ ^[0-9]+$ ]]
}
assert_clean_tree() {
local status
status="$(git status --porcelain)"
if [ -n "$status" ]; then
fail "Working tree is not clean. Commit/stash changes before starting."
fi
}
story_committed() {
local story_id="$1"
git log --format=%H -n 1 --extended-regexp --grep="^feat\\(story\\): ${story_id}( |$)" 2>/dev/null | grep -q .
}
story_in_records() {
local story_id="$1"
local record
for record in "${selected_stories[@]}"; do
if [ "${record%%|*}" = "$story_id" ]; then
return 0
fi
done
return 1
}
lookup_story_record() {
local story_id="$1"
local record
for record in "${story_catalog[@]}"; do
if [ "${record%%|*}" = "$story_id" ]; then
printf '%s\n' "$record"
return 0
fi
done
return 1
}
extract_story_block() {
local story_id="$1"
local story_file="$2"
awk -v story_id="$story_id" '
BEGIN { capture = 0 }
$0 ~ "^### " story_id " " { capture = 1 }
capture {
if ($0 ~ "^### ST-" && $0 !~ "^### " story_id " ") {
exit
}
print
}
' "$story_file"
}
extract_dependency_ids() {
local story_id="$1"
local story_file="$2"
awk -v story_id="$story_id" '
BEGIN { in_story = 0; in_deps = 0 }
$0 ~ "^### " story_id " " { in_story = 1; next }
in_story && $0 ~ "^### ST-" { exit }
in_story && $0 == "#### Dependencies" { in_deps = 1; next }
in_story && in_deps && $0 ~ "^#### " { exit }
in_story && in_deps { print }
' "$story_file" \
| tr '`' ' ' \
| rg -o 'ST-[A-Z]+-[0-9]{3}' \
| awk '!seen[$0]++' || true
}
validate_dependencies() {
local story_id="$1"
local story_file="$2"
local dep
local missing=()
while IFS= read -r dep; do
[ -z "$dep" ] && continue
if ! story_committed "$dep"; then
missing+=("$dep")
fi
done < <(extract_dependency_ids "$story_id" "$story_file")
if [ "${#missing[@]}" -gt 0 ]; then
printf '%s' "${missing[*]}"
return 1
fi
return 0
}
build_codex_cmd() {
CODEX_CMD=(codex exec --cd "$REPO_ROOT" --full-auto)
if [ -n "$CODEX_MODEL" ]; then
CODEX_CMD+=(--model "$CODEX_MODEL")
fi
if [ -n "$CODEX_PROFILE" ]; then
CODEX_CMD+=(--profile "$CODEX_PROFILE")
fi
if [ "${#CODEX_EXTRA_ARGS[@]}" -gt 0 ]; then
CODEX_CMD+=("${CODEX_EXTRA_ARGS[@]}")
fi
CODEX_CMD+=("-")
}
run_codex_prompt() {
local prompt_file="$1"
log "Running: ${CODEX_CMD[*]}"
"${CODEX_CMD[@]}" < "$prompt_file" | tee -a "$LOG_FILE"
}
run_precommit_with_fix_loops() {
local story_id="$1"
local story_title="$2"
local attempt=0
local precommit_log
local fix_prompt
precommit_log="$(mktemp "${TMPDIR:-/tmp}/ralph-precommit.XXXXXX")"
while true; do
log "Running mix precommit for ${story_id}"
if mix precommit >"$precommit_log" 2>&1; then
cat "$precommit_log" >> "$LOG_FILE"
rm -f "$precommit_log"
return 0
fi
cat "$precommit_log" >> "$LOG_FILE"
if [ "$attempt" -ge "$MAX_FIX_ATTEMPTS" ]; then
log "mix precommit still failing for ${story_id} after $((attempt + 1)) run(s)"
rm -f "$precommit_log"
return 1
fi
attempt=$((attempt + 1))
log "mix precommit failed for ${story_id}; running codex fix loop ${attempt}/${MAX_FIX_ATTEMPTS}"
fix_prompt="$(mktemp "${TMPDIR:-/tmp}/ralph-fix-prompt.XXXXXX")"
{
echo "You are fixing precommit failures for one story implementation."
echo
echo "Story ID: ${story_id}"
echo "Story title: ${story_title}"
echo
echo "Tasks:"
echo "- fix all compile/format/test issues"
echo "- run mix precommit and leave it passing"
echo "- do not commit"
echo "- do not push"
echo "- do not implement new stories"
echo
echo "Latest mix precommit output:"
echo '```text'
tail -n 300 "$precommit_log"
echo '```'
} > "$fix_prompt"
if ! run_codex_prompt "$fix_prompt"; then
rm -f "$fix_prompt" "$precommit_log"
return 1
fi
rm -f "$fix_prompt"
done
}
START_AT=""
END_AT=""
ONLY_STORY=""
MAX_STORIES=0
CODEX_MODEL=""
CODEX_PROFILE=""
CODEX_EXTRA_ARGS=()
STORIES_DIR="specs/stories"
TRACEABILITY_FILE=""
REMOTE_NAME="origin"
TARGET_BRANCH=""
DO_PUSH=1
RUN_PRECOMMIT=1
MAX_FIX_ATTEMPTS=2
SKIP_COMPLETED=1
AUTO_INCLUDE_DEPS=1
DRY_RUN=0
LOG_FILE="${LOG_FILE:-.ralph_wiggum_loop.log}"
EXPECTED_STORY_COUNT="${EXPECTED_STORY_COUNT:-}"
while [ "$#" -gt 0 ]; do
case "$1" in
--start-at)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
START_AT="$2"
shift 2
;;
--end-at)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
END_AT="$2"
shift 2
;;
--only)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
ONLY_STORY="$2"
shift 2
;;
--max)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
MAX_STORIES="$2"
shift 2
;;
--model)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
CODEX_MODEL="$2"
shift 2
;;
--profile)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
CODEX_PROFILE="$2"
shift 2
;;
--codex-arg)
require_next_arg "$1" "$#"
CODEX_EXTRA_ARGS+=("$2")
shift 2
;;
--stories-dir)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
STORIES_DIR="$2"
shift 2
;;
--traceability-file)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
TRACEABILITY_FILE="$2"
shift 2
;;
--remote)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
REMOTE_NAME="$2"
shift 2
;;
--branch)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
TARGET_BRANCH="$2"
shift 2
;;
--no-push)
DO_PUSH=0
shift
;;
--skip-precommit)
RUN_PRECOMMIT=0
shift
;;
--max-fix-attempts)
require_next_arg "$1" "$#"
require_non_flag_value "$1" "$2"
MAX_FIX_ATTEMPTS="$2"
shift 2
;;
--include-completed)
SKIP_COMPLETED=0
shift
;;
--no-auto-include-deps)
AUTO_INCLUDE_DEPS=0
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
is_non_negative_integer "$MAX_STORIES" || fail "--max must be a non-negative integer"
is_non_negative_integer "$MAX_FIX_ATTEMPTS" || fail "--max-fix-attempts must be a non-negative integer"
if [ -n "$EXPECTED_STORY_COUNT" ]; then
is_non_negative_integer "$EXPECTED_STORY_COUNT" || fail "EXPECTED_STORY_COUNT must be a non-negative integer"
fi
command -v rg >/dev/null 2>&1 || fail "ripgrep (rg) is required"
command -v git >/dev/null 2>&1 || fail "git is required"
if [ "$DRY_RUN" -eq 0 ]; then
command -v codex >/dev/null 2>&1 || fail "codex CLI is required"
fi
if [ "$RUN_PRECOMMIT" -eq 1 ] && [ "$DRY_RUN" -eq 0 ]; then
command -v mix >/dev/null 2>&1 || fail "mix is required unless --skip-precommit is set"
fi
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || fail "Must run inside a git repository"
cd "$REPO_ROOT"
if [ -z "$TRACEABILITY_FILE" ]; then
TRACEABILITY_FILE="${STORIES_DIR}/00_traceability_matrix.md"
fi
if [ "$DRY_RUN" -eq 0 ] && [ ! -f "$TRACEABILITY_FILE" ]; then
fail "Traceability matrix not found: $TRACEABILITY_FILE"
fi
if [ -z "$TARGET_BRANCH" ]; then
TARGET_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
fi
if [ -z "$TARGET_BRANCH" ]; then
fail "Unable to determine current branch; checkout or create a branch first"
fi
if [ "$TARGET_BRANCH" = "HEAD" ]; then
fail "Detached HEAD is not supported; checkout a branch first"
fi
build_codex_cmd
story_rows="$(rg -n -H --no-heading '^### ST-[A-Z]+-[0-9]{3}' "${STORIES_DIR}"/[0-9][0-9]_*.md | sort -t: -k1,1 -k2,2n || true)"
[ -n "$story_rows" ] || fail "No stories found under ${STORIES_DIR}"
story_catalog=()
while IFS= read -r row; do
[ -z "$row" ] && continue
story_file="${row%%:*}"
heading="${row#*:*:}"
rest="${heading#\#\#\# }"
story_id="${rest%% *}"
story_title="${rest#"$story_id" }"
story_title="$(printf '%s' "$story_title" | sed -E 's/^[^[:alnum:]]+[[:space:]]*//')"
story_catalog+=("${story_id}|${story_file}|${story_title}")
done <<< "$story_rows"
if [ -n "$EXPECTED_STORY_COUNT" ] && [ "${#story_catalog[@]}" -ne "$EXPECTED_STORY_COUNT" ]; then
log "Warning: expected ${EXPECTED_STORY_COUNT} stories, found ${#story_catalog[@]}"
fi
selected_stories=()
start_found=0
end_found=0
if [ -z "$START_AT" ]; then
start_found=1
fi
for record in "${story_catalog[@]}"; do
story_id="${record%%|*}"
if [ -n "$ONLY_STORY" ] && [ "$story_id" != "$ONLY_STORY" ]; then
continue
fi
if [ "$start_found" -eq 0 ]; then
if [ "$story_id" = "$START_AT" ]; then
start_found=1
else
continue
fi
fi
selected_stories+=("$record")
if [ -n "$END_AT" ] && [ "$story_id" = "$END_AT" ]; then
end_found=1
break
fi
if [ "$MAX_STORIES" -gt 0 ] && [ "${#selected_stories[@]}" -ge "$MAX_STORIES" ]; then
break
fi
done
if [ -n "$ONLY_STORY" ] && [ "${#selected_stories[@]}" -eq 0 ]; then
fail "Story not found: $ONLY_STORY"
fi
if [ -n "$START_AT" ] && [ "$start_found" -eq 0 ]; then
fail "Start story not found: $START_AT"
fi
if [ -n "$END_AT" ] && [ "$end_found" -eq 0 ]; then
fail "End story not found in selected range: $END_AT"
fi
[ "${#selected_stories[@]}" -gt 0 ] || fail "No stories selected"
if [ "$AUTO_INCLUDE_DEPS" -eq 1 ]; then
changed=1
while [ "$changed" -eq 1 ]; do
changed=0
for record in "${selected_stories[@]}"; do
story_id="${record%%|*}"
remainder="${record#*|}"
story_file="${remainder%%|*}"
while IFS= read -r dep; do
[ -z "$dep" ] && continue
if story_committed "$dep"; then
continue
fi
if story_in_records "$dep"; then
continue
fi
dep_record="$(lookup_story_record "$dep" || true)"
if [ -z "$dep_record" ]; then
fail "Dependency $dep required by $story_id was not found in ${STORIES_DIR}"
fi
selected_stories+=("$dep_record")
log "Auto-including dependency $dep required by $story_id"
changed=1
done < <(extract_dependency_ids "$story_id" "$story_file")
done
done
fi
log "Repository: $REPO_ROOT"
log "Remote/branch: $REMOTE_NAME/$TARGET_BRANCH"
log "Selected stories: ${#selected_stories[@]}"
log "Log file: $LOG_FILE"
if [ "$DRY_RUN" -eq 1 ]; then
idx=0
for record in "${selected_stories[@]}"; do
idx=$((idx + 1))
story_id="${record%%|*}"
remainder="${record#*|}"
story_file="${remainder%%|*}"
story_title="${remainder#*|}"
status="pending"
if [ "$SKIP_COMPLETED" -eq 1 ] && story_committed "$story_id"; then
status="already-committed"
fi
printf '%3d. %s [%s] %s (%s)\n' "$idx" "$story_id" "$status" "$story_title" "$story_file"
done
exit 0
fi
assert_clean_tree
loop_count=0
pending_stories=("${selected_stories[@]}")
pass=0
while [ "${#pending_stories[@]}" -gt 0 ]; do
pass=$((pass + 1))
progress_made=0
next_pending=()
blocked_details=()
for record in "${pending_stories[@]}"; do
story_id="${record%%|*}"
remainder="${record#*|}"
story_file="${remainder%%|*}"
story_title="${remainder#*|}"
if [ "$SKIP_COMPLETED" -eq 1 ] && story_committed "$story_id"; then
log "Skipping $story_id (already in git history)"
continue
fi
assert_clean_tree
missing_deps=""
if ! missing_deps="$(validate_dependencies "$story_id" "$story_file")"; then
next_pending+=("$record")
blocked_details+=("${story_id}:${missing_deps}")
continue
fi
loop_count=$((loop_count + 1))
progress_made=1
log "Loop ${loop_count}: ${story_id} - ${story_title}"
story_block="$(extract_story_block "$story_id" "$story_file")"
[ -n "$story_block" ] || fail "Could not extract story block for $story_id from $story_file"
trace_row="$(rg --no-heading "^\\| .*${story_id}.*\\|" "$TRACEABILITY_FILE" || true)"
[ -n "$trace_row" ] || fail "Traceability row not found for $story_id"
prompt_file="$(mktemp "${TMPDIR:-/tmp}/ralph-story-prompt.XXXXXX")"
{
echo "Implement exactly one backlog story in this repository."
echo
echo "Story ID: ${story_id}"
echo "Story title: ${story_title}"
echo "Story file: ${story_file}"
echo
echo "Traceability row:"
echo "${trace_row}"
echo
echo "Story card:"
echo '```markdown'
printf '%s\n' "$story_block"
echo '```'
echo
echo "Execution rules:"
echo "- Follow AGENTS.md and repository conventions."
echo "- Implement only this story."
echo "- Do not work on other stories."
echo "- Add or update tests for acceptance criteria."
echo "- Run mix precommit and leave it passing."
echo "- Do not commit."
echo "- Do not push."
echo "- Do not open a PR."
echo
echo "Final response format:"
echo "1) changed files"
echo "2) tests/commands executed"
echo "3) acceptance criteria coverage"
echo "4) blockers/assumptions"
} > "$prompt_file"
if ! run_codex_prompt "$prompt_file"; then
rm -f "$prompt_file"
fail "codex exec failed for $story_id"
fi
rm -f "$prompt_file"
if [ -z "$(git status --porcelain)" ]; then
fail "No changes detected after codex loop for $story_id"
fi
if [ "$RUN_PRECOMMIT" -eq 1 ]; then
if ! run_precommit_with_fix_loops "$story_id" "$story_title"; then
fail "mix precommit failed for $story_id"
fi
fi
git add -A
git commit -m "feat(story): ${story_id} ${story_title}" -m "Story-File: ${story_file}"
if [ "$DO_PUSH" -eq 1 ]; then
git push "$REMOTE_NAME" "HEAD:${TARGET_BRANCH}"
fi
log "Completed ${story_id}"
done
if [ "${#next_pending[@]}" -eq 0 ]; then
break
fi
if [ "$progress_made" -eq 0 ]; then
log "Unresolved dependency set after pass ${pass}:"
for detail in "${blocked_details[@]}"; do
log " ${detail}"
done
fail "No progress can be made due to missing dependencies"
fi
log "Pass ${pass} deferred ${#next_pending[@]} story(ies) for dependency satisfaction; retrying deferred set"
pending_stories=("${next_pending[@]}")
done
log "Story loops complete."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment