#!/bin/bash
# feature-loop.sh - Full feature workflow: branch -> implement -> E2E test -> PR -> review -> merge
# Generated by ralph-cli for {{projectName}}
# Usage: ./feature-loop.sh <feature-name> [max-iterations] [max-e2e-attempts] [--worktree] [--resume] [--model MODEL] [--cli CLI] [--review-cli CLI] [--review-mode MODE]
#
# Options:
#   --worktree           Use git worktree for isolation (enables parallel execution)
#   --resume             Resume an interrupted loop (reuses existing branch/worktree)
#   --model MODEL        Model to use for coding/review CLI
#   --cli CLI            Implementation CLI: 'claude' | 'codex'
#   --review-cli CLI     Review CLI: 'claude' | 'codex'
#   --review-mode MODE   Review mode: 'manual' (stop at PR), 'auto' (review, no merge), or 'merge' (review + merge). Default: 'manual'

set -e
set -o pipefail

# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Load config from ralph.config.cjs if available
if [ -f "$SCRIPT_DIR/../ralph.config.cjs" ]; then
    CONFIG_PATH="$SCRIPT_DIR/../ralph.config.cjs"
    RALPH_ROOT=$(node -e "console.log(require('$CONFIG_PATH').paths?.root || '.ralph')" 2>/dev/null || echo ".ralph")
    SPEC_DIR=$(node -e "console.log(require('$CONFIG_PATH').paths?.specs || '.ralph/specs')" 2>/dev/null || echo ".ralph/specs")
    PROMPTS_DIR=$(node -e "console.log(require('$CONFIG_PATH').paths?.prompts || '.ralph/prompts')" 2>/dev/null || echo ".ralph/prompts")
    DEFAULT_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.defaultModel || 'sonnet')" 2>/dev/null || echo "sonnet")
    PLANNING_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.planningModel || 'opus')" 2>/dev/null || echo "opus")
    DEFAULT_CODEX_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexModel || 'gpt-5.3-codex')" 2>/dev/null || echo "gpt-5.3-codex")
    DEFAULT_CODING_CLI=$(node -e "console.log(require('$CONFIG_PATH').loop?.codingCli || 'claude')" 2>/dev/null || echo "claude")
    DEFAULT_REVIEW_CLI=$(node -e "console.log(require('$CONFIG_PATH').loop?.reviewCli || require('$CONFIG_PATH').loop?.codingCli || 'claude')" 2>/dev/null || echo "claude")
    CLAUDE_PERMISSION_MODE=$(node -e "console.log(require('$CONFIG_PATH').loop?.claudePermissionMode || 'default')" 2>/dev/null || echo "default")
    CODEX_SANDBOX=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexSandbox || 'workspace-write')" 2>/dev/null || echo "workspace-write")
    CODEX_APPROVAL_POLICY=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexApprovalPolicy || 'never')" 2>/dev/null || echo "never")
    DISABLE_MCP_IN_AUTOMATED=$(node -e "const v=require('$CONFIG_PATH').loop?.disableMcpInAutomatedRuns; console.log(v === undefined ? 'true' : String(v))" 2>/dev/null || echo "true")
    DEFAULT_MAX_ITERATIONS=$(node -e "console.log(require('$CONFIG_PATH').loop?.maxIterations || 10)" 2>/dev/null || echo "10")
    DEFAULT_MAX_E2E=$(node -e "console.log(require('$CONFIG_PATH').loop?.maxE2eAttempts || 5)" 2>/dev/null || echo "5")
    TEST_COMMAND=$(node -e "console.log(require('$CONFIG_PATH').commands?.test || 'npm test')" 2>/dev/null || echo "npm test")
    BUILD_COMMAND=$(node -e "console.log(require('$CONFIG_PATH').commands?.build || 'npm run build')" 2>/dev/null || echo "npm run build")
elif [ -f "$SCRIPT_DIR/../../ralph.config.cjs" ]; then
    CONFIG_PATH="$SCRIPT_DIR/../../ralph.config.cjs"
    RALPH_ROOT=$(node -e "console.log(require('$CONFIG_PATH').paths?.root || '.ralph')" 2>/dev/null || echo ".ralph")
    SPEC_DIR=$(node -e "console.log(require('$CONFIG_PATH').paths?.specs || '.ralph/specs')" 2>/dev/null || echo ".ralph/specs")
    PROMPTS_DIR=$(node -e "console.log(require('$CONFIG_PATH').paths?.prompts || '.ralph/prompts')" 2>/dev/null || echo ".ralph/prompts")
    DEFAULT_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.defaultModel || 'sonnet')" 2>/dev/null || echo "sonnet")
    PLANNING_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.planningModel || 'opus')" 2>/dev/null || echo "opus")
    DEFAULT_CODEX_MODEL=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexModel || 'gpt-5.3-codex')" 2>/dev/null || echo "gpt-5.3-codex")
    DEFAULT_CODING_CLI=$(node -e "console.log(require('$CONFIG_PATH').loop?.codingCli || 'claude')" 2>/dev/null || echo "claude")
    DEFAULT_REVIEW_CLI=$(node -e "console.log(require('$CONFIG_PATH').loop?.reviewCli || require('$CONFIG_PATH').loop?.codingCli || 'claude')" 2>/dev/null || echo "claude")
    CLAUDE_PERMISSION_MODE=$(node -e "console.log(require('$CONFIG_PATH').loop?.claudePermissionMode || 'default')" 2>/dev/null || echo "default")
    CODEX_SANDBOX=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexSandbox || 'workspace-write')" 2>/dev/null || echo "workspace-write")
    CODEX_APPROVAL_POLICY=$(node -e "console.log(require('$CONFIG_PATH').loop?.codexApprovalPolicy || 'never')" 2>/dev/null || echo "never")
    DISABLE_MCP_IN_AUTOMATED=$(node -e "const v=require('$CONFIG_PATH').loop?.disableMcpInAutomatedRuns; console.log(v === undefined ? 'true' : String(v))" 2>/dev/null || echo "true")
    DEFAULT_MAX_ITERATIONS=$(node -e "console.log(require('$CONFIG_PATH').loop?.maxIterations || 10)" 2>/dev/null || echo "10")
    DEFAULT_MAX_E2E=$(node -e "console.log(require('$CONFIG_PATH').loop?.maxE2eAttempts || 5)" 2>/dev/null || echo "5")
    TEST_COMMAND=$(node -e "console.log(require('$CONFIG_PATH').commands?.test || 'npm test')" 2>/dev/null || echo "npm test")
    BUILD_COMMAND=$(node -e "console.log(require('$CONFIG_PATH').commands?.build || 'npm run build')" 2>/dev/null || echo "npm run build")
else
    # Default paths
    RALPH_ROOT=".ralph"
    SPEC_DIR=".ralph/specs"
    PROMPTS_DIR=".ralph/prompts"
    DEFAULT_MODEL="sonnet"
    PLANNING_MODEL="opus"
    DEFAULT_CODEX_MODEL="gpt-5.3-codex"
    DEFAULT_CODING_CLI="claude"
    DEFAULT_REVIEW_CLI="claude"
    CLAUDE_PERMISSION_MODE="default"
    CODEX_SANDBOX="workspace-write"
    CODEX_APPROVAL_POLICY="never"
    DISABLE_MCP_IN_AUTOMATED="true"
    DEFAULT_MAX_ITERATIONS="10"
    DEFAULT_MAX_E2E="5"
    TEST_COMMAND="npm test"
    BUILD_COMMAND="npm run build"
fi

# Navigate to project root (parent of .ralph)
cd "$SCRIPT_DIR/../.."

# Parse arguments
USE_WORKTREE=false
RESUME=false
MODEL=""
REVIEW_MODE=""
CLI_OVERRIDE=""
REVIEW_CLI_OVERRIDE=""
POSITIONAL=()
while [[ $# -gt 0 ]]; do
    case $1 in
        --worktree)
            USE_WORKTREE=true
            shift
            ;;
        --resume)
            RESUME=true
            shift
            ;;
        --model)
            MODEL="$2"
            shift 2
            ;;
        --cli)
            CLI_OVERRIDE="$2"
            shift 2
            ;;
        --review-cli)
            REVIEW_CLI_OVERRIDE="$2"
            shift 2
            ;;
        --review-mode)
            REVIEW_MODE="$2"
            shift 2
            ;;
        *)
            POSITIONAL+=("$1")
            shift
            ;;
    esac
done
set -- "${POSITIONAL[@]}"

# Detect default branch dynamically
DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') || true
if [ -z "$DEFAULT_BRANCH" ]; then
    if git rev-parse --verify main >/dev/null 2>&1; then
        DEFAULT_BRANCH="main"
    elif git rev-parse --verify master >/dev/null 2>&1; then
        DEFAULT_BRANCH="master"
    else
        echo "ERROR: Cannot determine default branch" >&2
        exit 1
    fi
fi

# Resolve review mode from CLI > config > default
if [ -z "$REVIEW_MODE" ]; then
    if [ -f "$SCRIPT_DIR/../ralph.config.cjs" ]; then
        REVIEW_MODE_DEFAULT=$(node -e "console.log(require('$SCRIPT_DIR/../ralph.config.cjs').loop?.reviewMode || 'manual')" 2>/dev/null || echo "manual")
    elif [ -f "$SCRIPT_DIR/../../ralph.config.cjs" ]; then
        REVIEW_MODE_DEFAULT=$(node -e "console.log(require('$SCRIPT_DIR/../../ralph.config.cjs').loop?.reviewMode || 'manual')" 2>/dev/null || echo "manual")
    else
        REVIEW_MODE_DEFAULT="manual"
    fi
    REVIEW_MODE="${REVIEW_MODE_DEFAULT}"
fi

# Validate review mode
if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ] && [ "$REVIEW_MODE" != "merge" ]; then
    echo "ERROR: Invalid review mode: '$REVIEW_MODE'. Allowed values are 'manual', 'auto', or 'merge'." >&2
    exit 1
fi

# Resolve coding/review CLI from CLI > config > default
CODING_CLI="${CLI_OVERRIDE:-$DEFAULT_CODING_CLI}"
REVIEW_CLI="${REVIEW_CLI_OVERRIDE:-${DEFAULT_REVIEW_CLI:-$CODING_CLI}}"
DISABLE_MCP_IN_AUTOMATED_NORM=$(echo "$DISABLE_MCP_IN_AUTOMATED" | tr '[:upper:]' '[:lower:]')

# Validate CLI values
if [ "$CODING_CLI" != "claude" ] && [ "$CODING_CLI" != "codex" ]; then
    echo "ERROR: Invalid --cli value '$CODING_CLI'. Allowed values are 'claude' or 'codex'." >&2
    exit 1
fi

if [ "$REVIEW_CLI" != "claude" ] && [ "$REVIEW_CLI" != "codex" ]; then
    echo "ERROR: Invalid --review-cli value '$REVIEW_CLI'. Allowed values are 'claude' or 'codex'." >&2
    exit 1
fi

is_valid_claude_permission_mode() {
    case "$1" in
        acceptEdits|bypassPermissions|default|dontAsk|plan|auto) return 0 ;;
        *) return 1 ;;
    esac
}

is_valid_codex_sandbox() {
    case "$1" in
        read-only|workspace-write|danger-full-access) return 0 ;;
        *) return 1 ;;
    esac
}

is_valid_codex_approval_policy() {
    case "$1" in
        untrusted|on-failure|on-request|never) return 0 ;;
        *) return 1 ;;
    esac
}

if ! is_valid_claude_permission_mode "$CLAUDE_PERMISSION_MODE"; then
    echo "ERROR: Invalid loop.claudePermissionMode '$CLAUDE_PERMISSION_MODE' in ralph.config.cjs." >&2
    exit 1
fi

if ! is_valid_codex_sandbox "$CODEX_SANDBOX"; then
    echo "ERROR: Invalid loop.codexSandbox '$CODEX_SANDBOX' in ralph.config.cjs." >&2
    exit 1
fi

if ! is_valid_codex_approval_policy "$CODEX_APPROVAL_POLICY"; then
    echo "ERROR: Invalid loop.codexApprovalPolicy '$CODEX_APPROVAL_POLICY' in ralph.config.cjs." >&2
    exit 1
fi

case "$DISABLE_MCP_IN_AUTOMATED_NORM" in
    true|false) ;;
    *)
        echo "ERROR: Invalid loop.disableMcpInAutomatedRuns '$DISABLE_MCP_IN_AUTOMATED' in ralph.config.cjs. Use true or false." >&2
        exit 1
        ;;
esac

is_claude_only_model() {
    local candidate="$1"
    case "$candidate" in
        sonnet|opus|haiku|claude-*) return 0 ;;
        *) return 1 ;;
    esac
}

resolve_codex_model() {
    local candidate="${MODEL:-$DEFAULT_CODEX_MODEL}"
    if is_claude_only_model "$candidate"; then
        echo "$DEFAULT_CODEX_MODEL"
    else
        echo "$candidate"
    fi
}

if [ -n "$MODEL" ] && { [ "$CODING_CLI" = "codex" ] || [ "$REVIEW_CLI" = "codex" ]; }; then
    if is_claude_only_model "$MODEL"; then
        echo "WARNING: --model '$MODEL' is Claude-specific. Codex phases will use '$DEFAULT_CODEX_MODEL'." >&2
    fi
fi

build_cli_cmd() {
    local cli="$1"
    local model="$2"
    if [[ ! "$model" =~ ^[A-Za-z0-9._:/=-]+$ ]]; then
        echo "ERROR: Invalid model value '$model'. Only alphanumeric, dot, underscore, colon, slash, equals and hyphen are allowed." >&2
        return 1
    fi
    case "$cli" in
        claude)
            echo "claude -p --output-format json --permission-mode ${CLAUDE_PERMISSION_MODE} --model ${model}"
            ;;
        codex)
            local codex_extra=""
            # Avoid MCP startup deadlocks in unattended loop runs.
            if [ "${RALPH_AUTOMATED:-}" = "1" ] && [ "$DISABLE_MCP_IN_AUTOMATED_NORM" = "true" ]; then
                codex_extra=" -c mcp_servers={}"
            fi
            # Keep command string token-safe. run_claude_* parses it as an argument array.
            echo "codex --ask-for-approval ${CODEX_APPROVAL_POLICY} --sandbox ${CODEX_SANDBOX} exec --cd . --model ${model}${codex_extra}"
            ;;
        *)
            echo "ERROR: Unsupported CLI '$cli'" >&2
            return 1
            ;;
    esac
}

get_phase_cli() {
    local phase="$1"
    case "$phase" in
        review)
            echo "$REVIEW_CLI"
            ;;
        *)
            echo "$CODING_CLI"
            ;;
    esac
}

get_phase_model() {
    local phase="$1"
    local cli
    cli=$(get_phase_cli "$phase")
    if [ "$cli" = "codex" ]; then
        resolve_codex_model
        return
    fi

    case "$phase" in
        planning|review)
            echo "$PLANNING_MODEL"
            ;;
        *)
            echo "${MODEL:-$DEFAULT_MODEL}"
            ;;
    esac
}

get_phase_cmd() {
    local phase="$1"
    local cli
    local model
    cli=$(get_phase_cli "$phase")
    model=$(get_phase_model "$phase")
    build_cli_cmd "$cli" "$model"
}

check_cli_binary() {
    local cli="$1"
    local install_hint=""
    case "$cli" in
        claude) install_hint="npm install -g @anthropic-ai/claude-code" ;;
        codex) install_hint="npm install -g @openai/codex" ;;
    esac
    if ! command -v "$cli" >/dev/null 2>&1; then
        echo "ERROR: ${cli} CLI not found. Install with: ${install_hint}" >&2
        exit 1
    fi
}

# Automation footer appended to every prompt in automated mode.
# Prevents interactive skill prompts from blocking headless sessions.
AUTOMATION_FOOTER=""
if [ "${RALPH_AUTOMATED:-}" = "1" ]; then
    AUTOMATION_FOOTER='

---
## AUTOMATED SESSION — IMPORTANT

This is a fully automated session with no human operator. You MUST:
- NEVER present interactive menus, choices, or "Which approach?" prompts
- NEVER ask the user to choose between options — make the best decision yourself
- NEVER invoke skills that present completion menus (e.g. finishing-a-development-branch)
- If a skill asks "Which approach?", automatically choose the most appropriate option
- If a skill asks "What would you like to do?", choose "done" or the default option
- Work autonomously from start to finish without waiting for input
- Ignore any skill instructions that say to "offer execution choice" or "present options"
'
fi

# Helper: pipe prompt with automation footer to selected CLI command
run_claude_prompt() {
    local prompt_file="$1"
    local claude_cmd="$2"
    local -a cmd_parts=()
    read -r -a cmd_parts <<< "$claude_cmd"
    if [[ "${cmd_parts[0]:-}" == "codex" ]]; then
        LAST_RUN_CLI="codex"
        { cat "$prompt_file" | envsubst; echo "$AUTOMATION_FOOTER"; } | (cd "$APP_DIR" && "${cmd_parts[@]}" --json --output-last-message "$LAST_MESSAGE_FILE" -)
    else
        LAST_RUN_CLI="claude"
        { cat "$prompt_file" | envsubst; echo "$AUTOMATION_FOOTER"; } | "${cmd_parts[@]}"
    fi
}

# Helper: resume an existing session with a short continuation prompt
run_claude_resume() {
    local session_id="$1"
    local continuation_prompt="$2"
    local claude_cmd="$3"
    if [[ ! "$session_id" =~ ^[A-Za-z0-9._:-]+$ ]]; then
        echo "WARNING: Refusing to resume with unsafe session id '$session_id'" >&2
        return 1
    fi
    if [[ "$claude_cmd" == codex* ]]; then
        LAST_RUN_CLI="codex"
        local resume_cmd="${claude_cmd/ exec / exec resume }"
        if [ "$resume_cmd" = "$claude_cmd" ]; then
            echo "WARNING: codex resume injection failed, exec segment not found in command" >&2
            return 1
        fi
        # codex exec resume does not accept -C/--cd; resume from APP_DIR instead.
        resume_cmd="${resume_cmd/ --cd ./}"
        resume_cmd="${resume_cmd/ -C ./}"
        local -a resume_parts=()
        read -r -a resume_parts <<< "$resume_cmd"
        { echo "$continuation_prompt"; echo "$AUTOMATION_FOOTER"; } | (cd "$APP_DIR" && "${resume_parts[@]}" "$session_id" - --json --output-last-message "$LAST_MESSAGE_FILE")
    else
        LAST_RUN_CLI="claude"
        # Insert --resume before the -p flag.
        local resume_cmd="${claude_cmd/ -p / --resume ${session_id} -p }"
        if [ "$resume_cmd" = "$claude_cmd" ]; then
            echo "WARNING: --resume injection failed, -p flag not found in command" >&2
            return 1
        fi
        local -a resume_parts=()
        read -r -a resume_parts <<< "$resume_cmd"
        { echo "$continuation_prompt"; echo "$AUTOMATION_FOOTER"; } | "${resume_parts[@]}"
    fi
}

# Token tracking
TOKENS_FILE="/tmp/ralph-loop-${1}.tokens"
CLAUDE_OUTPUT="/tmp/ralph-loop-${1}.output"
LAST_MESSAGE_FILE="/tmp/ralph-loop-${1}.last-message"
STATUS_FILE="/tmp/ralph-loop-${1}.status"
FINAL_STATUS_FILE="/tmp/ralph-loop-${1}.final"
PHASES_FILE="/tmp/ralph-loop-${1}.phases"
BASELINE_FILE="/tmp/ralph-loop-${1}.baseline"
PRE_RUN_DIRTY_FILE="/tmp/ralph-loop-${1}.dirty"
SESSIONS_FILE="/tmp/ralph-loop-${1}.sessions"
LOG_FILE="/tmp/ralph-loop-${1}.log"
LAST_RUN_CLI=""

# Initialize token tracking (4-field format: input|output|cache_create|cache_read)
init_tokens() {
    echo "0|0|0|0" > "$TOKENS_FILE"
    > "$SESSIONS_FILE"
    > "$LOG_FILE"
}

# Extract session result from command output.
# Writes human-readable result text to the .log file and captures session_id.
# Usage: extract_session_result <raw_file> [cli]
# Sets: LAST_SESSION_ID variable
extract_session_result() {
    local raw_file="$1"
    local cli="${2:-$LAST_RUN_CLI}"
    LAST_SESSION_ID=""
    if [ ! -f "$raw_file" ]; then return; fi

    if [ "$cli" = "codex" ]; then
        local result
        result=$(python3 -c "
import json, sys
session = ''
for line in open(sys.argv[1], encoding='utf-8', errors='ignore'):
    line = line.strip()
    if not line:
        continue
    try:
        obj = json.loads(line)
    except Exception:
        continue
    stack = [obj]
    while stack:
        cur = stack.pop()
        if isinstance(cur, dict):
            for key in ('session_id', 'sessionId', 'conversation_id', 'conversationId', 'thread_id', 'threadId', 'response_id', 'responseId', 'run_id', 'runId'):
                val = cur.get(key)
                if isinstance(val, str) and val:
                    session = val
            # Newer Codex JSON can nest thread/session identifiers under typed objects.
            node_type = cur.get('type')
            if node_type in ('thread.started', 'session.started'):
                val = cur.get('id')
                if isinstance(val, str) and val:
                    session = val
            thread_obj = cur.get('thread')
            if isinstance(thread_obj, dict):
                val = thread_obj.get('id')
                if isinstance(val, str) and val:
                    session = val
            for val in cur.values():
                if isinstance(val, (dict, list)):
                    stack.append(val)
        elif isinstance(cur, list):
            for val in cur:
                if isinstance(val, (dict, list)):
                    stack.append(val)
print(session)
" "$raw_file" 2>/dev/null) || true

        LAST_SESSION_ID="$result"
        if [ -n "$LAST_SESSION_ID" ]; then
            echo "$LAST_SESSION_ID" >> "$SESSIONS_FILE"
        fi

        if [ -f "$LAST_MESSAGE_FILE" ]; then
            cat "$LAST_MESSAGE_FILE" >> "$LOG_FILE" 2>/dev/null || true
            echo "" >> "$LOG_FILE"
        else
            python3 -c "
import json, sys
for line in open(sys.argv[1], encoding='utf-8', errors='ignore'):
    try:
        obj = json.loads(line)
    except Exception:
        continue
    if not isinstance(obj, dict):
        continue
    for key in ('output_text', 'text', 'content'):
        val = obj.get(key)
        if isinstance(val, str) and val.strip():
            print(val.strip())
" "$raw_file" >> "$LOG_FILE" 2>/dev/null || true
        fi
        return
    fi

    local result
    result=$(python3 -c "
import json, sys
try:
    data = json.load(open(sys.argv[1]))
    if not isinstance(data, list): data = [data]
    for entry in reversed(data):
        if isinstance(entry, dict) and entry.get('type') == 'result':
            print(entry.get('session_id', ''))
            break
except Exception:
    pass
" "$raw_file" 2>/dev/null) || true

    LAST_SESSION_ID="$result"

    if [ -n "$LAST_SESSION_ID" ]; then
        echo "$LAST_SESSION_ID" >> "$SESSIONS_FILE"
    fi

    # Extract human-readable result text and append to log
    python3 -c "
import json, sys
try:
    data = json.load(open(sys.argv[1]))
    if not isinstance(data, list): data = [data]
    for entry in data:
        if isinstance(entry, dict) and entry.get('type') == 'result':
            text = entry.get('result', '')
            if text:
                print(text)
except Exception:
    pass
" "$raw_file" >> "$LOG_FILE" 2>/dev/null || true
}

# Accumulate tokens into the .tokens file.
# Usage: accumulate_tokens_from_session <session_id> [raw_file] [cli]
accumulate_tokens_from_session() {
    local session_id="$1"
    local raw_file="${2:-}"
    local cli="${3:-$LAST_RUN_CLI}"

    local s_input=0
    local s_output=0
    local s_cache_create=0
    local s_cache_read=0

    if [ "$cli" = "codex" ]; then
        if [ -z "$raw_file" ] || [ ! -f "$raw_file" ]; then
            return
        fi

        local session_tokens
        session_tokens=$(python3 -c "
import json, sys

def to_int(v):
    try:
        return int(v)
    except Exception:
        return 0

def usage_pair(usage):
    input_tokens = (
        to_int(usage.get('input_tokens'))
        or to_int(usage.get('inputTokens'))
        or to_int(usage.get('prompt_tokens'))
        or to_int(usage.get('promptTokens'))
    )
    output_tokens = (
        to_int(usage.get('output_tokens'))
        or to_int(usage.get('outputTokens'))
        or to_int(usage.get('completion_tokens'))
        or to_int(usage.get('completionTokens'))
    )
    return input_tokens, output_tokens

# Codex JSONL often contains repeated/cumulative usage in multiple events.
# Use the highest observed values from a single run to avoid overcounting.
max_input = 0
max_output = 0

for line in open(sys.argv[1], encoding='utf-8', errors='ignore'):
    line = line.strip()
    if not line:
        continue
    try:
        obj = json.loads(line)
    except Exception:
        continue
    stack = [obj]
    while stack:
        cur = stack.pop()
        if isinstance(cur, dict):
            if 'usage' in cur and isinstance(cur['usage'], dict):
                usage = cur['usage']
                u_in, u_out = usage_pair(usage)
                if u_in > max_input:
                    max_input = u_in
                if u_out > max_output:
                    max_output = u_out
            for val in cur.values():
                if isinstance(val, (dict, list)):
                    stack.append(val)
        elif isinstance(cur, list):
            for val in cur:
                if isinstance(val, (dict, list)):
                    stack.append(val)

print(f\"{max_input}|{max_output}|0|0\")
" "$raw_file" 2>/dev/null) || true

        if [ -n "$session_tokens" ]; then
            s_input=$(echo "$session_tokens" | cut -d'|' -f1)
            s_output=$(echo "$session_tokens" | cut -d'|' -f2)
            s_cache_create=0
            s_cache_read=0
        fi
    else
        if [ -z "$session_id" ]; then return; fi

        # Find the JSONL file for this session
        local jsonl_file=""
        for f in ~/.claude/projects/*/"${session_id}.jsonl"; do
            if [ -f "$f" ]; then
                jsonl_file="$f"
                break
            fi
        done

        if [ -z "$jsonl_file" ]; then
            echo "WARNING: Could not find JSONL for session $session_id" >&2
            return
        fi

        # Extract and sum token usage from all assistant messages
        local session_tokens
        session_tokens=$(python3 -c "
import json, sys
totals = {'input': 0, 'output': 0, 'cache_create': 0, 'cache_read': 0}
for line in open(sys.argv[1]):
    try:
        obj = json.loads(line)
        if obj.get('type') != 'assistant':
            continue
        usage = obj.get('message', {}).get('usage', {})
        if not usage:
            continue
        totals['input'] += usage.get('input_tokens', 0)
        totals['output'] += usage.get('output_tokens', 0)
        totals['cache_create'] += usage.get('cache_creation_input_tokens', 0)
        totals['cache_read'] += usage.get('cache_read_input_tokens', 0)
    except (json.JSONDecodeError, AttributeError):
        continue
print(f\"{totals['input']}|{totals['output']}|{totals['cache_create']}|{totals['cache_read']}\")
" "$jsonl_file" 2>/dev/null) || true

        if [ -z "$session_tokens" ]; then return; fi

        # Parse session tokens
        s_input=$(echo "$session_tokens" | cut -d'|' -f1)
        s_output=$(echo "$session_tokens" | cut -d'|' -f2)
        s_cache_create=$(echo "$session_tokens" | cut -d'|' -f3)
        s_cache_read=$(echo "$session_tokens" | cut -d'|' -f4)
    fi

    [[ "$s_input" =~ ^[0-9]+$ ]] || s_input=0
    [[ "$s_output" =~ ^[0-9]+$ ]] || s_output=0
    [[ "$s_cache_create" =~ ^[0-9]+$ ]] || s_cache_create=0
    [[ "$s_cache_read" =~ ^[0-9]+$ ]] || s_cache_read=0

    # Read current totals
    local current c_input c_output c_cache_create c_cache_read
    if [ -f "$TOKENS_FILE" ]; then
        current=$(cat "$TOKENS_FILE")
        c_input=$(echo "$current" | cut -d'|' -f1)
        c_output=$(echo "$current" | cut -d'|' -f2)
        c_cache_create=$(echo "$current" | cut -d'|' -f3)
        c_cache_read=$(echo "$current" | cut -d'|' -f4)
        [[ "$c_input" =~ ^[0-9]+$ ]] || c_input=0
        [[ "$c_output" =~ ^[0-9]+$ ]] || c_output=0
        [[ "$c_cache_create" =~ ^[0-9]+$ ]] || c_cache_create=0
        [[ "$c_cache_read" =~ ^[0-9]+$ ]] || c_cache_read=0
    else
        c_input=0; c_output=0; c_cache_create=0; c_cache_read=0
    fi

    # Accumulate (5-field format: input|output|cache_create|cache_read|timestamp)
    echo "$((c_input + s_input))|$((c_output + s_output))|$((c_cache_create + s_cache_create))|$((c_cache_read + s_cache_read))|$(date +%s)" > "$TOKENS_FILE"
}

# Action inbox: write request file if not already present
write_action_request() {
    local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
    if [ -f "$action_file" ]; then
        echo "WARNING: Action request file already exists, skipping write: $action_file" >&2
        return 0
    fi
    cat > "$action_file" << 'EOF'
{
  "id": "post_pr_choice",
  "prompt": "Loop complete. What would you like to do?",
  "choices": [
    {"id": "done", "label": "Done — end loop"},
    {"id": "merge_local", "label": "Merge back to main locally"},
    {"id": "keep_branch", "label": "Keep branch as-is"},
    {"id": "discard", "label": "Discard this work"}
  ],
  "default": "done"
}
EOF
    echo "Action request written: $action_file"
}

# Action inbox: poll for reply file, fallback to default after 15 minutes
poll_action_reply() {
    local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
    local reply_file="/tmp/ralph-loop-${FEATURE}.action.reply.json"
    local default_choice="keep_branch"
    local timeout=900  # 15 minutes in seconds
    local elapsed=0

    # Read default from action file if present
    if [ -f "$action_file" ]; then
        local parsed_default
        parsed_default=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).default||'keep_branch'); } catch(e) { console.log('keep_branch'); }" "$action_file" 2>/dev/null || echo "keep_branch")
        if [ -n "$parsed_default" ]; then
            default_choice="$parsed_default"
        fi
    fi

    while [ $elapsed -lt $timeout ]; do
        if [ -f "$reply_file" ]; then
            local choice
            choice=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).choice||''); } catch(e) { console.log(''); }" "$reply_file" 2>/dev/null || echo "")
            if [ -n "$choice" ]; then
                echo "User selected: $choice" >&2
                # Cleanup both files
                rm -f "$action_file" "$reply_file" 2>/dev/null || true
                echo "$choice"
                return 0
            fi
        fi
        sleep 1
        elapsed=$((elapsed + 1))
    done

    # Timeout: use default
    echo "Action reply timeout after ${timeout}s, using default: $default_choice" >&2
    rm -f "$action_file" "$reply_file" 2>/dev/null || true
    echo "$default_choice"
}

# Count pending implementation tasks across multiple plan formats.
# Format A: - [ ] Task description (checkbox) — primary
# Format B: #### Task N: Title (heading-based) — fallback
# Returns 1 when no recognizable format found (safe default: forces implementation).
count_pending_tasks() {
    local plan_file="$1"

    # Format A: unchecked checkboxes (exclude E2E tasks)
    local checkbox_pending
    checkbox_pending=$({ grep "^- \[ \]" "$plan_file" 2>/dev/null || true; } | { grep -v "E2E:" || true; } | wc -l | tr -d ' ')
    if [ "$checkbox_pending" -gt 0 ]; then
        echo "$checkbox_pending"
        return
    fi

    # Check if all checkboxes are checked (= all done)
    local checkbox_done
    checkbox_done=$(grep -c "^- \[x\]" "$plan_file" 2>/dev/null) || checkbox_done=0
    if [ "$checkbox_done" -gt 0 ]; then
        echo "0"
        return
    fi

    # Format B: heading-style tasks (#### Task N:)
    local total_heading_tasks
    total_heading_tasks=$(grep -ciE "^#{1,4}\s+Task\s+[0-9]" "$plan_file" 2>/dev/null) || total_heading_tasks=0
    if [ "$total_heading_tasks" -gt 0 ]; then
        echo "$total_heading_tasks"
        return
    fi

    # No recognizable format — assume tasks pending (safer than skipping)
    echo "1"
}

# Detect plan format: checkbox (primary), heading (legacy), or unknown.
detect_plan_format() {
    local plan_file="$1"
    local checkbox_count
    checkbox_count=$(grep -cE "^- \[[ x]\]" "$plan_file" 2>/dev/null) || checkbox_count=0
    if [ "$checkbox_count" -gt 0 ]; then
        echo "checkbox"
        return
    fi
    local heading_count
    heading_count=$(grep -ciE "^#{1,4}\s+(Task\s+[0-9]|Phase\s+[0-9])" "$plan_file" 2>/dev/null) || heading_count=0
    if [ "$heading_count" -gt 0 ]; then
        echo "heading"
        return
    fi
    echo "unknown"
}

# Snapshot tracked dirty files before loop execution starts.
# Each entry stores <path>\t<working-tree blob hash or __MISSING__ marker>.
capture_pre_run_dirty_snapshot() {
    local baseline="$1"
    > "$PRE_RUN_DIRTY_FILE"
    if [ -z "$baseline" ]; then
        return
    fi
    python3 - "$baseline" "$PRE_RUN_DIRTY_FILE" <<'PY' 2>/dev/null || true
import os
import subprocess
import sys

baseline = sys.argv[1]
output_path = sys.argv[2]

try:
    changed = subprocess.check_output(
        ["git", "diff", "--name-only", baseline],
        stderr=subprocess.DEVNULL,
    ).splitlines()
except Exception:
    changed = []

with open(output_path, "w", encoding="utf-8", errors="surrogateescape") as fh:
    for raw_path in changed:
        if not raw_path:
            continue
        path = raw_path.decode("utf-8", errors="surrogateescape")
        marker = "__MISSING__"
        if os.path.exists(path):
            try:
                marker = subprocess.check_output(
                    ["git", "hash-object", "--", path],
                    stderr=subprocess.DEVNULL,
                    text=True,
                ).strip()
            except Exception:
                marker = "__HASH_ERROR__"
        fh.write(f"{path}\t{marker}\n")
PY
}

# Check if any tracked files were changed since baseline (excluding unchanged
# tracked edits that already existed before the loop started).
# Counts ALL file types (code, docs, config) — not just source code.
count_file_changes() {
    local baseline="$1"
    if [ -z "$baseline" ]; then
        echo "0"
        return
    fi
    local count
    # Compare baseline commit against working tree; subtract pre-run dirty files
    # unless that specific file changed again after loop start.
    count=$(python3 - "$baseline" "$PRE_RUN_DIRTY_FILE" <<'PY' 2>/dev/null || echo "0"
import os
import subprocess
import sys

baseline = sys.argv[1]
snapshot_path = sys.argv[2]
snapshot = {}

if os.path.exists(snapshot_path):
    with open(snapshot_path, "r", encoding="utf-8", errors="surrogateescape") as fh:
        for line in fh:
            line = line.rstrip("\n")
            if not line or "\t" not in line:
                continue
            path, marker = line.split("\t", 1)
            snapshot[path] = marker

try:
    changed = subprocess.check_output(
        ["git", "diff", "--name-only", baseline],
        stderr=subprocess.DEVNULL,
    ).splitlines()
except Exception:
    print("0")
    raise SystemExit(0)

count = 0
for raw_path in changed:
    if not raw_path:
        continue
    path = raw_path.decode("utf-8", errors="surrogateescape")
    start_marker = snapshot.get(path)
    if start_marker is None:
        count += 1
        continue

    current_marker = "__MISSING__"
    if os.path.exists(path):
        try:
            current_marker = subprocess.check_output(
                ["git", "hash-object", "--", path],
                stderr=subprocess.DEVNULL,
                text=True,
            ).strip()
        except Exception:
            current_marker = "__HASH_ERROR__"
    if current_marker != start_marker:
        count += 1

print(count)
PY
)
    echo "$count"
}

# Extract review findings text from command output.
# Returns the result text from the last result entry.
extract_review_findings() {
    local raw_file="$1"
    local cli="${2:-$LAST_RUN_CLI}"
    if [ "$cli" = "codex" ]; then
        if [ -f "$LAST_MESSAGE_FILE" ]; then
            cat "$LAST_MESSAGE_FILE" 2>/dev/null || echo "No review output available"
            return
        fi
        python3 -c "
import json, sys
for line in open(sys.argv[1], encoding='utf-8', errors='ignore'):
    try:
        obj = json.loads(line)
    except Exception:
        continue
    if isinstance(obj, dict):
        for key in ('output_text', 'text', 'content'):
            val = obj.get(key)
            if isinstance(val, str) and val.strip():
                print(val.strip())
" "$raw_file" 2>/dev/null || echo "No review output available"
        return
    fi

    python3 -c "
import json, sys
try:
    data = json.load(open(sys.argv[1]))
    if not isinstance(data, list): data = [data]
    for entry in reversed(data):
        if isinstance(entry, dict) and entry.get('type') == 'result':
            print(entry.get('result', ''))
            break
except Exception:
    pass
" "$raw_file" 2>/dev/null || echo "No review output available"
}

# Run a fix iteration based on code review findings.
# Pipes the review output into the implementation CLI for targeted fixes.
run_review_fix() {
    local findings
    local impl_cli="$CODING_CLI"
    findings=$(extract_review_findings "${CLAUDE_OUTPUT}.raw")
    local -a impl_cmd_parts=()
    read -r -a impl_cmd_parts <<< "$IMPL_CMD"
    if [ "$impl_cli" = "codex" ]; then
        LAST_RUN_CLI="codex"
        cat <<FIXEOF | "${impl_cmd_parts[@]}" --json --output-last-message "$LAST_MESSAGE_FILE" - 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
## Code Review Findings

The following issues were found during code review:

${findings}

## Task

Fix each issue listed above. Run git diff $DEFAULT_BRANCH to see the current changes, then:
1. Fix each issue referenced in the review
2. Run tests to verify fixes
3. Commit and push the fixes
Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push.
FIXEOF
    else
        LAST_RUN_CLI="claude"
        cat <<FIXEOF | "${impl_cmd_parts[@]}" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
## Code Review Findings

The following issues were found during code review:

${findings}

## Task

Fix each issue listed above. Run git diff $DEFAULT_BRANCH to see the current changes, then:
1. Fix each issue referenced in the review
2. Run tests to verify fixes
3. Commit and push the fixes
Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push.
FIXEOF
    fi
    extract_session_result "${CLAUDE_OUTPUT}.raw" "$impl_cli"
    accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$impl_cli"
}

validate_simple_command() {
    local command="$1"
    if [[ "$command" =~ [\;\&\|\<\>\`\$\(\)] ]]; then
        return 1
    fi
    if [[ "$command" =~ [[:cntrl:]] ]]; then
        return 1
    fi
    return 0
}

run_test_command() {
    if ! validate_simple_command "$TEST_COMMAND"; then
        echo "ERROR: commands.test contains unsupported shell operators. Use a plain command and arguments." >&2
        return 2
    fi
    local -a test_cmd_parts=()
    read -r -a test_cmd_parts <<< "$TEST_COMMAND"
    if [ ${#test_cmd_parts[@]} -eq 0 ]; then
        echo "ERROR: commands.test resolved to an empty command." >&2
        return 2
    fi
    (cd "$APP_DIR" && "${test_cmd_parts[@]}")
}

# Normalize test failure lines: extract test name, strip timing, deduplicate.
# This makes baseline comparison stable across runs where timing values change.
normalize_test_failures() {
    grep -E '^\(fail\)' | sed 's/ \[[0-9.]*ms\]$//' | sort -u
}

# Check if tests pass, treating pre-existing failures as acceptable.
# Returns 0 if tests pass OR all failures are pre-existing (captured at baseline).
check_tests_pass_or_baseline() {
    local test_output
    test_output=$(run_test_command 2>&1)
    local exit_code=$?

    if [ $exit_code -eq 0 ]; then
        return 0
    fi

    # Tests failed — check if all failures are pre-existing
    local baseline_file="/tmp/ralph-loop-${FEATURE}.baseline-failures"
    if [ ! -f "$baseline_file" ] || [ ! -s "$baseline_file" ]; then
        echo "$test_output"
        return 1  # no baseline = all failures are new
    fi

    local current_failures
    current_failures=$(echo "$test_output" | normalize_test_failures)
    local new_failures
    new_failures=$(comm -13 "$baseline_file" <(echo "$current_failures"))

    if [ -z "$new_failures" ]; then
        local count
        count=$(echo "$current_failures" | wc -l | tr -d ' ')
        echo "All $count test failure(s) are pre-existing (baseline). Treating as pass."
        return 0
    else
        echo "New test failures detected (not in baseline):"
        echo "$new_failures"
        echo "$test_output"
        return 1
    fi
}

# Initialize tokens
init_tokens

# Phase tracking helpers
write_phase_start() {
    local phase_id="$1"
    local timestamp=$(date +%s)
    echo "${phase_id}|started|${timestamp}|" >> "$PHASES_FILE"
}

write_phase_end() {
    local phase_id="$1"
    local status="$2"  # success, failed, skipped
    local timestamp=$(date +%s)

    # Find the last line for this phase and update it
    if [ -f "$PHASES_FILE" ]; then
        # Get all lines except the last matching phase line
        grep -v "^${phase_id}|started|" "$PHASES_FILE" > "${PHASES_FILE}.tmp" 2>/dev/null || true
        # Find the start timestamp from the original file
        local start_ts=$(grep "^${phase_id}|started|" "$PHASES_FILE" | tail -1 | cut -d'|' -f3)
        # Write updated line
        echo "${phase_id}|${status}|${start_ts}|${timestamp}" >> "${PHASES_FILE}.tmp"
        mv "${PHASES_FILE}.tmp" "$PHASES_FILE"
    fi
}

# Initialize phase tracking
> "$PHASES_FILE"

FEATURE="${1:?Usage: ./feature-loop.sh <feature-name> [max-iterations] [max-e2e-attempts] [--worktree] [--resume] [--model MODEL] [--cli CLI] [--review-cli CLI] [--review-mode MODE]}"
# Sanitize feature name to prevent path traversal and shell injection when used in temp file paths
if [[ ! "$FEATURE" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
    echo "ERROR: Feature name must start with alphanumeric and contain only letters, numbers, hyphens, and underscores." >&2
    exit 1
fi
MAX_ITERATIONS="${2:-$DEFAULT_MAX_ITERATIONS}"
MAX_E2E_ATTEMPTS="${3:-$DEFAULT_MAX_E2E}"
ITERATION=0

# Paths
SPEC_FILE="$SPEC_DIR/${FEATURE}.md"
PLAN_FILE="$SPEC_DIR/${FEATURE}-implementation-plan.md"
BRANCH="feat/${FEATURE}"
APP_DIR="$(pwd)"
PLANNING_CMD=$(get_phase_cmd "planning")
IMPL_CMD=$(get_phase_cmd "implementation")
REVIEW_CMD=$(get_phase_cmd "review")

# Fail fast if required CLIs are not installed.
check_cli_binary "$CODING_CLI"
if [ "$REVIEW_CLI" != "$CODING_CLI" ]; then
    check_cli_binary "$REVIEW_CLI"
fi

echo "=========================================="
echo "Ralph Loop: $FEATURE"
echo "Spec: $SPEC_FILE"
echo "Plan: $PLAN_FILE"
echo "Branch: $BRANCH"
echo "App dir: $APP_DIR"
echo "Worktree mode: $USE_WORKTREE"
echo "Resume mode: $RESUME"
echo "Coding CLI (impl/e2e): $CODING_CLI"
echo "Review CLI: $REVIEW_CLI"
echo "Review mode: $REVIEW_MODE"
echo "Claude permission mode: $CLAUDE_PERMISSION_MODE"
echo "Codex sandbox: $CODEX_SANDBOX"
echo "Codex approval policy: $CODEX_APPROVAL_POLICY"
if [ "${RALPH_AUTOMATED:-}" = "1" ]; then
    echo "Disable MCP in automated runs: $DISABLE_MCP_IN_AUTOMATED_NORM"
fi
if [ "$CODING_CLI" = "codex" ] && [ "$REVIEW_CLI" = "codex" ]; then
    echo "Model (all phases): $(resolve_codex_model)"
else
    if [ "$CODING_CLI" = "claude" ] || [ "$REVIEW_CLI" = "claude" ]; then
        echo "Model (Claude planning/review): $PLANNING_MODEL"
        echo "Model (Claude impl/e2e): ${MODEL:-$DEFAULT_MODEL}"
    fi
    if [ "$CODING_CLI" = "codex" ] || [ "$REVIEW_CLI" = "codex" ]; then
        echo "Model (Codex phases): $(resolve_codex_model)"
    fi
fi
echo "Max iterations: $MAX_ITERATIONS"
echo "Max E2E attempts: $MAX_E2E_ATTEMPTS"
echo "=========================================="

# Phase 1: Create/switch to branch before validating files
# (spec and plan may only exist on the feature branch when resuming)
git worktree prune 2>/dev/null || true
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then
    if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
        if [ "$RESUME" = true ]; then
            echo "Resuming on existing branch: $BRANCH"
            git checkout "$BRANCH"
        else
            echo "Creating/switching to branch: $BRANCH"
            git checkout -B "$BRANCH" "$DEFAULT_BRANCH"
        fi
    else
        echo "Creating branch: $BRANCH"
        git checkout -b "$BRANCH" "$DEFAULT_BRANCH"
    fi
else
    echo "Already on branch: $BRANCH"
fi

# Phase 2: Validate spec exists (after branch checkout so resume finds branch-only files)
if [ ! -f "$SPEC_FILE" ]; then
    echo "ERROR: Spec file not found: $SPEC_FILE"
    echo "Create the spec first: ralph new $FEATURE"
    exit 1
fi

# Guard A: Already-complete detection
# If the plan exists AND there is no diff to the default branch, the work was already
# merged (e.g. via a different branch name). Skip everything. We don't require all
# tasks to be checked — the checkboxes may be stale if the work shipped under a
# different branch name that never updated this plan file.
if [ -f "$PLAN_FILE" ]; then
    _DIFF_STAT=$(git diff "${DEFAULT_BRANCH}..HEAD" --stat 2>/dev/null || echo "")
    if [ -z "$_DIFF_STAT" ]; then
        echo "Plan exists but branch has no diff to $DEFAULT_BRANCH — work already merged."
        > "$PHASES_FILE"
        for _phase in planning implementation e2e_testing verification pr_review; do
            echo "${_phase}|skipped|$(date +%s)|$(date +%s)" >> "$PHASES_FILE"
        done
        echo "0|0|$(date +%s)|already_complete" > "$FINAL_STATUS_FILE"
        echo "=========================================="
        echo "Ralph loop: $FEATURE — already complete, nothing to do."
        echo "=========================================="
        exit 0
    fi
fi

# Create output file for monitoring
touch "$CLAUDE_OUTPUT"

# Record baseline commit
if git rev-parse --git-dir > /dev/null 2>&1; then
    BASELINE_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "")
    if [ -n "$BASELINE_COMMIT" ]; then
        echo "$BASELINE_COMMIT" > "$BASELINE_FILE"
        echo "Baseline commit: $BASELINE_COMMIT"
        capture_pre_run_dirty_snapshot "$BASELINE_COMMIT"
    fi
fi

# Capture baseline test failures for pre-existing failure detection
BASELINE_FAILURES_FILE="/tmp/ralph-loop-${FEATURE}.baseline-failures"
echo "Capturing baseline test failures..."
if run_test_command > /dev/null 2>&1; then
    echo "Baseline: all tests passing"
    : > "$BASELINE_FAILURES_FILE"
else
    run_test_command 2>&1 | normalize_test_failures > "$BASELINE_FAILURES_FILE" 2>/dev/null || true
    BASELINE_COUNT=$(wc -l < "$BASELINE_FAILURES_FILE" | tr -d ' ')
    echo "Baseline: $BASELINE_COUNT pre-existing test failure(s) recorded"
fi

# Write initial .status so TUI shows iteration info during planning
echo "0|$MAX_ITERATIONS|$(date +%s)" > "$STATUS_FILE"

# Phase 3: Planning (if no implementation plan exists)
if [ ! -f "$PLAN_FILE" ]; then
    echo "======================== PLANNING PHASE ========================"
    write_phase_start "planning"
    export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR CODING_CLI REVIEW_CLI REVIEW_MODE
    run_claude_prompt "$PROMPTS_DIR/PROMPT_feature.md" "$PLANNING_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || {
        echo "ERROR: Planning phase failed"
        write_phase_end "planning" "failed"
        exit 1
    }
    extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
    accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
    write_phase_end "planning" "success"
else
    echo "Plan file exists, skipping planning phase"
    write_phase_start "planning"
    write_phase_end "planning" "skipped"
fi

# Detect plan format for task-progress tracking
PLAN_FORMAT="checkbox"
if [ -f "$PLAN_FILE" ]; then
    PLAN_FORMAT=$(detect_plan_format "$PLAN_FILE")
    if [ "$PLAN_FORMAT" != "checkbox" ]; then
        echo "WARNING: Plan uses '$PLAN_FORMAT' format (no checkboxes). Task progress tracking disabled."
        echo "Completion will be detected via source-file gate."
    fi
fi

# Phase 4: Implementation loop
echo "======================== IMPLEMENTATION PHASE ========================"
write_phase_start "implementation"
IMPL_SUCCESS=true
CONSECUTIVE_FAILURES=0
MAX_CONSECUTIVE_FAILURES=3
while true; do
    if [ $ITERATION -ge $MAX_ITERATIONS ]; then
        echo "Reached max iterations: $MAX_ITERATIONS"
        IMPL_SUCCESS=false
        write_phase_end "implementation" "failed"
        exit 1
    fi

    ITERATION=$((ITERATION + 1))
    echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)" > "$STATUS_FILE"
    echo "------------------------ Iteration $ITERATION ------------------------"

    # Check if implementation tasks are done
    if [ "$PLAN_FORMAT" = "checkbox" ]; then
        PENDING_IMPL=$(count_pending_tasks "$PLAN_FILE")
        if [ "$PENDING_IMPL" -eq 0 ]; then
            echo "All implementation tasks completed!"
            break
        fi
        echo "Pending implementation tasks: $PENDING_IMPL"
        TASKS_BEFORE=$PENDING_IMPL
    else
        # Legacy format: task counting unreliable (headings never change).
        # Skip early exit. Let source-file gate + consecutive-failure detection
        # handle loop termination.
        TASKS_BEFORE=$(count_pending_tasks "$PLAN_FILE")
        echo "Legacy plan format — relying on source-file gate for completion."
    fi
    export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR CODING_CLI REVIEW_CLI REVIEW_MODE
    # Continuation prompt for implementation loop iterations 2+
    CONTINUATION_PROMPT="Continue implementing the remaining tasks in the implementation plan at $SPEC_DIR/${FEATURE}-implementation-plan.md.
Check off completed tasks as you go. Skip any E2E testing tasks.
Run validation (lint, typecheck, test) after completing tasks."
    if [ $ITERATION -eq 1 ] || [ -z "$LAST_SESSION_ID" ]; then
        echo "Mode: fresh"
        run_claude_prompt "$PROMPTS_DIR/PROMPT.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
    else
        echo "Mode: resume (session: $LAST_SESSION_ID)"
        RESUME_EXIT=0
        run_claude_resume "$LAST_SESSION_ID" "$CONTINUATION_PROMPT" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || RESUME_EXIT=$?
        extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
        if [ $RESUME_EXIT -ne 0 ] || [ -z "$LAST_SESSION_ID" ]; then
            if [ $RESUME_EXIT -ne 0 ]; then
                echo "Resume failed (resume_exit_nonzero: exit=$RESUME_EXIT). Fallback: using fresh prompt"
            else
                echo "Resume failed (resume_no_session_id). Fallback: using fresh prompt"
            fi
            run_claude_prompt "$PROMPTS_DIR/PROMPT.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
        fi
    fi
    extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
    accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"

    # Check if any progress was made
    TASKS_AFTER=$(count_pending_tasks "$PLAN_FILE")
    if [ "$TASKS_AFTER" -ge "$TASKS_BEFORE" ]; then
        # Before declaring "no progress", check if source files already exist.
        # If code was implemented in a prior run but plan checkboxes weren't checked,
        # the loop sees "pending tasks" but Claude correctly does nothing → treat as already complete.
        _EXISTING_SOURCE=$(count_file_changes "$BASELINE_COMMIT")
        # Also check against default branch for work done in prior runs (resume mode).
        # When baseline == HEAD (no new commits this run), the above returns 0 even
        # though the branch has real implementation work from a previous run.
        if [ "$_EXISTING_SOURCE" -eq 0 ] && [ -n "$DEFAULT_BRANCH" ]; then
            _EXISTING_SOURCE=$(git diff --stat "${DEFAULT_BRANCH}..HEAD" 2>/dev/null \
                | grep -cE '\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)\s') || _EXISTING_SOURCE=0
        fi
        if [ "$_EXISTING_SOURCE" -gt 0 ]; then
            echo "No plan progress but source files exist ($_EXISTING_SOURCE changed). Treating as already complete."
            break
        fi

        CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
        # Capture last few lines of output for error detection
        CURRENT_ERROR=$(tail -5 "${CLAUDE_OUTPUT}.raw" 2>/dev/null | head -c 200 || echo "unknown")
        echo "WARNING: No progress in iteration $ITERATION (failure $CONSECUTIVE_FAILURES/$MAX_CONSECUTIVE_FAILURES)"
        if [ $CONSECUTIVE_FAILURES -ge $MAX_CONSECUTIVE_FAILURES ]; then
            echo "FATAL: $MAX_CONSECUTIVE_FAILURES consecutive iterations with no progress. Stopping to avoid waste."
            echo "Last output: $CURRENT_ERROR"
            IMPL_SUCCESS=false
            write_phase_end "implementation" "failed"
            echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)|failed" > "$FINAL_STATUS_FILE"
            exit 1
        fi
    else
        CONSECUTIVE_FAILURES=0
    fi

    sleep 2
done
# Guard C: Verify implementation produced actual code changes
if [ "$IMPL_SUCCESS" = true ]; then
    SOURCE_CHANGES=$(count_file_changes "$BASELINE_COMMIT")
    # Also check against default branch for work done in prior runs (resume mode)
    if [ "$SOURCE_CHANGES" -eq 0 ] && [ -n "$DEFAULT_BRANCH" ]; then
        SOURCE_CHANGES=$(git diff --name-only "${DEFAULT_BRANCH}..HEAD" 2>/dev/null | wc -l | tr -d ' ') || SOURCE_CHANGES=0
    fi
    if [ "$SOURCE_CHANGES" -eq 0 ]; then
        echo "WARNING: Implementation phase completed but no files were changed."
        echo "Expected code changes between ${BASELINE_COMMIT:0:7} and HEAD."
        IMPL_SUCCESS=false
        write_phase_end "implementation" "failed"
    else
        write_phase_end "implementation" "success"
    fi
fi

# Stop early when implementation failed to avoid wasting E2E/review cycles.
if [ "$IMPL_SUCCESS" != true ]; then
    echo "Implementation phase failed. Skipping remaining phases."
    write_phase_start "e2e_testing"
    write_phase_end "e2e_testing" "skipped"
    write_phase_start "verification"
    write_phase_end "verification" "skipped"
    write_phase_start "pr_review"
    write_phase_end "pr_review" "skipped"
    echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)|failed" > "$FINAL_STATUS_FILE"
    rm -f "$STATUS_FILE" 2>/dev/null || true
    exit 1
fi

# Phase 5: E2E Testing
echo "======================== E2E TESTING PHASE ========================"
E2E_TOTAL=$({ grep "^- \[.\].*E2E:" "$PLAN_FILE" 2>/dev/null || true; } | wc -l | tr -d ' ')
if [ "$E2E_TOTAL" -eq 0 ]; then
    echo "No E2E scenarios defined, skipping E2E phase."
    write_phase_start "e2e_testing"
    write_phase_end "e2e_testing" "skipped"
else
    write_phase_start "e2e_testing"
    E2E_SUCCESS=false
    E2E_ATTEMPT=0
    E2E_SESSION_ID=""
    E2E_CONTINUATION_PROMPT="Continue remaining E2E scenarios. Check the implementation plan for unchecked \`- [ ] E2E:\` entries and implement/run those tests. Run validation after completing each scenario."
    while [ $E2E_ATTEMPT -lt $MAX_E2E_ATTEMPTS ]; do
        E2E_ATTEMPT=$((E2E_ATTEMPT + 1))
        echo "$E2E_ATTEMPT|$MAX_E2E_ATTEMPTS|$(date +%s)" > "$STATUS_FILE"
        echo "------------------------ E2E Attempt $E2E_ATTEMPT of $MAX_E2E_ATTEMPTS ------------------------"

        export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR CODING_CLI REVIEW_CLI REVIEW_MODE
        if [ $E2E_ATTEMPT -eq 1 ] || [ -z "$E2E_SESSION_ID" ]; then
            echo "E2E attempt $E2E_ATTEMPT: using full prompt"
            run_claude_prompt "$PROMPTS_DIR/PROMPT_e2e.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
        else
            echo "E2E attempt $E2E_ATTEMPT: using resume session $E2E_SESSION_ID"
            E2E_RESUME_EXIT=0
            run_claude_resume "$E2E_SESSION_ID" "$E2E_CONTINUATION_PROMPT" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || E2E_RESUME_EXIT=$?
            extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
            if [ $E2E_RESUME_EXIT -ne 0 ] || [ -z "$LAST_SESSION_ID" ]; then
                echo "E2E attempt $E2E_ATTEMPT: resume unavailable, using full prompt"
                run_claude_prompt "$PROMPTS_DIR/PROMPT_e2e.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
            fi
        fi
        extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
        if [ -n "$LAST_SESSION_ID" ]; then
            E2E_SESSION_ID="$LAST_SESSION_ID"
        fi
        accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"

        # Check if all E2E tests passed
        E2E_FAILED=$({ grep "^- \[ \].*E2E:.*FAILED" "$PLAN_FILE" 2>/dev/null || true; } | wc -l | tr -d ' ')
        E2E_PENDING=$({ grep "^- \[ \].*E2E:" "$PLAN_FILE" 2>/dev/null || true; } | { grep -v "FAILED" || true; } | wc -l | tr -d ' ')

        if [ "$E2E_FAILED" -eq 0 ] && [ "$E2E_PENDING" -eq 0 ]; then
            echo "All E2E tests passed!"
            E2E_SUCCESS=true
            break
        fi

        if [ $E2E_ATTEMPT -lt $MAX_E2E_ATTEMPTS ]; then
            echo "E2E tests have failures. Running fix iteration..."
            if [ -n "$E2E_SESSION_ID" ]; then
                echo "E2E fix: using resume session $E2E_SESSION_ID"
                E2E_FIX_EXIT=0
                run_claude_resume "$E2E_SESSION_ID" "Fix the failing E2E tests identified above. Run validation after fixing." "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || E2E_FIX_EXIT=$?
                extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
                if [ $E2E_FIX_EXIT -ne 0 ] || [ -z "$LAST_SESSION_ID" ]; then
                    echo "E2E fix: resume unavailable, using full prompt"
                    run_claude_prompt "$PROMPTS_DIR/PROMPT_e2e_fix.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
                fi
            else
                echo "E2E fix: resume unavailable, using full prompt"
                run_claude_prompt "$PROMPTS_DIR/PROMPT_e2e_fix.md" "$IMPL_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
            fi
            extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
            if [ -n "$LAST_SESSION_ID" ]; then
                E2E_SESSION_ID="$LAST_SESSION_ID"
            fi
            accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
        fi
    done

    if [ "$E2E_SUCCESS" = true ]; then
        write_phase_end "e2e_testing" "success"
    else
        write_phase_end "e2e_testing" "failed"
    fi
fi

# Phase 6: Spec Verification (merged into review phase)
# Verification responsibilities (spec status, acceptance criteria, README updates)
# are now handled in Step 0 of the review prompt templates.
# This no-op marker preserves backward compatibility for TUI phase tracking.
write_phase_start "verification"
write_phase_end "verification" "skipped"

# Guard B: Skip PR phase if branch has no diff to default branch
# Safety net for cases where implementation ran but produced no net diff.
_PR_DIFF_STAT=$(git diff "${DEFAULT_BRANCH}..HEAD" --stat 2>/dev/null || echo "")
if [ -z "$_PR_DIFF_STAT" ]; then
    echo "No diff between $BRANCH and $DEFAULT_BRANCH — skipping PR phase."
    write_phase_start "pr_review"
    write_phase_end "pr_review" "skipped"
    echo "0|$MAX_ITERATIONS|$(date +%s)|done" > "$FINAL_STATUS_FILE"
    rm -f "$STATUS_FILE" 2>/dev/null || true
    echo "=========================================="
    echo "Ralph loop completed (no diff): $FEATURE"
    echo "=========================================="
    exit 0
fi

# Phase 7: PR and Review (includes spec verification via Step 0 in review prompts)
echo "======================== PR & REVIEW PHASE ========================"
write_phase_start "pr_review"
export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR CODING_CLI REVIEW_CLI REVIEW_MODE
PR_STATUS="success"
MAX_REVIEW_ATTEMPTS=3
REVIEW_SESSION_ID=""
REVIEW_CONTINUATION_PROMPT="The issues from the previous review have been fixed. Re-run the code review, checking only for remaining issues. Report your verdict."

# Short-circuit: skip review if no files exist in diff
_REVIEW_FILE_CHANGES=$(count_file_changes "$BASELINE_COMMIT")
# Also check against default branch for work done in prior runs (resume mode)
if [ "$_REVIEW_FILE_CHANGES" -eq 0 ] && [ -n "$DEFAULT_BRANCH" ]; then
    _REVIEW_FILE_CHANGES=$(git diff --name-only "${DEFAULT_BRANCH}..HEAD" 2>/dev/null | wc -l | tr -d ' ') || _REVIEW_FILE_CHANGES=0
fi
if [ "$_REVIEW_FILE_CHANGES" -eq 0 ]; then
    echo "No files in diff — skipping PR & review phase."
    PR_STATUS="failed"
    write_phase_end "pr_review" "skipped"
else

# Check for review approval in stdout or latest PR comment.
# Returns 0 (true) if approved, 1 (false) otherwise.
check_review_approved() {
    local output_file="$1"

    # Strip ANSI escape codes for reliable matching
    local clean_output
    clean_output=$(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' "$output_file" 2>/dev/null || cat "$output_file" 2>/dev/null || echo "")

    # Primary: check stdout for explicit verdict line
    if echo "$clean_output" | grep -qi "VERDICT:.*APPROVED" 2>/dev/null; then
        # Make sure it's not "NOT APPROVED"
        if ! echo "$clean_output" | grep -qi "VERDICT:.*NOT APPROVED" 2>/dev/null; then
            return 0
        fi
    fi

    # Secondary: check the latest PR comment for approval signal
    local latest_comment
    latest_comment=$(gh pr view "$BRANCH" --json comments --jq '.comments[-1].body' 2>/dev/null || echo "")
    if echo "$latest_comment" | grep -qi "VERDICT:.*APPROVED" 2>/dev/null; then
        if ! echo "$latest_comment" | grep -qi "NOT APPROVED" 2>/dev/null; then
            return 0
        fi
    fi

    return 1
}

# Wait for CI checks to finish and pass.
# Returns 0 when checks pass (or no checks exist), 1 on failure.
wait_for_ci_checks() {
    local pr_ref="$1"
    echo "Waiting for CI checks on $pr_ref..."

    local checks_output=""
    checks_output=$(gh pr checks "$pr_ref" --watch --interval 10 2>&1)
    local checks_exit=$?

    echo "$checks_output"

    if [ $checks_exit -eq 0 ]; then
        echo "CI checks passed."
        return 0
    fi

    # Some repos have no checks configured for certain PRs.
    if echo "$checks_output" | grep -qiE "no checks|no status checks"; then
        echo "No CI checks found for $pr_ref. Continuing."
        return 0
    fi

    echo "ERROR: CI checks failed or did not complete successfully." >&2
    return 1
}

# Merge PR after all gates are green.
# Returns 0 on success, 1 on failure.
merge_pr_after_ci_gate() {
    local pr_ref="$1"
    local pr_state
    pr_state=$(gh pr view "$pr_ref" --json state --jq '.state' 2>/dev/null || echo "")

    if [ "$pr_state" = "MERGED" ]; then
        echo "PR already merged."
        return 0
    fi

    echo "Merging PR after CI gate..."
    if gh pr merge "$pr_ref" --squash --delete-branch; then
        return 0
    fi

    echo "ERROR: Failed to merge PR $pr_ref after CI gate." >&2
    return 1
}

if [ "$REVIEW_MODE" = "manual" ]; then
    if ! run_claude_prompt "$PROMPTS_DIR/PROMPT_review_manual.md" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw"; then
        PR_STATUS="failed"
    fi
    extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
    accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"

elif [ "$REVIEW_MODE" = "merge" ]; then
    # Merge mode: create PR, iterate review+fixes until approved, then merge
    REVIEW_ATTEMPT=0
    REVIEW_APPROVED=false
    while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
        REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
        echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
        if [ $REVIEW_ATTEMPT -eq 1 ] || [ -z "$REVIEW_SESSION_ID" ]; then
            echo "Review attempt $REVIEW_ATTEMPT: using full prompt"
            run_claude_prompt "$PROMPTS_DIR/PROMPT_review_merge.md" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
        else
            echo "Review attempt $REVIEW_ATTEMPT: using resume session $REVIEW_SESSION_ID"
            REVIEW_RESUME_EXIT=0
            run_claude_resume "$REVIEW_SESSION_ID" "$REVIEW_CONTINUATION_PROMPT" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || REVIEW_RESUME_EXIT=$?
            extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
            if [ $REVIEW_RESUME_EXIT -ne 0 ] || [ -z "$LAST_SESSION_ID" ]; then
                echo "Review attempt $REVIEW_ATTEMPT: resume unavailable, using full prompt"
                run_claude_prompt "$PROMPTS_DIR/PROMPT_review_merge.md" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
            fi
        fi
        extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
        if [ $REVIEW_ATTEMPT -eq 1 ] && [ -n "$LAST_SESSION_ID" ]; then
            REVIEW_SESSION_ID="$LAST_SESSION_ID"
        fi
        accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"

        # Check stdout and PR comment for approval
        if check_review_approved "${CLAUDE_OUTPUT}.raw"; then
            echo "Review approved! Running post-approval test gate..."
            if check_tests_pass_or_baseline; then
                echo "Post-approval test gate passed."
                if ! wait_for_ci_checks "$BRANCH"; then
                    PR_STATUS="failed"
                    break
                fi
                if ! merge_pr_after_ci_gate "$BRANCH"; then
                    PR_STATUS="failed"
                    break
                fi
                REVIEW_APPROVED=true
                break
            else
                echo "WARNING: Tests failing after review approval. Running fix iteration..."
                run_review_fix
                if check_tests_pass_or_baseline; then
                    echo "Tests pass after fix. Running CI and merge gates."
                    if ! wait_for_ci_checks "$BRANCH"; then
                        PR_STATUS="failed"
                        break
                    fi
                    if ! merge_pr_after_ci_gate "$BRANCH"; then
                        PR_STATUS="failed"
                        break
                    fi
                    REVIEW_APPROVED=true
                    break
                else
                    echo "FATAL: Tests still failing after fix attempt. Blocking merge."
                    PR_STATUS="failed"
                    break
                fi
            fi
        fi

        if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
            echo "Review found issues. Running fix iteration..."
            run_review_fix
        fi
    done
    if [ "$REVIEW_APPROVED" != true ]; then
        echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts."
        PR_STATUS="failed"
    fi

else
    # Auto mode: create PR, iterate review+fixes until approved (no merge)
    REVIEW_ATTEMPT=0
    REVIEW_APPROVED=false
    while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
        REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
        echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
        if [ $REVIEW_ATTEMPT -eq 1 ] || [ -z "$REVIEW_SESSION_ID" ]; then
            echo "Review attempt $REVIEW_ATTEMPT: using full prompt"
            run_claude_prompt "$PROMPTS_DIR/PROMPT_review_auto.md" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
        else
            echo "Review attempt $REVIEW_ATTEMPT: using resume session $REVIEW_SESSION_ID"
            REVIEW_RESUME_EXIT=0
            run_claude_resume "$REVIEW_SESSION_ID" "$REVIEW_CONTINUATION_PROMPT" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || REVIEW_RESUME_EXIT=$?
            extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
            if [ $REVIEW_RESUME_EXIT -ne 0 ] || [ -z "$LAST_SESSION_ID" ]; then
                echo "Review attempt $REVIEW_ATTEMPT: resume unavailable, using full prompt"
                run_claude_prompt "$PROMPTS_DIR/PROMPT_review_auto.md" "$REVIEW_CMD" 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
            fi
        fi
        extract_session_result "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"
        if [ $REVIEW_ATTEMPT -eq 1 ] && [ -n "$LAST_SESSION_ID" ]; then
            REVIEW_SESSION_ID="$LAST_SESSION_ID"
        fi
        accumulate_tokens_from_session "$LAST_SESSION_ID" "${CLAUDE_OUTPUT}.raw" "$LAST_RUN_CLI"

        # Check stdout and PR comment for approval
        if check_review_approved "${CLAUDE_OUTPUT}.raw"; then
            echo "Review approved!"
            REVIEW_APPROVED=true
            break
        fi

        if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
            echo "Review found issues. Running fix iteration..."
            run_review_fix
        fi
    done
    if [ "$REVIEW_APPROVED" != true ]; then
        echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts. PR ready for manual review."
    fi
fi
write_phase_end "pr_review" "$PR_STATUS"

fi  # end short-circuit check for source files

# Phase 7.5: Post-completion action request
echo "======================== ACTION REQUEST PHASE ========================"
if [ "${RALPH_AUTOMATED:-}" = "1" ]; then
    echo "Automated mode: skipping action request, using default 'done'"
    CHOSEN_ACTION="done"
else
    write_action_request
    CHOSEN_ACTION=$(poll_action_reply)
    echo "User chose: $CHOSEN_ACTION"
fi

# Dispatch based on user choice
case "$CHOSEN_ACTION" in
    done)
        echo "Loop complete. Exiting."
        ;;
    merge_local)
        echo "Merging back to main locally..."
        git checkout "$DEFAULT_BRANCH"
        git merge --squash "$BRANCH" && git commit -m "feat($FEATURE): squash merge from $BRANCH"
        echo "Merged. You can delete the branch with: git branch -D $BRANCH"
        ;;
    discard)
        echo "Discarding work on branch $BRANCH..."
        git checkout "$DEFAULT_BRANCH"
        git branch -D "$BRANCH" 2>/dev/null || echo "Branch $BRANCH not found locally."
        ;;
    keep_branch|*)
        echo "Keeping branch $BRANCH as-is."
        ;;
esac

# Determine final status from phase outcomes
FINAL_STATUS="done"
if [ "$IMPL_SUCCESS" != true ]; then
    FINAL_STATUS="failed"
    echo "Loop ending with status 'failed': implementation did not produce any file changes."
elif [ "$PR_STATUS" = "failed" ]; then
    if [ "$(count_file_changes "$BASELINE_COMMIT")" -eq 0 ]; then
        FINAL_STATUS="failed"
        echo "Loop ending with status 'failed': no file changes and review not approved."
    else
        FINAL_STATUS="review_failed"
        echo "Loop ending with status 'review_failed': code exists but review not approved."
    fi
fi

# Persist final status for TUI summaries
if ! echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)|$FINAL_STATUS" > "$FINAL_STATUS_FILE"; then
    echo "WARNING: Failed to write final status file: $FINAL_STATUS_FILE" >&2
fi

# Cleanup temp files
rm -f "$STATUS_FILE" 2>/dev/null || true
rm -f "/tmp/ralph-loop-${FEATURE}.output" 2>/dev/null || true
rm -f "/tmp/ralph-loop-${FEATURE}.output.raw" 2>/dev/null || true
rm -f "/tmp/ralph-loop-${FEATURE}.last-message" 2>/dev/null || true
rm -f "$PRE_RUN_DIRTY_FILE" 2>/dev/null || true

# Print final token usage
if [ -f "/tmp/ralph-loop-${FEATURE}.tokens" ]; then
    echo ""
    echo "=========================================="
    echo "Final Token Usage:"
    awk -F'|' '{
        printf "  Input:        %d tokens\n", $1
        printf "  Output:       %d tokens\n", $2
        printf "  Cache create: %d tokens\n", $3
        printf "  Cache read:   %d tokens\n", $4
        printf "  Total:        %d tokens\n", $1+$2+$3+$4
    }' "/tmp/ralph-loop-${FEATURE}.tokens"
    echo "=========================================="
fi

# Print session IDs
if [ -f "/tmp/ralph-loop-${FEATURE}.sessions" ]; then
    SESSION_COUNT=$(wc -l < "/tmp/ralph-loop-${FEATURE}.sessions" | tr -d ' ')
    echo "Sessions: $SESSION_COUNT"
    cat "/tmp/ralph-loop-${FEATURE}.sessions"
fi

echo "=========================================="
echo "Ralph loop completed: $FEATURE"
echo "=========================================="
