Per-iteration install/remove had a race condition: settings.local.json was written immediately before CC started, and CC could read the old file (without the hook) on the first iteration. Now the hook is installed once when loop.sh starts and removed on exit. The AGENT_LOOP_ACTIVE env var guard ensures it only fires for CC sessions spawned by the loop, so keeping it installed the whole time is safe.
394 lines
14 KiB
Bash
Executable File
394 lines
14 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)
|
|
#
|
|
# 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
|
|
|
|
# --- 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" "3")
|
|
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 ;;
|
|
[0-9]*) MAX_ITERATIONS="$1"; shift ;;
|
|
*) log "Unknown option: $1"; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
export ITERATION=0 MAX_ITERATIONS MODE
|
|
export AGENT_LOOP_ACTIVE=1
|
|
|
|
# --- 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() {
|
|
# Remove hooks in case we exit mid-agent (Ctrl+C during a claude session)
|
|
[ "$AUTO_HOOKS" = true ] && remove_hooks 2>/dev/null
|
|
release_lock
|
|
}
|
|
|
|
# Show final status and wait so tmux doesn't vanish
|
|
finish() {
|
|
local exit_code="${1:-0}"
|
|
local reason="${2:-}"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " LOOP FINISHED"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Stories: $(story_counts 2>/dev/null || echo 'N/A')"
|
|
echo " Iterations: $ITERATION / $MAX_ITERATIONS"
|
|
[ -n "$reason" ] && echo " Reason: $reason"
|
|
echo ""
|
|
echo " Progress: .loop/progress.md"
|
|
echo " Stories: .loop/prd.json"
|
|
echo ""
|
|
if any_stories_blocked 2>/dev/null; then
|
|
echo " ⚠ Some stories are blocked. Run /agent-loop:triage"
|
|
echo ""
|
|
fi
|
|
echo " Closing in 30 seconds. Press Enter to close now, or Ctrl+C to keep open."
|
|
# Auto-close after 30s so the tmux session exits and the background watcher fires
|
|
read -r -t 30 2>/dev/null || true
|
|
exit "$exit_code"
|
|
}
|
|
# Install Stop hook once at startup. The AGENT_LOOP_ACTIVE env var guard ensures
|
|
# it only fires for CC sessions spawned by this loop (not the user's other sessions).
|
|
# Installing once avoids a race condition where per-iteration install_hooks writes
|
|
# settings.local.json just before CC starts, and CC reads the old file.
|
|
[ "$AUTO_HOOKS" = true ] && install_hooks
|
|
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 /agent-loop:stories 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.
|
|
#
|
|
# Pipes prompt to claude WITHOUT --print. This gives the full interactive
|
|
# CC UI — tool calls, file edits, etc. A Stop hook sends SIGINT to the loop
|
|
# when claude finishes, returning control to the while loop for the next
|
|
# iteration. State is tracked via files (prd.json, .verdict), not stdout.
|
|
run_agent() {
|
|
local prompt="$1"
|
|
local role="${2:-}"
|
|
|
|
rm -f "$LOOP_DIR/.verdict"
|
|
|
|
local agent_exit=0
|
|
|
|
(
|
|
case "$TOOL" in
|
|
claude)
|
|
printf '%s\n' "$prompt" | claude --dangerously-skip-permissions
|
|
;;
|
|
amp)
|
|
printf '%s\n' "$prompt" | amp --dangerously-allow-all
|
|
;;
|
|
*)
|
|
log "ERROR: Unknown tool '$TOOL'"
|
|
exit 1
|
|
;;
|
|
esac
|
|
) || agent_exit=$?
|
|
sleep 2 # Brief pause between sessions
|
|
|
|
# Read verdict from file if evaluator wrote one
|
|
if [ "$role" = "evaluator" ] && [ -f "$LOOP_DIR/.verdict" ]; then
|
|
cat "$LOOP_DIR/.verdict"
|
|
fi
|
|
}
|
|
|
|
# --- 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
|
|
snapshot_for_archive
|
|
finish 0 "All stories complete"
|
|
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
|
|
snapshot_for_archive
|
|
if any_stories_blocked 2>/dev/null; then
|
|
finish $EXIT_ALL_BLOCKED "Some stories blocked — needs human review"
|
|
else
|
|
finish $EXIT_OK "No actionable stories remaining"
|
|
fi
|
|
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
|
|
|
|
run_agent "$GENERATOR_PROMPT" "generator"
|
|
|
|
# --- 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" ]; 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
|
|
|
|
# NOTE: Do NOT check all_stories_pass here. The generator marks its own story
|
|
# as passed, but the evaluator hasn't verified yet. Checking here would skip
|
|
# evaluation on the last story. The completion check is at the top of the loop.
|
|
|
|
# --- 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")
|
|
|
|
run_agent "$EVAL_PROMPT" "evaluator"
|
|
if [ -f "$LOOP_DIR/.verdict" ]; then
|
|
EVAL_OUTPUT=$(cat "$LOOP_DIR/.verdict")
|
|
else
|
|
log "WARNING: No verdict file found. Treating as REJECT."
|
|
EVAL_OUTPUT="<verdict>REJECT</verdict><rejection_reason>Evaluator produced no verdict file</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 ---
|
|
snapshot_for_archive
|
|
finish $EXIT_MAX_ITERATIONS "Max iterations reached ($MAX_ITERATIONS)"
|