Files
loop-loop/loop.sh

421 lines
15 KiB
Bash
Executable File

#!/bin/bash
# Autonomous AI agent loop orchestrator
# Combines generator-evaluator architecture with iterative context-reset pattern.
#
# Usage:
# ./loop.sh [options]
#
# Options:
# --mode <implement|explore|fix> Operating mode (default: from config.json)
# --max <N> Maximum iterations (default: from config.json)
# --skip-eval Skip evaluator pass
# --tool <claude|amp> AI tool to use (default: from config.json)
# --no-hooks Don't install stop hooks
# --dry-run Print assembled prompts without running agents
# --resume Skip already-passed stories (explicit mode)
# --replan (reserved — not yet implemented)
#
# Each iteration:
# 1. Generator: picks highest-priority incomplete story, does the work
# 2. Evaluator: verifies the work, can PASS or REJECT
# Both get fresh context windows. Loop continues until all stories pass or max iterations.
set -euo pipefail
# --- Exit codes ---
EXIT_OK=0 # All stories complete
EXIT_ERROR=1 # Configuration or runtime error
EXIT_MAX_ITERATIONS=2 # Max iterations reached, work remains
EXIT_ALL_BLOCKED=3 # All remaining stories blocked for human review
# --- Resolve paths ---
LOOP_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$LOOP_DIR/.." && pwd)"
export LOOP_DIR PROJECT_ROOT
# --- Lockfile (prevent concurrent runs) ---
LOCKFILE="$LOOP_DIR/.loop.lock"
acquire_lock() {
# mkdir is atomic on POSIX — prevents race between check and create
if ! mkdir "$LOCKFILE" 2>/dev/null; then
local old_pid
old_pid=$(cat "$LOCKFILE/pid" 2>/dev/null)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "[loop] ERROR: Another loop instance is running (PID $old_pid)."
echo "[loop] If this is stale, remove $LOCKFILE and retry."
exit 1
fi
# Stale lockfile — previous run crashed without cleanup
rm -rf "$LOCKFILE"
mkdir "$LOCKFILE"
fi
echo $$ > "$LOCKFILE/pid"
}
release_lock() {
rm -rf "$LOCKFILE"
}
acquire_lock
# --- Source libraries ---
source "$LOOP_DIR/lib/hooks.sh"
source "$LOOP_DIR/lib/state.sh"
source "$LOOP_DIR/lib/archive.sh"
source "$LOOP_DIR/lib/prompt.sh"
# --- Logging ---
log() { echo "[loop] $*"; }
log_header() {
echo ""
echo "═══════════════════════════════════════════════════════"
echo " $*"
echo "═══════════════════════════════════════════════════════"
echo ""
}
# --- Preflight checks ---
if ! command -v jq &>/dev/null && ! command -v python3 &>/dev/null; then
log "ERROR: Either jq or python3 is required. Install one and retry."
exit 1
fi
# --- macOS timeout compatibility ---
# macOS doesn't have GNU timeout. Use gtimeout (from coreutils) or a perl fallback.
if ! command -v timeout &>/dev/null; then
if command -v gtimeout &>/dev/null; then
timeout() { gtimeout "$@"; }
else
# Perl-based fallback: runs command with alarm signal
timeout() {
local duration="$1"; shift
perl -e '
alarm shift @ARGV;
exec @ARGV;
' "$duration" "$@"
}
fi
fi
# --- Load config defaults ---
CONFIG_FILE="$LOOP_DIR/config.json"
config_default() { get_config_value "$1" "$2"; }
TOOL=$(config_default ".tool" "claude")
MODE=$(config_default ".mode" "implement")
MAX_ITERATIONS=$(config_default ".maxIterations" "20")
SKIP_EVAL=$(config_default ".skipEval" "false")
EVAL_RETRIES=$(config_default ".evalRetries" "2")
AUTO_HOOKS=$(config_default ".autoHooks" "true")
DRY_RUN=false
RESUME=false
# --- Parse CLI args (override config) ---
while [[ $# -gt 0 ]]; do
case $1 in
--mode) MODE="$2"; shift 2 ;;
--mode=*) MODE="${1#*=}"; shift ;;
--max) MAX_ITERATIONS="$2"; shift 2 ;;
--max=*) MAX_ITERATIONS="${1#*=}"; shift ;;
--skip-eval) SKIP_EVAL=true; shift ;;
--tool) TOOL="$2"; shift 2 ;;
--tool=*) TOOL="${1#*=}"; shift ;;
--no-hooks) AUTO_HOOKS=false; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--resume) RESUME=true; shift ;;
--replan) log "ERROR: --replan is not yet implemented. Use /loop-plan interactively."; exit 1 ;;
[0-9]*) MAX_ITERATIONS="$1"; shift ;;
*) log "Unknown option: $1"; exit 1 ;;
esac
done
export ITERATION=0 MAX_ITERATIONS MODE
# --- Validate ---
if [[ ! "$MODE" =~ ^(implement|explore|fix)$ ]]; then
log "ERROR: Invalid mode '$MODE'. Must be: implement, explore, fix"
exit 1
fi
if [[ ! "$TOOL" =~ ^(claude|amp)$ ]]; then
log "ERROR: Invalid tool '$TOOL'. Must be: claude, amp"
exit 1
fi
# --- Setup ---
cd "$PROJECT_ROOT"
cleanup() {
[ -n "${LOOP_AGENT_TMPFILE:-}" ] && rm -f "$LOOP_AGENT_TMPFILE"
[ "$AUTO_HOOKS" = true ] && remove_hooks
release_lock
}
LOOP_AGENT_TMPFILE=""
if [ "$AUTO_HOOKS" = true ]; then
install_hooks
fi
trap cleanup EXIT INT TERM
check_archive
# Validate prd.json exists (AFTER archive check, which may delete it on branch change)
if [ ! -f "$LOOP_DIR/prd.json" ]; then
log "ERROR: No prd.json found. Run /loop-plan first to create one."
exit 1
fi
validate_prd
# Run project init script if it exists
if [ -f "$LOOP_DIR/init.sh" ]; then
log "Running init.sh..."
bash "$LOOP_DIR/init.sh"
fi
# Ensure correct git branch
BRANCH=$(prd_branch_name 2>/dev/null || echo "")
if [ -n "$BRANCH" ]; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then
log "Switching to branch: $BRANCH"
git checkout "$BRANCH" 2>/dev/null || \
git checkout -b "$BRANCH" "origin/$BRANCH" 2>/dev/null || \
git checkout -b "$BRANCH"
fi
fi
# --- Agent runner ---
# Runs a prompt through the selected AI tool and captures output.
# Output is displayed live via tee to /dev/tty (if available) and captured to a temp file.
# The function prints the captured output to stdout for the caller to capture.
run_agent() {
local prompt="$1"
local output_file
output_file=$(mktemp)
LOOP_AGENT_TMPFILE="$output_file" # exposed for trap cleanup
# Determine whether we can display live output
local has_tty=false
if { true > /dev/tty; } 2>/dev/null; then
has_tty=true
fi
# Run in subshell so a non-zero exit from the AI tool doesn't kill the loop.
# The subshell inherits set -e but its exit status is captured, not propagated.
local agent_exit=0
(
case "$TOOL" in
claude)
if [ "$has_tty" = true ]; then
printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \
claude --dangerously-skip-permissions --output-format text \
--print 2>&1 | tee /dev/tty > "$output_file"
else
printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \
claude --dangerously-skip-permissions --output-format text \
--print 2>&1 > "$output_file"
fi
;;
amp)
if [ "$has_tty" = true ]; then
printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \
amp --dangerously-allow-all 2>&1 | tee /dev/tty > "$output_file"
else
printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \
amp --dangerously-allow-all 2>&1 > "$output_file"
fi
;;
*)
log "ERROR: Unknown tool '$TOOL'"
exit 1
;;
esac
) || agent_exit=$?
if [ "$agent_exit" -ne 0 ] && [ ! -s "$output_file" ]; then
log "WARNING: Agent exited with code $agent_exit and produced no output."
fi
cat "$output_file"
rm -f "$output_file"
LOOP_AGENT_TMPFILE=""
}
# --- Parse evaluator verdict ---
parse_verdict() {
local output="$1"
if echo "$output" | grep -q "<verdict>REJECT</verdict>"; then
# Extract rejection reason (supports multiline)
local reason
reason=$(echo "$output" | sed -n '/<rejection_reason>/,/<\/rejection_reason>/p' \
| sed '1s/.*<rejection_reason>//' | sed '$s/<\/rejection_reason>.*//' \
| tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ //;s/ $//')
[ -z "$reason" ] && reason="Rejected without specific reason"
echo "REJECT:${reason}"
elif echo "$output" | grep -q "<verdict>PASS</verdict>"; then
echo "PASS"
else
# No explicit verdict — fail-safe: treat as reject so broken evaluators don't silently approve
log "WARNING: No verdict tag found in evaluator output. Treating as REJECT (fail-safe)."
echo "REJECT:Evaluator produced no verdict tag — output may be malformed"
fi
}
# --- Main loop ---
log_header "Loop Starting"
log "Mode: $MODE"
log "Tool: $TOOL"
log "Max iter: $MAX_ITERATIONS"
log "Eval: $([[ $SKIP_EVAL == true ]] && echo 'off' || echo 'on')"
log "Dry run: $([[ $DRY_RUN == true ]] && echo 'yes' || echo 'no')"
log "Project: $PROJECT_ROOT"
log "Stories: $(story_counts 2>/dev/null || echo 'N/A')"
echo ""
while [ "$ITERATION" -lt "$MAX_ITERATIONS" ]; do
ITERATION=$((ITERATION + 1))
export ITERATION
# Check if all stories already pass
if all_stories_pass 2>/dev/null; then
log_header "All Stories Complete! ($(story_counts))"
snapshot_for_archive
exit 0
fi
# Capture which story the generator will work on (highest-priority incomplete)
CURRENT_STORY_ID=$(next_story_id 2>/dev/null || echo "")
export CURRENT_STORY_ID
# No actionable story — all remaining are passed or blocked
if [ -z "$CURRENT_STORY_ID" ]; then
if [ "$RESUME" = true ]; then
log "Resume mode: no actionable stories remaining."
else
log "No actionable stories remaining (all passed or blocked)."
fi
snapshot_for_archive
if any_stories_blocked 2>/dev/null; then
log "Some stories are blocked and need human review. Run /loop-triage for details."
exit $EXIT_ALL_BLOCKED
fi
exit $EXIT_OK
fi
# Capture git state before generator runs (for evaluator diff)
PRE_GENERATOR_SHA=$(git rev-parse HEAD 2>/dev/null || echo "")
export PRE_GENERATOR_SHA
# --- Generator pass ---
log_header "Iteration $ITERATION / $MAX_ITERATIONS — GENERATOR${CURRENT_STORY_ID:+ ($CURRENT_STORY_ID)}"
GENERATOR_PROMPT=$(build_prompt "generator" "$MODE")
# --dry-run: print prompts and exit without running agents
if [ "$DRY_RUN" = true ]; then
log "=== GENERATOR PROMPT ==="
printf '%s\n' "$GENERATOR_PROMPT"
echo ""
if [ "$SKIP_EVAL" != true ] && [ -n "$CURRENT_STORY_ID" ]; then
EVAL_PROMPT=$(build_prompt "evaluator" "$MODE")
log "=== EVALUATOR PROMPT ==="
printf '%s\n' "$EVAL_PROMPT"
fi
log "Dry run complete. Showing prompts for story: ${CURRENT_STORY_ID:-unknown}"
exit 0
fi
GENERATOR_OUTPUT=$(run_agent "$GENERATOR_PROMPT")
if [ -z "$GENERATOR_OUTPUT" ]; then
log "WARNING: Generator produced empty output (timeout or crash). Skipping to next iteration."
continue
fi
# --- Scope budget check ---
# Verify the generator stayed within configured limits (files modified, lines written).
# Advisory in implement/fix modes (log warning), but enforced as rejection reason for evaluator.
if [ -n "$PRE_GENERATOR_SHA" ] && [ "$PRE_GENERATOR_SHA" != "" ]; then
SCOPE_FILES_MODIFIED=$(git diff --name-only "$PRE_GENERATOR_SHA" HEAD 2>/dev/null | wc -l | tr -d ' ')
SCOPE_LINES_WRITTEN=$(git diff --stat "$PRE_GENERATOR_SHA" HEAD 2>/dev/null | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0")
MAX_MODIFY=$(config_default ".scopeBudgets.${MODE}.maxFilesToModify" "10")
MAX_WRITE=$(config_default ".scopeBudgets.${MODE}.maxLinesToWrite" "500")
if [ "${SCOPE_FILES_MODIFIED:-0}" -gt "$MAX_MODIFY" ]; then
log "WARNING: Scope budget exceeded — modified $SCOPE_FILES_MODIFIED files (limit: $MAX_MODIFY)"
fi
if [ "${SCOPE_LINES_WRITTEN:-0}" -gt "$MAX_WRITE" ]; then
log "WARNING: Scope budget exceeded — wrote $SCOPE_LINES_WRITTEN lines (limit: $MAX_WRITE)"
fi
export SCOPE_FILES_MODIFIED SCOPE_LINES_WRITTEN
fi
# Check for completion sentinel
if echo "$GENERATOR_OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
log_header "Generator signaled COMPLETE ($(story_counts))"
snapshot_for_archive
exit 0
fi
# --- Evaluator pass ---
if [ "$SKIP_EVAL" != true ]; then
log_header "Iteration $ITERATION / $MAX_ITERATIONS — EVALUATOR${CURRENT_STORY_ID:+ ($CURRENT_STORY_ID)}"
if [ -z "$CURRENT_STORY_ID" ]; then
log "WARNING: No actionable story ID found. Skipping evaluator."
continue
fi
EVAL_PROMPT=$(build_prompt "evaluator" "$MODE")
EVAL_OUTPUT=$(run_agent "$EVAL_PROMPT")
if [ -z "$EVAL_OUTPUT" ]; then
log "WARNING: Evaluator produced empty output (timeout or crash). Treating as REJECT."
EVAL_OUTPUT="<verdict>REJECT</verdict><rejection_reason>Evaluator produced no output</rejection_reason>"
fi
VERDICT=$(parse_verdict "$EVAL_OUTPUT")
case "$VERDICT" in
PASS)
log "Evaluator: PASS"
if [ -n "$CURRENT_STORY_ID" ]; then
mark_story_pass "$CURRENT_STORY_ID"
fi
;;
REJECT:*)
REASON="${VERDICT#REJECT:}"
log "Evaluator: REJECT — $REASON"
if [ -n "$CURRENT_STORY_ID" ]; then
mark_story_reject "$CURRENT_STORY_ID" "$REASON"
# Check retry limit — block story to prevent infinite retries
REJECTIONS=$(story_rejections "$CURRENT_STORY_ID")
REJECTIONS="${REJECTIONS:-0}"
if [ "$REJECTIONS" -ge "$EVAL_RETRIES" ]; then
log "WARNING: Story $CURRENT_STORY_ID rejected $REJECTIONS times (limit: $EVAL_RETRIES). Blocking for human review."
mark_story_blocked "$CURRENT_STORY_ID" "Rejected $REJECTIONS times. Last: $REASON"
append_progress "### BLOCKED: $CURRENT_STORY_ID
Rejected $REJECTIONS times. Needs human review. Last reason: $REASON
---"
fi
fi
;;
esac
fi
done
# --- Max iterations reached ---
log_header "Max Iterations Reached ($MAX_ITERATIONS)"
log "Stories completed: $(story_counts)"
log "Run /loop-triage to generate a handoff brief."
snapshot_for_archive
exit $EXIT_MAX_ITERATIONS